Skip to content

Commit 050d9cf

Browse files
authored
feat: add conformance test for authorization server metadata (#170)
1 parent cb0f4c3 commit 050d9cf

File tree

7 files changed

+479
-10
lines changed

7 files changed

+479
-10
lines changed

src/index.ts

Lines changed: 138 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import {
1010
printServerSummary,
1111
runInteractiveMode
1212
} from './runner';
13+
import {
14+
printAuthorizationServerSummary,
15+
runAuthorizationServerConformanceTest
16+
} from './runner/authorization-server';
1317
import {
1418
listScenarios,
1519
listClientScenarios,
@@ -24,11 +28,17 @@ import {
2428
listScenariosForSpec,
2529
listClientScenariosForSpec,
2630
getScenarioSpecVersions,
31+
listClientScenariosForAuthorizationServer,
32+
listClientScenariosForAuthorizationServerForSpec,
2733
resolveSpecVersion
2834
} from './scenarios';
2935
import type { SpecVersion } from './scenarios';
3036
import { ConformanceCheck } from './types';
31-
import { ClientOptionsSchema, ServerOptionsSchema } from './schemas';
37+
import {
38+
AuthorizationServerOptionsSchema,
39+
ClientOptionsSchema,
40+
ServerOptionsSchema
41+
} from './schemas';
3242
import {
3343
loadExpectedFailures,
3444
evaluateBaseline,
@@ -44,12 +54,19 @@ import packageJson from '../package.json';
4454
function filterScenariosBySpecVersion(
4555
allScenarios: string[],
4656
version: SpecVersion,
47-
command: 'client' | 'server'
57+
command: 'client' | 'server' | 'authorization'
4858
): string[] {
49-
const versionScenarios =
50-
command === 'client'
51-
? listScenariosForSpec(version)
52-
: listClientScenariosForSpec(version);
59+
let versionScenarios: string[];
60+
if (command === 'client') {
61+
versionScenarios = listScenariosForSpec(version);
62+
} else if (command === 'server') {
63+
versionScenarios = listClientScenariosForSpec(version);
64+
} else if (command === 'authorization') {
65+
versionScenarios =
66+
listClientScenariosForAuthorizationServerForSpec(version);
67+
} else {
68+
versionScenarios = [];
69+
}
5370
const allowed = new Set(versionScenarios);
5471
return allScenarios.filter((s) => allowed.has(s));
5572
}
@@ -437,6 +454,87 @@ program
437454
}
438455
});
439456

457+
// Authorization command - tests an authorization server implementation
458+
program
459+
.command('authorization')
460+
.description(
461+
'Run conformance tests against an authorization server implementation'
462+
)
463+
.requiredOption('--url <url>', 'URL of the authorization server issuer')
464+
.option('-o, --output-dir <path>', 'Save results to this directory')
465+
.option(
466+
'--spec-version <version>',
467+
'Filter scenarios by spec version (cumulative for date versions)'
468+
)
469+
.action(async (options) => {
470+
try {
471+
// Validate options with Zod
472+
const validated = AuthorizationServerOptionsSchema.parse(options);
473+
const outputDir = options.outputDir;
474+
const specVersionFilter = options.specVersion
475+
? resolveSpecVersion(options.specVersion)
476+
: undefined;
477+
478+
let scenarios: string[];
479+
scenarios = listClientScenariosForAuthorizationServer();
480+
if (specVersionFilter) {
481+
scenarios = filterScenariosBySpecVersion(
482+
scenarios,
483+
specVersionFilter,
484+
'authorization'
485+
);
486+
}
487+
console.log(
488+
`Running test (${scenarios.length} scenarios) against ${validated.url}\n`
489+
);
490+
491+
const allResults: { scenario: string; checks: ConformanceCheck[] }[] = [];
492+
for (const scenarioName of scenarios) {
493+
console.log(`\n=== Running scenario: ${scenarioName} ===`);
494+
try {
495+
const result = await runAuthorizationServerConformanceTest(
496+
validated.url,
497+
scenarioName,
498+
outputDir
499+
);
500+
allResults.push({ scenario: scenarioName, checks: result.checks });
501+
} catch (error) {
502+
console.error(`Failed to run scenario ${scenarioName}:`, error);
503+
allResults.push({
504+
scenario: scenarioName,
505+
checks: [
506+
{
507+
id: scenarioName,
508+
name: scenarioName,
509+
description: 'Failed to run scenario',
510+
status: 'FAILURE',
511+
timestamp: new Date().toISOString(),
512+
errorMessage:
513+
error instanceof Error ? error.message : String(error)
514+
}
515+
]
516+
});
517+
}
518+
}
519+
const { totalFailed } = printAuthorizationServerSummary(allResults);
520+
process.exit(totalFailed > 0 ? 1 : 0);
521+
} catch (error) {
522+
if (error instanceof ZodError) {
523+
console.error('Validation error:');
524+
error.issues.forEach((err) => {
525+
console.error(` ${err.path.join('.')}: ${err.message}`);
526+
});
527+
console.error('\nAvailable authorization server scenarios:');
528+
listClientScenariosForAuthorizationServer().forEach((s) =>
529+
console.error(` - ${s}`)
530+
);
531+
process.exit(1);
532+
}
533+
console.error('Authorization server test error:', error);
534+
process.exit(1);
535+
}
536+
});
537+
440538
// Tier check command
441539
program.addCommand(createTierCheckCommand());
442540

