feat: add positive tests for the Authorization Code Grant (supersedes #267) (#364)

* feat: add positive tests for the Authorization Code Grant

* fix(auth): use import type for AuthorizationServerOptions in types.ts

* fix(auth): make client-id/client-secret optional; rename --secret→--client-secret

* fix(auth): generate PKCE verifier per-run; derive S256 challenge

* fix(auth): build authorization URL with URLSearchParams; drop hardcoded resource

* fix(auth): default token_endpoint_auth_methods_supported to client_secret_basic; add 'none' branch

* fix(auth): check auth-method support before starting callback server

* fix(auth): check error param first; treat state mismatch as fatal; drop fabricated code_challenge assertion

* fix(auth): redact tokens from check details

* fix(auth): harden callback server (path-scoped, close(), error handler, clear timeout)

* feat(auth): print redirect URI and timeout hint before browser prompt

---------

Co-authored-by: Michito Okai <michito.okai.zn@hitachi.com>
This commit is contained in:
Paul Carleton 2026-06-24 16:13:55 +01:00 • committed by GitHub
commit 565eaffc90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 842 additions and 20 deletions

View file

@ -522,6 +522,20 @@ program
)
.option('--url <url>', 'URL of the authorization server issuer')
.option('--scenario <scenario>', 'Test scenario to run')
.option(
'--client-id <id>',
'OAuth client ID registered with the authorization server'
)
.option(
'--client-secret <secret>',
'OAuth client secret (omit for public/PKCE-only clients)'
)
.option(
'-p, --port <port>',
'Port for the local OAuth callback server; register http://127.0.0.1:<port>/callback as a redirect URI',
(value) => Number(value),
3000
)
.option('-o, --output-dir <path>', 'Save results to this directory')
.option(
'--spec-version <version>',
@ -575,9 +589,11 @@ program
// If a single scenario is specified, run just that one
if (validated.scenario) {
const details: Record<string, unknown> = {};
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<string, unknown> = {};
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);

View file

@ -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<string, unknown>,
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(

View file

@ -0,0 +1,57 @@
import express from 'express';
export interface CallbackServer {
waitForCallback: (timeoutMs: number) => Promise<string>;
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<string>((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<string>((_, reject) => {
timer = setTimeout(() => {
server.close();
reject(new Error('Timeout: No callback received'));
}, timeoutMs);
timer.unref();
});
return Promise.race([promise, timeout]).finally(() =>
clearTimeout(timer)
);
}
};
}

View file

@ -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'
}
};

View file

@ -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<string, unknown>) {
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');
});
});

View file

@ -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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = { ...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<string, unknown>
): Promise<ConformanceCheck[]> {
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<string, unknown> };
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<string, string> = {
'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]
};
}
}

View file

@ -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<string, unknown> = {};
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);

View file

@ -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<ConformanceCheck[]> {
async run(
options: AuthorizationServerOptions,
_details: Record<string, unknown>
): Promise<ConformanceCheck[]> {
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 } : {})
}
];

View file

@ -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<string, ClientScenario>(
const allClientScenariosListForAuthorizationServer: ClientScenarioForAuthorizationServer[] =
[
// Authorization server scenarios
new AuthorizationServerMetadataEndpointScenario()
new AuthorizationServerMetadataEndpointScenario(),
new AuthorizationCodeGrantScenario()
];
// Client scenarios map for authorization server - built from list

View file

@ -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<

View file

@ -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<ConformanceCheck[]>;
run(
options: AuthorizationServerOptions,
details: Record<string, unknown>
): Promise<ConformanceCheck[]>;
}