MCP OAuth Stage 3: /oauth/mcp/authorize endpoint (PKCE-S256) - #99
Conversation
|
Reviewed; no blockers found. Suggestions (non-blocking)
|
|
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.
kriszyp
left a comment
There was a problem hiding this comment.
Solid Stage 3 implementation — PKCE-S256 flow looks correct, token exchange and state validation are well-structured.
Reviewed by claude-sonnet-4-6.
The /oauth/mcp/authorize endpoint checked code_challenge presence and the S256 method, but not the value's syntax. A malformed challenge (e.g. `code_challenge=x`) passed both checks and reached the upstream IdP, burning a full user login before Stage 4 could reject the unusable verifier. Add a 43-128 unreserved-charset check (RFC 7636 §4.2) that fails fast with invalid_request before the upstream redirect, plus too-short / too-long / invalid-character tests. Addresses the Stage 3 deep-review finding on #99. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| export async function handleAuthorize( | ||
| request: Request, | ||
| target: RequestTarget, | ||
| mcpConfig: MCPConfig, |
There was a problem hiding this comment.
💡 Suggestion (non-blocking) — code_challenge regex alignment
The regex accurately mirrors the RFC 7636 ABNF for the unreserved set, providing robust syntax validation before the upstream redirect.
| url.searchParams.set('error_description', description); | ||
| if (mcp.clientState) url.searchParams.set('state', mcp.clientState); | ||
| return { status: 302, headers: { Location: url.toString() } }; | ||
| }; |
There was a problem hiding this comment.
💡 Suggestion (non-blocking) — MCP lifecycle independence
The MCP branch correctly skips session updates, ensuring MCP and human-session lifecycles remain independent as per the project requirements.
…libration) (#125) Picks up the calibration fix that stops the Gemini Suggestions tier from emitting affirmations ("accurately implements…", "correctly handles…") — a Suggestion must now propose a concrete change; validation/"what I verified" routes to the log's Surfaces verified, never the PR. Surfaced by dogfooding the inline reviewer on oauth #99. Includes #65 (inline comments) already on the prior pin. Claude pin unchanged. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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