diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 770f4e8..5b06d36 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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)] @@ -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()) @@ -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, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 7db8db7..e1a1634 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -144,3 +144,13 @@ impl ClientWrapper { pub struct ActiveStreams { pub streams: Mutex>>, } + +/// 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>>, +} diff --git a/src-tauri/src/oauth.rs b/src-tauri/src/oauth.rs index e079343..2cf4e2a 100644 --- a/src-tauri/src/oauth.rs +++ b/src-tauri/src/oauth.rs @@ -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, @@ -43,6 +49,10 @@ pub struct OAuth2AuthCodeOptions { scope: Option, use_pkce: Option, redirect_uri: Option, + /// 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, } #[derive(Debug, Deserialize)] @@ -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 { let auth_url = required_field(options.auth_url, "auth_url")?; let token_url = required_field(options.token_url, "token_url")?; @@ -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()); @@ -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 { + 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}; diff --git a/src/App.tsx b/src/App.tsx index 19ed3ef..778edd7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 })} diff --git a/src/components/AuthConfigurator.tsx b/src/components/AuthConfigurator.tsx index 93f043b..c780e81 100644 --- a/src/components/AuthConfigurator.tsx +++ b/src/components/AuthConfigurator.tsx @@ -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:`. + * 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 = [ @@ -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 = [ @@ -154,6 +160,7 @@ export function AuthConfigurator({ auth, onAuthChange }: AuthConfiguratorProps) onAuthChange({ ...auth, oauth2 })} + flowKey={flowKey} /> )} diff --git a/src/components/GraphQLEditor.tsx b/src/components/GraphQLEditor.tsx index 3129332..e7d2e2d 100644 --- a/src/components/GraphQLEditor.tsx +++ b/src/components/GraphQLEditor.tsx @@ -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" @@ -213,8 +214,7 @@ export function GraphQLEditor({ // 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') { diff --git a/src/components/OAuthConfigurator.tsx b/src/components/OAuthConfigurator.tsx index 6842f12..df212bd 100644 --- a/src/components/OAuthConfigurator.tsx +++ b/src/components/OAuthConfigurator.tsx @@ -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 }[] = [ @@ -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) @@ -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 }) @@ -456,7 +459,7 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorP {isLoading ? ( <> - Getting Token… + {cancelTokenRequest ? 'Waiting for sign-in…' : 'Getting Token…'} ) : ( <> @@ -466,6 +469,25 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorP )} + {/* + 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 && ( + + )} + {oauth2.refreshToken && (