* 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:
parent
1464ec0d86
commit
565eaffc90
11 changed files with 842 additions and 20 deletions
28
src/index.ts
28
src/index.ts
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
12
src/scenarios/authorization-server/auth/spec-references.ts
Normal file
12
src/scenarios/authorization-server/auth/spec-references.ts
Normal 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'
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
376
src/scenarios/authorization-server/authorization-code-grant.ts
Normal file
376
src/scenarios/authorization-server/authorization-code-grant.ts
Normal 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]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {})
|
||||
}
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue