Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
# APP-4099: Fix MCP OAuth `redirect_uri` RFC 6749 Compliance
|
||||
|
||||
## Summary
|
||||
|
||||
Warp currently appends `?server_id=<uuid>` to the redirect URI used during MCP OAuth flows. Some OAuth servers normalize or strip query parameters from redirect URIs during Dynamic Client Registration (DCR), then reject subsequent authorization requests because the URI in the request no longer exactly matches the registered one. This breaks OAuth authentication for any MCP server whose OAuth provider performs exact redirect URI comparison, which is the RFC-recommended default.
|
||||
|
||||
The fix is to use a clean redirect URI (`warp://mcp/oauth2callback`) throughout the flow and route incoming callbacks using the standard OAuth `state` parameter instead.
|
||||
|
||||
## Problem
|
||||
|
||||
When a user attempts to connect to an OAuth-protected MCP server, Warp:
|
||||
|
||||
1. Registers the client via DCR, sending `redirect_uris: ["warp://mcp/oauth2callback?server_id=<uuid>"]`
|
||||
2. Some OAuth servers normalize this URI during registration (stripping the query parameter), storing only `warp://mcp/oauth2callback`
|
||||
3. Warp then sends the authorization request with `redirect_uri=warp://mcp/oauth2callback?server_id=<uuid>`
|
||||
4. The OAuth server rejects the request with `unknown_redirect_uri` because RFC 6749 §3.1.2.2 requires exact string comparison and the two URIs no longer match
|
||||
|
||||
Additionally, even when DCR succeeds with the full URI, the practice of embedding client-routing data in the `redirect_uri` violates the spirit of RFC 6749, which recommends using the `state` parameter for client-specific context.
|
||||
|
||||
This issue blocks Warp users from connecting to MCP servers backed by strict OAuth providers (e.g. Hydra/ORY, some enterprise identity providers), with no workaround available within Warp.
|
||||
|
||||
## Goals
|
||||
|
||||
- MCP OAuth flows succeed with OAuth servers that perform exact string comparison on redirect URIs
|
||||
- The `redirect_uri` used in DCR and authorization requests is identical and free of Warp-internal routing parameters
|
||||
- Server routing for incoming OAuth callbacks uses the standard `state` parameter
|
||||
- Multiple simultaneous MCP OAuth flows continue to work correctly
|
||||
- Existing MCP servers that already have successful cached credentials are unaffected
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing how Warp handles non-MCP OAuth flows (e.g. GitHub integration auth, Warp Drive auth)
|
||||
- Modifying the `rmcp` crate's CSRF token generation or validation logic
|
||||
- Supporting MCP servers that send back a modified `state` value in the callback (such servers would also break rmcp's existing CSRF validation, which is a separate issue)
|
||||
|
||||
## Figma / Design References
|
||||
|
||||
Figma: none provided (no UI changes in this fix)
|
||||
|
||||
## User Experience
|
||||
|
||||
### Normal OAuth connection flow (after fix)
|
||||
|
||||
1. User adds or enables an OAuth-protected MCP server in Settings → MCP
|
||||
2. Warp opens the server's OAuth authorization URL in the user's browser, as today
|
||||
3. The user completes authorization in the browser
|
||||
4. The browser redirects to `warp://mcp/oauth2callback?code=<code>&state=<csrf_token>` — no `server_id` appended
|
||||
5. Warp handles the callback, exchanges the code for tokens, and connects the server
|
||||
6. The MCP server appears as connected in Settings → MCP, as today
|
||||
|
||||
The user experience is identical to the current flow. The change is entirely internal.
|
||||
|
||||
### Error behavior
|
||||
|
||||
- If the callback `state` parameter is missing or does not match any active OAuth flow, Warp logs an error and the server remains in an unauthenticated state. The user may retry by toggling the server off and on
|
||||
- This error behavior is the same as the current behavior when `server_id` is missing or unrecognized
|
||||
|
||||
### Invariants
|
||||
|
||||
- The `redirect_uri` registered during DCR is always `{scheme}://mcp/oauth2callback` with no query parameters
|
||||
- The `redirect_uri` used in the authorization request is always identical to the one used in DCR
|
||||
- The `state` parameter in the authorization URL is an opaque CSRF token generated by rmcp; Warp does not modify it
|
||||
- The `state` value from the OAuth callback is used both for server routing and for CSRF validation (passed through to rmcp's `handle_callback`)
|
||||
- Each active OAuth flow has a unique `state` (CSRF token), allowing concurrent flows for different servers to be routed independently
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Warp successfully completes an MCP OAuth flow with an OAuth provider that rejects redirect URIs containing query parameters (e.g. Hydra/ORY)
|
||||
- The `redirect_uri` sent in the authorization request exactly matches the URI registered during DCR
|
||||
- Warp correctly routes OAuth callbacks to the right server using the `state` parameter
|
||||
- Concurrent OAuth flows for two different MCP servers both complete successfully
|
||||
- MCP servers with pre-existing cached credentials continue to connect without re-authenticating
|
||||
|
||||
## Validation
|
||||
|
||||
- Manual test: configure an MCP server backed by an OAuth provider with strict redirect URI validation (e.g. a local Hydra instance or the reporter's setup) and verify the full flow succeeds
|
||||
- Unit test: `handle_oauth_callback` correctly routes a callback using the `state` param, and returns an error when the `state` is unrecognized
|
||||
- Unit test: the redirect URI constructed in `make_authenticated_client` contains no query parameters
|
||||
- Regression: existing MCP servers with cached credentials (GitHub MCP, Figma MCP) continue to authenticate without re-auth
|
||||
|
||||
## Open Questions
|
||||
|
||||
None at this time.
|
||||
@@ -0,0 +1,249 @@
|
||||
# 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 (225–449)` — `make_authenticated_client`: constructs redirect URI and drives the full OAuth state machine
|
||||
- `app/src/ai/mcp/templatable_manager/oauth.rs (457–504)` — `handle_oauth_callback`: routes incoming callback URLs to waiting OAuth flows
|
||||
- `app/src/ai/mcp/templatable_manager.rs (41–86)` — `TemplatableMCPServerManager` and `SpawnedServerInfo` struct definitions
|
||||
- `app/src/uri/mod.rs:410–418` — URL scheme handler: dispatches `warp://mcp/*` URLs to `handle_oauth_callback`
|
||||
- `~/.cargo/git/checkouts/rmcp-349989f9317a4437/c0f65dc/crates/rmcp/src/transport/auth.rs:490–517` — 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 239–243):
|
||||
```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 457–504) 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
|
||||
Reference in New Issue
Block a user