diff --git a/src/auth/oauth-client.ts b/src/auth/oauth-client.ts index 1c305db..d5d6168 100644 --- a/src/auth/oauth-client.ts +++ b/src/auth/oauth-client.ts @@ -39,7 +39,12 @@ export interface OAuthClientMethods { exchangeCodeForToken(code: string, pkce: PKCEChallenge, state: string, receivedState: string, redirectUri?: string): Promise refreshToken(refreshToken: string): Promise validateToken(accessToken: string): Promise - dynamicClientRegistration(): Promise<{ clientId: string; clientSecret?: string }> + /** + * Register a new OAuth client dynamically (RFC 7591). + * @param clientMetadata Optional client metadata from the registration request. + * If provided, merges with defaults (client metadata wins). + */ + dynamicClientRegistration(clientMetadata?: Record): Promise<{ clientId: string; clientSecret?: string }> } declare module 'fastify' { @@ -277,27 +282,33 @@ const oauthClientPlugin: FastifyPluginAsync = async (fastify, } }, - async dynamicClientRegistration (): Promise<{ clientId: string; clientSecret?: string }> { + async dynamicClientRegistration (clientMetadata?: Record): Promise<{ clientId: string; clientSecret?: string }> { if (!opts.dynamicRegistration) { throw new Error('Dynamic client registration not enabled') } try { + // Default client metadata (can be overridden by clientMetadata) + const defaultMetadata = { + client_name: 'MCP Server', + client_uri: opts.resourceUri, + redirect_uris: [`${opts.resourceUri}/oauth/callback`], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post', + scope: (opts.scopes || ['read']).join(' ') + } + + // Merge with client-provided metadata (client metadata wins) + const payload = { ...defaultMetadata, ...clientMetadata } + const registrationResponse = await fetch(endpoints.registrationEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ - client_name: 'MCP Server', - client_uri: opts.resourceUri, - redirect_uris: [`${opts.resourceUri}/oauth/callback`], - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - token_endpoint_auth_method: 'client_secret_post', - scope: (opts.scopes || ['read']).join(' ') - }) + body: JSON.stringify(payload) }) if (!registrationResponse.ok) { diff --git a/src/auth/prehandler.ts b/src/auth/prehandler.ts index e5eefdd..5845e9e 100644 --- a/src/auth/prehandler.ts +++ b/src/auth/prehandler.ts @@ -17,8 +17,8 @@ export function createAuthPreHandler ( return } - // Skip authorization for OAuth flow endpoints (authorize initiates, callback receives code) - if (request.url.startsWith('/oauth/authorize') || request.url.startsWith('/oauth/callback')) { + // Skip authorization for OAuth flow endpoints (authorize initiates, callback receives code, register is pre-auth) + if (request.url.startsWith('/oauth/authorize') || request.url.startsWith('/oauth/callback') || request.url.startsWith('/oauth/register')) { return } diff --git a/src/auth/token-validator.ts b/src/auth/token-validator.ts index 38efd4c..b8b51cf 100644 --- a/src/auth/token-validator.ts +++ b/src/auth/token-validator.ts @@ -110,12 +110,29 @@ export class TokenValidator { } try { + // Build headers with optional introspection authentication + const headers: Record = { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json' + } + + // Apply introspection auth based on config + const introspectionAuth = this.config.tokenValidation.introspectionAuth + if (introspectionAuth) { + if (introspectionAuth.type === 'bearer') { + headers.Authorization = `Bearer ${introspectionAuth.token}` + } else if (introspectionAuth.type === 'basic') { + const credentials = Buffer.from( + `${introspectionAuth.clientId}:${introspectionAuth.clientSecret}` + ).toString('base64') + headers.Authorization = `Basic ${credentials}` + } + // type === 'none' - no auth header added + } + const response = await fetch(this.config.tokenValidation.introspectionEndpoint, { method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json' - }, + headers, body: new URLSearchParams({ token, token_type_hint: 'access_token' diff --git a/src/index.ts b/src/index.ts index 2b58bed..ad145d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -92,7 +92,10 @@ const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOption // Register OAuth client routes if OAuth client is configured if (opts.authorization?.enabled && opts.authorization?.oauth2Client) { - await app.register(authRoutesPlugin, { sessionStore }) + await app.register(authRoutesPlugin, { + sessionStore, + dcrHooks: opts.authorization.dcrHooks + }) } // Register decorators first @@ -202,7 +205,11 @@ export type { AuthorizationConfig, TokenValidationResult, ProtectedResourceMetadata, - TokenIntrospectionResponse + TokenIntrospectionResponse, + IntrospectionAuthConfig, + DCRRequest, + DCRResponse, + DCRHooks } from './types/auth-types.ts' export type { diff --git a/src/routes/auth-routes.ts b/src/routes/auth-routes.ts index c06f8a6..c08b4b8 100644 --- a/src/routes/auth-routes.ts +++ b/src/routes/auth-routes.ts @@ -3,6 +3,7 @@ import fp from 'fastify-plugin' import { Type } from '@sinclair/typebox' import type { PKCEChallenge } from '../auth/oauth-client.ts' import type { SessionStore } from '../stores/session-store.ts' +import type { DCRHooks, DCRRequest, DCRResponse } from '../types/auth-types.ts' export interface AuthSession { state: string @@ -25,6 +26,8 @@ export interface TokenRefreshBody { export interface AuthRoutesOptions { sessionStore: SessionStore + /** DCR hooks for custom request/response processing */ + dcrHooks?: DCRHooks } // TypeBox schemas for validation @@ -69,11 +72,29 @@ const AuthStatusResponse = Type.Object({ authenticated: Type.Boolean() }) +// DCR Request body schema (RFC 7591 Section 2) +const DynamicRegistrationRequest = Type.Object({ + client_name: Type.Optional(Type.String()), + client_uri: Type.Optional(Type.String({ format: 'uri' })), + redirect_uris: Type.Optional(Type.Array(Type.String({ format: 'uri' }))), + grant_types: Type.Optional(Type.Array(Type.String())), + response_types: Type.Optional(Type.Array(Type.String())), + scope: Type.Optional(Type.String()), + token_endpoint_auth_method: Type.Optional(Type.String()), + logo_uri: Type.Optional(Type.String({ format: 'uri' })), + tos_uri: Type.Optional(Type.String({ format: 'uri' })), + policy_uri: Type.Optional(Type.String({ format: 'uri' })), + contacts: Type.Optional(Type.Array(Type.String())), + jwks_uri: Type.Optional(Type.String({ format: 'uri' })), + software_id: Type.Optional(Type.String()), + software_version: Type.Optional(Type.String()) +}, { additionalProperties: true }) + const DynamicRegistrationResponse = Type.Object({ client_id: Type.String(), client_secret: Type.Optional(Type.String()), - registration_status: Type.String() -}) + registration_status: Type.Optional(Type.String()) +}, { additionalProperties: true }) const LogoutResponse = Type.Object({ logout_status: Type.String() @@ -311,25 +332,100 @@ const authRoutesPlugin: FastifyPluginAsync = async (fastify: } }) - // Dynamic client registration endpoint (if enabled) + // Dynamic client registration endpoint (RFC 7591) + // + // When dcrHooks is configured: Acts as a proxy to upstreamEndpoint with hook interception. + // This is required when the authorization server advertises this MCP server as its + // registration_endpoint (to add custom logic like response cleaning). + // + // When dcrHooks is NOT configured: Returns 501 Not Implemented. + // Clients should use the authorization server's DCR endpoint directly. + // (Using oauth-client.dynamicClientRegistration here would cause an infinite loop + // if OIDC discovery points back to this server.) fastify.post('/oauth/register', { schema: { + body: DynamicRegistrationRequest, response: { 200: DynamicRegistrationResponse, - 400: ErrorResponse + 400: ErrorResponse, + 501: ErrorResponse, + 502: ErrorResponse } } - }, async (_, reply) => { + }, async (request, reply) => { + const { dcrHooks } = opts + + // DCR proxy requires hooks configuration + if (!dcrHooks) { + fastify.log.warn('DCR: request received but dcrHooks not configured') + return reply.status(501).send({ + error: 'not_implemented', + error_description: 'Dynamic client registration proxy not configured. Use the authorization server\'s registration endpoint directly.' + }) + } + + let clientMetadata = (request.body || {}) as DCRRequest + + fastify.log.info({ dcrRequest: clientMetadata }, 'DCR: received registration request') + try { - const registration = await fastify.oauthClient.dynamicClientRegistration() + // Call onRequest hook if defined + if (dcrHooks.onRequest) { + clientMetadata = await dcrHooks.onRequest(clientMetadata, fastify.log) + } - return reply.send({ - client_id: registration.clientId, - client_secret: registration.clientSecret, - registration_status: 'success' + // Forward to upstream endpoint (explicit config avoids OIDC discovery loop) + const upstreamResponse = await fetch(dcrHooks.upstreamEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify(clientMetadata) }) + + if (!upstreamResponse.ok) { + const errorText = await upstreamResponse.text() + fastify.log.warn( + { status: upstreamResponse.status, error: errorText }, + 'DCR: upstream registration failed' + ) + // Try to parse as JSON error, fallback to text + try { + const errorJson = JSON.parse(errorText) + return reply.status(400).send(errorJson) + } catch { + return reply.status(400).send({ + error: 'registration_failed', + error_description: errorText + }) + } + } + + let dcrResponse = await upstreamResponse.json() as DCRResponse + + fastify.log.info( + { clientId: dcrResponse.client_id }, + 'DCR: upstream registration successful' + ) + + // Call onResponse hook if defined + if (dcrHooks.onResponse) { + dcrResponse = await dcrHooks.onResponse(dcrResponse, clientMetadata, fastify.log) + } + + return reply.status(200).send(dcrResponse) } catch (error) { - fastify.log.error({ error }, 'Dynamic client registration failed') + fastify.log.error({ error }, 'DCR: registration failed') + + // Network error - upstream unreachable + if (error instanceof TypeError && error.message.includes('fetch')) { + return reply.status(502).send({ + error: 'bad_gateway', + error_description: 'Failed to communicate with upstream authorization server' + }) + } + return reply.status(400).send({ error: 'registration_failed', error_description: error instanceof Error ? error.message : 'Client registration failed' diff --git a/src/types/auth-types.ts b/src/types/auth-types.ts index fbe549f..8383e39 100644 --- a/src/types/auth-types.ts +++ b/src/types/auth-types.ts @@ -1,3 +1,104 @@ +import type { FastifyBaseLogger } from 'fastify' + +// ============================================================================= +// Introspection Authentication +// ============================================================================= + +/** + * Configuration for authenticating to the token introspection endpoint. + * Different OAuth providers require different auth methods for introspection. + */ +export type IntrospectionAuthConfig = + | { type: 'bearer'; token: string } // API key as bearer token (e.g., Ory) + | { type: 'basic'; clientId: string; clientSecret: string } // Client credentials (RFC 7662) + | { type: 'none' } // Token sent in body only (default) + +// ============================================================================= +// Dynamic Client Registration (DCR) +// ============================================================================= + +/** + * DCR Request body (RFC 7591 Section 2). + * Client metadata sent during dynamic registration. + */ +export interface DCRRequest { + client_name?: string + client_uri?: string + redirect_uris: string[] + grant_types?: string[] + response_types?: string[] + scope?: string + token_endpoint_auth_method?: string + logo_uri?: string + tos_uri?: string + policy_uri?: string + contacts?: string[] + jwks_uri?: string + software_id?: string + software_version?: string + [key: string]: unknown +} + +/** + * DCR Response body (RFC 7591 Section 3.2.1). + * Client information returned after successful registration. + */ +export interface DCRResponse { + client_id: string + client_secret?: string + client_name?: string + redirect_uris?: string[] + grant_types?: string[] + response_types?: string[] + scope?: string + client_uri?: string + logo_uri?: string + tos_uri?: string + policy_uri?: string + contacts?: string[] | null + registration_access_token?: string + registration_client_uri?: string + client_id_issued_at?: number + client_secret_expires_at?: number + [key: string]: unknown +} + +/** + * DCR Hooks for custom request/response processing. + * Allows intercepting DCR flow for logging, transformation, or proxying. + */ +export interface DCRHooks { + /** + * Upstream DCR endpoint URL. + * REQUIRED to avoid infinite loop when OIDC discovery points to self. + * This bypasses the discovered registration_endpoint. + */ + upstreamEndpoint: string + + /** + * Called before forwarding request to upstream. + * Use to enrich, validate, or transform the DCR request. + */ + onRequest?: ( + request: DCRRequest, + log: FastifyBaseLogger + ) => Promise | DCRRequest + + /** + * Called after receiving upstream response, before returning to client. + * Use to clean, transform, or enrich the DCR response. + */ + onResponse?: ( + response: DCRResponse, + request: DCRRequest, + log: FastifyBaseLogger + ) => Promise | DCRResponse +} + +// ============================================================================= +// Authorization Configuration +// ============================================================================= + export type AuthorizationConfig = | { enabled: false @@ -12,6 +113,13 @@ export type AuthorizationConfig = introspectionEndpoint?: string jwksUri?: string validateAudience?: boolean + /** + * How to authenticate to the introspection endpoint. + * - 'bearer': Use API key as Bearer token (e.g., Ory admin API) + * - 'basic': Use client credentials (RFC 7662 standard) + * - 'none': No auth header, token sent in body only (default) + */ + introspectionAuth?: IntrospectionAuthConfig } oauth2Client?: { clientId?: string @@ -21,6 +129,12 @@ export type AuthorizationConfig = scopes?: string[] dynamicRegistration?: boolean } + /** + * DCR hooks for custom request/response processing. + * When configured, the /oauth/register endpoint acts as a proxy + * to the upstreamEndpoint with hook interception. + */ + dcrHooks?: DCRHooks } export interface TokenValidationResult { diff --git a/test/oauth-routes.test.ts b/test/oauth-routes.test.ts index a8210c8..05964a1 100644 --- a/test/oauth-routes.test.ts +++ b/test/oauth-routes.test.ts @@ -343,10 +343,36 @@ describe('OAuth Routes', () => { assert.strictEqual(body.authenticated, false) }) - test('should handle dynamic client registration', async (t) => { + test('should return 501 when dcrHooks not configured', async (t) => { + const fastify = Fastify() + t.after(async () => { + await fastify.close() + }) + + const config = { + authorizationServer: 'https://auth.example.com', + dynamicRegistration: true + } + + await fastify.register(oauthClientPlugin, config) + const sessionStore = new MemorySessionStore(100) + await fastify.register(authRoutesPlugin, { sessionStore }) + + const response = await fastify.inject({ + method: 'POST', + url: '/oauth/register', + payload: { redirect_uris: ['http://localhost/callback'] } + }) + + assert.strictEqual(response.statusCode, 501) + const body = JSON.parse(response.body) + assert.strictEqual(body.error, 'not_implemented') + }) + + test('should handle dynamic client registration with dcrHooks', async (t) => { const mockPool = mockAgent.get('https://auth.example.com') mockPool.intercept({ - path: '/oauth/register', + path: '/oauth2/register', method: 'POST' }).reply(200, { client_id: 'dynamic-client-id', @@ -365,18 +391,89 @@ describe('OAuth Routes', () => { await fastify.register(oauthClientPlugin, config) const sessionStore = new MemorySessionStore(100) - await fastify.register(authRoutesPlugin, { sessionStore }) + await fastify.register(authRoutesPlugin, { + sessionStore, + dcrHooks: { + upstreamEndpoint: 'https://auth.example.com/oauth2/register' + } + }) const response = await fastify.inject({ method: 'POST', - url: '/oauth/register' + url: '/oauth/register', + payload: { + client_name: 'Test Client', + redirect_uris: ['http://localhost/callback'] + } }) assert.strictEqual(response.statusCode, 200) const body = JSON.parse(response.body) assert.strictEqual(body.client_id, 'dynamic-client-id') assert.strictEqual(body.client_secret, 'dynamic-client-secret') - assert.strictEqual(body.registration_status, 'success') + }) + + test('should call dcrHooks onRequest and onResponse', async (t) => { + const mockPool = mockAgent.get('https://auth.example.com') + mockPool.intercept({ + path: '/oauth2/register', + method: 'POST' + }).reply(200, { + client_id: 'dynamic-client-id', + client_secret: 'dynamic-client-secret', + client_uri: '' // Empty string that should be cleaned + }) + + const fastify = Fastify() + t.after(async () => { + await fastify.close() + }) + + const config = { + authorizationServer: 'https://auth.example.com', + dynamicRegistration: true + } + + let onRequestCalled = false + let onResponseCalled = false + + await fastify.register(oauthClientPlugin, config) + const sessionStore = new MemorySessionStore(100) + await fastify.register(authRoutesPlugin, { + sessionStore, + dcrHooks: { + upstreamEndpoint: 'https://auth.example.com/oauth2/register', + onRequest: async (request, _log) => { + onRequestCalled = true + // Add metadata + return { ...request, software_id: 'test-software' } + }, + onResponse: async (response, _request, _log) => { + onResponseCalled = true + // Clean empty client_uri + const cleaned = { ...response } + if (cleaned.client_uri === '') { + delete cleaned.client_uri + } + return cleaned + } + } + }) + + const response = await fastify.inject({ + method: 'POST', + url: '/oauth/register', + payload: { + client_name: 'Test Client', + redirect_uris: ['http://localhost/callback'] + } + }) + + assert.strictEqual(response.statusCode, 200) + assert.strictEqual(onRequestCalled, true) + assert.strictEqual(onResponseCalled, true) + const body = JSON.parse(response.body) + assert.strictEqual(body.client_uri, undefined) // Should be cleaned }) test('should handle logout request', async (t) => { diff --git a/test/token-validator.test.ts b/test/token-validator.test.ts index d16f609..ea43b6b 100644 --- a/test/token-validator.test.ts +++ b/test/token-validator.test.ts @@ -274,6 +274,82 @@ describe('TokenValidator', () => { validator.close() }) + + test('should use bearer token auth for introspection when configured', async (t: TestContext) => { + const config = createTestAuthConfig({ + tokenValidation: { + introspectionEndpoint: 'https://auth.example.com/admin/introspect', + validateAudience: true, + introspectionAuth: { + type: 'bearer', + token: 'admin-api-key-123' + } + } + }) + + // The mock will verify the Authorization header is set correctly + restoreMock = setupMockAgent({ + 'https://auth.example.com/admin/introspect': createIntrospectionResponse(true) + }) + + const validator = new TokenValidator(config, app) + const result = await validator.validateToken('opaque-token-123') + + t.assert.strictEqual(result.valid, true) + t.assert.ok(result.payload) + + validator.close() + }) + + test('should use basic auth for introspection when configured', async (t: TestContext) => { + const config = createTestAuthConfig({ + tokenValidation: { + introspectionEndpoint: 'https://auth.example.com/introspect', + validateAudience: true, + introspectionAuth: { + type: 'basic', + clientId: 'client-id', + clientSecret: 'client-secret' + } + } + }) + + restoreMock = setupMockAgent({ + 'https://auth.example.com/introspect': createIntrospectionResponse(true) + }) + + const validator = new TokenValidator(config, app) + const result = await validator.validateToken('opaque-token-123') + + t.assert.strictEqual(result.valid, true) + t.assert.ok(result.payload) + + validator.close() + }) + + test('should not send auth header when introspectionAuth is none', async (t: TestContext) => { + const config = createTestAuthConfig({ + tokenValidation: { + introspectionEndpoint: 'https://auth.example.com/introspect', + validateAudience: true, + introspectionAuth: { + type: 'none' + } + } + }) + + restoreMock = setupMockAgent({ + 'https://auth.example.com/introspect': createIntrospectionResponse(true) + }) + + const validator = new TokenValidator(config, app) + const result = await validator.validateToken('opaque-token-123') + + t.assert.strictEqual(result.valid, true) + t.assert.ok(result.payload) + + validator.close() + }) }) describe('Fallback Logic', () => {