Files

11 KiB
Raw Permalink Blame History

APP-4099: Technical Spec — Fix MCP OAuth redirect_uri

Problem

make_authenticated_client in app/src/ai/mcp/templatable_manager/oauth.rs constructs the OAuth redirect URI as:

{scheme}://mcp/oauth2callback?server_id={uuid}

The ?server_id= query parameter is used by handle_oauth_callback to route incoming callbacks to the correct in-flight OAuth flow. However, including it in the redirect URI breaks RFC 6749 §3.1.2.2 exact-match validation: when an OAuth provider normalizes the URI during Dynamic Client Registration (stripping the query parameter), the subsequent authorization request—which still includes ?server_id=—does not match the registered URI and is rejected.

The fix removes server_id from the redirect URI and instead routes callbacks using the OAuth state parameter, which carries rmcp's CSRF token throughout the authorization flow.

Relevant Code

  • app/src/ai/mcp/templatable_manager/oauth.rs (225449)make_authenticated_client: constructs redirect URI and drives the full OAuth state machine
  • app/src/ai/mcp/templatable_manager/oauth.rs (457504)handle_oauth_callback: routes incoming callback URLs to waiting OAuth flows
  • app/src/ai/mcp/templatable_manager.rs (4186)TemplatableMCPServerManager and SpawnedServerInfo struct definitions
  • app/src/uri/mod.rs:410418 — URL scheme handler: dispatches warp://mcp/* URLs to handle_oauth_callback
  • ~/.cargo/git/checkouts/rmcp-349989f9317a4437/c0f65dc/crates/rmcp/src/transport/auth.rs:490517 — rmcp's get_authorization_url: generates a random CSRF token and embeds it as the state query parameter in the authorization URL

Current State

Redirect URI construction

make_authenticated_client (line 239243):

let server_id = uuid;
let redirect_uri = format!(
    "{}://mcp/oauth2callback?server_id={server_id}",
    ChannelState::url_scheme()
);

This redirect_uri is passed to both start_authorization and register_client, so it becomes part of the DCR request and the authorization URL.

Callback routing

handle_oauth_callback (lines 457504) routes by extracting server_id from the redirect URI's query parameters:

let Some(server_id) = query_params.get("server_id") else {
    bail!("Missing 'server_id' parameter in OAuth callback");
};
// ...
for (uuid, server_info) in &self.spawned_servers {
    if uuid.to_string() != *server_id.to_string() { continue; }
    // send result ...
}

rmcp's state parameter

rmcp's get_authorization_url generates a random CSRF token and stores it internally; it embeds it as state=<csrf_token> in the returned authorization URL string. The same CSRF token must be passed to handle_callback(code, csrf_token) after the user completes authorization, where rmcp validates it against the stored value.

Proposed Changes

1. Use a clean redirect URI

Remove ?server_id= from the redirect URI:

let redirect_uri = format!("{}://mcp/oauth2callback", ChannelState::url_scheme());

This single-line change makes the redirect URI identical across DCR registration and authorization requests.

2. Add pending_oauth_csrf to TemplatableMCPServerManager

Add a new field to map CSRF tokens to server UUIDs for callback routing:

// In TemplatableMCPServerManager (templatable_manager.rs)
#[cfg(not(target_family = "wasm"))]
pending_oauth_csrf: HashMap<String, Uuid>,

This map is populated when an OAuth flow begins and cleaned up when the callback is received (success or error).

3. Extract CSRF token from auth URL and register the mapping

After calling get_authorization_url, parse the state query parameter from the returned URL to obtain the CSRF token, then register the mapping via the spawner before opening the browser:

// After: let auth_url = auth_manager.get_authorization_url(scopes).await?;
let csrf_state = Url::parse(&auth_url)
    .ok()
    .and_then(|u| {
        u.query_pairs()
            .find(|(k, _)| k == "state")
            .map(|(_, v)| v.into_owned())
    })
    .unwrap_or_default();

// Replace the existing spawner.spawn block:
if let Err(e) = spawner
    .spawn(move |manager, ctx| {
        manager.register_oauth_csrf(csrf_state, uuid);
        ctx.open_url(&auth_url);
        manager.change_server_state(uuid, MCPServerState::Authenticating, ctx);
    })
    .await
{ ... }

Add register_oauth_csrf as a method on TemplatableMCPServerManager:

#[cfg(not(target_family = "wasm"))]
fn register_oauth_csrf(&mut self, csrf_state: String, uuid: Uuid) {
    self.pending_oauth_csrf.insert(csrf_state, uuid);
}

4. Update handle_oauth_callback to route by state

Replace the server_id lookup with a state lookup:

pub fn handle_oauth_callback(&self, url: &Url) -> anyhow::Result<()> {
    if url.path() != "/oauth2callback" {
        bail!(...);
    }

    let query_params: HashMap<_, _> = url.query_pairs().collect();

    let Some(state) = query_params.get("state") else {
        bail!("Missing 'state' parameter in OAuth callback");
    };

    let code = query_params.get("code");
    let error = query_params.get("error");

    let result = match code {
        Some(code) => CallbackResult::Success {
            code: code.to_string(),
            csrf_token: state.to_string(),
        },
        None => CallbackResult::Error {
            error: error.map(|e| e.to_string()),
        },
    };

    let Some(&server_uuid) = self.pending_oauth_csrf.get(state.as_ref()) else {
        bail!("No active OAuth flow found for state={state}");
    };

    let Some(server_info) = self.spawned_servers.get(&server_uuid) else {
        bail!("No spawned server found for uuid={server_uuid}");
    };

    block_on(server_info.oauth_result_tx.send(result))
        .map_err(|_| anyhow!("Failed to send OAuth result to server {server_uuid}"))?;

    self.pending_oauth_csrf.remove(state.as_ref());
    Ok(())
}

