From 5c8b78e83cf03d76b13f61a68c6a9a049f0d261a Mon Sep 17 00:00:00 2001 From: Michito Okai Date: Tue, 12 May 2026 12:10:51 +0900 Subject: [PATCH 01/11] feat: add positive tests for the Authorization Code Grant --- src/index.ts | 22 +- src/runner/authorization-server.ts | 8 +- .../auth/helpers/createCallbackServer.ts | 40 +++ .../auth/spec-references.ts | 12 + .../authorization-code-grant.test.ts | 309 ++++++++++++++++++ .../authorization-code-grant.ts | 309 ++++++++++++++++++ .../authorization-server-metadata.test.ts | 13 +- .../authorization-server-metadata.ts | 18 +- src/scenarios/index.ts | 4 +- src/schemas.ts | 9 +- src/types.ts | 6 +- 11 files changed, 730 insertions(+), 20 deletions(-) create mode 100644 src/scenarios/authorization-server/auth/helpers/createCallbackServer.ts create mode 100644 src/scenarios/authorization-server/auth/spec-references.ts create mode 100644 src/scenarios/authorization-server/authorization-code-grant.test.ts create mode 100644 src/scenarios/authorization-server/authorization-code-grant.ts diff --git a/src/index.ts b/src/index.ts index 1893423b..f346242a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -522,6 +522,14 @@ program ) .option('--url ', 'URL of the authorization server issuer') .option('--scenario ', 'Test scenario to run') + .requiredOption('--client-id ', 'Client ID') + .requiredOption('--secret ', 'Client Secret') + .option( + '-p, --port ', + 'redirect uri port', + (value) => Number(value), + 3000 + ) .option('-o, --output-dir ', 'Save results to this directory') .option( '--spec-version ', @@ -575,9 +583,11 @@ program // If a single scenario is specified, run just that one if (validated.scenario) { + const details: Record = {}; const result = await runAuthorizationServerConformanceTest( - validated.url, + validated, validated.scenario, + details, outputDir ); @@ -604,14 +614,22 @@ program ); const allResults: { scenario: string; checks: ConformanceCheck[] }[] = []; + const details: Record = {}; for (const scenarioName of scenarios) { console.log(`\n=== Running scenario: ${scenarioName} ===`); try { const result = await runAuthorizationServerConformanceTest( - validated.url, + validated, scenarioName, + details, outputDir ); + if ( + result.checks[0].status === 'SUCCESS' && + result.checks[0].details + ) { + details[scenarioName] = result.checks[0].details; + } allResults.push({ scenario: scenarioName, checks: result.checks }); } catch (error) { console.error(`Failed to run scenario ${scenarioName}:`, error); diff --git a/src/runner/authorization-server.ts b/src/runner/authorization-server.ts index ba9d535d..c4950ce5 100644 --- a/src/runner/authorization-server.ts +++ b/src/runner/authorization-server.ts @@ -3,10 +3,12 @@ import path from 'path'; import { ConformanceCheck } from '../types'; import { getClientScenarioForAuthorizationServer } from '../scenarios'; import { createResultDir, formatPrettyChecks } from './utils'; +import { AuthorizationServerOptions } from '../schemas'; export async function runAuthorizationServerConformanceTest( - serverUrl: string, + option: AuthorizationServerOptions, scenarioName: string, + details: Record, outputDir?: string ): Promise<{ checks: ConformanceCheck[]; @@ -28,10 +30,10 @@ export async function runAuthorizationServerConformanceTest( const scenario = getClientScenarioForAuthorizationServer(scenarioName)!; console.log( - `Running client scenario for authorization server '${scenarioName}' against server: ${serverUrl}` + `Running client scenario for authorization server '${scenarioName}' against server: ${option.url}` ); - const checks = await scenario.run(serverUrl); + const checks = await scenario.run(option, details); if (resultDir) { await fs.writeFile( diff --git a/src/scenarios/authorization-server/auth/helpers/createCallbackServer.ts b/src/scenarios/authorization-server/auth/helpers/createCallbackServer.ts new file mode 100644 index 00000000..0216dd47 --- /dev/null +++ b/src/scenarios/authorization-server/auth/helpers/createCallbackServer.ts @@ -0,0 +1,40 @@ +import express from 'express'; + +export interface CallbackServer { + waitForCallback: (timeoutMs: number) => Promise; +} + +export function startCallbackServer(port: number): CallbackServer { + const app = express(); + + let resolveFn: (url: string) => void; + + const promise = new Promise((resolve) => { + resolveFn = resolve; + }); + + const server = app.listen(port, '127.0.0.1', () => { + console.log(`Callback server started: http://localhost:${port}`); + }); + + app.use((req, res) => { + const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`; + res.send('OK. You can close this page.'); + + server.close(); + resolveFn(fullUrl); + }); + + return { + waitForCallback: (timeoutMs: number) => + Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => { + server.close(); + reject(new Error('Timeout: No callback received')); + }, timeoutMs) + ) + ]) + }; +} diff --git a/src/scenarios/authorization-server/auth/spec-references.ts b/src/scenarios/authorization-server/auth/spec-references.ts new file mode 100644 index 00000000..e89ec868 --- /dev/null +++ b/src/scenarios/authorization-server/auth/spec-references.ts @@ -0,0 +1,12 @@ +import { SpecReference } from '../../../types'; + +export const SpecReferences: { [key: string]: SpecReference } = { + MCP_AUTH_DISCOVERY: { + id: 'MCP-Authorization-metadata-discovery', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery' + }, + OAUTH_2_1_AUTHORIZATION_CODE_GRANT: { + id: 'OAUTH-2.1-authorization-code-grant', + url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#section-4.1' + } +}; diff --git a/src/scenarios/authorization-server/authorization-code-grant.test.ts b/src/scenarios/authorization-server/authorization-code-grant.test.ts new file mode 100644 index 00000000..8649038b --- /dev/null +++ b/src/scenarios/authorization-server/authorization-code-grant.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AuthorizationCodeGrantScenario } from './authorization-code-grant.js'; +import { request } from 'undici'; +import { startCallbackServer } from '../authorization-server/auth/helpers/createCallbackServer'; + +vi.mock('undici', () => ({ + request: vi.fn() +})); + +vi.mock('../authorization-server/auth/helpers/createCallbackServer', () => ({ + startCallbackServer: vi.fn() +})); + +const mockedRequest = vi.mocked(request); +const mockedStartCallbackServer = vi.mocked(startCallbackServer); + +const SERVER_URL = 'https://example.com'; +const AUTHORIZATION_ENDPOINT = `${SERVER_URL}/auth`; +const TOKEN_ENDPOINT = `${SERVER_URL}/token`; + +const OPTION = { + url: SERVER_URL, + clientId: 'client', + secret: 'secret', + port: 3000 +}; + +const METADATA = { + issuer: SERVER_URL, + authorization_endpoint: AUTHORIZATION_ENDPOINT, + token_endpoint: TOKEN_ENDPOINT, + token_endpoint_auth_methods_supported: ['client_secret_post'] +}; + +const METADATA_PRIVATE_KEY_JWT = { + issuer: SERVER_URL, + authorization_endpoint: AUTHORIZATION_ENDPOINT, + token_endpoint: TOKEN_ENDPOINT, + token_endpoint_auth_methods_supported: ['private_key_jwt'] +}; + +const DETAILS = { + 'authorization-server-metadata-endpoint': { + body: METADATA + } +}; + +const DETAILS_PRIVATE_KEY_JWT = { + 'authorization-server-metadata-endpoint': { + body: METADATA_PRIVATE_KEY_JWT + } +}; + +function mockCallbackServer( + scenario: AuthorizationCodeGrantScenario, + buildUrl: (state: string) => string +) { + mockedStartCallbackServer.mockReturnValue({ + waitForCallback: vi.fn().mockImplementation(async () => { + return buildUrl((scenario as any).state); + }) + } as any); +} + +function mockTokenResponse(body: Record) { + mockedRequest.mockResolvedValue({ + statusCode: 200, + headers: { + 'content-type': 'application/json', + 'cache-control': 'no-store' + }, + body: { + json: async () => body + } + } as any); +} + +describe('AuthorizationCodeGrantScenario', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns SUCCESS for valid authorization response and token response', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => + `http://localhost:3000/callback?code=abc&state=${state}&iss=${SERVER_URL}` + ); + + mockTokenResponse({ + access_token: 'access-token', + token_type: 'Bearer' + }); + + const checks = await scenario.run(OPTION, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('SUCCESS'); + expect(check.errorMessage).toBeUndefined(); + + expect(check.details).toBeDefined(); + + expect((check.details as any).authorizationRequest).toContain( + AUTHORIZATION_ENDPOINT + ); + + expect((check.details as any).authorizationResponseUrl).toContain( + 'code=abc' + ); + + expect((check.details as any).body.access_token).toBe('access-token'); + expect((check.details as any).body.token_type).toBe('Bearer'); + }); + + it('returns FAILURE when state parameter is invalid', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + () => 'http://localhost:3000/callback?code=abc&state=invalid' + ); + + mockTokenResponse({ + access_token: 'access-token', + token_type: 'Bearer' + }); + + const checks = await scenario.run(OPTION, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Invalid state parameter'); + }); + + it('returns FAILURE when code parameter is missing', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://localhost:3000/callback?state=${state}` + ); + + const checks = await scenario.run(OPTION, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Invalid code parameter'); + }); + + it('returns FAILURE when iss parameter is invalid', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => + `http://localhost:3000/callback?code=abc&state=${state}&iss=https://evil.example.com` + ); + + mockTokenResponse({ + access_token: 'access-token', + token_type: 'Bearer' + }); + + const checks = await scenario.run(OPTION, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Invalid iss parameter'); + }); + + it('returns FAILURE when token response does not include access_token', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://localhost:3000/callback?code=abc&state=${state}` + ); + + mockTokenResponse({ + token_type: 'Bearer' + }); + + const checks = await scenario.run(OPTION, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Missing access_token'); + }); + + it('returns FAILURE when token response does not include token_type', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://localhost:3000/callback?code=abc&state=${state}` + ); + + mockTokenResponse({ + access_token: 'access-token' + }); + + const checks = await scenario.run(OPTION, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Missing token_type'); + }); + + it('returns FAILURE when token response Content-Type is invalid', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://localhost:3000/callback?code=abc&state=${state}` + ); + + mockedRequest.mockResolvedValue({ + statusCode: 200, + headers: { + 'content-type': 'text/plain', + 'cache-control': 'no-store' + }, + body: { + json: async () => ({ + access_token: 'access-token', + token_type: 'Bearer' + }) + } + } as any); + + const checks = await scenario.run(OPTION, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Invalid Content-Type'); + }); + + it('returns FAILURE when token response Cache-Control is invalid', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://localhost:3000/callback?code=abc&state=${state}` + ); + + mockedRequest.mockResolvedValue({ + statusCode: 200, + headers: { + 'content-type': 'application/json', + 'cache-control': 'public' + }, + body: { + json: async () => ({ + access_token: 'access-token', + token_type: 'Bearer' + }) + } + } as any); + + const checks = await scenario.run(OPTION, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Invalid Cache-Control'); + }); + + it('returns SKIPPED when client_secret_post and client_secret_basic are missing', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://localhost:3000/callback?code=abc&state=${state}` + ); + + const checks = await scenario.run(OPTION, DETAILS_PRIVATE_KEY_JWT); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('SKIPPED'); + }); +}); diff --git a/src/scenarios/authorization-server/authorization-code-grant.ts b/src/scenarios/authorization-server/authorization-code-grant.ts new file mode 100644 index 00000000..2808adab --- /dev/null +++ b/src/scenarios/authorization-server/authorization-code-grant.ts @@ -0,0 +1,309 @@ +/** + * Authorization code grant test scenarios for MCP authorization servers + */ +import { + ClientScenarioForAuthorizationServer, + ConformanceCheck +} from '../../types'; +import { startCallbackServer } from '../authorization-server/auth/helpers/createCallbackServer'; +import { request } from 'undici'; +import { randomBytes } from 'crypto'; +import { AuthorizationServerOptions } from '../../schemas'; +import { SpecReferences } from '../authorization-server/auth/spec-references'; + +const REDIRECT_URI_ORIGIN = 'http://localhost'; +const REDIRECT_URI_PATH = '/callback'; +// These values are from RFC 7636 Appendix B. +const CODE_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; +const CODE_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; + +export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthorizationServer { + private state = randomBytes(32).toString('base64url'); + name = 'authorization-code-grant'; + readonly source = { introducedIn: '2025-03-26' } as const; + description = `Test authorization code grant. + +**Authorization Server Implementation Requirements:** + +**Endpoint**: \`authorization endpoint\`, \`token endpoint\` + +**Requirements**: +- The URI in the authorization response MUST match the redirect_uri parameter in the authorization request +- The code parameter MUST be present in the authorization response query parameters +- The code parameter MUST have a value +- The state parameter in the authorization response MUST match the state parameter in the authorization request query parameters if the state parameter is present in the authorization request query parameters +- The iss parameter in the authorization response MUST match the issuer claim of authorization server metadata if the iss parameter is present in the authorization response query parameters +- The code, state and iss parameters MUST NOT appear more than once +- The code_challenge parameter MUST NOT be present in the authorization response query parameters +- The error parameter MUST NOT be present in the authorization response query parameters +- HTTP response status code of token response MUST be 200 OK +- Content-Type header of token response MUST be application/json +- Cache-Control header of token response MUST be no-store +- Token response MUST return a JSON response including access_token and token_type`; + + async run( + option: AuthorizationServerOptions, + details: Record + ): Promise { + try { + this.state = randomBytes(32).toString('base64url'); + const resultMetadata = details[ + 'authorization-server-metadata-endpoint' + ] as { body?: Record }; + if (!resultMetadata) { + throw new Error('Invalid authorization server metadata'); + } + const body = resultMetadata.body; + + const callback = startCallbackServer(option.port); + const authorizationRequest = this.buildAuthorizationRequest(body, option); + console.log( + 'Access the following URL in your browser and complete the authentication process.' + ); + console.log(authorizationRequest); + console.log(''); + + const authorizationResponseUrl = await callback.waitForCallback(300_000); + + const errors: string[] = []; + const code = this.validateAuthorizationResponse( + authorizationResponseUrl, + body, + option, + errors + ); + + const tokenResponse = await this.requestToken(body, option, code); + if (tokenResponse === null) { + return [ + this.skippedCheck( + 'Server does not support client_secret_post or client_secret_basic auth methods' + ) + ]; + } + this.validateTokenResponse(tokenResponse, errors); + + if (errors.length > 0) { + return [this.failureCheck(errors.join(', '))]; + } + + return [ + this.successCheck({ + authorizationRequest, + authorizationResponseUrl, + body: tokenResponse.body + }) + ]; + } catch (error) { + return [this.failureCheck(error)]; + } + } + + private buildAuthorizationRequest( + metadata: any, + option: AuthorizationServerOptions + ): string { + if (!metadata?.authorization_endpoint) { + throw new Error('Unable to obtain authorization endpoint from metadata'); + } + + const redirectUri = encodeURIComponent( + `${REDIRECT_URI_ORIGIN}:${option.port}${REDIRECT_URI_PATH}` + ); + const params = + `response_type=code&client_id=${option.clientId}&state=${this.state}` + + `&redirect_uri=${redirectUri}&code_challenge=${CODE_CHALLENGE}` + + `&code_challenge_method=S256&resource=https%3A%2F%2Fapi.example.com%2Fapp%2F`; + + return `${metadata.authorization_endpoint}?${params}`; + } + + private validateAuthorizationResponse( + responseUrl: string, + metadata: any, + option: AuthorizationServerOptions, + errors: string[] + ): string { + const url = new URL(responseUrl); + + if (url.origin !== REDIRECT_URI_ORIGIN + ':' + option.port) { + errors.push(`Invalid origin of redirect URL: ${url.origin}`); + } + if (url.pathname !== REDIRECT_URI_PATH) { + errors.push(`Invalid path of redirect URL: ${url.pathname}`); + } + + const code = url.searchParams.getAll('code'); + if (code.length !== 1 || code[0] === '') { + throw new Error(`Invalid code parameter: ${code ?? 'missing'}`); + } + + const state = url.searchParams.getAll('state'); + if (state.length !== 1 || state[0] !== this.state) { + errors.push(`Invalid state parameter: ${state ?? 'missing'}`); + } + + const iss = url.searchParams.getAll('iss'); + if (iss.length > 0) { + if (iss.length !== 1 || iss[0] !== metadata.issuer) { + errors.push(`Invalid iss parameter: ${iss}`); + } + } + + if (url.searchParams.has('code_challenge')) { + errors.push('code_challenge must not be present'); + } + + if (url.searchParams.has('error')) { + errors.push(`Error parameter: ${url.searchParams.get('error')}`); + } + + return code[0]; + } + + private async requestToken( + metadata: any, + option: AuthorizationServerOptions, + code: string + ): Promise<{ body: any; headers: any } | null> { + if (!metadata?.token_endpoint) { + throw new Error('Unable to obtain token endpoint from metadata'); + } + + const authMethods = metadata.token_endpoint_auth_methods_supported || []; + let response; + if (authMethods.includes('client_secret_post')) { + const params = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: + REDIRECT_URI_ORIGIN + ':' + option.port + REDIRECT_URI_PATH, + client_id: option.clientId, + client_secret: option.secret, + code_verifier: CODE_VERIFIER + }); + + response = await request(metadata.token_endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded' + }, + body: params.toString() + }); + } else if (authMethods.includes('client_secret_basic')) { + const credentials = Buffer.from( + `${option.clientId}:${option.secret}` + ).toString('base64'); + + const params = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: + REDIRECT_URI_ORIGIN + ':' + option.port + REDIRECT_URI_PATH, + code_verifier: CODE_VERIFIER + }); + + response = await request(metadata.token_endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + authorization: `Basic ${credentials}` + }, + body: params.toString() + }); + } else { + // Supporting client authentication methods such as client_secret_jwt, private_key_jwt, and tls_client_auth requires implementing a significant amount of code. + // Their implementation is marked as TODO and these tests are skipped. + return null; + } + + if (response.statusCode !== 200) { + throw new Error(`Invalid status code: ${response.statusCode}`); + } + + const body = await response.body.json(); + return { body, headers: response.headers }; + } + + private validateTokenResponse( + response: { + body: any; + headers: any; + }, + errors: string[] + ): void { + const { body, headers } = response; + + this.assertHeader( + headers['content-type'], + 'application/json', + 'Content-Type', + errors + ); + this.assertHeader( + headers['cache-control'], + 'no-store', + 'Cache-Control', + errors + ); + + if (typeof body !== 'object' || body === null) { + throw new Error('Token response body is not an object'); + } + + if (typeof body.access_token !== 'string') { + errors.push('Missing access_token'); + } + + if (typeof body.token_type !== 'string') { + errors.push('Missing token_type'); + } + } + + private assertHeader( + value: unknown, + expected: string, + name: string, + errors: string[] + ): void { + if (typeof value !== 'string' || !value.toLowerCase().includes(expected)) { + errors.push(`Invalid ${name}: ${value ?? '(missing)'}`); + } + } + + private successCheck(details: any): ConformanceCheck { + return { + id: 'authorization-code-grant', + name: 'AuthorizationCodeGrant', + description: 'Valid authorization code grant', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.OAUTH_2_1_AUTHORIZATION_CODE_GRANT], + details + }; + } + + private failureCheck(error: unknown): ConformanceCheck { + return { + id: 'authorization-code-grant', + name: 'AuthorizationCodeGrant', + description: 'Valid authorization code grant', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: error instanceof Error ? error.message : String(error), + specReferences: [SpecReferences.OAUTH_2_1_AUTHORIZATION_CODE_GRANT] + }; + } + + private skippedCheck(reason: string): ConformanceCheck { + return { + id: 'authorization-code-grant', + name: 'AuthorizationCodeGrant', + description: 'Valid authorization code grant', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: reason, + specReferences: [SpecReferences.OAUTH_2_1_AUTHORIZATION_CODE_GRANT] + }; + } +} diff --git a/src/scenarios/authorization-server/authorization-server-metadata.test.ts b/src/scenarios/authorization-server/authorization-server-metadata.test.ts index 1c1d6bb2..3f2564d9 100644 --- a/src/scenarios/authorization-server/authorization-server-metadata.test.ts +++ b/src/scenarios/authorization-server/authorization-server-metadata.test.ts @@ -11,6 +11,13 @@ const mockedRequest = vi.mocked(request); const SERVER_URL = 'https://example.com'; const AUTHORIZATION_ENDPOINT = `${SERVER_URL}/auth`; const TOKEN_ENDPOINT = `${SERVER_URL}/token`; +const OPTION = { + url: SERVER_URL, + clientId: 'client', + secret: 'secret', + port: 3000 +}; +const details: Record = {}; const validMetadata = { issuer: SERVER_URL, @@ -33,7 +40,7 @@ describe('AuthorizationServerMetadataEndpointScenario', () => { const scenario = new AuthorizationServerMetadataEndpointScenario(); mockMetadataResponse(validMetadata); - const checks = await scenario.run(SERVER_URL); + const checks = await scenario.run(OPTION, details); expect(checks).toHaveLength(1); @@ -64,7 +71,7 @@ describe('AuthorizationServerMetadataEndpointScenario', () => { response_types_supported: validMetadata.response_types_supported }); - const checks = await scenario.run(SERVER_URL); + const checks = await scenario.run(OPTION, details); expect(checks).toHaveLength(1); @@ -80,7 +87,7 @@ describe('AuthorizationServerMetadataEndpointScenario', () => { code_challenge_methods_supported: ['plain'] }); - const checks = await scenario.run(SERVER_URL); + const checks = await scenario.run(OPTION, details); expect(checks).toHaveLength(1); diff --git a/src/scenarios/authorization-server/authorization-server-metadata.ts b/src/scenarios/authorization-server/authorization-server-metadata.ts index 00c930fc..c5cc5ac7 100644 --- a/src/scenarios/authorization-server/authorization-server-metadata.ts +++ b/src/scenarios/authorization-server/authorization-server-metadata.ts @@ -1,11 +1,13 @@ /** * Authorization server metadata endpoint test scenarios for MCP authorization servers */ +import { AuthorizationServerOptions } from '../../schemas'; import { ClientScenarioForAuthorizationServer, ConformanceCheck } from '../../types'; import { request } from 'undici'; +import { SpecReferences } from '../authorization-server/auth/spec-references'; type Status = 'SUCCESS' | 'FAILURE'; @@ -24,13 +26,16 @@ export class AuthorizationServerMetadataEndpointScenario implements ClientScenar - Return a JSON response including issuer, authorization_endpoint, token_endpoint and response_types_supported - The issuer value MUST match the URI obtained by removing the well-known URI string from the authorization server metadata URI.`; - async run(serverUrl: string): Promise { + async run( + option: AuthorizationServerOptions, + _details: Record + ): Promise { let status: Status = 'SUCCESS'; let errorMessage: string | undefined; let details: any; let response: any | null = null; try { - const wellKnownUrls = this.createWellKnownUrl(serverUrl); + const wellKnownUrls = this.createWellKnownUrl(option.url); for (const url of wellKnownUrls) { try { @@ -54,7 +59,7 @@ export class AuthorizationServerMetadataEndpointScenario implements ClientScenar const body = await this.parseJson(response); const errors: string[] = []; - this.validateMetadataBody(body, serverUrl, errors); + this.validateMetadataBody(body, option.url, errors); if (errors.length > 0) { status = 'FAILURE'; @@ -78,12 +83,7 @@ export class AuthorizationServerMetadataEndpointScenario implements ClientScenar status, timestamp: new Date().toISOString(), errorMessage, - specReferences: [ - { - id: 'Authorization-Server-Metadata', - url: 'https://datatracker.ietf.org/doc/html/rfc8414' - } - ], + specReferences: [SpecReferences.MCP_AUTH_DISCOVERY], ...(details ? { details } : {}) } ]; diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 6769b604..9db6351f 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -110,6 +110,7 @@ import { } from './client/auth/index'; import { listMetadataScenarios } from './client/auth/discovery-metadata'; import { AuthorizationServerMetadataEndpointScenario } from './authorization-server/authorization-server-metadata'; +import { AuthorizationCodeGrantScenario } from './authorization-server/authorization-code-grant'; import { HttpStandardHeadersScenario } from './client/http-standard-headers'; import { @@ -274,7 +275,8 @@ export const clientScenarios = new Map( const allClientScenariosListForAuthorizationServer: ClientScenarioForAuthorizationServer[] = [ // Authorization server scenarios - new AuthorizationServerMetadataEndpointScenario() + new AuthorizationServerMetadataEndpointScenario(), + new AuthorizationCodeGrantScenario() ]; // Client scenarios map for authorization server - built from list diff --git a/src/schemas.ts b/src/schemas.ts index 4888dd5f..e3696371 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -54,7 +54,14 @@ export const AuthorizationServerOptionsSchema = z.object({ error: (iss) => `Unknown scenario '${iss.input}'` } ) - .optional() + .optional(), + clientId: z.string().min(1, 'Client id cannot be empty'), + secret: z.string().min(1, 'Client secret cannot be empty'), + port: z + .number() + .int('Port must be an integer') + .min(1, 'Port must be >= 1') + .max(65535, 'Port must be <= 65535') }); export type AuthorizationServerOptions = z.infer< diff --git a/src/types.ts b/src/types.ts index 0a5d3a8e..d394691a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import type { RunContext } from './connection'; import type { ScenarioContext } from './mock-server'; +import { AuthorizationServerOptions } from './schemas'; export type CheckStatus = | 'SUCCESS' @@ -140,5 +141,8 @@ export interface ClientScenarioForAuthorizationServer { name: string; description: string; source: ScenarioSource; - run(serverUrl: string): Promise; + run( + option: AuthorizationServerOptions, + details: Record + ): Promise; } From 3aadb2f86137f4192b6c2495fc93ea49c133fd4a Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 23 Jun 2026 13:53:10 +0000 Subject: [PATCH 02/11] fix(auth): use import type for AuthorizationServerOptions in types.ts --- src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index d394691a..2cf00d68 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,6 @@ import type { RunContext } from './connection'; import type { ScenarioContext } from './mock-server'; -import { AuthorizationServerOptions } from './schemas'; +import type { AuthorizationServerOptions } from './schemas'; export type CheckStatus = | 'SUCCESS' From eb80940c1703acc2be33cdd033023e70b39d3aed Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 23 Jun 2026 13:53:44 +0000 Subject: [PATCH 03/11] =?UTF-8?q?fix(auth):=20make=20client-id/client-secr?= =?UTF-8?q?et=20optional;=20rename=20--secret=E2=86=92--client-secret?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.ts | 12 +++++-- src/runner/authorization-server.ts | 6 ++-- .../authorization-code-grant.test.ts | 22 ++++++------- .../authorization-code-grant.ts | 32 +++++++++---------- .../authorization-server-metadata.test.ts | 10 +++--- .../authorization-server-metadata.ts | 6 ++-- src/schemas.ts | 5 +-- src/types.ts | 2 +- 8 files changed, 51 insertions(+), 44 deletions(-) diff --git a/src/index.ts b/src/index.ts index f346242a..0e8b14f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -522,11 +522,17 @@ program ) .option('--url ', 'URL of the authorization server issuer') .option('--scenario ', 'Test scenario to run') - .requiredOption('--client-id ', 'Client ID') - .requiredOption('--secret ', 'Client Secret') + .option( + '--client-id ', + 'OAuth client ID registered with the authorization server' + ) + .option( + '--client-secret ', + 'OAuth client secret (omit for public/PKCE-only clients)' + ) .option( '-p, --port ', - 'redirect uri port', + 'Port for the local OAuth callback server; register http://127.0.0.1:/callback as a redirect URI', (value) => Number(value), 3000 ) diff --git a/src/runner/authorization-server.ts b/src/runner/authorization-server.ts index c4950ce5..6d10c9e3 100644 --- a/src/runner/authorization-server.ts +++ b/src/runner/authorization-server.ts @@ -6,7 +6,7 @@ import { createResultDir, formatPrettyChecks } from './utils'; import { AuthorizationServerOptions } from '../schemas'; export async function runAuthorizationServerConformanceTest( - option: AuthorizationServerOptions, + options: AuthorizationServerOptions, scenarioName: string, details: Record, outputDir?: string @@ -30,10 +30,10 @@ export async function runAuthorizationServerConformanceTest( const scenario = getClientScenarioForAuthorizationServer(scenarioName)!; console.log( - `Running client scenario for authorization server '${scenarioName}' against server: ${option.url}` + `Running client scenario for authorization server '${scenarioName}' against server: ${options.url}` ); - const checks = await scenario.run(option, details); + const checks = await scenario.run(options, details); if (resultDir) { await fs.writeFile( diff --git a/src/scenarios/authorization-server/authorization-code-grant.test.ts b/src/scenarios/authorization-server/authorization-code-grant.test.ts index 8649038b..0e1a1bb7 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.test.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.test.ts @@ -18,10 +18,10 @@ const SERVER_URL = 'https://example.com'; const AUTHORIZATION_ENDPOINT = `${SERVER_URL}/auth`; const TOKEN_ENDPOINT = `${SERVER_URL}/token`; -const OPTION = { +const OPTIONS = { url: SERVER_URL, clientId: 'client', - secret: 'secret', + clientSecret: 'secret', port: 3000 }; @@ -94,7 +94,7 @@ describe('AuthorizationCodeGrantScenario', () => { token_type: 'Bearer' }); - const checks = await scenario.run(OPTION, DETAILS); + const checks = await scenario.run(OPTIONS, DETAILS); expect(checks).toHaveLength(1); @@ -130,7 +130,7 @@ describe('AuthorizationCodeGrantScenario', () => { token_type: 'Bearer' }); - const checks = await scenario.run(OPTION, DETAILS); + const checks = await scenario.run(OPTIONS, DETAILS); expect(checks).toHaveLength(1); @@ -148,7 +148,7 @@ describe('AuthorizationCodeGrantScenario', () => { (state) => `http://localhost:3000/callback?state=${state}` ); - const checks = await scenario.run(OPTION, DETAILS); + const checks = await scenario.run(OPTIONS, DETAILS); expect(checks).toHaveLength(1); @@ -172,7 +172,7 @@ describe('AuthorizationCodeGrantScenario', () => { token_type: 'Bearer' }); - const checks = await scenario.run(OPTION, DETAILS); + const checks = await scenario.run(OPTIONS, DETAILS); expect(checks).toHaveLength(1); @@ -194,7 +194,7 @@ describe('AuthorizationCodeGrantScenario', () => { token_type: 'Bearer' }); - const checks = await scenario.run(OPTION, DETAILS); + const checks = await scenario.run(OPTIONS, DETAILS); expect(checks).toHaveLength(1); @@ -216,7 +216,7 @@ describe('AuthorizationCodeGrantScenario', () => { access_token: 'access-token' }); - const checks = await scenario.run(OPTION, DETAILS); + const checks = await scenario.run(OPTIONS, DETAILS); expect(checks).toHaveLength(1); @@ -248,7 +248,7 @@ describe('AuthorizationCodeGrantScenario', () => { } } as any); - const checks = await scenario.run(OPTION, DETAILS); + const checks = await scenario.run(OPTIONS, DETAILS); expect(checks).toHaveLength(1); @@ -280,7 +280,7 @@ describe('AuthorizationCodeGrantScenario', () => { } } as any); - const checks = await scenario.run(OPTION, DETAILS); + const checks = await scenario.run(OPTIONS, DETAILS); expect(checks).toHaveLength(1); @@ -298,7 +298,7 @@ describe('AuthorizationCodeGrantScenario', () => { (state) => `http://localhost:3000/callback?code=abc&state=${state}` ); - const checks = await scenario.run(OPTION, DETAILS_PRIVATE_KEY_JWT); + const checks = await scenario.run(OPTIONS, DETAILS_PRIVATE_KEY_JWT); expect(checks).toHaveLength(1); diff --git a/src/scenarios/authorization-server/authorization-code-grant.ts b/src/scenarios/authorization-server/authorization-code-grant.ts index 2808adab..c2454b18 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.ts @@ -42,7 +42,7 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz - Token response MUST return a JSON response including access_token and token_type`; async run( - option: AuthorizationServerOptions, + options: AuthorizationServerOptions, details: Record ): Promise { try { @@ -55,8 +55,8 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz } const body = resultMetadata.body; - const callback = startCallbackServer(option.port); - const authorizationRequest = this.buildAuthorizationRequest(body, option); + const callback = startCallbackServer(options.port); + const authorizationRequest = this.buildAuthorizationRequest(body, options); console.log( 'Access the following URL in your browser and complete the authentication process.' ); @@ -69,11 +69,11 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz const code = this.validateAuthorizationResponse( authorizationResponseUrl, body, - option, + options, errors ); - const tokenResponse = await this.requestToken(body, option, code); + const tokenResponse = await this.requestToken(body, options, code); if (tokenResponse === null) { return [ this.skippedCheck( @@ -101,17 +101,17 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz private buildAuthorizationRequest( metadata: any, - option: AuthorizationServerOptions + options: AuthorizationServerOptions ): string { if (!metadata?.authorization_endpoint) { throw new Error('Unable to obtain authorization endpoint from metadata'); } const redirectUri = encodeURIComponent( - `${REDIRECT_URI_ORIGIN}:${option.port}${REDIRECT_URI_PATH}` + `${REDIRECT_URI_ORIGIN}:${options.port}${REDIRECT_URI_PATH}` ); const params = - `response_type=code&client_id=${option.clientId}&state=${this.state}` + + `response_type=code&client_id=${options.clientId}&state=${this.state}` + `&redirect_uri=${redirectUri}&code_challenge=${CODE_CHALLENGE}` + `&code_challenge_method=S256&resource=https%3A%2F%2Fapi.example.com%2Fapp%2F`; @@ -121,12 +121,12 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz private validateAuthorizationResponse( responseUrl: string, metadata: any, - option: AuthorizationServerOptions, + options: AuthorizationServerOptions, errors: string[] ): string { const url = new URL(responseUrl); - if (url.origin !== REDIRECT_URI_ORIGIN + ':' + option.port) { + if (url.origin !== REDIRECT_URI_ORIGIN + ':' + options.port) { errors.push(`Invalid origin of redirect URL: ${url.origin}`); } if (url.pathname !== REDIRECT_URI_PATH) { @@ -163,7 +163,7 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz private async requestToken( metadata: any, - option: AuthorizationServerOptions, + options: AuthorizationServerOptions, code: string ): Promise<{ body: any; headers: any } | null> { if (!metadata?.token_endpoint) { @@ -177,9 +177,9 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz grant_type: 'authorization_code', code, redirect_uri: - REDIRECT_URI_ORIGIN + ':' + option.port + REDIRECT_URI_PATH, - client_id: option.clientId, - client_secret: option.secret, + REDIRECT_URI_ORIGIN + ':' + options.port + REDIRECT_URI_PATH, + client_id: options.clientId, + client_secret: options.clientSecret, code_verifier: CODE_VERIFIER }); @@ -192,14 +192,14 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz }); } else if (authMethods.includes('client_secret_basic')) { const credentials = Buffer.from( - `${option.clientId}:${option.secret}` + `${options.clientId}:${options.clientSecret}` ).toString('base64'); const params = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: - REDIRECT_URI_ORIGIN + ':' + option.port + REDIRECT_URI_PATH, + REDIRECT_URI_ORIGIN + ':' + options.port + REDIRECT_URI_PATH, code_verifier: CODE_VERIFIER }); diff --git a/src/scenarios/authorization-server/authorization-server-metadata.test.ts b/src/scenarios/authorization-server/authorization-server-metadata.test.ts index 3f2564d9..fef6fa58 100644 --- a/src/scenarios/authorization-server/authorization-server-metadata.test.ts +++ b/src/scenarios/authorization-server/authorization-server-metadata.test.ts @@ -11,10 +11,10 @@ const mockedRequest = vi.mocked(request); const SERVER_URL = 'https://example.com'; const AUTHORIZATION_ENDPOINT = `${SERVER_URL}/auth`; const TOKEN_ENDPOINT = `${SERVER_URL}/token`; -const OPTION = { +const OPTIONS = { url: SERVER_URL, clientId: 'client', - secret: 'secret', + clientSecret: 'secret', port: 3000 }; const details: Record = {}; @@ -40,7 +40,7 @@ describe('AuthorizationServerMetadataEndpointScenario', () => { const scenario = new AuthorizationServerMetadataEndpointScenario(); mockMetadataResponse(validMetadata); - const checks = await scenario.run(OPTION, details); + const checks = await scenario.run(OPTIONS, details); expect(checks).toHaveLength(1); @@ -71,7 +71,7 @@ describe('AuthorizationServerMetadataEndpointScenario', () => { response_types_supported: validMetadata.response_types_supported }); - const checks = await scenario.run(OPTION, details); + const checks = await scenario.run(OPTIONS, details); expect(checks).toHaveLength(1); @@ -87,7 +87,7 @@ describe('AuthorizationServerMetadataEndpointScenario', () => { code_challenge_methods_supported: ['plain'] }); - const checks = await scenario.run(OPTION, details); + const checks = await scenario.run(OPTIONS, details); expect(checks).toHaveLength(1); diff --git a/src/scenarios/authorization-server/authorization-server-metadata.ts b/src/scenarios/authorization-server/authorization-server-metadata.ts index c5cc5ac7..cd656031 100644 --- a/src/scenarios/authorization-server/authorization-server-metadata.ts +++ b/src/scenarios/authorization-server/authorization-server-metadata.ts @@ -27,7 +27,7 @@ export class AuthorizationServerMetadataEndpointScenario implements ClientScenar - The issuer value MUST match the URI obtained by removing the well-known URI string from the authorization server metadata URI.`; async run( - option: AuthorizationServerOptions, + options: AuthorizationServerOptions, _details: Record ): Promise { let status: Status = 'SUCCESS'; @@ -35,7 +35,7 @@ export class AuthorizationServerMetadataEndpointScenario implements ClientScenar let details: any; let response: any | null = null; try { - const wellKnownUrls = this.createWellKnownUrl(option.url); + const wellKnownUrls = this.createWellKnownUrl(options.url); for (const url of wellKnownUrls) { try { @@ -59,7 +59,7 @@ export class AuthorizationServerMetadataEndpointScenario implements ClientScenar const body = await this.parseJson(response); const errors: string[] = []; - this.validateMetadataBody(body, option.url, errors); + this.validateMetadataBody(body, options.url, errors); if (errors.length > 0) { status = 'FAILURE'; diff --git a/src/schemas.ts b/src/schemas.ts index e3696371..040aa2e3 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -55,13 +55,14 @@ export const AuthorizationServerOptionsSchema = z.object({ } ) .optional(), - clientId: z.string().min(1, 'Client id cannot be empty'), - secret: z.string().min(1, 'Client secret cannot be empty'), + clientId: z.string().min(1, 'Client id cannot be empty').optional(), + clientSecret: z.string().min(1, 'Client secret cannot be empty').optional(), port: z .number() .int('Port must be an integer') .min(1, 'Port must be >= 1') .max(65535, 'Port must be <= 65535') + .default(3000) }); export type AuthorizationServerOptions = z.infer< diff --git a/src/types.ts b/src/types.ts index 2cf00d68..4ebdd60f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -142,7 +142,7 @@ export interface ClientScenarioForAuthorizationServer { description: string; source: ScenarioSource; run( - option: AuthorizationServerOptions, + options: AuthorizationServerOptions, details: Record ): Promise; } From 33aacf600d130ad7a16cf190d261745322c7aab0 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 23 Jun 2026 13:54:11 +0000 Subject: [PATCH 04/11] fix(auth): generate PKCE verifier per-run; derive S256 challenge --- .../authorization-code-grant.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/scenarios/authorization-server/authorization-code-grant.ts b/src/scenarios/authorization-server/authorization-code-grant.ts index c2454b18..4663ccf6 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.ts @@ -7,18 +7,17 @@ import { } from '../../types'; import { startCallbackServer } from '../authorization-server/auth/helpers/createCallbackServer'; import { request } from 'undici'; -import { randomBytes } from 'crypto'; +import { createHash, randomBytes } from 'crypto'; import { AuthorizationServerOptions } from '../../schemas'; import { SpecReferences } from '../authorization-server/auth/spec-references'; const REDIRECT_URI_ORIGIN = 'http://localhost'; const REDIRECT_URI_PATH = '/callback'; -// These values are from RFC 7636 Appendix B. -const CODE_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; -const CODE_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthorizationServer { private state = randomBytes(32).toString('base64url'); + private codeVerifier = ''; + private codeChallenge = ''; name = 'authorization-code-grant'; readonly source = { introducedIn: '2025-03-26' } as const; description = `Test authorization code grant. @@ -47,6 +46,10 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz ): Promise { try { this.state = randomBytes(32).toString('base64url'); + this.codeVerifier = randomBytes(32).toString('base64url'); + this.codeChallenge = createHash('sha256') + .update(this.codeVerifier) + .digest('base64url'); const resultMetadata = details[ 'authorization-server-metadata-endpoint' ] as { body?: Record }; @@ -112,7 +115,7 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz ); const params = `response_type=code&client_id=${options.clientId}&state=${this.state}` + - `&redirect_uri=${redirectUri}&code_challenge=${CODE_CHALLENGE}` + + `&redirect_uri=${redirectUri}&code_challenge=${this.codeChallenge}` + `&code_challenge_method=S256&resource=https%3A%2F%2Fapi.example.com%2Fapp%2F`; return `${metadata.authorization_endpoint}?${params}`; @@ -180,7 +183,7 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz REDIRECT_URI_ORIGIN + ':' + options.port + REDIRECT_URI_PATH, client_id: options.clientId, client_secret: options.clientSecret, - code_verifier: CODE_VERIFIER + code_verifier: this.codeVerifier }); response = await request(metadata.token_endpoint, { @@ -200,7 +203,7 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz code, redirect_uri: REDIRECT_URI_ORIGIN + ':' + options.port + REDIRECT_URI_PATH, - code_verifier: CODE_VERIFIER + code_verifier: this.codeVerifier }); response = await request(metadata.token_endpoint, { From 8c06567919a7050e0c48da5b42b888ba4b635d05 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 23 Jun 2026 13:54:21 +0000 Subject: [PATCH 05/11] fix(auth): build authorization URL with URLSearchParams; drop hardcoded resource --- .../authorization-code-grant.test.ts | 18 +++++++++--------- .../authorization-code-grant.ts | 19 ++++++++++--------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/scenarios/authorization-server/authorization-code-grant.test.ts b/src/scenarios/authorization-server/authorization-code-grant.test.ts index 0e1a1bb7..7b57d638 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.test.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.test.ts @@ -86,7 +86,7 @@ describe('AuthorizationCodeGrantScenario', () => { mockCallbackServer( scenario, (state) => - `http://localhost:3000/callback?code=abc&state=${state}&iss=${SERVER_URL}` + `http://127.0.0.1:3000/callback?code=abc&state=${state}&iss=${SERVER_URL}` ); mockTokenResponse({ @@ -122,7 +122,7 @@ describe('AuthorizationCodeGrantScenario', () => { mockCallbackServer( scenario, - () => 'http://localhost:3000/callback?code=abc&state=invalid' + () => 'http://127.0.0.1:3000/callback?code=abc&state=invalid' ); mockTokenResponse({ @@ -145,7 +145,7 @@ describe('AuthorizationCodeGrantScenario', () => { mockCallbackServer( scenario, - (state) => `http://localhost:3000/callback?state=${state}` + (state) => `http://127.0.0.1:3000/callback?state=${state}` ); const checks = await scenario.run(OPTIONS, DETAILS); @@ -164,7 +164,7 @@ describe('AuthorizationCodeGrantScenario', () => { mockCallbackServer( scenario, (state) => - `http://localhost:3000/callback?code=abc&state=${state}&iss=https://evil.example.com` + `http://127.0.0.1:3000/callback?code=abc&state=${state}&iss=https://evil.example.com` ); mockTokenResponse({ @@ -187,7 +187,7 @@ describe('AuthorizationCodeGrantScenario', () => { mockCallbackServer( scenario, - (state) => `http://localhost:3000/callback?code=abc&state=${state}` + (state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}` ); mockTokenResponse({ @@ -209,7 +209,7 @@ describe('AuthorizationCodeGrantScenario', () => { mockCallbackServer( scenario, - (state) => `http://localhost:3000/callback?code=abc&state=${state}` + (state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}` ); mockTokenResponse({ @@ -231,7 +231,7 @@ describe('AuthorizationCodeGrantScenario', () => { mockCallbackServer( scenario, - (state) => `http://localhost:3000/callback?code=abc&state=${state}` + (state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}` ); mockedRequest.mockResolvedValue({ @@ -263,7 +263,7 @@ describe('AuthorizationCodeGrantScenario', () => { mockCallbackServer( scenario, - (state) => `http://localhost:3000/callback?code=abc&state=${state}` + (state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}` ); mockedRequest.mockResolvedValue({ @@ -295,7 +295,7 @@ describe('AuthorizationCodeGrantScenario', () => { mockCallbackServer( scenario, - (state) => `http://localhost:3000/callback?code=abc&state=${state}` + (state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}` ); const checks = await scenario.run(OPTIONS, DETAILS_PRIVATE_KEY_JWT); diff --git a/src/scenarios/authorization-server/authorization-code-grant.ts b/src/scenarios/authorization-server/authorization-code-grant.ts index 4663ccf6..52c8f42e 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.ts @@ -11,7 +11,7 @@ import { createHash, randomBytes } from 'crypto'; import { AuthorizationServerOptions } from '../../schemas'; import { SpecReferences } from '../authorization-server/auth/spec-references'; -const REDIRECT_URI_ORIGIN = 'http://localhost'; +const REDIRECT_URI_ORIGIN = 'http://127.0.0.1'; const REDIRECT_URI_PATH = '/callback'; export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthorizationServer { @@ -110,15 +110,16 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz throw new Error('Unable to obtain authorization endpoint from metadata'); } - const redirectUri = encodeURIComponent( - `${REDIRECT_URI_ORIGIN}:${options.port}${REDIRECT_URI_PATH}` - ); - const params = - `response_type=code&client_id=${options.clientId}&state=${this.state}` + - `&redirect_uri=${redirectUri}&code_challenge=${this.codeChallenge}` + - `&code_challenge_method=S256&resource=https%3A%2F%2Fapi.example.com%2Fapp%2F`; + const params = new URLSearchParams({ + response_type: 'code', + client_id: options.clientId ?? '', + state: this.state, + redirect_uri: `${REDIRECT_URI_ORIGIN}:${options.port}${REDIRECT_URI_PATH}`, + code_challenge: this.codeChallenge, + code_challenge_method: 'S256' + }); - return `${metadata.authorization_endpoint}?${params}`; + return `${metadata.authorization_endpoint}?${params.toString()}`; } private validateAuthorizationResponse( From 711c7cb5dd0938472da023eb08ed9663e144f574 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 23 Jun 2026 13:54:34 +0000 Subject: [PATCH 06/11] fix(auth): default token_endpoint_auth_methods_supported to client_secret_basic; add 'none' branch --- .../authorization-code-grant.ts | 73 +++++++++---------- 1 file changed, 33 insertions(+), 40 deletions(-) diff --git a/src/scenarios/authorization-server/authorization-code-grant.ts b/src/scenarios/authorization-server/authorization-code-grant.ts index 52c8f42e..93b3c8b3 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.ts @@ -174,53 +174,46 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz throw new Error('Unable to obtain token endpoint from metadata'); } - const authMethods = metadata.token_endpoint_auth_methods_supported || []; - let response; - if (authMethods.includes('client_secret_post')) { - const params = new URLSearchParams({ - grant_type: 'authorization_code', - code, - redirect_uri: - REDIRECT_URI_ORIGIN + ':' + options.port + REDIRECT_URI_PATH, - client_id: options.clientId, - client_secret: options.clientSecret, - code_verifier: this.codeVerifier - }); - - response = await request(metadata.token_endpoint, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded' - }, - body: params.toString() - }); + // RFC 8414 §2: omitted token_endpoint_auth_methods_supported means + // the default is "client_secret_basic". + const authMethods: string[] = + metadata.token_endpoint_auth_methods_supported ?? ['client_secret_basic']; + const redirectUri = `${REDIRECT_URI_ORIGIN}:${options.port}${REDIRECT_URI_PATH}`; + + const params = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + code_verifier: this.codeVerifier + }); + const headers: Record = { + 'content-type': 'application/x-www-form-urlencoded' + }; + + if (!options.clientSecret || authMethods.includes('none')) { + // Public client (PKCE-only). RFC 6749 §3.2.1: client_id in the body. + params.set('client_id', options.clientId!); + } else if (authMethods.includes('client_secret_post')) { + params.set('client_id', options.clientId!); + params.set('client_secret', options.clientSecret); } else if (authMethods.includes('client_secret_basic')) { + // RFC 6749 §2.3.1: form-urlencode each component before base64. const credentials = Buffer.from( - `${options.clientId}:${options.clientSecret}` + `${encodeURIComponent(options.clientId!)}:${encodeURIComponent(options.clientSecret)}` ).toString('base64'); - - const params = new URLSearchParams({ - grant_type: 'authorization_code', - code, - redirect_uri: - REDIRECT_URI_ORIGIN + ':' + options.port + REDIRECT_URI_PATH, - code_verifier: this.codeVerifier - }); - - response = await request(metadata.token_endpoint, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - authorization: `Basic ${credentials}` - }, - body: params.toString() - }); + headers.authorization = `Basic ${credentials}`; } else { - // Supporting client authentication methods such as client_secret_jwt, private_key_jwt, and tls_client_auth requires implementing a significant amount of code. - // Their implementation is marked as TODO and these tests are skipped. + // client_secret_jwt / private_key_jwt / tls_client_auth are not yet + // implemented; skip rather than fail. return null; } + const response = await request(metadata.token_endpoint, { + method: 'POST', + headers, + body: params.toString() + }); + if (response.statusCode !== 200) { throw new Error(`Invalid status code: ${response.statusCode}`); } From 2913e354a1bb96e25d1bd2af15c150295d5d100f Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 23 Jun 2026 13:55:08 +0000 Subject: [PATCH 07/11] fix(auth): check auth-method support before starting callback server --- .../authorization-code-grant.ts | 89 +++++++++++++------ 1 file changed, 62 insertions(+), 27 deletions(-) diff --git a/src/scenarios/authorization-server/authorization-code-grant.ts b/src/scenarios/authorization-server/authorization-code-grant.ts index 93b3c8b3..2b596ec0 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.ts @@ -45,21 +45,42 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz details: Record ): Promise { try { + if (!options.clientId) { + return [ + this.skippedCheck('authorization-code-grant requires --client-id') + ]; + } + this.state = randomBytes(32).toString('base64url'); this.codeVerifier = randomBytes(32).toString('base64url'); this.codeChallenge = createHash('sha256') .update(this.codeVerifier) .digest('base64url'); + const resultMetadata = details[ 'authorization-server-metadata-endpoint' ] as { body?: Record }; - if (!resultMetadata) { + if (!resultMetadata?.body) { throw new Error('Invalid authorization server metadata'); } - const body = resultMetadata.body; + const metadata = resultMetadata.body; + + // Decide how we'll authenticate to the token endpoint *before* + // binding a port and asking the user to open a browser. + const authMethod = this.selectTokenAuthMethod(metadata, options); + if (authMethod === null) { + return [ + this.skippedCheck( + 'Server does not support client_secret_post, client_secret_basic, or none auth methods' + ) + ]; + } const callback = startCallbackServer(options.port); - const authorizationRequest = this.buildAuthorizationRequest(body, options); + const authorizationRequest = this.buildAuthorizationRequest( + metadata, + options + ); console.log( 'Access the following URL in your browser and complete the authentication process.' ); @@ -71,19 +92,17 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz const errors: string[] = []; const code = this.validateAuthorizationResponse( authorizationResponseUrl, - body, + metadata, options, errors ); - const tokenResponse = await this.requestToken(body, options, code); - if (tokenResponse === null) { - return [ - this.skippedCheck( - 'Server does not support client_secret_post or client_secret_basic auth methods' - ) - ]; - } + const tokenResponse = await this.requestToken( + metadata, + options, + code, + authMethod + ); this.validateTokenResponse(tokenResponse, errors); if (errors.length > 0) { @@ -165,19 +184,39 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz return code[0]; } + private selectTokenAuthMethod( + metadata: any, + options: AuthorizationServerOptions + ): 'none' | 'client_secret_post' | 'client_secret_basic' | null { + // RFC 8414 §2: omitted token_endpoint_auth_methods_supported means + // the default is "client_secret_basic". + const authMethods: string[] = + metadata.token_endpoint_auth_methods_supported ?? ['client_secret_basic']; + + if (!options.clientSecret || authMethods.includes('none')) { + return 'none'; + } + if (authMethods.includes('client_secret_post')) { + return 'client_secret_post'; + } + if (authMethods.includes('client_secret_basic')) { + return 'client_secret_basic'; + } + // client_secret_jwt / private_key_jwt / tls_client_auth are not yet + // implemented; skip rather than fail. + return null; + } + private async requestToken( metadata: any, options: AuthorizationServerOptions, - code: string - ): Promise<{ body: any; headers: any } | null> { + code: string, + authMethod: 'none' | 'client_secret_post' | 'client_secret_basic' + ): Promise<{ body: any; headers: any }> { if (!metadata?.token_endpoint) { throw new Error('Unable to obtain token endpoint from metadata'); } - // RFC 8414 §2: omitted token_endpoint_auth_methods_supported means - // the default is "client_secret_basic". - const authMethods: string[] = - metadata.token_endpoint_auth_methods_supported ?? ['client_secret_basic']; const redirectUri = `${REDIRECT_URI_ORIGIN}:${options.port}${REDIRECT_URI_PATH}`; const params = new URLSearchParams({ @@ -190,22 +229,18 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz 'content-type': 'application/x-www-form-urlencoded' }; - if (!options.clientSecret || authMethods.includes('none')) { + if (authMethod === 'none') { // Public client (PKCE-only). RFC 6749 §3.2.1: client_id in the body. params.set('client_id', options.clientId!); - } else if (authMethods.includes('client_secret_post')) { + } else if (authMethod === 'client_secret_post') { params.set('client_id', options.clientId!); - params.set('client_secret', options.clientSecret); - } else if (authMethods.includes('client_secret_basic')) { + params.set('client_secret', options.clientSecret!); + } else { // RFC 6749 §2.3.1: form-urlencode each component before base64. const credentials = Buffer.from( - `${encodeURIComponent(options.clientId!)}:${encodeURIComponent(options.clientSecret)}` + `${encodeURIComponent(options.clientId!)}:${encodeURIComponent(options.clientSecret!)}` ).toString('base64'); headers.authorization = `Basic ${credentials}`; - } else { - // client_secret_jwt / private_key_jwt / tls_client_auth are not yet - // implemented; skip rather than fail. - return null; } const response = await request(metadata.token_endpoint, { From cfdc3fdf44a5a8632ab5f2b879237d325fcf45ef Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 23 Jun 2026 13:55:30 +0000 Subject: [PATCH 08/11] fix(auth): check error param first; treat state mismatch as fatal; drop fabricated code_challenge assertion --- .../authorization-code-grant.test.ts | 30 ++++++++++++++---- .../authorization-code-grant.ts | 31 ++++++++++--------- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/scenarios/authorization-server/authorization-code-grant.test.ts b/src/scenarios/authorization-server/authorization-code-grant.test.ts index 7b57d638..c9f83c9b 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.test.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.test.ts @@ -117,7 +117,7 @@ describe('AuthorizationCodeGrantScenario', () => { expect((check.details as any).body.token_type).toBe('Bearer'); }); - it('returns FAILURE when state parameter is invalid', async () => { + it('returns FAILURE when state parameter is invalid and does not request a token', async () => { const scenario = new AuthorizationCodeGrantScenario(); mockCallbackServer( @@ -125,11 +125,6 @@ describe('AuthorizationCodeGrantScenario', () => { () => 'http://127.0.0.1:3000/callback?code=abc&state=invalid' ); - mockTokenResponse({ - access_token: 'access-token', - token_type: 'Bearer' - }); - const checks = await scenario.run(OPTIONS, DETAILS); expect(checks).toHaveLength(1); @@ -138,6 +133,29 @@ describe('AuthorizationCodeGrantScenario', () => { expect(check.status).toBe('FAILURE'); expect(check.errorMessage).toContain('Invalid state parameter'); + // CSRF enforcement: the token endpoint must never be called when state + // doesn't bind to the request we sent. + expect(mockedRequest).not.toHaveBeenCalled(); + }); + + it('returns FAILURE when the authorization response carries an error parameter', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + () => + 'http://127.0.0.1:3000/callback?error=access_denied&error_description=nope' + ); + + const checks = await scenario.run(OPTIONS, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Authorization error: access_denied'); + expect(mockedRequest).not.toHaveBeenCalled(); }); it('returns FAILURE when code parameter is missing', async () => { diff --git a/src/scenarios/authorization-server/authorization-code-grant.ts b/src/scenarios/authorization-server/authorization-code-grant.ts index 2b596ec0..7470249e 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.ts @@ -33,7 +33,6 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz - The state parameter in the authorization response MUST match the state parameter in the authorization request query parameters if the state parameter is present in the authorization request query parameters - The iss parameter in the authorization response MUST match the issuer claim of authorization server metadata if the iss parameter is present in the authorization response query parameters - The code, state and iss parameters MUST NOT appear more than once -- The code_challenge parameter MUST NOT be present in the authorization response query parameters - The error parameter MUST NOT be present in the authorization response query parameters - HTTP response status code of token response MUST be 200 OK - Content-Type header of token response MUST be application/json @@ -149,6 +148,14 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz ): string { const url = new URL(responseUrl); + // RFC 6749 §4.1.2.1: an error response and a code response are mutually + // exclusive. Surface the AS-reported error before any other validation. + if (url.searchParams.has('error')) { + const error = url.searchParams.get('error'); + const desc = url.searchParams.get('error_description'); + throw new Error(`Authorization error: ${error} ${desc ?? ''}`.trim()); + } + if (url.origin !== REDIRECT_URI_ORIGIN + ':' + options.port) { errors.push(`Invalid origin of redirect URL: ${url.origin}`); } @@ -156,14 +163,16 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz errors.push(`Invalid path of redirect URL: ${url.pathname}`); } - const code = url.searchParams.getAll('code'); - if (code.length !== 1 || code[0] === '') { - throw new Error(`Invalid code parameter: ${code ?? 'missing'}`); - } - + // CSRF binding: state mismatch is fatal — never proceed to token + // exchange with an unbound authorization response. const state = url.searchParams.getAll('state'); if (state.length !== 1 || state[0] !== this.state) { - errors.push(`Invalid state parameter: ${state ?? 'missing'}`); + throw new Error(`Invalid state parameter: ${state.join(',') || 'missing'}`); + } + + const code = url.searchParams.getAll('code'); + if (code.length !== 1 || code[0] === '') { + throw new Error(`Invalid code parameter: ${code.join(',') || 'missing'}`); } const iss = url.searchParams.getAll('iss'); @@ -173,14 +182,6 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz } } - if (url.searchParams.has('code_challenge')) { - errors.push('code_challenge must not be present'); - } - - if (url.searchParams.has('error')) { - errors.push(`Error parameter: ${url.searchParams.get('error')}`); - } - return code[0]; } From c4513a72764a51d0ceb046d19e7ad01ffbceae58 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 23 Jun 2026 13:55:39 +0000 Subject: [PATCH 09/11] fix(auth): redact tokens from check details --- .../authorization-code-grant.test.ts | 6 +++-- .../authorization-code-grant.ts | 23 ++++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/scenarios/authorization-server/authorization-code-grant.test.ts b/src/scenarios/authorization-server/authorization-code-grant.test.ts index c9f83c9b..af5cef0f 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.test.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.test.ts @@ -90,7 +90,8 @@ describe('AuthorizationCodeGrantScenario', () => { ); mockTokenResponse({ - access_token: 'access-token', + access_token: 'eyJhbGciOiJIUzI1NiJ9.payload.sig0123', + refresh_token: 'short', token_type: 'Bearer' }); @@ -113,7 +114,8 @@ describe('AuthorizationCodeGrantScenario', () => { 'code=abc' ); - expect((check.details as any).body.access_token).toBe('access-token'); + expect((check.details as any).body.access_token).toBe('eyJh…0123 (len=36)'); + expect((check.details as any).body.refresh_token).toBe('[redacted, len=5]'); expect((check.details as any).body.token_type).toBe('Bearer'); }); diff --git a/src/scenarios/authorization-server/authorization-code-grant.ts b/src/scenarios/authorization-server/authorization-code-grant.ts index 7470249e..843aa07e 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.ts @@ -14,6 +14,27 @@ import { SpecReferences } from '../authorization-server/auth/spec-references'; const REDIRECT_URI_ORIGIN = 'http://127.0.0.1'; const REDIRECT_URI_PATH = '/callback'; +const REDACTED_KEYS = ['access_token', 'refresh_token', 'id_token'] as const; + +/** + * Mask live token material so it never lands in checks.json. Keep a short + * prefix/suffix so the value can still be correlated against AS logs. + * Tokens shorter than 16 chars are fully redacted. + */ +function redactTokens(body: Record): Record { + const out: Record = { ...body }; + for (const key of REDACTED_KEYS) { + const value = out[key]; + if (typeof value === 'string') { + out[key] = + value.length < 16 + ? `[redacted, len=${value.length}]` + : `${value.slice(0, 4)}…${value.slice(-4)} (len=${value.length})`; + } + } + return out; +} + export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthorizationServer { private state = randomBytes(32).toString('base64url'); private codeVerifier = ''; @@ -112,7 +133,7 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz this.successCheck({ authorizationRequest, authorizationResponseUrl, - body: tokenResponse.body + body: redactTokens(tokenResponse.body) }) ]; } catch (error) { From c1506b3fec9768fb89013f0af861ea54164531f3 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 23 Jun 2026 13:56:12 +0000 Subject: [PATCH 10/11] fix(auth): harden callback server (path-scoped, close(), error handler, clear timeout) --- .../auth/helpers/createCallbackServer.ts | 45 +++++++---- .../authorization-code-grant.test.ts | 3 +- .../authorization-code-grant.ts | 81 ++++++++++--------- 3 files changed, 76 insertions(+), 53 deletions(-) diff --git a/src/scenarios/authorization-server/auth/helpers/createCallbackServer.ts b/src/scenarios/authorization-server/auth/helpers/createCallbackServer.ts index 0216dd47..f6780dd1 100644 --- a/src/scenarios/authorization-server/auth/helpers/createCallbackServer.ts +++ b/src/scenarios/authorization-server/auth/helpers/createCallbackServer.ts @@ -2,39 +2,56 @@ import express from 'express'; export interface CallbackServer { waitForCallback: (timeoutMs: number) => Promise; + close: () => void; } export function startCallbackServer(port: number): CallbackServer { const app = express(); let resolveFn: (url: string) => void; + let rejectFn: (err: Error) => void; - const promise = new Promise((resolve) => { + const promise = new Promise((resolve, reject) => { resolveFn = resolve; + rejectFn = reject; }); const server = app.listen(port, '127.0.0.1', () => { - console.log(`Callback server started: http://localhost:${port}`); + console.log(`Callback server started: http://127.0.0.1:${port}`); }); - app.use((req, res) => { - const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`; + server.on('error', (err) => { + rejectFn(err instanceof Error ? err : new Error(String(err))); + }); + + app.get('/callback', (req, res) => { + // Do not derive origin from the client-supplied Host header — reconstruct + // from the bind address so a forged Host can't influence validation. + const fullUrl = `http://127.0.0.1:${port}${req.originalUrl}`; res.send('OK. You can close this page.'); server.close(); resolveFn(fullUrl); }); + const close = () => { + server.close(); + }; + return { - waitForCallback: (timeoutMs: number) => - Promise.race([ - promise, - new Promise((_, reject) => - setTimeout(() => { - server.close(); - reject(new Error('Timeout: No callback received')); - }, timeoutMs) - ) - ]) + close, + waitForCallback: (timeoutMs: number) => { + let timer: NodeJS.Timeout; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + server.close(); + reject(new Error('Timeout: No callback received')); + }, timeoutMs); + timer.unref(); + }); + return Promise.race([promise, timeout]).finally(() => + clearTimeout(timer) + ); + } }; } diff --git a/src/scenarios/authorization-server/authorization-code-grant.test.ts b/src/scenarios/authorization-server/authorization-code-grant.test.ts index af5cef0f..6b51bf69 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.test.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.test.ts @@ -58,7 +58,8 @@ function mockCallbackServer( mockedStartCallbackServer.mockReturnValue({ waitForCallback: vi.fn().mockImplementation(async () => { return buildUrl((scenario as any).state); - }) + }), + close: vi.fn() } as any); } diff --git a/src/scenarios/authorization-server/authorization-code-grant.ts b/src/scenarios/authorization-server/authorization-code-grant.ts index 843aa07e..b448b685 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.ts @@ -97,45 +97,50 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz } const callback = startCallbackServer(options.port); - const authorizationRequest = this.buildAuthorizationRequest( - metadata, - options - ); - console.log( - 'Access the following URL in your browser and complete the authentication process.' - ); - console.log(authorizationRequest); - console.log(''); - - const authorizationResponseUrl = await callback.waitForCallback(300_000); - - const errors: string[] = []; - const code = this.validateAuthorizationResponse( - authorizationResponseUrl, - metadata, - options, - errors - ); - - const tokenResponse = await this.requestToken( - metadata, - options, - code, - authMethod - ); - this.validateTokenResponse(tokenResponse, errors); - - if (errors.length > 0) { - return [this.failureCheck(errors.join(', '))]; - } - - return [ - this.successCheck({ - authorizationRequest, + try { + const authorizationRequest = this.buildAuthorizationRequest( + metadata, + options + ); + console.log( + 'Access the following URL in your browser and complete the authentication process.' + ); + console.log(authorizationRequest); + console.log(''); + + const authorizationResponseUrl = + await callback.waitForCallback(300_000); + + const errors: string[] = []; + const code = this.validateAuthorizationResponse( authorizationResponseUrl, - body: redactTokens(tokenResponse.body) - }) - ]; + metadata, + options, + errors + ); + + const tokenResponse = await this.requestToken( + metadata, + options, + code, + authMethod + ); + this.validateTokenResponse(tokenResponse, errors); + + if (errors.length > 0) { + return [this.failureCheck(errors.join(', '))]; + } + + return [ + this.successCheck({ + authorizationRequest, + authorizationResponseUrl, + body: redactTokens(tokenResponse.body) + }) + ]; + } finally { + callback.close(); + } } catch (error) { return [this.failureCheck(error)]; } From c58073e62dc9aca42f0f2472c55979dc0e1a3e6d Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 23 Jun 2026 13:56:19 +0000 Subject: [PATCH 11/11] feat(auth): print redirect URI and timeout hint before browser prompt --- .../authorization-server/authorization-code-grant.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/scenarios/authorization-server/authorization-code-grant.ts b/src/scenarios/authorization-server/authorization-code-grant.ts index b448b685..48bcdeed 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.ts @@ -102,11 +102,17 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz metadata, options ); + console.log( + `Ensure ${REDIRECT_URI_ORIGIN}:${options.port}${REDIRECT_URI_PATH} is registered as a redirect URI for client '${options.clientId}'.` + ); console.log( 'Access the following URL in your browser and complete the authentication process.' ); console.log(authorizationRequest); console.log(''); + console.log( + 'Waiting up to 5 minutes for the authorization callback...' + ); const authorizationResponseUrl = await callback.waitForCallback(300_000); @@ -193,7 +199,9 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz // exchange with an unbound authorization response. const state = url.searchParams.getAll('state'); if (state.length !== 1 || state[0] !== this.state) { - throw new Error(`Invalid state parameter: ${state.join(',') || 'missing'}`); + throw new Error( + `Invalid state parameter: ${state.join(',') || 'missing'}` + ); } const code = url.searchParams.getAll('code');