Skip to content

Restore authorization-code flow via II finish_url redirect (so Claude.ai can connect) - #28

Merged
aterga merged 3 commits into
mainfrom
claude/mcp-spec-compliance-9202ka
Jul 4, 2026
Merged

aterga merged 3 commits into
mainfrom
claude/mcp-spec-compliance-9202ka

Conversation

@aterga

@aterga aterga commented Jul 3, 2026 •

Copy link
Copy Markdown
Collaborator

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 with 401 {"error":"invalid_token"} (an unmatched-route fall-through to the token gate).

This restores the authorization-code path — following the finish_url hook in docs/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)

  1. GET /oauth/authorize — require response_type=code, validate client_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 by state (= session id), and redirect the browser to II's /mcp handshake.
  2. Key-request callback (POST /oauth/connect/callback, no expiration) returns {"public_key": …, "finish_url": "<origin>/oauth/finish?id=<state>"}. The finish_url is returned only for the auth-code flow; the device flow omits it and the II tab finishes on its own.
  3. II registers the session key (mcp_register, under the user's own auth) and navigates the browser to finish_url.
  4. GET /oauth/finish — arrival is not proof of registration, so it confirms the grant (the completion-POST flag, or a signed mcp_get_accounts returning Ok, with a bounded retry for propagation), then mints a single-use PKCE-bound code and 302s to redirect_uri?code=…&state=….
  5. POST /oauth/token — exchanges the authorization_code (PKCE-verified) for the bearer token; the device_code grant is still supported too.

Other requirements from the guide

  • Metadata advertises authorization_endpoint, response_types_supported: ["code"], and both grants; DCR honours requested grant_types; client registrations persist to OAUTH_CLIENTS_FILE.
  • /mcp is now gated with route_layer (not layer), 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 observed invalid_token).
  • Redirect validation requires a registered client for all redirects (tightened per review discussion): hosted URIs match exactly; loopback URIs match port-agnostically (RFC 8252 §7.3, for native clients that bind an ephemeral port each run); a registered hosted URI grants no loopback latitude.
  • .gitignores the runtime oauth-clients.json; README updated.

Testing

  • cargo build clean (one pre-existing macro warning); cargo test — 33/33.
  • Drove the full auth-code flow locally against a running server (no live II needed, using the completion POST to mark the grant live): register (echoes requested grant_types) → authorize (400 on unregistered client — even loopback — / wrong path / missing PKCE / missing or non-code response_type; a registered loopback redirect is accepted at a different port) → key-request returns public_key + finish_url → completion 204 → GET /oauth/finish 302s to claude.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 omits finish_url.

Notes for review

  • Branch restarted from main after 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).
  • Live end-to-end needs the #4086 II build at II_URL (which drives the finish_url navigation).
  • Refresh tokens aren't issued (the II grant caps the session at ~1h, after which the client re-authorizes); can add if a client needs them.

🤖 Generated with Claude Code

https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym

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
aterga force-pushed the claude/mcp-spec-compliance-9202ka branch from ba31df1 to b6879f4 Compare July 3, 2026 19:20
@aterga aterga changed the title Restore authorization-code flow (poll bridge) so redirect-based clients (Claude.ai) can connect Restore authorization-code flow via II finish_url redirect (so Claude.ai can connect) Jul 3, 2026
@aterga
aterga requested a review from Copilot July 3, 2026 21:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/finish endpoints and authorization-code issuance/exchange logic (PKCE-bound, single-use).
  • Updates OAuth discovery + dynamic client registration to advertise/support both authorization_code and device_code grants.
  • Applies the /mcp bearer-token middleware via route_layer to 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.

Comment thread src/auth.rs Resolved
Comment thread src/auth.rs Resolved
Comment thread src/auth.rs Resolved
Comment thread src/auth.rs Resolved
claude added 2 commits July 3, 2026 21:14
- 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
@aterga
aterga marked this pull request as ready for review July 4, 2026 16:33
Copilot AI review requested due to automatic review settings July 4, 2026 16:33
@aterga
aterga merged commit 647a158 into main Jul 4, 2026
1 check passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 4 changed files in this pull request and generated 2 comments.

Comment thread src/auth.rs
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 thread src/auth.rs
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
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants