Public: anyone can read this; the crew can change it.

OAuth for the MCP, step one: discovery documents, dynamic client registration, and a 401 that names them #579

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

The forge now serves both OAuth discovery documents, accepts dynamic client registration, and its `/mcp` 401 names the resource metadata. `test/oauth-discovery.test.ts`, `test/mcp.test.ts` and `test/check-list.test.ts` pass. I could not run `tsc --noEmit`, which `forge.json` runs: there is no `node_modules`, so it fails on the missing `bun` type definitions.

shipped production is serving it deploy cc205a8

opened this

The forge now serves both OAuth discovery documents, accepts dynamic client registration, and its /mcp 401 names the resource metadata. test/oauth-discovery.test.ts, test/mcp.test.ts and test/check-list.test.ts pass. I could not run tsc --noEmit, which forge.json runs: there is no node_modules, so it fails on the missing bun type definitions.

What changed

  • Discovery documents: GET /.well-known/oauth-protected-resource (RFC 9728) and GET /.well-known/oauth-authorization-server (RFC 8414), both built from the request's public origin. They give the issuer, the authorization, token and registration endpoints, S256 only, and scopes ro and rw.
  • 401 header: a 401 from POST /mcp now carries WWW-Authenticate: Bearer realm="forge", resource_metadata="<origin>/.well-known/oauth-protected-resource". Bearer tokens work as before.
  • Registration: POST /oauth/register (RFC 7591) returns 201 with a client_id and stores an oauth_client row.

- It refuses redirect_uris that are missing, empty or over 10, over 2000 characters, or not https (http only for localhost, 127.0.0.1 or [::1]). It also refuses a URI carrying credentials or a fragment. - It refuses a client_name over 100 characters or containing control characters, any token_endpoint_auth_method other than none, and bodies over 16 KB. - Refusals are 400s with invalid_redirect_uri or invalid_client_metadata.

  • Rate limit: 10 registrations per minute per address, using RateLimiter from src/core/ratelimit.ts and answering 429 with Retry-After. The address is the socket peer. Behind a proxy on loopback it is the first X-Forwarded-For hop, so the header cannot be spoofed by an outside caller.
  • Migration: migration 45 creates oauth_client with CREATE TABLE IF NOT EXISTS. The test runs it twice.
  • Gate: test/oauth-discovery.test.ts is added to scripts/gate.ts, which test/check-list.test.ts requires.
  • Code: the logic is in src/core/oauth.ts and src/http/oauth.ts, wired through src/core/schema.ts, src/http/mcp.ts and src/http/server.ts.

What I left out

  • /oauth/authorize and /oauth/token are named in the documents but not served yet; that is the next step.
  • There is no same-origin check on registration: it takes no cookie and grants nothing.
  • There is no CORS preflight handler. The GET documents and the registration answer send access-control-allow-origin: *, but OPTIONS is not routed.
  • Registered clients are never expired or listed.

How to try it

curl $FORGE_URL/.well-known/oauth-authorization-server
curl -si -X POST $FORGE_URL/mcp -d '{}'
curl -X POST $FORGE_URL/oauth/register -H 'content-type: application/json' \
  -d '{"client_name":"x","redirect_uris":["http://localhost:6274/cb"]}'
bun test test/oauth-discovery.test.ts

Tests that start the server need to run outside this agent sandbox. It blocks the unix socket the server listens on.

🤖 Generated with Claude Code

Closes #573

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs