Repository navigation
Restore authorization-code flow via II finish_url redirect (so Claude.ai can connect) - #28
Merged
Merged
Conversation
Claude.ai (and other redirect-based MCP clients) use the OAuth 2.1 authorization-code flow and hit /oauth/authorize, which the device-grant-only migration had removed — so connecting failed with 401 invalid_token (an unmatched-route fall-through to the token gate). Restore it using II's finish_url hook (docs/mcp-server-guide.md §2a/§2c, dfinity/internet-identity #4086), keeping the device grant so any client works. - GET /oauth/authorize: validate client + redirect (registered/loopback), require PKCE (S256), record a pending connect, and redirect to II's handshake. - Key-request callback returns finish_url (auth-code flow only); the device flow omits it and the II tab finishes on its own. - GET /oauth/finish: II navigates here after mcp_register. Arrival is not proof of registration — confirm via the completion flag or a signed mcp_get_accounts Ok (bounded retry for propagation), then mint a PKCE-bound code and 302 to the client's redirect_uri?code=...&state=... - POST /oauth/token: support authorization_code (single-use, PKCE-verified) alongside the device_code grant. - connect_callback recognizes state for both flows; metadata advertises authorization_code + device_code; DCR honours requested grant_types. - Gate /mcp with route_layer (not layer) so unmatched paths 404 instead of 401, keeping /oauth/* and /.well-known/* exempt from the bearer middleware. - .gitignore the runtime oauth-clients.json; README updated for the flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
aterga
force-pushed
the
claude/mcp-spec-compliance-9202ka
branch
from
July 3, 2026 19:20
ba31df1 to
b6879f4
Compare
There was a problem hiding this comment.
Pull request overview
Restores OAuth 2.1 authorization-code support (alongside the existing device grant) for Internet Identity MCP connect, using II’s finish_url navigation to complete redirect-based logins (e.g., Claude.ai). Also adjusts router middleware application so unmatched paths correctly return 404 instead of falling through to the bearer-token gate.
Changes:
- Adds
/oauth/authorize+/oauth/finishendpoints and authorization-code issuance/exchange logic (PKCE-bound, single-use). - Updates OAuth discovery + dynamic client registration to advertise/support both
authorization_codeanddevice_codegrants. - Applies the
/mcpbearer-token middleware viaroute_layerto avoid gating unrelated/unmatched routes.
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| src/main.rs | Switches /mcp protection to route_layer and adds the new OAuth routes. |
| src/auth.rs | Implements authorization-code flow via finish_url, redirect validation, and dual-grant /oauth/token support. |
| README.md | Documents the restored auth-code flow and updated OAuth 2.1 behavior/endpoints. |
| .gitignore | Ignores runtime OAuth client registration file (oauth-clients.json). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- authorize: require response_type=code (reject missing → invalid_request, other → unsupported_response_type) instead of ignoring it. - finish: reserve the auth code under the authz lock, then insert into the codes map AFTER releasing it, so we never hold one map's lock while awaiting the other's — consistent ordering with token_authorization_code (removes the latent deadlock hazard the reviewer flagged). - Fix the stale /oauth/authorize doc comment (it described the removed poll-bridge/connect_status; it now redirects to II and returns via finish_url). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
Tighten redirect validation per PR review discussion: an unregistered client_id no longer gets loopback latitude. The client must be registered (open DCR — an audit trail, not vetting), and the requested redirect must match a registered URI exactly, or match a registered LOOPBACK URI on scheme/host/path/query with any port (RFC 8252 §7.3 — native clients bind an ephemeral loopback port each run, so the exact port cannot be pre-registered). A registered hosted URI grants no loopback latitude. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
Comment on lines
807
to
+811
| pub async fn register(State(store): State<AuthStore>, Json(req): Json<RegisterRequest>) -> Response { | ||
| let client_id = format!("client-{}", Uuid::new_v4()); | ||
| let snapshot = { | ||
| let mut clients = store.clients.write().await; | ||
| clients.insert( | ||
| client_id.clone(), | ||
| ClientReg { | ||
| redirect_uris: req.redirect_uris.clone(), | ||
| }, | ||
| ); | ||
| clients.insert(client_id.clone(), ClientReg { redirect_uris: req.redirect_uris.clone() }); |
Comment on lines
+407
to
414
| fn build_redirect(redirect_uri: &str, code: &str, client_state: &str) -> String { | ||
| let sep = if redirect_uri.contains('?') { '&' } else { '?' }; | ||
| let mut r = format!("{redirect_uri}{sep}code={}", urlencoding::encode(code)); | ||
| if !client_state.is_empty() { | ||
| r.push_str(&format!("&state={}", urlencoding::encode(client_state))); | ||
| } | ||
| r | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Follow-up to #27. Testing against Claude.ai surfaced that its MCP connector uses the OAuth 2.1 authorization-code flow (
response_type=code+redirect_uri+ PKCE) and hits/oauth/authorize— which #27's device-grant-only migration had removed, so connecting failed with401 {"error":"invalid_token"}(an unmatched-route fall-through to the token gate).This restores the authorization-code path — following the
finish_urlhook indocs/mcp-server-guide.md§2a/§2c (dfinity/internet-identity#4086) — and keeps the device grant, so any MCP client works. This uses II's own return navigation (same-tab, no popup).The flow (per the guide)
GET /oauth/authorize— requireresponse_type=code, validateclient_id+redirect_uri(the client must be registered; hosted redirects match a registered URI exactly, loopback redirects match a registered loopback URI at any port per RFC 8252 §7.3), require PKCE (S256), record a pending connect keyed bystate(= session id), and redirect the browser to II's/mcphandshake.POST /oauth/connect/callback, noexpiration) returns{"public_key": …, "finish_url": "<origin>/oauth/finish?id=<state>"}. Thefinish_urlis returned only for the auth-code flow; the device flow omits it and the II tab finishes on its own.mcp_register, under the user's own auth) and navigates the browser tofinish_url.GET /oauth/finish— arrival is not proof of registration, so it confirms the grant (the completion-POST flag, or a signedmcp_get_accountsreturningOk, with a bounded retry for propagation), then mints a single-use PKCE-bound code and 302s toredirect_uri?code=…&state=….POST /oauth/token— exchanges theauthorization_code(PKCE-verified) for the bearer token; thedevice_codegrant is still supported too.Other requirements from the guide
authorization_endpoint,response_types_supported: ["code"], and both grants; DCR honours requestedgrant_types; client registrations persist toOAUTH_CLIENTS_FILE./mcpis now gated withroute_layer(notlayer), so unmatched paths return 404 instead of the token gate's 401, and/oauth/*+/.well-known/*stay exempt from the bearer middleware (this was the root of the observedinvalid_token)..gitignores the runtimeoauth-clients.json; README updated.Testing
cargo buildclean (one pre-existing macro warning);cargo test— 33/33.grant_types) →authorize(400 on unregistered client — even loopback — / wrong path / missing PKCE / missing or non-coderesponse_type; a registered loopback redirect is accepted at a different port) → key-request returnspublic_key+finish_url→ completion204→GET /oauth/finish302s toclaude.ai/api/mcp/auth_callback?code=…&state=…→ token exchange issues the bearer token (wrong PKCE verifier rejected; code single-use). Verified unmatched path → 404,/mcp→ 401, device-flow key-request omitsfinish_url.Notes for review
mainafter Migrate II connect to session-key registration + device grant (mcp-server-guide / II #4086) #27's squash-merge; this is a fresh PR (force-pushed to a single clean commit superseding an earlier poll-bridge draft).II_URL(which drives thefinish_urlnavigation).🤖 Generated with Claude Code
https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym