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
113 changes: 93 additions & 20 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,10 +36,15 @@ 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 }>
/**
* 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<string, unknown>): Promise<{ clientId: string; clientSecret?: string }>
}

declare module 'fastify' {
Expand All @@ -48,7 +53,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 +155,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 +168,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 +188,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 +218,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 +252,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 @@ -215,27 +282,33 @@ const oauthClientPlugin: FastifyPluginAsync<OAuthClientConfig> = async (fastify,
}
},

async dynamicClientRegistration (): Promise<{ clientId: string; clientSecret?: string }> {
async dynamicClientRegistration (clientMetadata?: Record<string, unknown>): Promise<{ clientId: string; clientSecret?: string }> {
if (!opts.dynamicRegistration) {
throw new Error('Dynamic client registration not enabled')
}

try {
const registrationResponse = await fetch(`${opts.authorizationServer}/oauth/register`, {
// 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) {
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, register is pre-auth)
if (request.url.startsWith('/oauth/authorize') || request.url.startsWith('/oauth/callback') || request.url.startsWith('/oauth/register')) {
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
25 changes: 21 additions & 4 deletions src/auth/token-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,29 @@ export class TokenValidator {
}

try {
// Build headers with optional introspection authentication
const headers: Record<string, string> = {
'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'
Expand Down
11 changes: 9 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -196,7 +199,11 @@ export type {
AuthorizationConfig,
TokenValidationResult,
ProtectedResourceMetadata,
TokenIntrospectionResponse
TokenIntrospectionResponse,
IntrospectionAuthConfig,
DCRRequest,
DCRResponse,
DCRHooks
} from './types/auth-types.ts'

export type {
Expand Down
Loading