Skip to content

Commit acfcec2

Browse files
its-mashclaude
andauthored
Add consent token security & rate limiting for OAuth flow (#102)
Signed-off-by: Claude <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9c63e49 commit acfcec2

8 files changed

Lines changed: 395 additions & 132 deletions

File tree

Cargo.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/src/commands/oauth.rs

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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]
662704
pub 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

Comments
 (0)