Skip to content

Commit 565eaff

Browse files
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>
1 parent 1464ec0 commit 565eaff

11 files changed

Lines changed: 842 additions & 20 deletions

src/index.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,20 @@ program
522522
)
523523
.option('--url <url>', 'URL of the authorization server issuer')
524524
.option('--scenario <scenario>', 'Test scenario to run')
525+
.option(
526+
'--client-id <id>',
527+
'OAuth client ID registered with the authorization server'
528+
)
529+
.option(
530+
'--client-secret <secret>',
531+
'OAuth client secret (omit for public/PKCE-only clients)'
532+
)
533+
.option(
534+
'-p, --port <port>',
535+
'Port for the local OAuth callback server; register http://127.0.0.1:<port>/callback as a redirect URI',
536+
(value) => Number(value),
537+
3000
538+
)
525539
.option('-o, --output-dir <path>', 'Save results to this directory')
526540
.option(
527541
'--spec-version <version>',
@@ -575,9 +589,11 @@ program
575589

576590
// If a single scenario is specified, run just that one
577591
if (validated.scenario) {
592+
const details: Record<string, unknown> = {};
578593
const result = await runAuthorizationServerConformanceTest(
579-
validated.url,
594+
validated,
580595
validated.scenario,
596+
details,
581597
outputDir
582598
);
583599

@@ -604,14 +620,22 @@ program
604620
);
605621

606622
const allResults: { scenario: string; checks: ConformanceCheck[] }[] = [];
623+
const details: Record<string, unknown> = {};
607624
for (const scenarioName of scenarios) {
608625
console.log(`\n=== Running scenario: ${scenarioName} ===`);
609626
try {
610627
const result = await runAuthorizationServerConformanceTest(
611-
validated.url,
628+
validated,
612629
scenarioName,
630+
details,
613631
outputDir
614632
);
633+
if (
634+
result.checks[0].status === 'SUCCESS' &&
635+
result.checks[0].details
636+
) {
637+
details[scenarioName] = result.checks[0].details;
638+
}
615639
allResults.push({ scenario: scenarioName, checks: result.checks });
616640
} catch (error) {
617641
console.error(`Failed to run scenario ${scenarioName}:`, error);

src/runner/authorization-server.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import path from 'path';
33
import { ConformanceCheck } from '../types';
44
import { getClientScenarioForAuthorizationServer } from '../scenarios';
55
import { createResultDir, formatPrettyChecks } from './utils';
6+
import { AuthorizationServerOptions } from '../schemas';
67

78
export async function runAuthorizationServerConformanceTest(
8-
serverUrl: string,
9+
options: AuthorizationServerOptions,
910
scenarioName: string,
11+
details: Record<string, unknown>,
1012
outputDir?: string
1113
): Promise<{
1214
checks: ConformanceCheck[];
@@ -28,10 +30,10 @@ export async function runAuthorizationServerConformanceTest(
2830
const scenario = getClientScenarioForAuthorizationServer(scenarioName)!;
2931

3032
console.log(
31-
`Running client scenario for authorization server '${scenarioName}' against server: ${serverUrl}`
33+
`Running client scenario for authorization server '${scenarioName}' against server: ${options.url}`
3234
);
3335

34-
const checks = await scenario.run(serverUrl);
36+
const checks = await scenario.run(options, details);
3537

3638
if (resultDir) {
3739
await fs.writeFile(
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import express from 'express';
2+
3+
export interface CallbackServer {
4+
waitForCallback: (timeoutMs: number) => Promise<string>;
5+
close: () => void;
6+
}
7+
8+
export function startCallbackServer(port: number): CallbackServer {
9+
const app = express();
10+
11+
let resolveFn: (url: string) => void;
12+
let rejectFn: (err: Error) => void;
13+
14+
const promise = new Promise<string>((resolve, reject) => {
15+
resolveFn = resolve;
16+
rejectFn = reject;
17+
});
18+
19+
const server = app.listen(port, '127.0.0.1', () => {
20+
console.log(`Callback server started: http://127.0.0.1:${port}`);
21+
});
22+
23+
server.on('error', (err) => {
24+
rejectFn(err instanceof Error ? err : new Error(String(err)));
25+
});
26+
27+
app.get('/callback', (req, res) => {
28+
// Do not derive origin from the client-supplied Host header — reconstruct
29+
// from the bind address so a forged Host can't influence validation.
30+
const fullUrl = `http://127.0.0.1:${port}${req.originalUrl}`;
31+
res.send('OK. You can close this page.');
32+
33+
server.close();
34+
resolveFn(fullUrl);
35+
});
36+
37+
const close = () => {
38+
server.close();
39+
};
40+
41+
return {
42+
close,
43+
waitForCallback: (timeoutMs: number) => {
44+
let timer: NodeJS.Timeout;
45+
const timeout = new Promise<string>((_, reject) => {
46+
timer = setTimeout(() => {
47+
server.close();
48+
reject(new Error('Timeout: No callback received'));
49+
}, timeoutMs);
50+
timer.unref();
51+
});
52+
return Promise.race([promise, timeout]).finally(() =>
53+
clearTimeout(timer)
54+
);
55+
}
56+
};
57+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { SpecReference } from '../../../types';
2+
3+
export const SpecReferences: { [key: string]: SpecReference } = {
4+
MCP_AUTH_DISCOVERY: {
5+
id: 'MCP-Authorization-metadata-discovery',
6+
url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery'
7+
},
8+
OAUTH_2_1_AUTHORIZATION_CODE_GRANT: {
9+
id: 'OAUTH-2.1-authorization-code-grant',
10+
url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#section-4.1'
11+
}
12+
};

0 commit comments

Comments
 (0)