From 13008b4fe9ca57f2d9056b4e44aa33cd332c2b7c Mon Sep 17 00:00:00 2001 From: getlarge Date: Sun, 25 Jan 2026 16:22:59 +0100 Subject: [PATCH 1/3] fix(test): add type guards for discriminated union types in integration tests The MCP SDK types use discriminated unions (e.g., text | image content). TypeScript requires type narrowing before accessing type-specific properties. This fixes typecheck failures introduced by SDK type changes. Co-Authored-By: Claude Opus 4.5 --- test/integration.test.ts | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/test/integration.test.ts b/test/integration.test.ts index a0b3044..f77f11e 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -160,7 +160,11 @@ describe('MCP Integration Tests', () => { } }, CallToolResultSchema) - t.assert.strictEqual(divResult.content[0].text, 'Result: 5') + const divContent = divResult.content[0] + t.assert.strictEqual(divContent.type, 'text') + if (divContent.type === 'text') { + t.assert.strictEqual(divContent.text, 'Result: 5') + } // Test tool execution with error const errorResult = await client.request({ @@ -172,7 +176,11 @@ describe('MCP Integration Tests', () => { }, CallToolResultSchema) t.assert.strictEqual(errorResult.isError, true) - t.assert.ok((errorResult.content[0].text as string).includes('Invalid operation')) + const errorContent = errorResult.content[0] + t.assert.strictEqual(errorContent.type, 'text') + if (errorContent.type === 'text') { + t.assert.ok(errorContent.text.includes('Invalid operation')) + } // Test resources listing const resourcesResult = await client.request({ @@ -189,9 +197,11 @@ describe('MCP Integration Tests', () => { params: { uri: 'config://settings.json' } }, ReadResourceResultSchema) - t.assert.strictEqual(configResult.contents[0].uri, 'config://settings.json') - t.assert.strictEqual(configResult.contents[0].mimeType, 'application/json') - const config = JSON.parse(configResult.contents[0].text as string) + const configContent = configResult.contents[0] + t.assert.strictEqual(configContent.uri, 'config://settings.json') + t.assert.strictEqual(configContent.mimeType, 'application/json') + t.assert.ok('text' in configContent, 'Expected text content') + const config = JSON.parse((configContent as { text: string }).text) t.assert.strictEqual(config.mode, 'test') t.assert.strictEqual(config.debug, true) @@ -215,7 +225,11 @@ describe('MCP Integration Tests', () => { t.assert.strictEqual(promptResult.messages.length, 1) t.assert.strictEqual(promptResult.messages[0].role, 'user') - t.assert.ok((promptResult.messages[0].content.text as string).includes('typescript')) + const promptContent = promptResult.messages[0].content + t.assert.strictEqual(promptContent.type, 'text') + if (promptContent.type === 'text') { + t.assert.ok(promptContent.text.includes('typescript')) + } } catch (error) { t.assert.fail(`MCP SDK integration test failed: ${error}`) } @@ -341,6 +355,10 @@ describe('MCP Integration Tests', () => { }, CallToolResultSchema) t.assert.strictEqual(callResult.isError, true) - t.assert.ok((callResult.content[0].text as string).includes('no handler implementation')) + const noHandlerContent = callResult.content[0] + t.assert.strictEqual(noHandlerContent.type, 'text') + if (noHandlerContent.type === 'text') { + t.assert.ok(noHandlerContent.text.includes('no handler implementation')) + } }) }) From e16fdc5952fc5489fc605acc1f76a14817729d4b Mon Sep 17 00:00:00 2001 From: getlarge Date: Sun, 25 Jan 2026 16:23:41 +0100 Subject: [PATCH 2/3] feat(auth): add OIDC discovery and redirect_uri support - Add OIDC discovery to fetch endpoints from /.well-known/openid-configuration with 5-minute caching and fallback to default /oauth/* paths - Include redirect_uri in authorization request (required for OIDC 1.0) - Pass redirect_uri to token exchange (must match authorization request) - Skip /oauth/callback in auth prehandler - Add excludedPaths option for custom routes to bypass authorization (e.g., health checks) This enables compatibility with OAuth providers like Ory Hydra that use non-standard endpoint paths (e.g., /oauth2/auth instead of /oauth/authorize). Closes #95 Co-Authored-By: Claude Opus 4.5 --- src/auth/oauth-client.ts | 80 ++++++++++++++++++++++++++++++++++----- src/auth/prehandler.ts | 11 +++++- src/routes/auth-routes.ts | 17 ++++++--- src/types/auth-types.ts | 2 + 4 files changed, 94 insertions(+), 16 deletions(-) diff --git a/src/auth/oauth-client.ts b/src/auth/oauth-client.ts index 9b60f39..1c305db 100644 --- a/src/auth/oauth-client.ts +++ b/src/auth/oauth-client.ts @@ -1,4 +1,4 @@ -import type { FastifyPluginAsync } from 'fastify' +import type { FastifyPluginAsync, FastifyBaseLogger } from 'fastify' import fp from 'fastify-plugin' import { createHash, randomBytes } from 'node:crypto' import { validateTokenResponse, validateIntrospectionResponse, validateClientRegistrationResponse } from './oauth-schemas.ts' @@ -36,7 +36,7 @@ export interface OAuthClientMethods { generatePKCEChallenge(): PKCEChallenge generateState(): string createAuthorizationRequest(additionalParams?: Record): Promise - exchangeCodeForToken(code: string, pkce: PKCEChallenge, state: string, receivedState: string): Promise + 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 }> @@ -48,7 +48,66 @@ declare module 'fastify' { } } +// OIDC Discovery types +interface OIDCEndpoints { + authorizationEndpoint: string + tokenEndpoint: string + introspectionEndpoint: string + registrationEndpoint: string +} + +// OIDC Discovery cache (per authorization server) +const discoveryCache = new Map() +const DISCOVERY_CACHE_TTL = 5 * 60 * 1000 // 5 minutes + +async function discoverOIDCEndpoints ( + authorizationServer: string, + logger?: FastifyBaseLogger +): Promise { + const now = Date.now() + const cached = discoveryCache.get(authorizationServer) + + if (cached && (now - cached.timestamp) < DISCOVERY_CACHE_TTL) { + return cached.endpoints + } + + try { + const discoveryUrl = `${authorizationServer}/.well-known/openid-configuration` + logger?.info({ discoveryUrl }, 'OAuth client: fetching OIDC discovery document') + + const response = await fetch(discoveryUrl) + if (response.ok) { + const metadata = await response.json() as Record + const endpoints: OIDCEndpoints = { + authorizationEndpoint: metadata.authorization_endpoint, + tokenEndpoint: metadata.token_endpoint, + introspectionEndpoint: metadata.introspection_endpoint, + registrationEndpoint: metadata.registration_endpoint + } + discoveryCache.set(authorizationServer, { endpoints, timestamp: now }) + logger?.info({ endpoints }, 'OAuth client: OIDC endpoints discovered') + return endpoints + } + logger?.warn({ status: response.status }, 'OAuth client: OIDC discovery failed, using defaults') + } catch (error) { + logger?.warn({ error: error instanceof Error ? error.message : String(error) }, 'OAuth client: OIDC discovery error, using defaults') + } + + // Default endpoints (original behavior for backwards compatibility) + const defaults: OIDCEndpoints = { + authorizationEndpoint: `${authorizationServer}/oauth/authorize`, + tokenEndpoint: `${authorizationServer}/oauth/token`, + introspectionEndpoint: `${authorizationServer}/oauth/introspect`, + registrationEndpoint: `${authorizationServer}/oauth/register` + } + discoveryCache.set(authorizationServer, { endpoints: defaults, timestamp: now }) + return defaults +} + const oauthClientPlugin: FastifyPluginAsync = async (fastify, opts) => { + // Discover OIDC endpoints on startup + const endpoints = await discoverOIDCEndpoints(opts.authorizationServer, fastify.log) + // Our OAuth client implementation is completely independent and doesn't need @fastify/oauth2 // @fastify/oauth2 can be optionally registered by users if they want the additional routes, // but our implementation provides all necessary OAuth client functionality @@ -91,7 +150,7 @@ const oauthClientPlugin: FastifyPluginAsync = async (fastify, params.set('resource', opts.resourceUri) } - const authorizationUrl = `${opts.authorizationServer}/oauth/authorize?${params.toString()}` + const authorizationUrl = `${endpoints.authorizationEndpoint}?${params.toString()}` return { authorizationUrl, @@ -104,7 +163,8 @@ const oauthClientPlugin: FastifyPluginAsync = async (fastify, code: string, pkce: PKCEChallenge, state: string, - receivedState: string + receivedState: string, + redirectUri?: string ): Promise { // Validate state parameter to prevent CSRF if (state !== receivedState) { @@ -112,7 +172,7 @@ const oauthClientPlugin: FastifyPluginAsync = async (fastify, } try { - const tokenResponse = await fetch(`${opts.authorizationServer}/oauth/token`, { + const tokenResponse = await fetch(endpoints.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -123,7 +183,9 @@ const oauthClientPlugin: FastifyPluginAsync = async (fastify, code, client_id: opts.clientId || '', code_verifier: pkce.codeVerifier, - ...(opts.clientSecret && { client_secret: opts.clientSecret }) + ...(opts.clientSecret && { client_secret: opts.clientSecret }), + // redirect_uri must match the one used in authorization request (required for OIDC) + ...(redirectUri && { redirect_uri: redirectUri }) }).toString() }) @@ -151,7 +213,7 @@ const oauthClientPlugin: FastifyPluginAsync = async (fastify, } try { - const tokenResponse = await fetch(`${opts.authorizationServer}/oauth/token`, { + const tokenResponse = await fetch(endpoints.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -185,7 +247,7 @@ const oauthClientPlugin: FastifyPluginAsync = async (fastify, async validateToken (accessToken: string): Promise { try { - const introspectionResponse = await fetch(`${opts.authorizationServer}/oauth/introspect`, { + const introspectionResponse = await fetch(endpoints.introspectionEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -221,7 +283,7 @@ const oauthClientPlugin: FastifyPluginAsync = async (fastify, } try { - const registrationResponse = await fetch(`${opts.authorizationServer}/oauth/register`, { + const registrationResponse = await fetch(endpoints.registrationEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/src/auth/prehandler.ts b/src/auth/prehandler.ts index dab95b8..e5eefdd 100644 --- a/src/auth/prehandler.ts +++ b/src/auth/prehandler.ts @@ -17,8 +17,15 @@ export function createAuthPreHandler ( return } - // Skip authorization for the start of the OAuth authorization flow. - if (request.url.startsWith('/oauth/authorize')) { + // Skip authorization for OAuth flow endpoints (authorize initiates, callback receives code) + if (request.url.startsWith('/oauth/authorize') || request.url.startsWith('/oauth/callback')) { + return + } + + // Skip authorization for custom excluded paths + if (config.excludedPaths?.some(path => + typeof path === 'string' ? request.url.startsWith(path) : path.test(request.url) + )) { return } diff --git a/src/routes/auth-routes.ts b/src/routes/auth-routes.ts index 2180c0d..c06f8a6 100644 --- a/src/routes/auth-routes.ts +++ b/src/routes/auth-routes.ts @@ -9,6 +9,7 @@ export interface AuthSession { pkce: PKCEChallenge resourceUri?: string originalUrl?: string + callbackUrl?: string // The redirect_uri used in authorization request (required for OIDC token exchange) } export interface AuthorizationCallbackQuery { @@ -90,9 +91,13 @@ const authRoutesPlugin: FastifyPluginAsync = async (fastify: // eslint-disable-next-line camelcase const { resource, redirect_uri } = request.query as { resource?: string; redirect_uri?: string } - // Create authorization request with PKCE + // Build the callback URL for this server (required for OIDC compliance) + const callbackUrl = `${request.protocol}://${request.host}/oauth/callback` + + // Create authorization request with PKCE and redirect_uri const authRequest = await fastify.oauthClient.createAuthorizationRequest({ - ...(resource && { resource }) + ...(resource && { resource }), + redirect_uri: callbackUrl }) // Store session data in session store @@ -101,7 +106,8 @@ const authRoutesPlugin: FastifyPluginAsync = async (fastify: pkce: authRequest.pkce, resourceUri: resource, // eslint-disable-next-line camelcase - originalUrl: redirect_uri + originalUrl: redirect_uri, + callbackUrl // Store for token exchange (must match) } // Create session metadata with auth session data @@ -175,12 +181,13 @@ const authRoutesPlugin: FastifyPluginAsync = async (fastify: // Clean up session data await sessionStore.delete(state) - // Exchange authorization code for tokens + // Exchange authorization code for tokens (include redirect_uri for OIDC compliance) const tokens = await fastify.oauthClient.exchangeCodeForToken( code, sessionData.pkce, sessionData.state, - state + state, + sessionData.callbackUrl ) // Return tokens to client or redirect with tokens diff --git a/src/types/auth-types.ts b/src/types/auth-types.ts index 68b0ebf..fbe549f 100644 --- a/src/types/auth-types.ts +++ b/src/types/auth-types.ts @@ -6,6 +6,8 @@ export type AuthorizationConfig = enabled: true authorizationServers: string[] resourceUri: string + /** Paths to exclude from authorization (e.g., health checks). Supports string prefix or RegExp. */ + excludedPaths?: (string | RegExp)[] tokenValidation: { introspectionEndpoint?: string jwksUri?: string From dfe6556c040db572a8469130352c272a4621a7c8 Mon Sep 17 00:00:00 2001 From: getlarge Date: Wed, 28 Jan 2026 16:20:05 +0100 Subject: [PATCH 3/3] feat(auth): add introspectionAuth config and dcrHooks for DCR proxy This commit adds several OAuth/DCR improvements: Patch 3.1: Skip /oauth/register in auth prehandler - DCR endpoint must be accessible before client has credentials - Fixes chicken-and-egg problem with dynamic registration Patch 3.2: Add introspectionAuth configuration - Supports bearer (API key), basic (client credentials), or none - Required for Ory Hydra admin introspection endpoint - Backwards compatible - defaults to no auth header Patch 3.3: Pass DCR request body through to dynamicClientRegistration - Client metadata is now merged with defaults (client wins) - Allows clients to specify their own redirect_uris, client_name, etc. Patch 4: Add dcrHooks for DCR proxy pattern - upstreamEndpoint: Required, bypasses OIDC discovery to avoid loops - onRequest: Hook to transform/enrich DCR request - onResponse: Hook to clean/transform DCR response (e.g., remove empty fields) - Returns 501 when not configured (prevents infinite loop) Breaking change: /oauth/register now returns 501 unless dcrHooks is configured. This prevents infinite loops when OIDC discovery points back to the MCP server. Co-Authored-By: Claude Opus 4.5 --- src/auth/oauth-client.ts | 33 ++++++---- src/auth/prehandler.ts | 4 +- src/auth/token-validator.ts | 25 ++++++-- src/index.ts | 11 +++- src/routes/auth-routes.ts | 118 +++++++++++++++++++++++++++++++---- src/types/auth-types.ts | 114 +++++++++++++++++++++++++++++++++ test/oauth-routes.test.ts | 107 +++++++++++++++++++++++++++++-- test/token-validator.test.ts | 76 ++++++++++++++++++++++ 8 files changed, 453 insertions(+), 35 deletions(-) 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 4aa9d5f..554ef07 100644 --- a/src/index.ts +++ b/src/index.ts @@ -91,7 +91,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 @@ -196,7 +199,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', () => {