MCP OAuth Stage 3: /oauth/mcp/authorize endpoint (PKCE-S256)#99
Conversation
|
Reviewed; no blockers found. Prior finding 1 resolved by address review commit eb707ba; prior finding 2 resolved (file no longer in diff). The PR correctly implements the MCP |
|
Reviewed; no blockers found. |
heskew
left a comment
There was a problem hiding this comment.
No blockers.
🤖 Posted by Antigravity on Nathan's behalf
Closes #93. Stage 3 of MCP OAuth support (parent #86). Bridges incoming MCP authorization requests into the existing upstream-IdP flow, mints a single-use authorization code on the upstream callback, and redirects the user-agent back to the MCP client's redirect_uri. - New `mcp_auth_codes` Harper table (`@table(expiration: 300)`) — codes auto-expire after 5 min via native TTL; Stage 4's /token will read-then-delete for one-time use - New MCPAuthCodeStore with the explicit-field-access encode/decode pattern (CLAUDE.md tracked-object spread gotcha) - New handleAuthorize: two-phase validation per OAuth 2.1 §3.1.2.5 — client_id + redirect_uri exact-match before any redirect, everything else returns errors to the verified redirect_uri - RFC 8252 §7.3 loopback port flexibility — native MCP clients (Claude Desktop, mcp-remote) bind a dynamic port at runtime; we accept any port on registered loopback URIs (127.0.0.1 / [::1] / localhost) - PKCE S256 only (OAuth 2.1 §7.5.2 forbids plain); reject `plain` with invalid_request to the client URI - RFC 8707 `resource` parameter required, validated against canonical resource URI from Stage 2 - v1 single-provider constraint: mcp.providers (or the full registry) must resolve to exactly one upstream provider; 0 or >1 returns server_error. Multi-provider chooser is v1.1 - Bridge via CSRF state: the existing CSRFTokenData carries an `mcp` payload that handlers.ts handleCallback detects and detours into handleMCPCallback (callback.ts) — mints auth code, redirects to MCP client. Upstream IdP token never reaches the MCP client (spec MUST NOT, MCP authorization spec 2025-06-18) - handleCallback restructured: verify CSRF state FIRST so MCP-initiated upstream errors route back to the MCP client redirect_uri instead of the Harper app's default path. Legacy human flow behavior preserved for the no-state edge case - onLogin-mapped username (hookData.user) flows through to the issued auth code — Stage 4's JWT inherits the mapped identity, not the raw OAuth username - No Harper session is created on the MCP branch (independent lifecycle per #86 resolved decision) Gemini cross-review caught 4 of the above before push (loopback ports, mapped username, CSRF-before-IdP-error ordering, MCP-aware error redirects); all addressed in this PR before opening. 603 unit tests pass locally (+51 from this PR), including new MCP-branch integration tests in handlers.test.js. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
20e4123 to
06527e1
Compare
|
Pushed a follow-up commit (
No blockers. Full write-up in the description under Rebase + hardening. 608 unit tests pass. 🤖 Posted by Claude on Nathan's behalf |
609b130 to
e8e7bbd
Compare
Cross-model review (Codex + Harper-domain) of the Stage 3 authorize endpoint surfaced two hardening gaps: - MCP issuer/audience could fall back to the client-controlled Host header when unpinned. Fail fast at startup: require `mcp.issuer` when `mcp.enabled` (a pinned issuer anchors both `iss` and the default `<issuer>/mcp` resource; a pinned `resource` alone leaves `iss` Host-derived). Throws on initial load before provider state is mutated; caught + logged on a later options change. - A provider keyed `mcp` is shadowed by the `/oauth/mcp/*` dispatcher and would silently 404 at request time. Reject the collision loudly in initializeProviders. Docs + tests updated. 608 unit tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
e8e7bbd to
eb707ba
Compare
heskew
left a comment
There was a problem hiding this comment.
Codex deep-review pass: I found one small Stage 3 contract gap around PKCE validation. Everything else I traced in the authorize/callback bridge looked consistent with the PR scope.
| return redirect('unsupported_response_type', 'response_type must be "code"'); | ||
| } | ||
|
|
||
| if (!query.code_challenge) { |
There was a problem hiding this comment.
Codex deep-review finding: could we validate the PKCE challenge syntax here, not just presence? Issue #93 calls for a well-formed code_challenge, and RFC 7636 defines it as 43-128 unreserved characters. I confirmed code_challenge=x currently reaches the upstream IdP, which can burn a full login before Stage 4 rejects/miscompares the unusable code. A small regex/length check here plus too-short/too-long/invalid-character tests should close it cleanly.
Closes #93. Sub-issue of #86.
Summary
Stage 3 of MCP OAuth. Adds the user-facing authorize endpoint plus the surrounding plumbing — auth-code table with native TTL, bridge through the existing upstream IdP flow, callback branch that mints the code and redirects to the MCP client. No JWTs yet (that's Stage 4 / #94).
Where to look
src/lib/mcp/authorize.ts—handleAuthorizewith two-phase validation per OAuth 2.1 §3.1.2.5:client_id+redirect_urimust validate before any redirect; everything else routes errors to the verified redirect_uri. The interesting helper isredirectUriMatches— implements RFC 8252 §7.3 loopback port flexibility for127.0.0.1/[::1]/localhost. Native MCP clients bind dynamic ports at runtime and can't pre-register them.src/lib/handlers.ts—handleCallbackrestructured. CSRF state is now verified BEFORE the upstream error param is processed, so MCP-initiated errors (user denied at GitHub, etc.) route back to the MCP client's redirect_uri with OAuth-spec error codes instead of bouncing to the Harper app's default path. The legacy "no state, has error" UX is preserved for human-OAuth flows. On the success path, theonLogin-mapped username (hookData?.user ?? user.username) flows into the auth-code record, so Stage 4's JWT inherits the mapped identity.src/lib/mcp/callback.ts— the MCP branch fromhandleCallback. Mints the code withcrypto.randomBytes(32).toString('base64url'), persists the binding fields (client_id, user, resource, PKCE challenge, redirect_uri, scope), redirects to the client URI. No Harper session is created on this branch (independent MCP/human lifecycle per Add MCP OAuth flow support #86 resolved decision). Asserts no upstream IdP token leaks into the redirect URL.src/lib/mcp/authCodeStore.ts— CRUD against the newmcp_auth_codestable. Explicit field access in encode/decode (CLAUDE.md tracked-object spread gotcha).schema/oauth.graphql— newmcp_auth_codestable with@table(database: "oauth", expiration: 300)for native TTL.src/types.ts—MCPAuthorizeState(carried through CSRF metadata) andMCPAuthCodeRecord. Newmcp.providersconfig field.Design decisions worth flagging
mcp.providers(or, when unset, the full registry) must resolve to exactly one effective provider; 0 or >1 returnsserver_error. Multi-provider chooser UI is v1.1. Documented in theselectMCPProviderhelper.resourceparam exact match. Validated againstresolveResource(request, mcpConfig)from Stage 2. The plugin now requiresmcp.issuerat startup whenmcp.enabled(see Rebase + hardening below), so the issuer and audience can't be derived from the client-controlled Host header.expiration: 300— 5-minute TTL is a safety net; Stage 4's /token will read-then-delete on exchange for one-time use.What was reviewed
Gemini CLI 0.42.0 cross-reviewed the pre-push diff. Four real findings, all addressed before push:
redirect_uri, breaking native clients. Now uses port-flexible matching on loopback hosts. Tests added covering all loopback variants.onLoginusername mapping lost in MCP branch — auth code was binding the raw OAuth username, ignoring the mapped identity from the hook. Now passeshookData?.user ?? user.usernamethrough.handleCallbackerror ordering — upstream IdP error was handled before CSRF verification, so MCP errors bounced to the Harper default path instead of the MCP client. CSRF verify is now first; legacy no-state UX preserved.catchblocks ignoredtokenData.mcpand usedoriginalUrl || postLoginRedirect(undefined for MCP flows). Both branches now route to the MCP client redirect_uri whenmcpStateis present.One Gemini point (resource URI stability via Host header) was informational — already covered by Stage 2's documentation.
Rebase + hardening (2026-06-09)
Rebased onto current
main(was ~10 commits behind — the alpha.2 release + dynamic-provider-cache work); clean, zero conflicts. Then ran a full cross-model review (Codex + Harper-domain pass; the Gemini leg was unavailable this run). No blockers. It surfaced one significant concern and one suggestion, both now fixed in this PR (commit609b130):mcp.issuerwhenmcp.enabled(fail fast at startup). With neithermcp.issuernormcp.resourcepinned, both were derived from the client-controlled Host header — letting a client influence the advertisedissand theaudbound into the auth code (audience confusion once Stage 4 signs tokens). A pinned issuer anchors both (resourcedefaults to<issuer>/mcp); a pinnedresourcealone is not sufficient — it leavesissHost-derived. Codex caught that subtlety on the first cut.mcp. The/oauth/mcp/*dispatcher reserves that path segment, so a provider namedmcpwould silently 404 at request time. Now rejected loudly at config load.Deferred to Stage 4: client-supplied
scopemust be intersected against permitted scopes before it lands in a JWT (it's only stored, not consumed, in this PR).Test plan
npm test)npm run lintcleannpm run format:checkcleanhandleCallbacktests cover the legacy behavior including the "no state, has error" preservationmcp-remotedriving the full discovery → DCR → authorize → token → bearer call is the conformance proofOut of scope
/oauth/mcp/tokenexchange (Stage 4, MCP OAuth Stage 4: /oauth/mcp/token endpoint + JWT issuer #94) — codes from this PR are unused until that landswithMCPAuthwrapper that consumes Stage 4 tokens (Stage 5, MCP OAuth Stage 5: withMCPAuth wrapper #95)🤖 Generated with Claude Code