Note: handle_oauth_callback currently takes &self but needs &mut self to remove from pending_oauth_csrf. The current call site in uri/mod.rs calls it via TemplatableMCPServerManager::as_ref(ctx) — this will need to change to as_mut(ctx) or equivalent mutable access pattern used elsewhere in the manager.

5. Clean up pending_oauth_csrf on flow cancellation/error

When a spawned server task is aborted (e.g. user cancels, server errors out), the CSRF entry should be removed from pending_oauth_csrf. Look for where spawned_servers entries are removed and add a corresponding removal from pending_oauth_csrf there.

Alternatively, since the map is keyed by CSRF token (a random string with low collision probability) and entries are small, stale entries from cancelled flows are harmless until the next restart — but it is cleaner to remove them proactively.

End-to-End Flow

User enables OAuth MCP server
        ↓
make_authenticated_client()
  redirect_uri = "{scheme}://mcp/oauth2callback"   ← no server_id
  OAuthState::start_authorization(redirect_uri)
  auth_manager.register_client(redirect_uri)       ← DCR with clean URI
  auth_url = auth_manager.get_authorization_url()  ← contains ?state=<csrf>
  csrf_state = parse state param from auth_url
  manager.register_oauth_csrf(csrf_state, uuid)    ← csrf→uuid mapping
  open browser → auth_url
        ↓
User approves in browser
        ↓
Browser → warp://mcp/oauth2callback?code=<code>&state=<csrf>
        ↓
uri/mod.rs UriHost::Mcp handler
  handle_oauth_callback(url)
    state = url.query_pairs["state"]              ← CSRF token from callback
    uuid = pending_oauth_csrf[state]             ← route to correct server
    send CallbackResult::Success { code, csrf_token: state } to server
    remove pending_oauth_csrf[state]
        ↓
make_authenticated_client() (resumed)
  oauth_state.handle_callback(code, csrf_token)   ← rmcp validates CSRF
  exchange code for token
  persist credentials
  return AuthClient

Diagrams

sequenceDiagram
    participant W as Warp
    participant AS as OAuth Server
    participant B as Browser
    participant U as URL Handler

    W->>W: redirect_uri = "warp://mcp/oauth2callback"
    W->>AS: POST /register { redirect_uris: ["warp://mcp/oauth2callback"] }
    AS-->>W: { client_id, ... }
    W->>W: auth_url = get_authorization_url() → ?state=<csrf>
    W->>W: register_oauth_csrf(csrf, uuid)
    W->>B: open auth_url
    B->>AS: GET /authorize?...&redirect_uri=warp://mcp/oauth2callback&state=<csrf>
    AS-->>B: redirect to warp://mcp/oauth2callback?code=<code>&state=<csrf>
    B->>U: warp://mcp/oauth2callback?code=<code>&state=<csrf>
    U->>W: handle_oauth_callback(url)
    W->>W: uuid = pending_oauth_csrf[csrf]
    W->>W: send CallbackResult to server channel
    W->>AS: POST /token { code, ... }
    AS-->>W: { access_token, ... }

Risks and Mitigations

Risk: handle_oauth_callback currently takes &self; it needs &mut self to remove from pending_oauth_csrf. Mitigation: Change the call site in uri/mod.rs from as_ref to use mutable access. The handler already writes to the channel via block_on, so the mutability change is consistent with the existing pattern for other manager mutations triggered by URI handling. Check the existing patterns in the codebase for other handle_* methods called from the URI handler.

Risk: OAuth servers that modify or re-encode the state value would break routing. Mitigation: Such servers would also break rmcp's CSRF validation (which is a pre-existing issue unrelated to this fix). We can log a clear error message if the state is unrecognized to aid debugging.

Risk: Stale pending_oauth_csrf entries if OAuth flows are aborted before the callback. Mitigation: Entries are small (string → uuid). Add cleanup in the server-stopping path alongside where spawned_servers entries are removed. As a safety net, the map could be periodically pruned, but this is a low-priority follow-up.

Risk: Regression for existing GitHub/Figma MCP servers that currently work. Mitigation: Servers with valid cached credentials skip the entire OAuth flow and are unaffected. For re-authentication scenarios, the redirect URI change is transparent because both DCR and auth requests now use the same clean URI.

Testing and Validation

  • Unit test (handle_oauth_callback): verify routing by state param, clean removal from pending_oauth_csrf, and error on unknown state
  • Unit test (redirect URI): verify make_authenticated_client constructs a redirect URI with no query parameters
  • Manual test: end-to-end flow against an OAuth provider with strict redirect URI matching (e.g. local Hydra instance)
  • Regression: GitHub MCP and Figma MCP continue to connect with cached credentials

Follow-ups

  • Proactively clean up pending_oauth_csrf when a server's spawn task is aborted (abort_handle is triggered), to avoid indefinite accumulation of stale entries
  • Consider whether handle_oauth_callback should accept error callbacks even when state is absent (some OAuth servers omit state on error responses); this would be a separate improvement