Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>',
Expand Down Expand Up @@ -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
);

Expand All @@ -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);
Expand Down
8 changes: 5 additions & 3 deletions src/runner/authorization-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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 changes: 12 additions & 0 deletions src/scenarios/authorization-server/auth/spec-references.ts
Original file line number Diff line number Diff line change
@@ -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'
}
};
Loading
Loading