@@ -74,6 +74,10 @@ pub struct ConsentRequestDetails {
7474 pub state : Option < String > ,
7575 /// When this request expires (Unix timestamp)
7676 pub expires_at : i64 ,
77+ /// Cryptographic consent token (shared only via this IPC call, never over HTTP).
78+ /// Must be returned in the approval request to prove the caller is the
79+ /// legitimate desktop app UI—not an external script or bot.
80+ pub consent_token : String ,
7781}
7882
7983/// Handle an incoming deep link URL
@@ -360,6 +364,16 @@ pub async fn get_pending_consent(
360364 return Err ( ConsentError :: expired ( & request_id) ) ;
361365 }
362366
367+ // Extract consent_token (required for security—ensures only the desktop
368+ // app that retrieved this token via IPC can approve the request)
369+ let consent_token = auth. consent_token . clone ( ) . ok_or_else ( || {
370+ error ! ( "[OAuth] Pending authorization missing consent_token" ) ;
371+ ConsentError {
372+ code : "NOT_FOUND" . to_string ( ) ,
373+ message : "Authorization request is missing consent token — it may have been created before this security update. Please retry." . to_string ( ) ,
374+ }
375+ } ) ?;
376+
363377 // Build response with authoritative data from backend
364378 // The client_name here comes from our database lookup in handlers.rs
365379 let details = ConsentRequestDetails {
@@ -373,6 +387,7 @@ pub async fn get_pending_consent(
373387 scope : auth. scope . clone ( ) . unwrap_or_default ( ) ,
374388 state : auth. state . clone ( ) ,
375389 expires_at : auth. expires_at ,
390+ consent_token,
376391 } ;
377392
378393 info ! (
@@ -390,6 +405,9 @@ pub struct ConsentApprovalRequest {
390405 pub request_id : String ,
391406 /// Whether the user approved the request
392407 pub approved : bool ,
408+ /// Cryptographic consent token (must match the one issued via get_pending_consent).
409+ /// This proves the caller obtained the token through Tauri IPC, not HTTP scraping.
410+ pub consent_token : String ,
393411 /// Optional alias name for the client
394412 pub client_alias : Option < String > ,
395413 /// Connection mode: "follow_active", "locked", or "ask_on_change"
@@ -453,6 +471,27 @@ pub async fn approve_oauth_consent(
453471 } ) ;
454472 } ;
455473
474+ // Validate consent_token: proves the caller obtained this token via Tauri
475+ // IPC (get_pending_consent), not by scraping the HTTP authorization page.
476+ match & pending. consent_token {
477+ Some ( expected_token) => {
478+ if request. consent_token != * expected_token {
479+ error ! (
480+ "[OAuth] Consent token mismatch for request_id: {} — possible unauthorized approval attempt" ,
481+ request. request_id
482+ ) ;
483+ return Err ( "Invalid consent token" . to_string ( ) ) ;
484+ }
485+ }
486+ None => {
487+ error ! (
488+ "[OAuth] Pending authorization missing consent_token for request_id: {}" ,
489+ request. request_id
490+ ) ;
491+ return Err ( "Consent token not available" . to_string ( ) ) ;
492+ }
493+ }
494+
456495 // Remove the pending authorization (it's been processed)
457496 {
458497 let mut state = gw_state. write ( ) . await ;
@@ -504,6 +543,7 @@ pub async fn approve_oauth_consent(
504543 code_challenge : pending. code_challenge . clone ( ) ,
505544 code_challenge_method : pending. code_challenge_method . clone ( ) ,
506545 expires_at : code_expires_at,
546+ consent_token : None , // Auth code entries don't need consent tokens
507547 } ;
508548
509549 state. store_pending_authorization ( & code, new_pending) ;
@@ -656,13 +696,19 @@ pub async fn get_oauth_clients(
656696 Ok ( client_infos)
657697}
658698
659- /// Approve a registered OAuth client by ID (for E2E testing).
660- /// In production, clients are approved via the consent flow.
699+ /// Approve a registered OAuth client by ID (for E2E testing only).
700+ ///
701+ /// Guarded by the `MCPMUX_E2E_TEST` environment variable. In production
702+ /// builds this command is a no-op that returns an error.
661703#[ tauri:: command]
662704pub async fn approve_oauth_client (
663705 client_id : String ,
664706 gateway_state : State < ' _ , Arc < RwLock < GatewayAppState > > > ,
665707) -> Result < ( ) , String > {
708+ if std:: env:: var ( "MCPMUX_E2E_TEST" ) . is_err ( ) {
709+ return Err ( "approve_oauth_client is only available in E2E test mode" . to_string ( ) ) ;
710+ }
711+
666712 let app_state = gateway_state. read ( ) . await ;
667713 let Some ( ref gw_state) = app_state. gateway_state else {
668714 return Err ( "Gateway not running" . to_string ( ) ) ;
@@ -674,7 +720,10 @@ pub async fn approve_oauth_client(
674720 repo. approve_client ( & client_id)
675721 . await
676722 . map_err ( |e| format ! ( "Failed to approve client: {}" , e) ) ?;
677- info ! ( "[OAuth] Approved client via test command: {}" , client_id) ;
723+ info ! (
724+ "[OAuth] Approved client via E2E test command: {}" ,
725+ client_id
726+ ) ;
678727 Ok ( ( ) )
679728}
680729
0 commit comments