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
80 changes: 71 additions & 9 deletions src/auth/oauth-client.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -36,7 +36,7 @@ export interface OAuthClientMethods {
generatePKCEChallenge(): PKCEChallenge
generateState(): string
createAuthorizationRequest(additionalParams?: Record<string, string>): Promise<AuthorizationRequest>
exchangeCodeForToken(code: string, pkce: PKCEChallenge, state: string, receivedState: string): Promise<TokenResponse>
exchangeCodeForToken(code: string, pkce: PKCEChallenge, state: string, receivedState: string, redirectUri?: string): Promise<TokenResponse>
refreshToken(refreshToken: string): Promise<TokenResponse>
validateToken(accessToken: string): Promise<boolean>
dynamicClientRegistration(): Promise<{ clientId: string; clientSecret?: string }>
Expand All @@ -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<string, { endpoints: OIDCEndpoints; timestamp: number }>()
const DISCOVERY_CACHE_TTL = 5 * 60 * 1000 // 5 minutes

async function discoverOIDCEndpoints (
authorizationServer: string,
logger?: FastifyBaseLogger
): Promise<OIDCEndpoints> {
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<string, string>
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<OAuthClientConfig> = 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
Expand Down Expand Up @@ -91,7 +150,7 @@ const oauthClientPlugin: FastifyPluginAsync<OAuthClientConfig> = async (fastify,
params.set('resource', opts.resourceUri)
}

const authorizationUrl = `${opts.authorizationServer}/oauth/authorize?${params.toString()}`
const authorizationUrl = `${endpoints.authorizationEndpoint}?${params.toString()}`

return {
authorizationUrl,
Expand All @@ -104,15 +163,16 @@ const oauthClientPlugin: FastifyPluginAsync<OAuthClientConfig> = async (fastify,
code: string,
pkce: PKCEChallenge,
state: string,
receivedState: string
receivedState: string,
redirectUri?: string
): Promise<TokenResponse> {
// Validate state parameter to prevent CSRF
if (state !== receivedState) {
throw new Error('Invalid state parameter - possible CSRF attack')
}

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',
Expand All @@ -123,7 +183,9 @@ const oauthClientPlugin: FastifyPluginAsync<OAuthClientConfig> = 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()
})

Expand Down Expand Up @@ -151,7 +213,7 @@ const oauthClientPlugin: FastifyPluginAsync<OAuthClientConfig> = 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',
Expand Down Expand Up @@ -185,7 +247,7 @@ const oauthClientPlugin: FastifyPluginAsync<OAuthClientConfig> = async (fastify,

async validateToken (accessToken: string): Promise<boolean> {
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',
Expand Down Expand Up @@ -221,7 +283,7 @@ const oauthClientPlugin: FastifyPluginAsync<OAuthClientConfig> = async (fastify,
}

try {
const registrationResponse = await fetch(`${opts.authorizationServer}/oauth/register`, {
const registrationResponse = await fetch(endpoints.registrationEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand Down
11 changes: 9 additions & 2 deletions src/auth/prehandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
17 changes: 12 additions & 5 deletions src/routes/auth-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -90,9 +91,13 @@ const authRoutesPlugin: FastifyPluginAsync<AuthRoutesOptions> = 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
Expand All @@ -101,7 +106,8 @@ const authRoutesPlugin: FastifyPluginAsync<AuthRoutesOptions> = 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
Expand Down Expand Up @@ -175,12 +181,13 @@ const authRoutesPlugin: FastifyPluginAsync<AuthRoutesOptions> = 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
Expand Down
2 changes: 2 additions & 0 deletions src/types/auth-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 25 additions & 7 deletions test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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({
Expand All @@ -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)

Expand All @@ -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}`)
}
Expand Down Expand Up @@ -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'))
}
})
})