diff --git a/README.md b/README.md index bfd15b3..e7e7fcb 100644 --- a/README.md +++ b/README.md @@ -1837,9 +1837,11 @@ When `oauth2Client` is configured, the plugin registers these legacy server-side ### Well-Known Endpoints These routes are registered only when `authorization.enabled` is true. They are -all unauthenticated: the bearer-token check skips every path under -`/.well-known/`. They allow cross-origin `GET` requests (CORS) so browser-based -clients can discover them. +all unauthenticated: the bearer-token check skips them by route, so they stay +public under a route prefix too (it also still skips every other path under +`/.well-known/` at the origin root). They allow cross-origin `GET` requests +(CORS) so browser-based clients can discover them. The paths below are relative +to the prefix the plugin is registered under, if any. - `GET /.well-known/oauth-protected-resource` - OAuth 2.0 Protected Resource Metadata (RFC 9728). Its `resource` field is `resourceUri`. - `GET /.well-known/oauth-protected-resource{path}` - Same metadata, served when `resourceUri` has a path (e.g. `resourceUri: 'https://mcp.example.com/mcp'` is served at `/.well-known/oauth-protected-resource/mcp`). This is the URL advertised in the `resource_metadata` parameter of `WWW-Authenticate` challenges, as required by RFC 9728 §3.1, and its `resource` field equals `resourceUri` exactly. @@ -1851,6 +1853,58 @@ Authorization server metadata is not served by this plugin. Clients discover it These endpoints send CORS headers for any origin. This applies only to `/.well-known/*`; see [CORS](#cors) for `/mcp`. +#### Under a Route Prefix + +A plugin registered under a prefix can only add routes below that prefix, so +with `prefix: '/api'` the metadata is served at +`/api/.well-known/oauth-protected-resource{path}`. The `resource_metadata` +parameter of `WWW-Authenticate` challenges points at that URL, so MCP clients, +which follow `resource_metadata` when it is present, find the metadata: + +```typescript +await app.register(async (api) => { + await api.register(mcpPlugin, { + authorization: { + enabled: true, + authorizationServers: ['https://auth.example.com'], + resourceUri: 'https://mcp.example.com/api/mcp', + tokenValidation: { jwksUri: 'https://auth.example.com/.well-known/jwks.json' } + } + }) +}, { prefix: '/api' }) + +// 401 challenges advertise +// resource_metadata="https://mcp.example.com/api/.well-known/oauth-protected-resource/api/mcp" +``` + +RFC 9728 §3.1 places the metadata at the origin root +(`/.well-known/oauth-protected-resource/api/mcp` here), which is where clients +look when a challenge carries no `resource_metadata`. To serve it there too, +register the exported `wellKnownRoutes` plugin on the root instance and point +`resourceMetadataUrl` at it: + +```typescript +import mcpPlugin, { wellKnownRoutes, getResourceMetadataUrl } from '@platformatic/mcp' +import type { AuthorizationConfig } from '@platformatic/mcp' + +const authorization: AuthorizationConfig = { + enabled: true, + authorizationServers: ['https://auth.example.com'], + resourceUri: 'https://mcp.example.com/api/mcp', + // https://mcp.example.com/.well-known/oauth-protected-resource/api/mcp + resourceMetadataUrl: getResourceMetadataUrl('https://mcp.example.com/api/mcp'), + tokenValidation: { jwksUri: 'https://auth.example.com/.well-known/jwks.json' } +} + +await app.register(async (api) => { + await api.register(mcpPlugin, { authorization }) +}, { prefix: '/api' }) + +// Serves /.well-known/oauth-protected-resource and +// /.well-known/oauth-protected-resource/api/mcp at the origin root +await app.register(wellKnownRoutes, { authConfig: authorization }) +``` + ### Security Considerations #### Token Security @@ -2059,6 +2113,7 @@ await app.register(import('@fastify/bearer-auth'), { - `enabled`: Enable OAuth 2.1 authorization (default: false) - `authorizationServers`: Authorization server URIs. JWTs must carry an `iss` matching one of these (a trailing slash difference is tolerated), plus `sub` and `exp`; introspection responses must carry `sub` and, if they report `iss`, a matching one - `resourceUri`: Resource URI. May include a path; the protected resource metadata is then served at `/.well-known/oauth-protected-resource{path}` (RFC 9728 §3.1) + - `resourceMetadataUrl`: Absolute URL advertised as `resource_metadata` in `WWW-Authenticate` challenges (optional). Defaults to the URL the plugin serves the metadata at, including the route prefix if any; see [Under a Route Prefix](#under-a-route-prefix) - `requiredScopes`: Scopes a token must carry (optional). Tokens missing any of them get a 403 `insufficient_scope` challenge. When set, the scopes are also advertised as `scopes_supported` in the protected resource metadata (every `/.well-known/oauth-protected-resource` route) and as `scope="..."` in the `WWW-Authenticate` header of every 401 challenge - `scopeImplies`: `(granted, required) => boolean` deciding whether a granted scope satisfies a required one (default: exact match; see `colonScopeHierarchy`) - `tokenValidation`: JWT token validation configuration diff --git a/src/auth/prehandler.ts b/src/auth/prehandler.ts index 2c8e60c..82559c6 100644 --- a/src/auth/prehandler.ts +++ b/src/auth/prehandler.ts @@ -5,6 +5,13 @@ import { getResourceMetadataUrl } from './resource-metadata.ts' export const BEARER_SCHEME = /^bearer +(.*)$/i +/** + * Route config key marking the plugin's own public routes (protected resource + * metadata, OAuth flow entry points). Matching the route rather than the URL + * keeps them public whatever prefix the plugin is registered under. + */ +export const kPublicRoute = Symbol('mcp.publicRoute') + export function createAuthPreHandler ( config: AuthorizationConfig, tokenValidator: TokenValidator @@ -15,7 +22,13 @@ export function createAuthPreHandler ( return } - // Skip authorization for well-known endpoints + // Skip authorization for the plugin's discovery and OAuth flow routes + if (request.routeOptions.config?.[kPublicRoute] === true) { + return + } + + // Skip authorization for well-known endpoints at the origin root. Kept so + // application routes under /.well-known/ stay public as before. if (request.url.startsWith('/.well-known/') || request.url.startsWith('/mcp/.well-known')) { return } @@ -163,7 +176,7 @@ export function generateWWWAuthenticateHeader (config: AuthorizationConfig, chal if (!config.enabled) { throw new Error('Authorization is disabled') } - const resourceMetadataUrl = getResourceMetadataUrl(config.resourceUri) + const resourceMetadataUrl = config.resourceMetadataUrl ?? getResourceMetadataUrl(config.resourceUri) const params = ['realm="MCP Server"', `resource_metadata="${resourceMetadataUrl}"`] if (challenge.error) { @@ -184,6 +197,9 @@ export function generateWWWAuthenticateHeader (config: AuthorizationConfig, chal // Type augmentation for FastifyRequest to include tokenPayload declare module 'fastify' { + interface FastifyContextConfig { + [kPublicRoute]?: boolean + } interface FastifyRequest { tokenPayload?: any /** Canonical issuer identity of the validated token, see TokenValidationResult.issuer */ diff --git a/src/auth/resource-metadata.ts b/src/auth/resource-metadata.ts index 494e0c2..6558a63 100644 --- a/src/auth/resource-metadata.ts +++ b/src/auth/resource-metadata.ts @@ -17,3 +17,14 @@ export function getResourceMetadataPath (resourceUri: string): string { export function getResourceMetadataUrl (resourceUri: string): string { return `${new URL(resourceUri).origin}${getResourceMetadataPath(resourceUri)}` } + +/** + * Absolute URL at which the plugin, registered under `prefix`, serves the + * metadata for `resourceUri`. Without a prefix this is the RFC 9728 §3.1 URL. + * Under a prefix the routes can only live below it, so this is the URL that + * actually resolves; advertising the origin-root one would be a 404. + */ +export function resolveResourceMetadataUrl (resourceUri: string, prefix: string = ''): string { + const normalizedPrefix = prefix.replace(/\/+$/, '') + return `${new URL(resourceUri).origin}${normalizedPrefix}${getResourceMetadataPath(resourceUri)}` +} diff --git a/src/index.ts b/src/index.ts index a57f9b3..f5c84b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import routes from './routes/mcp.ts' import wellKnownRoutes from './routes/well-known.ts' import { TokenValidator } from './auth/token-validator.ts' import { createAuthPreHandler } from './auth/prehandler.ts' +import { resolveResourceMetadataUrl } from './auth/resource-metadata.ts' import oauthClientPlugin from './auth/oauth-client.ts' import authRoutesPlugin from './routes/auth-routes.ts' import { quitWithTimeout } from './redis-quit-with-timeout.ts' @@ -216,7 +217,11 @@ const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOption if (opts.authorization?.enabled) { tokenValidator = new TokenValidator(opts.authorization, app) - const authPreHandler = createAuthPreHandler(opts.authorization, tokenValidator) + const authPreHandler = createAuthPreHandler({ + ...opts.authorization, + resourceMetadataUrl: opts.authorization.resourceMetadataUrl ?? + resolveResourceMetadataUrl(opts.authorization.resourceUri, app.prefix) + }, tokenValidator) if (opts.authorization.protectedRoutes === 'mcp') { mcpAuthPreHandler = authPreHandler } else { @@ -417,6 +422,11 @@ export type { export { colonScopeHierarchy } from './auth/prehandler.ts' +// Well-known routes, for mounting the RFC 9728 metadata at the origin root when +// the plugin itself is registered under a prefix +export { wellKnownRoutes } +export { getResourceMetadataUrl, getResourceMetadataPath } from './auth/resource-metadata.ts' + // Rate limiting helper export { RateLimiter } from './security.ts' diff --git a/src/routes/auth-routes.ts b/src/routes/auth-routes.ts index 26c7a47..400066d 100644 --- a/src/routes/auth-routes.ts +++ b/src/routes/auth-routes.ts @@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyPluginAsync } from 'fastify' import fp from 'fastify-plugin' import { Type } from '@sinclair/typebox' import type { PKCEChallenge } from '../auth/oauth-client.ts' -import { BEARER_SCHEME } from '../auth/prehandler.ts' +import { BEARER_SCHEME, kPublicRoute } from '../auth/prehandler.ts' import type { SessionStore } from '../stores/session-store.ts' import type { DCRHooks, DCRRequest, DCRResponse } from '../types/auth-types.ts' @@ -100,11 +100,16 @@ const LogoutResponse = Type.Object({ logout_status: Type.String() }) +// authorize starts the flow, callback receives the code and register is +// pre-authentication, so the bearer-token check skips them whatever the prefix +const publicRoute = { [kPublicRoute]: true } + const authRoutesPlugin: FastifyPluginAsync = async (fastify: FastifyInstance, opts) => { const { sessionStore } = opts // Initiate OAuth authorization flow fastify.get('/oauth/authorize', { + config: publicRoute, schema: { querystring: AuthorizeQuerystring } @@ -162,6 +167,7 @@ const authRoutesPlugin: FastifyPluginAsync = async (fastify: // Handle OAuth callback fastify.get('/oauth/callback', { + config: publicRoute, schema: { querystring: CallbackQuerystring, response: { @@ -344,6 +350,7 @@ const authRoutesPlugin: FastifyPluginAsync = async (fastify: // (Using oauth-client.dynamicClientRegistration here would cause an infinite loop // if OIDC discovery points back to this server.) fastify.post('/oauth/register', { + config: publicRoute, schema: { body: DynamicRegistrationRequest, response: { diff --git a/src/routes/well-known.ts b/src/routes/well-known.ts index 24fa25f..b6cc55f 100644 --- a/src/routes/well-known.ts +++ b/src/routes/well-known.ts @@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' import fp from 'fastify-plugin' import type { AuthorizationConfig, ProtectedResourceMetadata } from '../types/auth-types.ts' import { getResourceMetadataPath, PROTECTED_RESOURCE_METADATA_PATH } from '../auth/resource-metadata.ts' +import { kPublicRoute } from '../auth/prehandler.ts' interface WellKnownRoutesOptions { authConfig?: AuthorizationConfig @@ -35,6 +36,10 @@ const wellKnownRoutesPlugin = fp(async function (fastify: FastifyInstance, opts: name: 'well-known-routes' }) +// Discovery must work before the client has a token, so the bearer-token check +// skips these routes, matched by route rather than URL so a prefix is fine +const publicRoute = { [kPublicRoute]: true } + async function wellKnownRoutes (app: FastifyInstance, authConfig: AuthorizationConfig & { enabled: true }) { // Discovery endpoints are public, so allow cross-origin requests from any origin app.addHook('onRequest', async (request, reply) => { @@ -54,11 +59,12 @@ async function wellKnownRoutes (app: FastifyInstance, authConfig: AuthorizationC }) // Lets the router accept preflight requests; they are answered in the hook above - app.options('/.well-known/*', async (_request, reply) => { + app.options('/.well-known/*', { config: publicRoute }, async (_request, reply) => { return reply.callNotFound() }) const protectedResourceSchema = { + config: publicRoute, schema: { response: { 200: { @@ -116,6 +122,7 @@ async function wellKnownRoutes (app: FastifyInstance, authConfig: AuthorizationC // Health check endpoint that can be used to verify the resource server is operational app.get('/.well-known/mcp-resource-health', { + config: publicRoute, schema: { response: { 200: { diff --git a/src/types/auth-types.ts b/src/types/auth-types.ts index b62e177..8d4ea7a 100644 --- a/src/types/auth-types.ts +++ b/src/types/auth-types.ts @@ -110,6 +110,15 @@ export type AuthorizationConfig = enabled: true authorizationServers: string[] resourceUri: string + /** + * Absolute URL advertised as `resource_metadata` in `WWW-Authenticate` + * challenges. Defaults to the URL the plugin serves the metadata at: + * `{origin of resourceUri}{route prefix}/.well-known/oauth-protected-resource{path of resourceUri}`, + * which is the RFC 9728 §3.1 location when the plugin has no prefix. Set it + * when the metadata is served elsewhere, for example at the origin root by + * registering `wellKnownRoutes` there. + */ + resourceMetadataUrl?: string /** Paths to exclude from authorization (e.g., health checks). Supports string prefix or RegExp. */ excludedPaths?: (string | RegExp)[] /** diff --git a/test/well-known-prefix.test.ts b/test/well-known-prefix.test.ts new file mode 100644 index 0000000..56acadb --- /dev/null +++ b/test/well-known-prefix.test.ts @@ -0,0 +1,170 @@ +import { test, describe } from 'node:test' +import type { TestContext } from 'node:test' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import mcpPlugin, { wellKnownRoutes, getResourceMetadataUrl } from '../src/index.ts' +import type { AuthorizationConfig } from '../src/index.ts' +import { createTestAuthConfig } from './auth-test-utils.ts' + +const ping = { jsonrpc: '2.0', id: 1, method: 'ping' } + +async function buildPrefixedApp (t: TestContext, authorization: AuthorizationConfig, prefix = '/api') { + const app = Fastify({ logger: false }) + t.after(() => app.close()) + await app.register(async (api: FastifyInstance) => { + await api.register(mcpPlugin, { + serverInfo: { name: 'test-server', version: '1.0.0' }, + authorization + }) + }, { prefix }) + return app +} + +function advertisedMetadataUrl (wwwAuthenticate: string): string { + const match = /resource_metadata="([^"]+)"/.exec(wwwAuthenticate) + if (!match) { + throw new Error(`missing resource_metadata in ${wwwAuthenticate}`) + } + return match[1] +} + +// Issue #340: OAuth discovery must work when the plugin is registered under a prefix. +describe('Protected resource metadata under a route prefix', () => { + for (const protectedRoutes of ['all', 'mcp'] as const) { + test(`well-known routes are public under the prefix (protectedRoutes: '${protectedRoutes}')`, async (t: TestContext) => { + const app = await buildPrefixedApp(t, createTestAuthConfig({ + resourceUri: 'https://mcp.example.com/api/mcp', + protectedRoutes + })) + await app.ready() + + for (const url of [ + '/api/.well-known/oauth-protected-resource', + '/api/.well-known/oauth-protected-resource/api/mcp', + '/api/.well-known/oauth-protected-resource/mcp', + '/api/.well-known/mcp-resource-health' + ]) { + const response = await app.inject({ method: 'GET', url }) + t.assert.strictEqual(response.statusCode, 200, `${url}: ${response.body}`) + } + }) + } + + test('the advertised resource_metadata URL resolves to the metadata', async (t: TestContext) => { + const resourceUri = 'https://mcp.example.com/api/mcp' + const app = await buildPrefixedApp(t, createTestAuthConfig({ resourceUri })) + await app.ready() + + const unauthorized = await app.inject({ method: 'POST', url: '/api/mcp', payload: ping }) + t.assert.strictEqual(unauthorized.statusCode, 401) + + const metadataUrl = advertisedMetadataUrl(unauthorized.headers['www-authenticate'] as string) + t.assert.strictEqual(metadataUrl, + 'https://mcp.example.com/api/.well-known/oauth-protected-resource/api/mcp') + + const metadata = await app.inject({ method: 'GET', url: new URL(metadataUrl).pathname }) + t.assert.strictEqual(metadata.statusCode, 200, metadata.body) + t.assert.strictEqual(metadata.json().resource, resourceUri) + }) + + test('a nested prefix is reflected in the advertised URL', async (t: TestContext) => { + const resourceUri = 'https://mcp.example.com' + const app = await buildPrefixedApp(t, createTestAuthConfig({ resourceUri }), '/v1/tenant') + await app.ready() + + const unauthorized = await app.inject({ method: 'POST', url: '/v1/tenant/mcp', payload: ping }) + const metadataUrl = advertisedMetadataUrl(unauthorized.headers['www-authenticate'] as string) + t.assert.strictEqual(metadataUrl, + 'https://mcp.example.com/v1/tenant/.well-known/oauth-protected-resource') + + const metadata = await app.inject({ method: 'GET', url: new URL(metadataUrl).pathname }) + t.assert.strictEqual(metadata.statusCode, 200, metadata.body) + t.assert.strictEqual(metadata.json().resource, resourceUri) + }) + + test('CORS preflight on the prefixed metadata route is answered', async (t: TestContext) => { + const app = await buildPrefixedApp(t, createTestAuthConfig()) + await app.ready() + + const preflight = await app.inject({ + method: 'OPTIONS', + url: '/api/.well-known/oauth-protected-resource', + headers: { + origin: 'https://client.example.com', + 'access-control-request-method': 'GET' + } + }) + t.assert.strictEqual(preflight.statusCode, 204) + t.assert.strictEqual(preflight.headers['access-control-allow-origin'], 'https://client.example.com') + }) + + test('other routes under the prefix still require a token', async (t: TestContext) => { + const app = Fastify({ logger: false }) + t.after(() => app.close()) + await app.register(async (api: FastifyInstance) => { + await api.register(mcpPlugin, { + serverInfo: { name: 'test-server', version: '1.0.0' }, + authorization: createTestAuthConfig() + }) + api.get('/private', async () => ({ ok: true })) + }, { prefix: '/api' }) + await app.ready() + + const response = await app.inject({ method: 'GET', url: '/api/private' }) + t.assert.strictEqual(response.statusCode, 401) + }) + + test('OAuth flow entry points are public under the prefix', async (t: TestContext) => { + const app = await buildPrefixedApp(t, createTestAuthConfig({ + oauth2Client: { + clientId: 'test-client', + authorizationServer: 'https://auth.example.com' + } + })) + await app.ready() + + const response = await app.inject({ + method: 'GET', + url: '/api/oauth/authorize?resource=https://mcp.example.com' + }) + t.assert.strictEqual(response.statusCode, 302, response.body) + }) + + test('resourceMetadataUrl overrides the advertised URL, with the routes mounted at the origin root', async (t: TestContext) => { + // RFC 9728 §3.1 location for https://mcp.example.com/api/mcp. A plugin + // registered under /api cannot add routes outside it, so the application + // mounts the well-known routes at the root itself. + const resourceUri = 'https://mcp.example.com/api/mcp' + const authorization = createTestAuthConfig({ + resourceUri, + resourceMetadataUrl: getResourceMetadataUrl(resourceUri) + }) + const app = await buildPrefixedApp(t, authorization) + await app.register(wellKnownRoutes, { authConfig: authorization }) + await app.ready() + + const unauthorized = await app.inject({ method: 'POST', url: '/api/mcp', payload: ping }) + t.assert.strictEqual(unauthorized.statusCode, 401) + const metadataUrl = advertisedMetadataUrl(unauthorized.headers['www-authenticate'] as string) + t.assert.strictEqual(metadataUrl, + 'https://mcp.example.com/.well-known/oauth-protected-resource/api/mcp') + + const metadata = await app.inject({ method: 'GET', url: new URL(metadataUrl).pathname }) + t.assert.strictEqual(metadata.statusCode, 200, metadata.body) + t.assert.strictEqual(metadata.json().resource, resourceUri) + }) + + test('without a prefix the advertised URL is unchanged', async (t: TestContext) => { + const app = Fastify({ logger: false }) + t.after(() => app.close()) + await app.register(mcpPlugin, { + serverInfo: { name: 'test-server', version: '1.0.0' }, + authorization: createTestAuthConfig({ resourceUri: 'https://mcp.example.com/mcp' }) + }) + await app.ready() + + const unauthorized = await app.inject({ method: 'POST', url: '/mcp', payload: ping }) + t.assert.strictEqual(advertisedMetadataUrl(unauthorized.headers['www-authenticate'] as string), + 'https://mcp.example.com/.well-known/oauth-protected-resource/mcp') + }) +})