Files

250 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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):
```rust
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:
```rust
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:
```rust
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:
```rust
// 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:
```rust
// 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`:
```rust
#[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:
```rust
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
```mermaid
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