Skip to content

fix(auth): keep OAuth discovery working under a route prefix - #353

Open
mcollina wants to merge 1 commit into
mainfrom
fix/well-known-under-prefix
Open

mcollina wants to merge 1 commit into
mainfrom
fix/well-known-under-prefix

Conversation

@mcollina

Copy link
Copy Markdown
Member

Problem

With the plugin registered under a prefix (e.g. /api) and authorization enabled, OAuth discovery failed:

  • /api/.well-known/oauth-protected-resource and /api/.well-known/oauth-protected-resource/api/mcp answered 401 authorization_required, because the bearer-token check exempted only URLs starting with /.well-known/ or /mcp/.well-known;
  • the resource_metadata URL in WWW-Authenticate (https://host/.well-known/oauth-protected-resource/api/mcp) was 404, because the routes are registered under the prefix.

The same URL-based exemption also made /api/oauth/authorize, /api/oauth/callback and /api/oauth/register require a token.

Fix

  1. Exempt by route identity. The plugin's well-known routes and OAuth flow entry points carry a route config flag (a private Symbol, so it cannot clash with application config), and the preHandler skips routes that have it. They are now public whatever the prefix.
  2. Advertise a URL that resolves. The resource_metadata URL is now {origin of resourceUri}{prefix}/.well-known/oauth-protected-resource{path of resourceUri}, i.e. the URL the plugin actually serves. Without a prefix this is byte-for-byte the RFC 9728 §3.1 URL advertised today.
  3. Opt-in origin-root serving. New authorization.resourceMetadataUrl overrides the advertised URL, and wellKnownRoutes plus getResourceMetadataUrl are exported, so an application can serve the metadata at the RFC 9728 location as well:
const authorization = { ..., resourceUri: 'https://host/api/mcp',
  resourceMetadataUrl: getResourceMetadataUrl('https://host/api/mcp') }
await app.register(async (api) => api.register(mcpPlugin, { authorization }), { prefix: '/api' })
await app.register(wellKnownRoutes, { authConfig: authorization })

Design decisions

  • Why not serve at the origin root automatically? A Fastify plugin registered under a prefix cannot add routes outside it; the prefix is applied to every route in that context. Reaching the root would mean something like hooking onReady on the root instance from inside the plugin, which breaks encapsulation and could collide with the application's own /.well-known/* routes or with a second MCP instance under another prefix. So by default the plugin advertises a URL it really serves, and the application, which owns the root, can mount the standard location explicitly.
  • Is advertising a non-standard path OK? The MCP authorization spec says clients MUST use resource_metadata from the WWW-Authenticate header when present, and RFC 9728 §5.1 lets that parameter be any URL. So a prefixed server is discoverable by any compliant client without extra setup. Clients that probe without a challenge need the root mount above, which is documented in the README ("Under a Route Prefix").
  • Why the doubled /api in the URL (/api/.well-known/oauth-protected-resource/api/mcp)? The plugin builds the same route set as without a prefix (root route, {path of resourceUri} route, legacy /mcp route) and the prefix applies to all of them. Keeping one formula means the advertised URL and the served route cannot drift apart. The shorter /api/.well-known/oauth-protected-resource is also served and returns the same document.
  • URL exemption kept for root /.well-known/. The README promised that every path under /.well-known/ skips the token check, and applications may serve their own documents there (e.g. apple-app-site-association) with protectedRoutes: 'all'. Removing it would be a breaking change in a fix, so it stays; the plugin's own routes no longer depend on it. Dropping it (and the odd /mcp/.well-known exemption) could be done in the next major. The /oauth/* URL exemptions are kept for the same reason.

Interaction with other work

Tests

New test/well-known-prefix.test.ts (failing before the fix, except the no-prefix regression guard and the "other routes still need a token" guard):

  • metadata and health routes under /api return 200 with protectedRoutes: 'all' and 'mcp';
  • the advertised resource_metadata URL resolves to 200 with resource === resourceUri, for /api and a nested /v1/tenant prefix;
  • CORS preflight under the prefix is answered;
  • user routes under the prefix still require a token;
  • /api/oauth/authorize is reachable without a token;
  • resourceMetadataUrl + root-mounted wellKnownRoutes advertises and serves the RFC 9728 origin-root URL;
  • without a prefix the advertised URL is unchanged.

npm run lint, npm run typecheck, and the full npm run test (775 tests) pass.

Fixes #340

🤖 Generated with Claude Code

https://claude.ai/code/session_01LmhsFjk8W96X2PvmShmDm9

When the plugin was registered under a prefix such as /api with
authorization enabled, the protected resource metadata routes answered
401, because the bearer-token check exempted them by URL (/.well-known/
at the origin root only), and the resource_metadata URL advertised in
WWW-Authenticate pointed at the origin root, where nothing is served.

- Exempt the plugin's well-known and OAuth flow routes by route config
  instead of URL, so they are public whatever the prefix. The root
  /.well-known/ URL exemption stays for application routes.
- Advertise the URL the metadata is actually served at, including the
  prefix. Without a prefix it is unchanged.
- Add authorization.resourceMetadataUrl to override the advertised URL,
  and export wellKnownRoutes and getResourceMetadataUrl so applications
  can also serve the metadata at the RFC 9728 origin-root location.

Fixes #340

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LmhsFjk8W96X2PvmShmDm9

This branch has not been deployed

No deployments
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.

Under a route prefix, protected resource metadata requires a token and the advertised URL is 404

1 participant