diff --git a/src/index.ts b/src/index.ts index 1893423b..0e8b14f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -522,6 +522,20 @@ program ) .option('--url ', 'URL of the authorization server issuer') .option('--scenario ', 'Test scenario to run') + .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 ', + 'Port for the local OAuth callback server; register http://127.0.0.1:/callback as a redirect URI', + (value) => Number(value), + 3000 + ) .option('-o, --output-dir ', 'Save results to this directory') .option( '--spec-version ', @@ -575,9 +589,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 +620,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..6d10c9e3 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, + options: 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: ${options.url}` ); - const checks = await scenario.run(serverUrl); + const checks = await scenario.run(options, 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..f6780dd1 --- /dev/null +++ b/src/scenarios/authorization-server/auth/helpers/createCallbackServer.ts @@ -0,0 +1,57 @@ +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, reject) => { + resolveFn = resolve; + rejectFn = reject; + }); + + const server = app.listen(port, '127.0.0.1', () => { + console.log(`Callback server started: http://127.0.0.1:${port}`); + }); + + 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 { + 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/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..6b51bf69 --- /dev/null +++ b/src/scenarios/authorization-server/authorization-code-grant.test.ts @@ -0,0 +1,330 @@ +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 OPTIONS = { + url: SERVER_URL, + clientId: 'client', + clientSecret: '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); + }), + close: vi.fn() + } 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://127.0.0.1:3000/callback?code=abc&state=${state}&iss=${SERVER_URL}` + ); + + mockTokenResponse({ + access_token: 'eyJhbGciOiJIUzI1NiJ9.payload.sig0123', + refresh_token: 'short', + token_type: 'Bearer' + }); + + const checks = await scenario.run(OPTIONS, 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('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'); + }); + + it('returns FAILURE when state parameter is invalid and does not request a token', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + () => 'http://127.0.0.1:3000/callback?code=abc&state=invalid' + ); + + const checks = await scenario.run(OPTIONS, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + 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 () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://127.0.0.1:3000/callback?state=${state}` + ); + + const checks = await scenario.run(OPTIONS, 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://127.0.0.1:3000/callback?code=abc&state=${state}&iss=https://evil.example.com` + ); + + mockTokenResponse({ + access_token: 'access-token', + token_type: 'Bearer' + }); + + const checks = await scenario.run(OPTIONS, 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://127.0.0.1:3000/callback?code=abc&state=${state}` + ); + + mockTokenResponse({ + token_type: 'Bearer' + }); + + const checks = await scenario.run(OPTIONS, 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://127.0.0.1:3000/callback?code=abc&state=${state}` + ); + + mockTokenResponse({ + access_token: 'access-token' + }); + + const checks = await scenario.run(OPTIONS, 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://127.0.0.1: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(OPTIONS, 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://127.0.0.1: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(OPTIONS, 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://127.0.0.1:3000/callback?code=abc&state=${state}` + ); + + const checks = await scenario.run(OPTIONS, 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..48bcdeed --- /dev/null +++ b/src/scenarios/authorization-server/authorization-code-grant.ts @@ -0,0 +1,376 @@ +/** + * 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 { createHash, randomBytes } from 'crypto'; +import { AuthorizationServerOptions } from '../../schemas'; +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 = ''; + private codeChallenge = ''; + 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 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( + options: AuthorizationServerOptions, + 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?.body) { + throw new Error('Invalid authorization server metadata'); + } + 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); + try { + const authorizationRequest = this.buildAuthorizationRequest( + 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); + + 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, + authorizationResponseUrl, + body: redactTokens(tokenResponse.body) + }) + ]; + } finally { + callback.close(); + } + } catch (error) { + return [this.failureCheck(error)]; + } + } + + private buildAuthorizationRequest( + metadata: any, + options: AuthorizationServerOptions + ): string { + if (!metadata?.authorization_endpoint) { + throw new Error('Unable to obtain authorization endpoint from metadata'); + } + + 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.toString()}`; + } + + private validateAuthorizationResponse( + responseUrl: string, + metadata: any, + options: AuthorizationServerOptions, + errors: string[] + ): 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}`); + } + if (url.pathname !== REDIRECT_URI_PATH) { + errors.push(`Invalid path of redirect URL: ${url.pathname}`); + } + + // 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) { + 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'); + if (iss.length > 0) { + if (iss.length !== 1 || iss[0] !== metadata.issuer) { + errors.push(`Invalid iss parameter: ${iss}`); + } + } + + 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, + 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'); + } + + 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 (authMethod === 'none') { + // Public client (PKCE-only). RFC 6749 §3.2.1: client_id in the body. + params.set('client_id', options.clientId!); + } else if (authMethod === 'client_secret_post') { + params.set('client_id', options.clientId!); + 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!)}` + ).toString('base64'); + headers.authorization = `Basic ${credentials}`; + } + + 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}`); + } + + 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..fef6fa58 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 OPTIONS = { + url: SERVER_URL, + clientId: 'client', + clientSecret: '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(OPTIONS, 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(OPTIONS, 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(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 00c930fc..cd656031 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( + options: 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(options.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, options.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..040aa2e3 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -54,7 +54,15 @@ export const AuthorizationServerOptionsSchema = z.object({ error: (iss) => `Unknown scenario '${iss.input}'` } ) - .optional() + .optional(), + 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 0a5d3a8e..4ebdd60f 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 type { 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( + options: AuthorizationServerOptions, + details: Record + ): Promise; }