Skip to content

Add MCP OAuth 2.1 surface: discovery, DCR, authorize/consent, token#29

Open
koomen wants to merge 1 commit into
mainfrom
mcp-oauth
Open

Add MCP OAuth 2.1 surface: discovery, DCR, authorize/consent, token#29
koomen wants to merge 1 commit into
mainfrom
mcp-oauth

Conversation

@koomen

@koomen koomen commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

First of two PRs adding a remote MCP server so agents (Claude Code) can publish Scratchwork projects with no CLI installed. This PR is the auth surface; the follow-up (#next) adds the /mcp endpoint and tools.

What this adds

MCP-spec OAuth 2.1 on the app origin, built on the existing chokepoints:

  • Discovery: RFC 9728 protected-resource metadata + RFC 8414 authorization-server metadata (root and /mcp path-inserted forms).
  • Dynamic client registration (RFC 7591): public clients only (token_endpoint_auth_method: none), stored in PrimitiveDb with a 90-day TTL and a 1000-live-client cap; redirect URIs are https-or-loopback with RFC 8252 §7.3 loopback port variance.
  • Authorize + consent: client/redirect validated before anything ever redirects; PKCE S256 mandatory; consent page (session cookie) with a signed, user-bound transaction token, same-origin enforcement, and frame-ancestors 'none'.
  • Token endpoint: authorization_code (burn-before-verify one-time codes) and refresh_token grants with RFC 6749 error bodies.
  • Four new token kinds in auth.tsmcp-consent, mcp-code, mcp-access (1h, audience-bound to <appBase>/mcp), mcp-refresh (session TTL) — under an independent MCP_TOKEN_VERSION revocation lever, all minted/verified through the private signValue/verifySignedValue chokepoints.
  • requireMcpUser resolves the future /mcp bearer principal: an mcp-access token, or an existing session bearer as a capability-neutral fallback (sessions already authorize the full /api surface); cookies are never read on that path.

Threat review (invariants 3/5)

  • Code replay: mcp-code.id burns via conditional create before the possession check (shared burnOneTimeCode, also adopted by the CLI exchange) — replays and wrong-verifier races fail closed.
  • PKCE downgrade: code_challenge mandatory at authorize, method literal S256, no plain path exists; verifier digests compare via timingSafeEqual.
  • Open redirect: an unvalidated client_id/redirect_uri gets a 400 page, never a redirect; loopback matching varies only the port; the code pins the redirect and the token endpoint re-requires byte equality; oversized state is never echoed.
  • Audience confusion: closed both directions by kind discrimination and the aud claim — mechanized in the policy matrix (an mcp-access bearer is denied on every /api route) and the token corpus (9-kind cross-pairing).
  • DCR spam: 16 KB body cap, ≤10 URIs, 90-day expiry, 1000-live cap.
  • Consent CSRF/clickjacking: same-origin check + user-bound signed txn + POST-only approval + framing refused.
  • Origin isolation: /oauth/* and /.well-known/* canonicalize to the app origin before any cookie/issuer derivation; mcp and oauth are now reserved slugs; Access-Control-Allow-Origin: * appears only on the four cookie-free endpoints.

Accepted trade-offs

  • Stateless mcp-access/mcp-refresh tokens are replayable within TTL, like sessions; levers are the allow-list (re-checked at every mint and verify) and MCP_TOKEN_VERSION. The refresh grant does not rotate refresh tokens — stateless rotation cannot revoke the predecessor, so rotation would be theater.
  • DB-backed (not stateless) client registrations, so expiry/GC is enforceable and the consent page can re-read client_name.

Tests

Token corpus rows for all four kinds (bit-flip, cross-kind, boundary expiry, audience/scope confusion, future issuance, lifecycle); full-loop mcp-oauth.test.ts (discover → register → authorize → consent → token → principal) plus the negatives above; policy-matrix extensions (deny-all mcp-access row, MCP OAuth route completeness, CORS posture); redirect-URI rule units. bun run ci green.

🤖 Generated with Claude Code

Groundwork for a remote MCP endpoint at /mcp: MCP-spec OAuth so clients
like Claude Code connect with the standard one-click browser flow, no
CLI install required.

- auth.ts: four new HMAC token kinds behind the signValue chokepoints —
  mcp-consent (user-bound consent transaction), mcp-code (60s one-time,
  PKCE-bound), and audience-bound mcp-access (1h) / mcp-refresh
  (session TTL, non-rotating) — under an independent MCP_TOKEN_VERSION
  revocation lever; requireMcpUser resolves the /mcp bearer principal
  (mcp-access, or an existing session bearer as a capability-neutral
  fallback; never cookies).
- mcp-clients.ts: RFC 7591 dynamic client registration in PrimitiveDb
  (90-day TTL, 1000-live cap, redirect-URI policy with RFC 8252
  loopback port variance).
- mcp-oauth-routes.ts: RFC 9728/8414 discovery metadata, /oauth/register,
  /oauth/authorize + consent page, /oauth/token (code + refresh grants,
  RFC 6749 error bodies), enumerated in MCP_OAUTH_ROUTES for the policy
  matrix; ACAO * only on the cookie-free endpoints.
- app.ts: dispatch + canonical app-origin redirect for /oauth/* and
  /.well-known/*; access.ts reserves the mcp and oauth slugs.
- api-routes.ts: one-time code burn extracted as burnOneTimeCode,
  shared by the CLI and MCP exchanges.
- Tests: token corpus rows for all four kinds (cross-kind, bit-flip,
  audience/scope/lifecycle), full-loop + adversarial mcp-oauth.test.ts,
  policy-matrix rows (mcp-access denied API-wide; MCP OAuth route
  completeness and CORS posture).

Threat review (invariants 3/5): codes burn before possession checks so
replays and wrong-verifier races fail closed; PKCE is mandatory S256
with no plain path; unvalidated client_id/redirect_uri 400s and never
redirects, loopback matching varies only the port; audience confusion
is closed both directions and mechanized in the matrix; DCR spam is
bounded by body/URI/registration caps; consent is same-origin,
user-bound, and unframeable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant