diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index c251597..1071828 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -43,6 +43,11 @@ export interface AuthServerOptions { scope?: string; timestamp: string; }) => void; + onRegistrationRequest?: (req: Request) => { + clientId: string; + clientSecret?: string; + tokenEndpointAuthMethod?: string; + }; } export function createAuthServer( @@ -62,7 +67,8 @@ export function createAuthServer( clientIdMetadataDocumentSupported, tokenVerifier, onTokenRequest, - onAuthorizationRequest + onAuthorizationRequest, + onRegistrationRequest } = options; // Track scopes from the most recent authorization request @@ -236,6 +242,17 @@ export function createAuthServer( }); app.post(authRoutes.registration_endpoint, (req: Request, res: Response) => { + let clientId = 'test-client-id'; + let clientSecret: string | undefined = 'test-client-secret'; + let tokenEndpointAuthMethod: string | undefined; + + if (onRegistrationRequest) { + const result = onRegistrationRequest(req); + clientId = result.clientId; + clientSecret = result.clientSecret; + tokenEndpointAuthMethod = result.tokenEndpointAuthMethod; + } + checks.push({ id: 'client-registration', name: 'ClientRegistration', @@ -245,15 +262,19 @@ export function createAuthServer( specReferences: [SpecReferences.MCP_DCR], details: { endpoint: '/register', - clientName: req.body.client_name + clientName: req.body.client_name, + ...(tokenEndpointAuthMethod && { tokenEndpointAuthMethod }) } }); res.status(201).json({ - client_id: 'test-client-id', - client_secret: 'test-client-secret', + client_id: clientId, + ...(clientSecret && { client_secret: clientSecret }), client_name: req.body.client_name || 'test-client', - redirect_uris: req.body.redirect_uris || [] + redirect_uris: req.body.redirect_uris || [], + ...(tokenEndpointAuthMethod && { + token_endpoint_auth_method: tokenEndpointAuthMethod + }) }); }); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 170c1cd..6a3ad1c 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -12,6 +12,11 @@ import { ScopeStepUpAuthScenario, ScopeRetryLimitScenario } from './scope-handling'; +import { + ClientSecretBasicAuthScenario, + ClientSecretPostAuthScenario, + PublicClientAuthScenario +} from './token-endpoint-auth'; import { ClientCredentialsJwtScenario, ClientCredentialsBasicScenario @@ -27,6 +32,9 @@ export const authScenariosList: Scenario[] = [ new ScopeOmittedWhenUndefinedScenario(), new ScopeStepUpAuthScenario(), new ScopeRetryLimitScenario(), + new ClientSecretBasicAuthScenario(), + new ClientSecretPostAuthScenario(), + new PublicClientAuthScenario(), new ClientCredentialsJwtScenario(), new ClientCredentialsBasicScenario() ]; diff --git a/src/scenarios/client/auth/token-endpoint-auth.ts b/src/scenarios/client/auth/token-endpoint-auth.ts new file mode 100644 index 0000000..976f69b --- /dev/null +++ b/src/scenarios/client/auth/token-endpoint-auth.ts @@ -0,0 +1,176 @@ +import type { Scenario, ConformanceCheck } from '../../../types.js'; +import { ScenarioUrls } from '../../../types.js'; +import { createAuthServer } from './helpers/createAuthServer.js'; +import { createServer } from './helpers/createServer.js'; +import { ServerLifecycle } from './helpers/serverLifecycle.js'; +import { SpecReferences } from './spec-references.js'; +import { MockTokenVerifier } from './helpers/mockTokenVerifier.js'; + +type AuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none'; + +function detectAuthMethod( + authorizationHeader?: string, + bodyClientSecret?: string +): AuthMethod { + if (authorizationHeader?.startsWith('Basic ')) { + return 'client_secret_basic'; + } + if (bodyClientSecret) { + return 'client_secret_post'; + } + return 'none'; +} + +function validateBasicAuthFormat(authorizationHeader: string): { + valid: boolean; + error?: string; +} { + const encoded = authorizationHeader.substring('Basic '.length); + try { + const decoded = Buffer.from(encoded, 'base64').toString('utf-8'); + if (!decoded.includes(':')) { + return { valid: false, error: 'missing colon separator' }; + } + return { valid: true }; + } catch { + return { valid: false, error: 'base64 decoding failed' }; + } +} + +const AUTH_METHOD_NAMES: Record = { + client_secret_basic: 'HTTP Basic authentication (client_secret_basic)', + client_secret_post: 'client_secret_post', + none: 'no authentication (public client)' +}; + +class TokenEndpointAuthScenario implements Scenario { + name: string; + description: string; + private expectedAuthMethod: AuthMethod; + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + constructor(expectedAuthMethod: AuthMethod) { + this.expectedAuthMethod = expectedAuthMethod; + this.name = `auth/token-endpoint-auth-${expectedAuthMethod === 'client_secret_basic' ? 'basic' : expectedAuthMethod === 'client_secret_post' ? 'post' : 'none'}`; + this.description = `Tests that client uses ${AUTH_METHOD_NAMES[expectedAuthMethod]} when server only supports ${expectedAuthMethod}`; + } + + async start(): Promise { + this.checks = []; + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + tokenVerifier, + tokenEndpointAuthMethodsSupported: [this.expectedAuthMethod], + onTokenRequest: (req, timestamp) => { + const authorizationHeader = req.headers.authorization as + | string + | undefined; + const bodyClientSecret = req.body.client_secret; + const actualMethod = detectAuthMethod( + authorizationHeader, + bodyClientSecret + ); + const isCorrect = actualMethod === this.expectedAuthMethod; + + // For basic auth, also validate the format + let formatError: string | undefined; + if (actualMethod === 'client_secret_basic' && authorizationHeader) { + const validation = validateBasicAuthFormat(authorizationHeader); + if (!validation.valid) { + formatError = validation.error; + } + } + + const status = isCorrect && !formatError ? 'SUCCESS' : 'FAILURE'; + let description: string; + + if (formatError) { + description = `Client sent Basic auth header but ${formatError}`; + } else if (isCorrect) { + description = `Client correctly used ${AUTH_METHOD_NAMES[this.expectedAuthMethod]} for token endpoint`; + } else { + description = `Client used ${actualMethod} but server only supports ${this.expectedAuthMethod}`; + } + + this.checks.push({ + id: 'token-endpoint-auth-method', + name: 'Token endpoint authentication method', + description, + status, + timestamp, + specReferences: [SpecReferences.OAUTH_2_1_TOKEN], + details: { + expectedAuthMethod: this.expectedAuthMethod, + actualAuthMethod: actualMethod, + hasAuthorizationHeader: !!authorizationHeader, + hasBodyClientSecret: !!bodyClientSecret, + ...(formatError && { formatError }) + } + }); + }, + onRegistrationRequest: () => ({ + clientId: `test-client-${Date.now()}`, + clientSecret: + this.expectedAuthMethod === 'none' + ? undefined + : `test-secret-${Date.now()}`, + tokenEndpointAuthMethod: this.expectedAuthMethod + }) + }); + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + prmPath: '/.well-known/oauth-protected-resource/mcp', + requiredScopes: [], + tokenVerifier + } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + if (!this.checks.some((c) => c.id === 'token-endpoint-auth-method')) { + this.checks.push({ + id: 'token-endpoint-auth-method', + name: 'Token endpoint authentication method', + description: 'Client did not make a token request', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.OAUTH_2_1_TOKEN] + }); + } + return this.checks; + } +} + +export class ClientSecretBasicAuthScenario extends TokenEndpointAuthScenario { + constructor() { + super('client_secret_basic'); + } +} + +export class ClientSecretPostAuthScenario extends TokenEndpointAuthScenario { + constructor() { + super('client_secret_post'); + } +} + +export class PublicClientAuthScenario extends TokenEndpointAuthScenario { + constructor() { + super('none'); + } +}