Skip to content

Commit 899a5f3

Browse files
committed
feat: add conformance test for authorization server metadata
1 parent a2855b0 commit 899a5f3

File tree

7 files changed

+481
-10
lines changed

7 files changed

+481
-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,
@@ -23,11 +27,17 @@ import {
2327
listScenariosForSpec,
2428
listClientScenariosForSpec,
2529
getScenarioSpecVersions,
30+
listClientScenariosForAuthorizationServer,
31+
listClientScenariosForAuthorizationServerForSpec,
2632
ALL_SPEC_VERSIONS
2733
} from './scenarios';
2834
import type { SpecVersion } from './scenarios';
2935
import { ConformanceCheck } from './types';
30-
import { ClientOptionsSchema, ServerOptionsSchema } from './schemas';
36+
import {
37+
AuthorizationServerOptionsSchema,
38+
ClientOptionsSchema,
39+
ServerOptionsSchema
40+
} from './schemas';
3141
import {
3242
loadExpectedFailures,
3343
evaluateBaseline,
@@ -52,12 +62,19 @@ function resolveSpecVersion(value: string): SpecVersion {
5262
function filterScenariosBySpecVersion(
5363
allScenarios: string[],
5464
version: SpecVersion,
55-
command: 'client' | 'server'
65+
command: 'client' | 'server' | 'authorization'
5666
): string[] {
57-
const versionScenarios =
58-
command === 'client'
59-
? listScenariosForSpec(version)
60-
: listClientScenariosForSpec(version);
67+
let versionScenarios: string[];
68+
if (command === 'client') {
69+
versionScenarios = listScenariosForSpec(version);
70+
} else if (command === 'server') {
71+
versionScenarios = listClientScenariosForSpec(version);
72+
} else if (command === 'authorization') {
73+
versionScenarios =
74+
listClientScenariosForAuthorizationServerForSpec(version);
75+
} else {
76+
versionScenarios = [];
77+
}
6178
const allowed = new Set(versionScenarios);
6279
return allScenarios.filter((s) => allowed.has(s));
6380
}
@@ -444,6 +461,87 @@ program
444461
}
445462
});
446463

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

@@ -453,6 +551,7 @@ program
453551
.description('List available test scenarios')
454552
.option('--client', 'List client scenarios')
455553
.option('--server', 'List server scenarios')
554+
.option('--authorization', 'List authorization server scenarios')
456555
.option(
457556
'--spec-version <version>',
458557
'Filter scenarios by spec version (cumulative for date versions)'
@@ -462,7 +561,10 @@ program
462561
? resolveSpecVersion(options.specVersion)
463562
: undefined;
464563

465-
if (options.server || (!options.client && !options.server)) {
564+
if (
565+
options.server ||
566+
(!options.client && !options.server && !options.authorization)
567+
) {
466568
console.log('Server scenarios (test against a server):');
467569
let serverScenarios = listClientScenarios();
468570
if (specVersionFilter) {
@@ -478,7 +580,10 @@ program
478580
});
479581
}
480582

481-
if (options.client || (!options.client && !options.server)) {
583+
if (
584+
options.client ||
585+
(!options.client && !options.server && !options.authorization)
586+
) {
482587
if (options.server || (!options.client && !options.server)) {
483588
console.log('');
484589
}
@@ -496,6 +601,31 @@ program
496601
console.log(` - ${s}${v ? ` [${v}]` : ''}`);
497602
});
498603
}
604+
605+
if (
606+
options.authorization ||
607+
(!options.authorization && !options.server && !options.client)
608+
) {
609+
if (!(options.authorization && !options.server && !options.client)) {
610+
console.log('');
611+
}
612+
console.log(
613+
'Authorization server scenarios (test against an authorization server):'
614+
);
615+
let authorizationServerScenarios =
616+
listClientScenariosForAuthorizationServer();
617+
if (specVersionFilter) {
618+
authorizationServerScenarios = filterScenariosBySpecVersion(
619+
authorizationServerScenarios,
620+
specVersionFilter,
621+
'authorization'
622+
);
623+
}
624+
authorizationServerScenarios.forEach((s) => {
625+
const v = getScenarioSpecVersions(s);
626+
console.log(` - ${s}${v ? ` [${v}]` : ''}`);
627+
});
628+
}
499629
});
500630

501631
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)