From 13008b4fe9ca57f2d9056b4e44aa33cd332c2b7c Mon Sep 17 00:00:00 2001 From: getlarge Date: Sun, 25 Jan 2026 16:22:59 +0100 Subject: [PATCH 1/2] 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/2] 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