diff --git a/src/index.ts b/src/index.ts index 64d1eaaa..9a6be418 100644 --- a/src/index.ts +++ b/src/index.ts @@ -463,6 +463,14 @@ program 'Run conformance tests against an authorization server implementation' ) .requiredOption('--url ', 'URL of the authorization server issuer') + .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 ', @@ -491,14 +499,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 5b4094b5..10a0fd82 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 } 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/authorization-code-grant.test.ts b/src/scenarios/authorization-server/authorization-code-grant.test.ts new file mode 100644 index 00000000..3f545855 --- /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 '../client/auth/helpers/createCallbackServer'; + +vi.mock('undici', () => ({ + request: vi.fn() +})); + +vi.mock('../client/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..e36da077 --- /dev/null +++ b/src/scenarios/authorization-server/authorization-code-grant.ts @@ -0,0 +1,308 @@ +/** + * Authorization code grant test scenarios for MCP authorization servers + */ +import { + ClientScenarioForAuthorizationServer, + ConformanceCheck, + SpecVersion +} from '../../types'; +import { startCallbackServer } from '../client/auth/helpers/createCallbackServer'; +import { request } from 'undici'; +import { randomBytes } from 'crypto'; +import { AuthorizationServerOptions } from '../../schemas'; +import { SpecReferences } from '../client/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'; + specVersions: SpecVersion[] = ['2025-03-26', '2025-06-18', '2025-11-25']; + 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 { + 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 1cf862b8..59871815 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 96b36699..112c175b 100644 --- a/src/scenarios/authorization-server/authorization-server-metadata.ts +++ b/src/scenarios/authorization-server/authorization-server-metadata.ts @@ -1,12 +1,14 @@ /** * Authorization server metadata endpoint test scenarios for MCP authorization servers */ +import { AuthorizationServerOptions } from '../../schemas'; import { ClientScenarioForAuthorizationServer, ConformanceCheck, SpecVersion } from '../../types'; import { request } from 'undici'; +import { SpecReferences } from '../client/auth/spec-references'; type Status = 'SUCCESS' | 'FAILURE'; @@ -25,13 +27,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 { @@ -55,7 +60,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'; @@ -79,12 +84,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/client/auth/helpers/createCallbackServer.ts b/src/scenarios/client/auth/helpers/createCallbackServer.ts new file mode 100644 index 00000000..0216dd47 --- /dev/null +++ b/src/scenarios/client/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/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 768dd65f..e12afcf2 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -65,6 +65,10 @@ export const SpecReferences: { [key: string]: SpecReference } = { id: 'RFC-7523-JWT-Client-Auth', url: 'https://datatracker.ietf.org/doc/html/rfc7523#section-2.2' }, + 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' + }, OAUTH_2_1_CLIENT_CREDENTIALS: { id: 'OAUTH-2.1-client-credentials-grant', url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#section-4.2' diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 0e2191aa..1427c19a 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -71,6 +71,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'; // Pending client scenarios (not yet fully tested/implemented) const pendingClientScenariosList: ClientScenario[] = [ @@ -157,15 +158,17 @@ export const clientScenarios = new Map( ); // All client scenarios for authorization server -const allClientScenariosListForAuthorizationServer: ClientScenario[] = [ - // Authorization server scenarios - new AuthorizationServerMetadataEndpointScenario() -]; +const allClientScenariosListForAuthorizationServer: ClientScenarioForAuthorizationServer[] = + [ + // Authorization server scenarios + new AuthorizationServerMetadataEndpointScenario(), + new AuthorizationCodeGrantScenario() + ]; // Client scenarios map for authorization server - built from list export const clientScenariosForAuthorizationServer = new Map< string, - ClientScenario + ClientScenarioForAuthorizationServer >( allClientScenariosListForAuthorizationServer.map((scenario) => [ scenario.name, diff --git a/src/schemas.ts b/src/schemas.ts index e0df8ced..7f7806f5 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -40,7 +40,14 @@ export type ServerOptions = z.infer; // Authorization server command options schema export const AuthorizationServerOptionsSchema = z.object({ - url: z.string().url('Invalid authorization server URL') + url: z.string().url('Invalid authorization server URL'), + 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 51cee73c..10091b9c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -93,5 +93,8 @@ export interface ClientScenarioForAuthorizationServer { name: string; description: string; specVersions: ScenarioSpecTag[]; - run(serverUrl: string): Promise; + run( + option: any, + details: Record + ): Promise; }