Skip to content

auth/mcp: ship the OAuth authorization server that createMcpRoute's 401 pointer promises (well-known docs, DCR, authorize, token) #93

Description

@tonite31

Summary

@spfn/mcp protects /mcp with a bearer token and answers 401 with a WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource" pointer, then hands the token to the app's validateToken. Nothing in @spfn/mcp or @spfn/auth serves what that pointer promises, so an app that wants Claude Code or Codex to connect from a user's machine has to build the entire OAuth authorization server itself. That is the same work for every SPFN app that exposes MCP, and the framework should own it.

What an MCP client actually does (observed)

Tested with Claude Code 2.1.x (claude mcp add --transport http) and Codex CLI 0.150 (mcp_servers.<name>.url), and confirmed against three public remote MCP servers (Linear, Notion, GitHub) by running the client side of the flow:

  1. POST /mcp without a token → 401 with resource_metadata.
  2. GET /.well-known/oauth-protected-resource[/<path>]{ resource, authorization_servers: [...] }.
  3. GET <authorization server>/.well-known/oauth-authorization-server[/<issuer path>]authorization_endpoint, token_endpoint, registration_endpoint, code_challenge_methods_supported: ["S256"].
  4. POST <registration_endpoint> (dynamic client registration, RFC 7591) with redirect_uris: ["http://localhost:<random port>/callback"], token_endpoint_auth_method: "none". The CLI has no way to carry a pre-registered client id, so without DCR the client cannot connect at all. Servers that lack DCR (GitHub) require the operator to register an OAuth app by hand, which does not work for end users' local CLIs.
  5. Browser: authorization_endpoint with PKCE S256, state, and the resource parameter (RFC 8707); the user logs in and approves; redirect to the localhost callback.
  6. POST <token_endpoint> with code_verifier; later grant_type=refresh_token. Both public servers rotate the refresh token on refresh.
  7. Neither CLI does device-code login or accepts a manually typed token for a remote HTTP server in this flow, so @spfn/auth's device-code login and ops tokens do not substitute.

What is missing

Piece @spfn/mcp today @spfn/auth today
401 challenge with resource_metadata yes
/.well-known/oauth-protected-resource document no (pointer only) no
/.well-known/oauth-authorization-server document no no
Dynamic client registration endpoint no no
Authorize endpoint + consent screen bound to the app's session no no (OAuth here is the client side: Google, Kakao, …)
Token endpoint (code → tokens, refresh with rotation), PKCE S256, resource audience no no
Token verification the app can hand to validateToken app-provided no

Proposal

  • @spfn/auth: an authorization server module. Routes: the two well-known documents, POST /register (DCR; public clients, none auth method, redirect URIs restricted to http://localhost:* and http://127.0.0.1:* plus an app allow-list), GET /authorize (requires the SPFN session; renders or delegates a consent screen listing the client name, scopes and the resource), POST /token (authorization code with PKCE S256 required, refresh with rotation, audience = resource). Storage: OAuth clients, authorization codes, access/refresh tokens, through the package's own entities like the rest of @spfn/auth. A verifyAccessToken(token, resource) export that returns { clientId, scopes, expiresAt, userId }, i.e. exactly the shape createMcpRoute's validateToken wants.
  • @spfn/mcp: serve the protected-resource document itself from appUrl (resource, authorization_servers, scopes_supported, bearer_methods_supported: ["header"]), and document the two-line wiring: validateToken: verifyAccessToken from auth.
  • Docs: one page "Let a user's Claude Code or Codex connect to your app", the flow above, and the two config snippets that result (claude mcp add --transport http <name> <url>, Codex [mcp_servers.<name>] url = …).

Acceptance

  • A fresh SPFN app with @spfn/auth + @spfn/mcp and no custom OAuth code: claude mcp add --transport http app https://<app>/mcp followed by /mcp in Claude Code completes login in the browser and lists the app's tools; the same with Codex codex mcp add app --url https://<app>/mcp.
  • Refresh works after the access token expires without a new browser round trip; the rotated refresh token is persisted.
  • A token minted for one resource is rejected on another.
  • DCR refuses non-loopback http redirect URIs and anything not on the allow-list.

Notes from the client-side test that shape the design

  • Metadata discovery must handle the path-aware well-known form (/.well-known/oauth-authorization-server/<issuer path>); GitHub only answers there. The server side should publish the root form and, if the issuer has a path, the path form too.
  • Some hosts (Notion behind Cloudflare) 403 a bare Python-urllib User-Agent; not our concern as a server, but worth a line in the docs for apps that also act as clients.
  • Access-token lifetimes in the wild: 8 h (GitHub), 24 h (Linear). Refresh tokens rotate on every refresh at both, and GitHub's refresh token has its own expiry. Defaults in the same range are reasonable.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions