diff --git a/Cargo.lock b/Cargo.lock index c64d2f20..28d93b7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2609,7 +2609,7 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "mcpmux" -version = "0.0.12" +version = "0.1.0" dependencies = [ "anyhow", "async-trait", @@ -2646,7 +2646,7 @@ dependencies = [ [[package]] name = "mcpmux-core" -version = "0.0.12" +version = "0.1.0" dependencies = [ "anyhow", "async-trait", @@ -2669,7 +2669,7 @@ dependencies = [ [[package]] name = "mcpmux-gateway" -version = "0.0.12" +version = "0.1.0" dependencies = [ "anyhow", "async-stream", @@ -2709,7 +2709,7 @@ dependencies = [ [[package]] name = "mcpmux-mcp" -version = "0.0.12" +version = "0.1.0" dependencies = [ "anyhow", "async-trait", @@ -2728,7 +2728,7 @@ dependencies = [ [[package]] name = "mcpmux-storage" -version = "0.0.12" +version = "0.1.0" dependencies = [ "anyhow", "async-trait", diff --git a/apps/desktop/src-tauri/src/commands/oauth.rs b/apps/desktop/src-tauri/src/commands/oauth.rs index 258afdda..48bcb6e5 100644 --- a/apps/desktop/src-tauri/src/commands/oauth.rs +++ b/apps/desktop/src-tauri/src/commands/oauth.rs @@ -74,6 +74,10 @@ pub struct ConsentRequestDetails { pub state: Option, /// When this request expires (Unix timestamp) pub expires_at: i64, + /// Cryptographic consent token (shared only via this IPC call, never over HTTP). + /// Must be returned in the approval request to prove the caller is the + /// legitimate desktop app UI—not an external script or bot. + pub consent_token: String, } /// Handle an incoming deep link URL @@ -360,6 +364,16 @@ pub async fn get_pending_consent( return Err(ConsentError::expired(&request_id)); } + // Extract consent_token (required for security—ensures only the desktop + // app that retrieved this token via IPC can approve the request) + let consent_token = auth.consent_token.clone().ok_or_else(|| { + error!("[OAuth] Pending authorization missing consent_token"); + ConsentError { + code: "NOT_FOUND".to_string(), + message: "Authorization request is missing consent token — it may have been created before this security update. Please retry.".to_string(), + } + })?; + // Build response with authoritative data from backend // The client_name here comes from our database lookup in handlers.rs let details = ConsentRequestDetails { @@ -373,6 +387,7 @@ pub async fn get_pending_consent( scope: auth.scope.clone().unwrap_or_default(), state: auth.state.clone(), expires_at: auth.expires_at, + consent_token, }; info!( @@ -390,6 +405,9 @@ pub struct ConsentApprovalRequest { pub request_id: String, /// Whether the user approved the request pub approved: bool, + /// Cryptographic consent token (must match the one issued via get_pending_consent). + /// This proves the caller obtained the token through Tauri IPC, not HTTP scraping. + pub consent_token: String, /// Optional alias name for the client pub client_alias: Option, /// Connection mode: "follow_active", "locked", or "ask_on_change" @@ -453,6 +471,27 @@ pub async fn approve_oauth_consent( }); }; + // Validate consent_token: proves the caller obtained this token via Tauri + // IPC (get_pending_consent), not by scraping the HTTP authorization page. + match &pending.consent_token { + Some(expected_token) => { + if request.consent_token != *expected_token { + error!( + "[OAuth] Consent token mismatch for request_id: {} — possible unauthorized approval attempt", + request.request_id + ); + return Err("Invalid consent token".to_string()); + } + } + None => { + error!( + "[OAuth] Pending authorization missing consent_token for request_id: {}", + request.request_id + ); + return Err("Consent token not available".to_string()); + } + } + // Remove the pending authorization (it's been processed) { let mut state = gw_state.write().await; @@ -504,6 +543,7 @@ pub async fn approve_oauth_consent( code_challenge: pending.code_challenge.clone(), code_challenge_method: pending.code_challenge_method.clone(), expires_at: code_expires_at, + consent_token: None, // Auth code entries don't need consent tokens }; state.store_pending_authorization(&code, new_pending); @@ -656,13 +696,19 @@ pub async fn get_oauth_clients( Ok(client_infos) } -/// Approve a registered OAuth client by ID (for E2E testing). -/// In production, clients are approved via the consent flow. +/// Approve a registered OAuth client by ID (for E2E testing only). +/// +/// Guarded by the `MCPMUX_E2E_TEST` environment variable. In production +/// builds this command is a no-op that returns an error. #[tauri::command] pub async fn approve_oauth_client( client_id: String, gateway_state: State<'_, Arc>>, ) -> Result<(), String> { + if std::env::var("MCPMUX_E2E_TEST").is_err() { + return Err("approve_oauth_client is only available in E2E test mode".to_string()); + } + let app_state = gateway_state.read().await; let Some(ref gw_state) = app_state.gateway_state else { return Err("Gateway not running".to_string()); @@ -674,7 +720,10 @@ pub async fn approve_oauth_client( repo.approve_client(&client_id) .await .map_err(|e| format!("Failed to approve client: {}", e))?; - info!("[OAuth] Approved client via test command: {}", client_id); + info!( + "[OAuth] Approved client via E2E test command: {}", + client_id + ); Ok(()) } diff --git a/apps/desktop/src/components/OAuthConsentModal.tsx b/apps/desktop/src/components/OAuthConsentModal.tsx index 9d1b7fa9..2dc26cc1 100644 --- a/apps/desktop/src/components/OAuthConsentModal.tsx +++ b/apps/desktop/src/components/OAuthConsentModal.tsx @@ -2,7 +2,7 @@ * OAuth Consent Modal * * Displays when an MCP client requests authorization via deep link. - * + * * ## Flow * 1. Deep link received with request_id only * 2. Call get_pending_consent to validate and get full details from backend @@ -14,14 +14,7 @@ import { useState, useEffect } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { Check, X, AlertCircle, Loader2, Globe, Lock } from 'lucide-react'; -import { - Button, - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, -} from '@mcpmux/ui'; +import { Button, Card, CardHeader, CardTitle, CardDescription, CardContent } from '@mcpmux/ui'; import { listSpaces, type Space } from '@/lib/api/spaces'; import { resolveKnownClientKey } from '@/lib/clientIcons'; import cursorIcon from '@/assets/client-icons/cursor.svg'; @@ -40,7 +33,7 @@ const CLIENT_ICON_ASSETS: Record = { /** Look up a bundled logo for a known client by name */ function getClientLogo(clientName: string): string | null { const key = resolveKnownClientKey(clientName); - return key ? CLIENT_ICON_ASSETS[key] ?? null : null; + return key ? (CLIENT_ICON_ASSETS[key] ?? null) : null; } /** Minimal deep link payload - only request_id */ @@ -57,6 +50,8 @@ interface ConsentRequestDetails { scope: string; state: string | null; expiresAt: number; + /** Cryptographic token shared only via Tauri IPC—must be sent back on approval */ + consentToken: string; } /** Error from get_pending_consent */ @@ -73,7 +68,7 @@ interface ConsentApprovalResponse { } /** Current modal state */ -type ModalState = +type ModalState = | { type: 'hidden' } | { type: 'loading'; requestId: string } | { type: 'error'; requestId: string; error: ConsentError } @@ -124,6 +119,8 @@ export function OAuthConsentModal() { const [spaces, setSpaces] = useState([]); const [isProcessing, setIsProcessing] = useState(false); const [processError, setProcessError] = useState(null); + /** 2-second cooldown before the Approve button becomes active */ + const [approveReady, setApproveReady] = useState(false); // Load spaces when modal opens useEffect(() => { @@ -132,24 +129,35 @@ export function OAuthConsentModal() { } }, [modalState.type]); + // 2-second cooldown: prevents instant automated approval by requiring the + // consent modal to be visible for at least 2 seconds before Approve is active. + useEffect(() => { + if (modalState.type === 'consent') { + setApproveReady(false); + const timer = setTimeout(() => setApproveReady(true), 2000); + return () => clearTimeout(timer); + } + setApproveReady(false); + }, [modalState.type]); + useEffect(() => { // Listen for OAuth consent requests from the backend (deep link) const unlisten = listen('oauth-consent-request', async (event) => { console.log('[OAuth] Received deep link, validating request:', event.payload.requestId); - + const requestId = event.payload.requestId; setModalState({ type: 'loading', requestId }); setClientAlias(''); setConnectionMode('follow_active'); setLockedSpaceId(null); setProcessError(null); - + try { // Validate and get full details from backend const details = await invoke('get_pending_consent', { requestId, }); - + console.log('[OAuth] Consent validated:', details); setModalState({ type: 'consent', details }); setClientAlias(details.clientName); @@ -162,7 +170,7 @@ export function OAuthConsentModal() { }); return () => { - unlisten.then(fn => fn()); + unlisten.then((fn) => fn()); }; }, []); @@ -178,6 +186,7 @@ export function OAuthConsentModal() { request: { request_id: details.requestId, approved: true, + consent_token: details.consentToken, client_alias: clientAlias || null, connection_mode: connectionMode, locked_space_id: connectionMode === 'locked' ? lockedSpaceId : null, @@ -211,6 +220,7 @@ export function OAuthConsentModal() { request: { request_id: details.requestId, approved: false, + consent_token: details.consentToken, client_alias: null, }, }); @@ -241,10 +251,10 @@ export function OAuthConsentModal() { // Loading state - show spinner if (modalState.type === 'loading') { return ( -
- - - +
+ + +

Validating authorization request...

@@ -255,25 +265,21 @@ export function OAuthConsentModal() { // Error state - show error with dismiss button if (modalState.type === 'error') { return ( -
- +
+
-
+
Authorization Failed - - Could not process the authorization request - + Could not process the authorization request
-

- {getErrorMessage(modalState.error)} -

+

{getErrorMessage(modalState.error)}

@@ -289,36 +295,26 @@ export function OAuthConsentModal() { const logoUrl = getClientLogo(details.clientName); return ( -
- +
+
- McpMux + McpMux
Authorization Request - - {details.clientName} wants to connect - + {details.clientName} wants to connect
{/* Client Info */} -
+
{logoUrl && ( - {details.clientName} + {details.clientName} )}
-
{details.clientName}
-
+
{details.clientName}
+
{details.clientId.length > 50 ? `${details.clientId.substring(0, 50)}...` : details.clientId} @@ -328,12 +324,12 @@ export function OAuthConsentModal() { {/* Scopes */}
-
Requested permissions:
+
Requested permissions:
{scopes.map((scope, i) => ( {scope} @@ -343,30 +339,26 @@ export function OAuthConsentModal() { {/* Alias Input */}
- + setClientAlias(e.target.value)} placeholder="e.g., Work Cursor, Personal Claude" - className="mt-1 w-full px-3 py-2 rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface))] text-[rgb(var(--foreground))] placeholder:text-[rgb(var(--muted))] focus:outline-none focus:ring-2 focus:ring-primary-500/20" + className="focus:ring-primary-500/20 mt-1 w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface))] px-3 py-2 text-[rgb(var(--foreground))] placeholder:text-[rgb(var(--muted))] focus:outline-none focus:ring-2" /> -

+

Give this client a friendly name to identify it later

{/* Space Mode Selection */}
- +
{/* Follow Active Option */}