From ba4cd2a6dea130f8bb7d0305cd5d87f0a9d33ba1 Mon Sep 17 00:00:00 2001 From: Michito Okai Date: Wed, 15 Apr 2026 10:39:09 +0900 Subject: [PATCH 1/5] feat: add JSON-based setup option to authorization server conformance test --- authorization-server-settings.json | 3 ++ src/index.ts | 48 ++++++++++++++++++++++++++++-- src/schemas.ts | 11 +++++++ 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 authorization-server-settings.json diff --git a/authorization-server-settings.json b/authorization-server-settings.json new file mode 100644 index 00000000..650998a0 --- /dev/null +++ b/authorization-server-settings.json @@ -0,0 +1,3 @@ +{ + "url": "http://auth.example.com" +} diff --git a/src/index.ts b/src/index.ts index 6612d763..c157d8ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { ZodError } from 'zod'; +import { readFile } from 'fs/promises'; import { runConformanceTest, printClientResults, @@ -37,6 +38,7 @@ import { import type { SpecVersion } from './scenarios'; import { ConformanceCheck } from './types'; import { + AuthorizationServerFileOptionsSchema, AuthorizationServerOptionsSchema, ClientOptionsSchema, ServerOptionsSchema @@ -76,6 +78,17 @@ function filterScenariosBySpecVersion( return allScenarios.filter((s) => allowed.has(s)); } +function mergeAuthorizationServerOptions( + cli: Record, + file: Record +) { + return { + url: cli.url ?? file?.url, + outputDir: cli.outputDir, + specVersion: cli.specVersion + }; +} + const program = new Command(); program @@ -514,7 +527,8 @@ program .description( 'Run conformance tests against an authorization server implementation' ) - .requiredOption('--url ', 'URL of the authorization server issuer') + .option('--file ', 'authorization server settings file') + .option('--url ', 'URL of the authorization server issuer') .option('--scenario ', 'Test scenario to run') .option('-o, --output-dir ', 'Save results to this directory') .option( @@ -524,8 +538,38 @@ program .option('--verbose', 'Show verbose output (JSON instead of pretty print)') .action(async (options) => { try { + let fileOptions: Record = {}; + if (options.file) { + try { + const content = await readFile(options.file, 'utf-8'); + fileOptions = AuthorizationServerFileOptionsSchema.parse( + JSON.parse(content) + ) as Record; + } catch (error) { + if (error instanceof SyntaxError) { + console.error(`Invalid JSON in setting file: ${options.file}`); + } else if (error instanceof ZodError) { + const details = error.issues + .map((e) => `${e.path.join('.')}: ${e.message}`) + .join(', '); + console.error( + `Invalid setting file format: ${options.file}${details}` + ); + } else { + console.error( + `Failed to read setting file: ${options.file}` + + (error instanceof Error ? `: ${error.message}` : '') + ); + } + process.exit(1); + } + } + const mergedOptions = mergeAuthorizationServerOptions( + options, + fileOptions + ); // Validate options with Zod - const validated = AuthorizationServerOptionsSchema.parse(options); + const validated = AuthorizationServerOptionsSchema.parse(mergedOptions); const verbose = options.verbose ?? false; const outputDir = options.outputDir; const specVersionFilter = options.specVersion diff --git a/src/schemas.ts b/src/schemas.ts index 4888dd5f..8876169c 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -61,6 +61,17 @@ export type AuthorizationServerOptions = z.infer< typeof AuthorizationServerOptionsSchema >; +// Authorization server file options schema +export const AuthorizationServerFileOptionsSchema = z + .object({ + url: z.string().url().optional() + }) + .strict(); + +export type AuthorizationServerFileOptions = z.infer< + typeof AuthorizationServerFileOptionsSchema +>; + // Interactive command options schema export const InteractiveOptionsSchema = z.object({ scenario: z From a7c0b010f0d4ec5a8607bc344c7462d9af7c545a Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 23 Jun 2026 17:22:02 +0000 Subject: [PATCH 2/5] refactor(auth): single-schema validation for --file; drop duplicate schema/merge --- src/index.ts | 63 ++++++++++++++++++-------------------------------- src/schemas.ts | 11 --------- 2 files changed, 23 insertions(+), 51 deletions(-) diff --git a/src/index.ts b/src/index.ts index c157d8ea..6c3f3ec7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import { ZodError } from 'zod'; -import { readFile } from 'fs/promises'; +import { promises as fs } from 'fs'; import { runConformanceTest, printClientResults, @@ -38,7 +38,6 @@ import { import type { SpecVersion } from './scenarios'; import { ConformanceCheck } from './types'; import { - AuthorizationServerFileOptionsSchema, AuthorizationServerOptionsSchema, ClientOptionsSchema, ServerOptionsSchema @@ -78,17 +77,6 @@ function filterScenariosBySpecVersion( return allScenarios.filter((s) => allowed.has(s)); } -function mergeAuthorizationServerOptions( - cli: Record, - file: Record -) { - return { - url: cli.url ?? file?.url, - outputDir: cli.outputDir, - specVersion: cli.specVersion - }; -} - const program = new Command(); program @@ -527,7 +515,10 @@ program .description( 'Run conformance tests against an authorization server implementation' ) - .option('--file ', 'authorization server settings file') + .option( + '--file ', + 'Path to JSON settings file (see examples/authorization-server-settings.example.json)' + ) .option('--url ', 'URL of the authorization server issuer') .option('--scenario ', 'Test scenario to run') .option('-o, --output-dir ', 'Save results to this directory') @@ -538,38 +529,30 @@ program .option('--verbose', 'Show verbose output (JSON instead of pretty print)') .action(async (options) => { try { - let fileOptions: Record = {}; + let fileOptions: Record = {}; if (options.file) { try { - const content = await readFile(options.file, 'utf-8'); - fileOptions = AuthorizationServerFileOptionsSchema.parse( - JSON.parse(content) - ) as Record; + fileOptions = JSON.parse(await fs.readFile(options.file, 'utf-8')); } catch (error) { - if (error instanceof SyntaxError) { - console.error(`Invalid JSON in setting file: ${options.file}`); - } else if (error instanceof ZodError) { - const details = error.issues - .map((e) => `${e.path.join('.')}: ${e.message}`) - .join(', '); - console.error( - `Invalid setting file format: ${options.file}${details}` - ); - } else { - console.error( - `Failed to read setting file: ${options.file}` + - (error instanceof Error ? `: ${error.message}` : '') - ); - } + console.error( + `Failed to read settings file '${options.file}': ` + + (error instanceof Error ? error.message : String(error)) + ); process.exit(1); } } - const mergedOptions = mergeAuthorizationServerOptions( - options, - fileOptions - ); - // Validate options with Zod - const validated = AuthorizationServerOptionsSchema.parse(mergedOptions); + // CLI flags override file values; undefined CLI values must not clobber file values + const merged = { + ...fileOptions, + ...Object.fromEntries( + Object.entries(options).filter(([, v]) => v !== undefined) + ) + }; + if (!merged.url) { + console.error("error: must provide --url or a --file containing 'url'"); + process.exit(1); + } + const validated = AuthorizationServerOptionsSchema.parse(merged); const verbose = options.verbose ?? false; const outputDir = options.outputDir; const specVersionFilter = options.specVersion diff --git a/src/schemas.ts b/src/schemas.ts index 8876169c..4888dd5f 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -61,17 +61,6 @@ export type AuthorizationServerOptions = z.infer< typeof AuthorizationServerOptionsSchema >; -// Authorization server file options schema -export const AuthorizationServerFileOptionsSchema = z - .object({ - url: z.string().url().optional() - }) - .strict(); - -export type AuthorizationServerFileOptions = z.infer< - typeof AuthorizationServerFileOptionsSchema ->; - // Interactive command options schema export const InteractiveOptionsSchema = z.object({ scenario: z From 2e0b888767d17335fdf0bed0a9d8d6c84cb96b7a Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 23 Jun 2026 17:22:14 +0000 Subject: [PATCH 3/5] chore: move authorization-server settings sample to examples/ --- authorization-server-settings.json | 3 --- examples/authorization-server-settings.example.json | 3 +++ 2 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 authorization-server-settings.json create mode 100644 examples/authorization-server-settings.example.json diff --git a/authorization-server-settings.json b/authorization-server-settings.json deleted file mode 100644 index 650998a0..00000000 --- a/authorization-server-settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "url": "http://auth.example.com" -} diff --git a/examples/authorization-server-settings.example.json b/examples/authorization-server-settings.example.json new file mode 100644 index 00000000..a511114d --- /dev/null +++ b/examples/authorization-server-settings.example.json @@ -0,0 +1,3 @@ +{ + "url": "https://auth.example.com" +} From 069f7f365cb7b69617bdad781ae905be05c63b65 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 23 Jun 2026 17:22:28 +0000 Subject: [PATCH 4/5] test(auth): cover --file merge precedence and validation --- src/authorization-file-options.test.ts | 51 ++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/authorization-file-options.test.ts diff --git a/src/authorization-file-options.test.ts b/src/authorization-file-options.test.ts new file mode 100644 index 00000000..39f9ae27 --- /dev/null +++ b/src/authorization-file-options.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { AuthorizationServerOptionsSchema } from './schemas'; + +// Mirrors the merge logic in src/index.ts for the `authorization` command: +// CLI flags override file values; undefined CLI values must not clobber file values. +function merge( + fileOptions: Record, + cliOptions: Record +) { + return { + ...fileOptions, + ...Object.fromEntries( + Object.entries(cliOptions).filter(([, v]) => v !== undefined) + ) + }; +} + +describe('authorization --file merge precedence', () => { + it('CLI url overrides file url', () => { + const file = { url: 'https://file.example.com' }; + const cli = { url: 'https://cli.example.com' }; + const result = AuthorizationServerOptionsSchema.parse(merge(file, cli)); + expect(result.url).toBe('https://cli.example.com'); + }); + + it('file-only url passes when CLI url is undefined', () => { + const file = { url: 'https://file.example.com' }; + const cli = { url: undefined, outputDir: undefined }; + const result = AuthorizationServerOptionsSchema.parse(merge(file, cli)); + expect(result.url).toBe('https://file.example.com'); + }); + + it('missing url fails validation', () => { + const file = {}; + const cli = { outputDir: '/tmp/out' }; + expect(() => + AuthorizationServerOptionsSchema.parse(merge(file, cli)) + ).toThrow(); + }); + + it('strips unknown keys from file input', () => { + const file = { + url: 'https://file.example.com', + __proto__: { polluted: true }, + junk: 'ignored' + }; + const result = AuthorizationServerOptionsSchema.parse(merge(file, {})); + expect(result).toEqual({ url: 'https://file.example.com' }); + expect(Object.keys(result)).toEqual(['url']); + }); +}); From aab03c6e8aadbeb9100352c7bde225bb19ffbaaf Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 23 Jun 2026 17:57:21 +0000 Subject: [PATCH 5/5] fix(auth): validate --file standalone via .strict(); reject partial/typo'd files The settings file must be a complete, valid AuthorizationServerOptions on its own; CLI flags are optional overrides on top. .strict() rejects unknown keys so a typo'd field surfaces as an error instead of being silently ignored. ZodError from file parsing is reported with the file path. --- src/authorization-file-options.test.ts | 49 ++++++++++++++------------ src/index.ts | 34 ++++++++++++------ 2 files changed, 51 insertions(+), 32 deletions(-) diff --git a/src/authorization-file-options.test.ts b/src/authorization-file-options.test.ts index 39f9ae27..91762162 100644 --- a/src/authorization-file-options.test.ts +++ b/src/authorization-file-options.test.ts @@ -2,7 +2,10 @@ import { describe, it, expect } from 'vitest'; import { AuthorizationServerOptionsSchema } from './schemas'; // Mirrors the merge logic in src/index.ts for the `authorization` command: -// CLI flags override file values; undefined CLI values must not clobber file values. +// the file is validated standalone via .strict(), then CLI flags override. +// Undefined CLI values must not clobber file values. +const FileSchema = AuthorizationServerOptionsSchema.strict(); + function merge( fileOptions: Record, cliOptions: Record @@ -15,37 +18,39 @@ function merge( }; } +describe('authorization --file validation', () => { + it('accepts a complete file', () => { + const result = FileSchema.parse({ url: 'https://file.example.com' }); + expect(result.url).toBe('https://file.example.com'); + }); + + it('rejects a file missing required url (even if --url would be supplied)', () => { + expect(() => FileSchema.parse({})).toThrow(); + }); + + it('rejects unknown keys (catches typos)', () => { + expect(() => + FileSchema.parse({ url: 'https://file.example.com', urll: 'typo' }) + ).toThrow(); + }); + + it('rejects an invalid url value', () => { + expect(() => FileSchema.parse({ url: 'not-a-url' })).toThrow(); + }); +}); + describe('authorization --file merge precedence', () => { it('CLI url overrides file url', () => { - const file = { url: 'https://file.example.com' }; + const file = FileSchema.parse({ url: 'https://file.example.com' }); const cli = { url: 'https://cli.example.com' }; const result = AuthorizationServerOptionsSchema.parse(merge(file, cli)); expect(result.url).toBe('https://cli.example.com'); }); it('file-only url passes when CLI url is undefined', () => { - const file = { url: 'https://file.example.com' }; + const file = FileSchema.parse({ url: 'https://file.example.com' }); const cli = { url: undefined, outputDir: undefined }; const result = AuthorizationServerOptionsSchema.parse(merge(file, cli)); expect(result.url).toBe('https://file.example.com'); }); - - it('missing url fails validation', () => { - const file = {}; - const cli = { outputDir: '/tmp/out' }; - expect(() => - AuthorizationServerOptionsSchema.parse(merge(file, cli)) - ).toThrow(); - }); - - it('strips unknown keys from file input', () => { - const file = { - url: 'https://file.example.com', - __proto__: { polluted: true }, - junk: 'ignored' - }; - const result = AuthorizationServerOptionsSchema.parse(merge(file, {})); - expect(result).toEqual({ url: 'https://file.example.com' }); - expect(Object.keys(result)).toEqual(['url']); - }); }); diff --git a/src/index.ts b/src/index.ts index 6c3f3ec7..1893423b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,7 @@ import { ClientOptionsSchema, ServerOptionsSchema } from './schemas'; +import type { AuthorizationServerOptions } from './schemas'; import { loadExpectedFailures, evaluateBaseline, @@ -529,18 +530,35 @@ program .option('--verbose', 'Show verbose output (JSON instead of pretty print)') .action(async (options) => { try { - let fileOptions: Record = {}; + let fileOptions: AuthorizationServerOptions | undefined; if (options.file) { try { - fileOptions = JSON.parse(await fs.readFile(options.file, 'utf-8')); + const raw = JSON.parse(await fs.readFile(options.file, 'utf-8')); + // The file must be a complete, valid config on its own; CLI flags + // are optional overrides. .strict() rejects unknown keys so typos + // surface instead of being silently ignored. + fileOptions = AuthorizationServerOptionsSchema.strict().parse(raw); } catch (error) { - console.error( - `Failed to read settings file '${options.file}': ` + - (error instanceof Error ? error.message : String(error)) - ); + if (error instanceof ZodError) { + const details = error.issues + .map((e) => ` ${e.path.join('.') || '(root)'}: ${e.message}`) + .join('\n'); + console.error( + `Invalid settings file '${options.file}':\n${details}` + ); + } else { + console.error( + `Failed to read settings file '${options.file}': ` + + (error instanceof Error ? error.message : String(error)) + ); + } process.exit(1); } } + if (!fileOptions && !options.url) { + console.error('error: must provide --url or --file'); + process.exit(1); + } // CLI flags override file values; undefined CLI values must not clobber file values const merged = { ...fileOptions, @@ -548,10 +566,6 @@ program Object.entries(options).filter(([, v]) => v !== undefined) ) }; - if (!merged.url) { - console.error("error: must provide --url or a --file containing 'url'"); - process.exit(1); - } const validated = AuthorizationServerOptionsSchema.parse(merged); const verbose = options.verbose ?? false; const outputDir = options.outputDir;