@@ -446,6 +544,7 @@ program
446544
.description('List available test scenarios')
447545
.option('--client', 'List client scenarios')
448546
.option('--server', 'List server scenarios')
547+
.option('--authorization', 'List authorization server scenarios')
449548
.option(
450549
'--spec-version <version>',
451550
'Filter scenarios by spec version (cumulative for date versions)'
@@ -455,7 +554,10 @@ program
455554
? resolveSpecVersion(options.specVersion)
456555
: undefined;
457556

458-
if (options.server || (!options.client && !options.server)) {
557+
if (
558+
options.server ||
559+
(!options.client && !options.server && !options.authorization)
560+
) {
459561
console.log('Server scenarios (test against a server):');
460562
let serverScenarios = listClientScenarios();
461563
if (specVersionFilter) {
@@ -471,7 +573,10 @@ program
471573
});
472574
}
473575

474-
if (options.client || (!options.client && !options.server)) {
576+
if (
577+
options.client ||
578+
(!options.client && !options.server && !options.authorization)
579+
) {
475580
if (options.server || (!options.client && !options.server)) {
476581
console.log('');
477582
}
@@ -489,6 +594,31 @@ program
489594
console.log(` - ${s}${v ? ` [${v}]` : ''}`);
490595
});
491596
}
597+
598+
if (
599+
options.authorization ||
600+
(!options.authorization && !options.server && !options.client)
601+
) {
602+
if (!(options.authorization && !options.server && !options.client)) {
603+
console.log('');
604+
}
605+
console.log(
606+
'Authorization server scenarios (test against an authorization server):'
607+
);
608+
let authorizationServerScenarios =
609+
listClientScenariosForAuthorizationServer();
610+
if (specVersionFilter) {
611+
authorizationServerScenarios = filterScenariosBySpecVersion(
612+
authorizationServerScenarios,
613+
specVersionFilter,
614+
'authorization'
615+
);
616+
}
617+
authorizationServerScenarios.forEach((s) => {
618+
const v = getScenarioSpecVersions(s);
619+
console.log(` - ${s}${v ? ` [${v}]` : ''}`);
620+
});
621+
}
492622
});
493623

494624
program.parse();

