Skip to content
Merged
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
33 changes: 22 additions & 11 deletions src/auth/oauth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ export interface OAuthClientMethods {
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 Down Expand Up @@ -277,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 {
// 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
4 changes: 2 additions & 2 deletions src/auth/prehandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

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 @@ -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
Expand Down Expand Up @@ -202,7 +205,11 @@ export type {
AuthorizationConfig,
TokenValidationResult,
ProtectedResourceMetadata,
TokenIntrospectionResponse
TokenIntrospectionResponse,
IntrospectionAuthConfig,
DCRRequest,
DCRResponse,
DCRHooks
} from './types/auth-types.ts'

export type {
Expand Down
118 changes: 107 additions & 11 deletions src/routes/auth-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -311,25 +332,100 @@ const authRoutesPlugin: FastifyPluginAsync<AuthRoutesOptions> = 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'
Expand Down
Loading