Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ mod websocket;
use std::collections::HashMap;
use std::sync::Mutex;

use models::{ActiveStreams, ClientWrapper};
use models::{ActiveStreams, ClientWrapper, PendingOAuthFlows};
use websocket::ActiveWebSockets;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
Expand All @@ -21,6 +21,10 @@ pub fn run() {
connections: Mutex::new(HashMap::new()),
};

let pending_oauth_flows = PendingOAuthFlows {
flows: Mutex::new(HashMap::new()),
};

tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_http::init())
Expand All @@ -29,12 +33,14 @@ pub fn run() {
.manage(ClientWrapper::new())
.manage(active_streams)
.manage(active_websockets)
.manage(pending_oauth_flows)
.invoke_handler(tauri::generate_handler![
http_client::send_request,
streaming::stream_sse,
streaming::cancel_stream,
oauth::oauth2_token_exchange,
oauth::oauth2_auth_code_flow,
oauth::oauth2_cancel_flow,
oauth::oauth2_refresh,
websocket::ws_connect,
websocket::ws_send,
Expand Down
10 changes: 10 additions & 0 deletions src-tauri/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,13 @@ impl ClientWrapper {
pub struct ActiveStreams {
pub streams: Mutex<HashMap<String, tokio::sync::watch::Sender<bool>>>,
}

/// In-flight authorization code flows, keyed by a frontend-supplied id.
///
/// The flow parks on a loopback listener waiting for the provider to redirect
/// back. If the provider refuses to redirect at all — an unregistered redirect
/// URI is the usual reason — nothing ever arrives, and without this the user is
/// stuck watching a spinner until the timeout expires with no way out.
pub struct PendingOAuthFlows {
pub flows: Mutex<HashMap<String, tokio::sync::watch::Sender<bool>>>,
}
83 changes: 75 additions & 8 deletions src-tauri/src/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@ use tauri::Url;
use tauri_plugin_http::reqwest;
use tauri_plugin_opener::OpenerExt;

use crate::models::ClientWrapper;
use crate::models::{ClientWrapper, PendingOAuthFlows};

const OAUTH_TOKEN_ACCEPT_HEADER: &str =
"application/json, application/x-www-form-urlencoded, text/plain";
const TOKEN_CONTAINER_KEYS: &[&str] = &["data", "token", "result", "response"];

/// How long to wait for the provider to redirect back to the loopback listener.
///
/// Deliberately generous: a real sign-in can involve MFA, an account chooser and
/// a password manager. Cancelling is the UI's job, not the timeout's.
const AUTH_CALLBACK_TIMEOUT_SECS: u64 = 300;

#[derive(Debug, Deserialize)]
pub struct OAuth2TokenExchangeOptions {
token_url: String,
Expand Down Expand Up @@ -43,6 +49,10 @@ pub struct OAuth2AuthCodeOptions {
scope: Option<String>,
use_pkce: Option<bool>,
redirect_uri: Option<String>,
/// Identifies this flow so the UI can cancel it. Optional — a flow started
/// without one simply cannot be cancelled, which keeps older callers working.
#[serde(default)]
flow_id: Option<String>,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -452,6 +462,7 @@ pub async fn oauth2_auth_code_flow(
options: OAuth2AuthCodeOptions,
app: tauri::AppHandle,
client_wrapper: tauri::State<'_, ClientWrapper>,
pending_flows: tauri::State<'_, PendingOAuthFlows>,
) -> Result<OAuth2TokenResponse, String> {
let auth_url = required_field(options.auth_url, "auth_url")?;
let token_url = required_field(options.token_url, "token_url")?;
Expand Down Expand Up @@ -533,13 +544,46 @@ pub async fn oauth2_auth_code_flow(
.open_url(auth_uri.as_str(), None::<&str>)
.map_err(|e| format!("Failed to open browser: {}", e))?;

let (code, received_state) = tokio::time::timeout(
std::time::Duration::from_secs(120),
wait_for_callback(listener),
)
.await
.map_err(|_| "Authorization timed out after 2 minutes".to_string())?
.map_err(|e| format!("Callback error: {}", e))?;
// Register a cancel channel so the UI can abort a flow that is never going
// to complete. Signing in can legitimately take a while (MFA, a password
// manager, picking an account), so the timeout is generous and the cancel
// button is the real escape hatch.
let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false);
let flow_id = normalize_optional_input(options.flow_id);
if let Some(id) = &flow_id {
pending_flows
.flows
.lock()
.map_err(|_| "Failed to register the authorization flow".to_string())?
.insert(id.clone(), cancel_tx);
}

let outcome = tokio::select! {
result = tokio::time::timeout(
std::time::Duration::from_secs(AUTH_CALLBACK_TIMEOUT_SECS),
wait_for_callback(listener),
) => match result {
Ok(inner) => inner.map_err(|e| format!("Callback error: {}", e)),
Err(_) => Err(format!(
"Timed out after {} minutes waiting for the provider to redirect back. \
If your browser showed an error instead of a sign-in page, register \
exactly this callback URL with the provider: {}",
AUTH_CALLBACK_TIMEOUT_SECS / 60,
redirect_uri
)),
},
_ = cancel_rx.changed() => Err("Authorization cancelled".to_string()),
};

// Always deregister, whichever way the flow ended, so a retry with the same
// id does not find a stale sender.
if let Some(id) = &flow_id {
if let Ok(mut flows) = pending_flows.flows.lock() {
flows.remove(id);
}
}

let (code, received_state) = outcome?;

if received_state != state {
return Err("State mismatch - possible CSRF attack".to_string());
Expand Down Expand Up @@ -574,6 +618,29 @@ pub async fn oauth2_auth_code_flow(
parse_oauth_token_response(res).await
}

/// Abort an in-flight authorization code flow.
///
/// Returns whether a flow was actually waiting: the UI uses that to tell "we
/// stopped it" apart from "it had already finished or timed out".
#[tauri::command]
pub async fn oauth2_cancel_flow(
flow_id: String,
pending_flows: tauri::State<'_, PendingOAuthFlows>,
) -> Result<bool, String> {
let sender = pending_flows
.flows
.lock()
.map_err(|_| "Failed to read in-flight authorization flows".to_string())?
.remove(&flow_id);

match sender {
// The receiver side treats any change as cancellation, so the value
// only has to differ from the `false` it was created with.
Some(sender) => Ok(sender.send(true).is_ok()),
None => Ok(false),
}
}

async fn wait_for_callback(listener: tokio::net::TcpListener) -> Result<(String, String), String> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};

Expand Down
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ function App() {
onHeadersChange={(headers) => updateTab(currentTab.id, { headers })}
onBodyChange={(body) => updateTab(currentTab.id, { body })}
onContentTypeChange={(contentType) => updateTab(currentTab.id, { contentType })}
tabId={currentTab.id}
onAuthChange={(auth) => updateTab(currentTab.id, { auth })}
onCookiesChange={(cookies) => updateTab(currentTab.id, { cookies })}
onTestScriptsChange={(testScripts) => updateTab(currentTab.id, { testScripts })}
Expand Down
9 changes: 8 additions & 1 deletion src/components/AuthConfigurator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import { ShieldOff, KeyRound, Lock, Key, ShieldCheck } from "lucide-react"
interface AuthConfiguratorProps {
auth: AuthConfig
onAuthChange: (auth: AuthConfig) => void
/**
* Identifies whose auth this is — a request tab id, or `collection:<id>`.
* Passed through so an in-flight OAuth sign-in is scoped to it rather than to
* this component, which unmounts whenever the auth type changes.
*/
flowKey?: string
}

const AUTH_TYPES = [
Expand All @@ -19,7 +25,7 @@ const AUTH_TYPES = [
{ value: 'oauth2', label: 'OAuth 2.0', icon: ShieldCheck },
]

export function AuthConfigurator({ auth, onAuthChange }: AuthConfiguratorProps) {
export function AuthConfigurator({ auth, onAuthChange, flowKey }: AuthConfiguratorProps) {
const themeClass = useThemeClass()
// Every auth field that supports {{var}} substitution, for the peek badge
const authText = [
Expand Down Expand Up @@ -154,6 +160,7 @@ export function AuthConfigurator({ auth, onAuthChange }: AuthConfiguratorProps)
<OAuthConfigurator
oauth2={auth.oauth2 || { grantType: 'authorization_code', clientId: '' }}
onOAuth2Change={(oauth2) => onAuthChange({ ...auth, oauth2 })}
flowKey={flowKey}
/>
)}
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/components/GraphQLEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useRef, useEffect, useState, useMemo } from "react"
import { basicAuthValue } from "@/utils/base64"
import Editor, { OnMount, loader } from "@monaco-editor/react"
import type { editor as MonacoEditor, IDisposable } from "monaco-editor"
import { Button } from "@/components/ui/button"
Expand Down Expand Up @@ -213,8 +214,7 @@

// Apply auth
if (auth.type === 'basic' && auth.username) {
const credentials = btoa(`${auth.username}:${auth.password || ''}`)
headerRecord['Authorization'] = `Basic ${credentials}`
headerRecord['Authorization'] = basicAuthValue(auth.username, auth.password || '')
} else if (auth.type === 'bearer' && auth.token) {
headerRecord['Authorization'] = `Bearer ${auth.token}`
} else if (auth.type === 'api-key' && auth.key && auth.value && auth.addTo === 'header') {
Expand Down Expand Up @@ -430,7 +430,7 @@
/**
* Build the JSON body payload from GraphQL editor fields.
*/
export function buildGraphQLBody(

Check warning on line 433 in src/components/GraphQLEditor.tsx

View workflow job for this annotation

GitHub Actions / Frontend Quality Gates

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
query: string,
variables: string,
operationName: string
Expand Down
30 changes: 26 additions & 4 deletions src/components/OAuthConfigurator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ import { useEnvironmentStore } from "@/store/environments"
import { detectEntraV1Url, fetchOidcDiscovery } from "@/utils/oidcDiscovery"
import { substituteVariables } from "@/utils/variables"
import { decodeToken } from "@/utils/jwt"
import { Loader2, KeyRound, RefreshCw, Globe, Shield, Wand2 } from "lucide-react"
import { Loader2, KeyRound, RefreshCw, Globe, Shield, Wand2, X } from "lucide-react"
import { useMemo, useState } from "react"

interface OAuthConfiguratorProps {
oauth2: OAuth2Config
onOAuth2Change: (config: OAuth2Config) => void
/** Scopes in-flight sign-in state to this tab or collection — see the store. */
flowKey?: string
}

const GRANT_TYPES: { value: OAuth2GrantType; label: string; description: string }[] = [
Expand Down Expand Up @@ -59,7 +61,7 @@ function FormField({ label, hint, children }: {
)
}

export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorProps) {
export function OAuthConfigurator({ oauth2, onOAuth2Change, flowKey }: OAuthConfiguratorProps) {
const themeClass = useThemeClass()
const { getVariable } = useEnvironmentStore()
const [isDiscovering, setIsDiscovering] = useState(false)
Expand All @@ -75,9 +77,10 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorP
getNewToken,
refreshToken,
clearToken,
cancelTokenRequest,
isExpired,
expiresIn,
} = useOAuth2TokenActions({ oauth2, onOAuth2Change })
} = useOAuth2TokenActions({ oauth2, onOAuth2Change, flowKey })

const updateField = (field: keyof OAuth2Config, value: string) => {
onOAuth2Change({ ...oauth2, [field]: value })
Expand Down Expand Up @@ -456,7 +459,7 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorP
{isLoading ? (
<>
<Loader2 size={14} className="mr-2 animate-spin" />
Getting Token…
{cancelTokenRequest ? 'Waiting for sign-in…' : 'Getting Token…'}
</>
) : (
<>
Expand All @@ -466,6 +469,25 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorP
)}
</Button>

{/*
Only the authorization code flow parks waiting on the browser, and it
can wait forever: if the redirect URI is not registered the provider
shows an error page and never redirects back, so nothing ever arrives
on the callback listener.
*/}
{cancelTokenRequest && (
<Button
onClick={cancelTokenRequest}
size="sm"
variant="outline"
className="border-border/40"
data-testid="cancel-token-request"
>
<X size={14} className="mr-1" />
Cancel
</Button>
)}

{oauth2.refreshToken && (
<Button
onClick={refreshToken}
Expand Down
5 changes: 4 additions & 1 deletion src/components/RequestPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ interface RequestPanelProps {
onBodyChange: (body: string) => void
onContentTypeChange: (contentType: string) => void
onAuthChange: (auth: AuthConfig) => void
/** The tab this panel is showing, used to scope in-flight OAuth sign-ins. */
tabId?: string
onCookiesChange: (cookies: Cookie[]) => void
onTestScriptsChange: (scripts: TestScript[]) => void
onPreRequestScriptsChange?: (scripts: TestScript[]) => void
Expand Down Expand Up @@ -189,6 +191,7 @@ export function RequestPanel({
body,
contentType,
auth,
tabId,
cookies,
response,
testScripts,
Expand Down Expand Up @@ -663,7 +666,7 @@ export function RequestPanel({
<TabsContent value="auth" className="h-full p-4 pt-2 data-[state=active]:flex data-[state=active]:flex-col">
<ScrollArea className="flex-1 min-h-0">
<div className="space-y-4 pr-4">
<AuthConfigurator auth={auth} onAuthChange={onAuthChange} />
<AuthConfigurator auth={auth} onAuthChange={onAuthChange} flowKey={tabId} />
</div>
</ScrollArea>
</TabsContent>
Expand Down
4 changes: 2 additions & 2 deletions src/components/WebSocketPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { basicAuthValue } from '@/utils/base64'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
Expand Down Expand Up @@ -113,8 +114,7 @@ export function WebSocketPanel({ url, headers, auth }: WebSocketPanelProps) {

// Apply auth
if (auth.type === 'basic' && auth.username) {
const credentials = btoa(`${auth.username}:${auth.password || ''}`)
headerRecord['Authorization'] = `Basic ${credentials}`
headerRecord['Authorization'] = basicAuthValue(auth.username, auth.password || '')
} else if (auth.type === 'bearer' && auth.token) {
headerRecord['Authorization'] = `Bearer ${auth.token}`
} else if (auth.type === 'api-key' && auth.key && auth.value && auth.addTo === 'header') {
Expand Down
1 change: 1 addition & 0 deletions src/components/collections/CollectionSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export function CollectionSettings({ collection, onUpdateCollection }: Collectio
<AuthConfigurator
auth={collection.auth ?? { type: "none" }}
onAuthChange={(auth) => onUpdateCollection(collection.id, { auth })}
flowKey={`collection:${collection.id}`}
/>
<p className="text-[11px] text-muted-foreground/50 leading-snug pt-2">
Requests in this collection use this unless they set their own.
Expand Down
Loading
Loading