src/runner/authorization-server.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { promises as fs } from 'fs';
2+
import path from 'path';
3+
import { ConformanceCheck } from '../types';
4+
import { getClientScenarioForAuthorizationServer } from '../scenarios';
5+
import { createResultDir } from './utils';
6+
7+
export async function runAuthorizationServerConformanceTest(
8+
serverUrl: string,
9+
scenarioName: string,
10+
outputDir?: string
11+
): Promise<{
12+
checks: ConformanceCheck[];
13+
resultDir?: string;
14+
scenarioDescription: string;
15+
}> {
16+
let resultDir: string | undefined;
17+
18+
if (outputDir) {
19+
resultDir = createResultDir(
20+
outputDir,
21+
scenarioName,
22+
'authorization-server'
23+
);
24+
await fs.mkdir(resultDir, { recursive: true });
25+
}
26+
27+
// Scenario is guaranteed to exist by CLI validation
28+
const scenario = getClientScenarioForAuthorizationServer(scenarioName)!;
29+
30+
console.log(
31+
`Running client scenario for authorization server '${scenarioName}' against server: ${serverUrl}`
32+
);
33+
34+
const checks = await scenario.run(serverUrl);
35+
36+
if (resultDir) {
37+
await fs.writeFile(
38+
path.join(resultDir, 'checks.json'),
39+
JSON.stringify(checks, null, 2)
40+
);
41+
42+
console.log(`Results saved to ${resultDir}`);
43+
}
44+
45+
return {
46+
checks,
47+
resultDir,
48+
scenarioDescription: scenario.description
49+
};
50+
}
51+
52+
export function printAuthorizationServerSummary(
53+
allResults: { scenario: string; checks: ConformanceCheck[] }[]
54+
): { totalPassed: number; totalFailed: number } {
55+
console.log('\n\n=== SUMMARY ===');
56+
let totalPassed = 0;
57+
let totalFailed = 0;
58+
59+
for (const result of allResults) {
60+
const passed = result.checks.filter((c) => c.status === 'SUCCESS').length;
61+
const failed = result.checks.filter((c) => c.status === 'FAILURE').length;
62+
totalPassed += passed;
63+
totalFailed += failed;
64+
65+
const status = failed === 0 ? '✓' : '✗';
66+
console.log(
67+
`${status} ${result.scenario}: ${passed} passed, ${failed} failed`
68+
);
69+
}
70+
71+
console.log(`\nTotal: ${totalPassed} passed, ${totalFailed} failed`);
72+
73+
return { totalPassed, totalFailed };
74+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { AuthorizationServerMetadataEndpointScenario } from './authorization-server-metadata.js';
3+
import { request } from 'undici';
4+
5+
vi.mock('undici', () => ({
6+
request: vi.fn()
7+
}));
8+
9+
const mockedRequest = vi.mocked(request);
10+
11+
describe('AuthorizationServerMetadataEndpointScenario (SUCCESS only)', () => {
12+
it('returns SUCCESS for valid authorization server metadata', async () => {
13+
const scenario = new AuthorizationServerMetadataEndpointScenario();
14+
const serverUrl = 'https://example.com';
15+
16+
mockedRequest.mockResolvedValue({
17+
statusCode: 200,
18+
headers: {
19+
'content-type': 'application/json'
20+
},
21+
body: {
22+
json: async () => ({
23+
issuer: 'https://example.com',
24+
authorization_endpoint: 'https://example.com/auth',
25+
token_endpoint: 'https://example.com/token',
26+
response_types_supported: ['code']
27+
})
28+
}
29+
} as any);
30+
31+
const checks = await scenario.run(serverUrl);
32+
33+
expect(checks).toHaveLength(1);
34+
35+
const check = checks[0];
36+
expect(check.status).toBe('SUCCESS');
37+
expect(check.errorMessage).toBeUndefined();
38+
expect(check.details).toBeDefined();
39+
expect(check.details!.contentType).toContain('application/json');
40+
expect((check.details!.body as any).issuer).toBe('https://example.com');
41+
expect((check.details!.body as any).authorization_endpoint).toBe(
42+
'https://example.com/auth'
43+
);
44+
expect((check.details!.body as any).token_endpoint).toBe(
45+
'https://example.com/token'
46+
);
47+
expect((check.details!.body as any).response_types_supported).toEqual([
48+
'code'
49+
]);
50+
});
51+
});

0 commit comments

Comments
 (0)