Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 58 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions src/auth/prehandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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 */
Expand Down
11 changes: 11 additions & 0 deletions src/auth/resource-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`
}
12 changes: 11 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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'

Expand Down
9 changes: 8 additions & 1 deletion src/routes/auth-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<AuthRoutesOptions> = async (fastify: FastifyInstance, opts) => {
const { sessionStore } = opts

// Initiate OAuth authorization flow
fastify.get('/oauth/authorize', {
config: publicRoute,
schema: {
querystring: AuthorizeQuerystring
}
Expand Down Expand Up @@ -162,6 +167,7 @@ const authRoutesPlugin: FastifyPluginAsync<AuthRoutesOptions> = async (fastify:

// Handle OAuth callback
fastify.get('/oauth/callback', {
config: publicRoute,
schema: {
querystring: CallbackQuerystring,
response: {
Expand Down Expand Up @@ -344,6 +350,7 @@ const authRoutesPlugin: FastifyPluginAsync<AuthRoutesOptions> = 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: {
Expand Down
9 changes: 8 additions & 1 deletion src/routes/well-known.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down
9 changes: 9 additions & 0 deletions src/types/auth-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)[]
/**
Expand Down
Loading
Loading