diff --git a/examples/resource-authorization-server-settings.example.json b/examples/resource-authorization-server-settings.example.json new file mode 100644 index 00000000..0608019f --- /dev/null +++ b/examples/resource-authorization-server-settings.example.json @@ -0,0 +1,12 @@ +{ + "url": "https://resource-as.example.com", + "clientId": "mcp-client", + "clientSecret": "REPLACE_WITH_CLIENT_SECRET", + "trustedIdpIssuer": "https://trusted-idp.example.com", + "untrustedIdpIssuer": "https://untrusted-idp.example.com", + "trustedMcpServer": "https://mcp.example/", + "untrustedMcpServer": "https://other-mcp.example/", + "scope": "mcp.read", + "sub": "user-123", + "idpSub": "idp-user-123" +} diff --git a/src/index.ts b/src/index.ts index 4fdb5bcb..3e8cf5cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,12 @@ import { printAuthorizationServerSummary, runAuthorizationServerConformanceTest } from './runner/authorization-server'; +import { + printResourceAuthorizationServerResults, + printResourceAuthorizationServerSummary, + runResourceAuthorizationServerConformanceTest, + startIdps +} from './runner/resource-authorization-server'; import { listScenarios, listClientScenarios, @@ -33,6 +39,7 @@ import { getScenarioSpecVersions, listClientScenariosForAuthorizationServer, listClientScenariosForAuthorizationServerForSpec, + listScenariosForResourceAuthorizationServer, resolveSpecVersion } from './scenarios'; import type { SpecVersion } from './scenarios'; @@ -40,9 +47,13 @@ import { ConformanceCheck } from './types'; import { AuthorizationServerOptionsSchema, ClientOptionsSchema, - ServerOptionsSchema + ServerOptionsSchema, + ResourceAuthorizationServerOptionsSchema +} from './schemas'; +import type { + AuthorizationServerOptions, + ResourceAuthorizationServerOptions } from './schemas'; -import type { AuthorizationServerOptions } from './schemas'; import { withWireRecorder } from './validation/wire-schema'; import { filterScenariosByRequirements, @@ -909,6 +920,182 @@ program } }); +// Resource Authorization Server command - tests a Resource AS implementation +// (EMA / ID-JAG, ISSUE-470). The runner also hosts the trusted and untrusted +// IdP Authorization Server(s) these scenarios need to mint ID-JAGs; the target +// Resource AS must already be configured (out of band) to trust the printed +// trusted issuer. +program + .command('resource-authorization-server') + .description( + 'Run conformance tests against a Resource Authorization Server implementation (EMA / ID-JAG)' + ) + .option( + '--file ', + 'Path to JSON settings file (see examples/authorization-server-settings.example.json for the format)' + ) + .option('--url ', 'Issuer URL of the resource authorization server') + .option('--scenario ', 'Test scenario to run') + .option( + '--client-id ', + 'client_id of the MCP Client registered with the resource authorization server' + ) + .option( + '--client-secret ', + 'Client secret for that MCP Client (client_secret_post)' + ) + .option( + '--trusted-idp-issuer ', + 'Issuer URL for the runner-hosted trusted IdP AS; a http(s)://localhost:PORT or 127.0.0.1:PORT value binds directly to that port, other URLs must be fronted by a tunnel (defaults to an ephemeral localhost URL)' + ) + .option( + '--untrusted-idp-issuer ', + 'Issuer URL for the runner-hosted untrusted IdP AS; a http(s)://localhost:PORT or 127.0.0.1:PORT value binds directly to that port, other URLs must be fronted by a tunnel (defaults to an ephemeral localhost URL)' + ) + .option( + '--trusted-mcp-server ', + 'URL of an MCP Server the resource authorization server trusts' + ) + .option( + '--untrusted-mcp-server ', + 'URL of an MCP Server the resource authorization server does not trust' + ) + .option( + '--scope ', + 'OAuth scope the resource authorization server recognises' + ) + .option( + '--sub ', + "User id registered with the resource authorization server (expected in the issued access token's sub claim)" + ) + .option( + '--idp-sub ', + 'User id registered with the trusted IdP AS for the same person as --sub (used as the ID-JAG sub claim)' + ) + .option('-o, --output-dir ', 'Save results to this directory') + .option('--verbose', 'Show verbose output (JSON instead of pretty print)') + .action(async (options) => { + let stopIdps: (() => Promise) | undefined; + try { + let fileOptions: ResourceAuthorizationServerOptions | undefined; + if (options.file) { + try { + 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 = + ResourceAuthorizationServerOptionsSchema.strict().parse(raw); + } catch (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, + ...Object.fromEntries( + Object.entries(options).filter(([, v]) => v !== undefined) + ) + }; + const validated = ResourceAuthorizationServerOptionsSchema.parse(merged); + const verbose = options.verbose ?? false; + const outputDir = options.outputDir; + + const idps = await startIdps(validated); + stopIdps = idps.stop; + + // If a single scenario is specified, run just that one + if (validated.scenario) { + const result = await runResourceAuthorizationServerConformanceTest( + validated, + validated.scenario, + idps.details, + outputDir + ); + + const { failed } = printResourceAuthorizationServerResults( + result.checks, + result.scenarioDescription, + verbose + ); + + await stopIdps(); + process.exit(failed > 0 ? 1 : 0); + } + + const scenarios = listScenariosForResourceAuthorizationServer(); + console.log( + `Running test (${scenarios.length} scenarios) against ${validated.url}\n` + ); + + const allResults: { scenario: string; checks: ConformanceCheck[] }[] = []; + for (const scenarioName of scenarios) { + console.log(`\n=== Running scenario: ${scenarioName} ===`); + try { + const result = await runResourceAuthorizationServerConformanceTest( + validated, + scenarioName, + idps.details, + outputDir + ); + allResults.push({ scenario: scenarioName, checks: result.checks }); + } catch (error) { + console.error(`Failed to run scenario ${scenarioName}:`, error); + allResults.push({ + scenario: scenarioName, + checks: [ + { + id: scenarioName, + name: scenarioName, + description: 'Failed to run scenario', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + error instanceof Error ? error.message : String(error) + } + ] + }); + } + } + await stopIdps(); + const { totalFailed } = + printResourceAuthorizationServerSummary(allResults); + process.exit(totalFailed > 0 ? 1 : 0); + } catch (error) { + await stopIdps?.(); + if (error instanceof ZodError) { + console.error('Validation error:'); + error.issues.forEach((err) => { + console.error(` ${err.path.join('.')}: ${err.message}`); + }); + console.error('\nAvailable resource authorization server scenarios:'); + listScenariosForResourceAuthorizationServer().forEach((s) => + console.error(` - ${s}`) + ); + process.exit(1); + } + console.error('Resource authorization server test error:', error); + process.exit(1); + } + }); + // Tier check command program.addCommand(createTierCheckCommand()); @@ -930,6 +1117,10 @@ program .option('--client', 'List client scenarios') .option('--server', 'List server scenarios') .option('--authorization', 'List authorization server scenarios') + .option( + '--resource-authorization-server', + 'List resource authorization server scenarios' + ) .option( '--spec-version ', 'Filter scenarios by spec version (cumulative for date versions)' @@ -942,6 +1133,11 @@ program const specVersionFilter = options.specVersion ? resolveSpecVersion(options.specVersion) : undefined; + const noCategoryFlag = + !options.client && + !options.server && + !options.authorization && + !options.resourceAuthorizationServer; if (options.requirements !== undefined) { for (const rev of String(options.requirements).split(',')) { @@ -950,10 +1146,7 @@ program return; } - if ( - options.server || - (!options.client && !options.server && !options.authorization) - ) { + if (options.server || noCategoryFlag) { console.log('Server scenarios (test against a server):'); let serverScenarios = listClientScenarios(); if (specVersionFilter) { @@ -969,11 +1162,8 @@ program }); } - if ( - options.client || - (!options.client && !options.server && !options.authorization) - ) { - if (options.server || (!options.client && !options.server)) { + if (options.client || noCategoryFlag) { + if (options.server || noCategoryFlag) { console.log(''); } console.log('Client scenarios (test against a client):'); @@ -991,11 +1181,8 @@ program }); } - if ( - options.authorization || - (!options.authorization && !options.server && !options.client) - ) { - if (!(options.authorization && !options.server && !options.client)) { + if (options.authorization || noCategoryFlag) { + if (options.server || options.client || noCategoryFlag) { console.log(''); } console.log( @@ -1015,6 +1202,26 @@ program console.log(` - ${s}${v ? ` [${v}]` : ''}`); }); } + + if (options.resourceAuthorizationServer || noCategoryFlag) { + if ( + options.server || + options.client || + options.authorization || + noCategoryFlag + ) { + console.log(''); + } + console.log( + 'Resource authorization server scenarios (test against a Resource AS, EMA / ID-JAG):' + ); + // Spec-version filtering does not apply: these scenarios are all + // extension-sourced (`source.extensionId`), never a dated spec version. + listScenariosForResourceAuthorizationServer().forEach((s) => { + const v = getScenarioSpecVersions(s); + console.log(` - ${s}${v ? ` [${v}]` : ''}`); + }); + } }); program.parse(); diff --git a/src/runner/resource-authorization-server.ts b/src/runner/resource-authorization-server.ts new file mode 100644 index 00000000..545b7af3 --- /dev/null +++ b/src/runner/resource-authorization-server.ts @@ -0,0 +1,175 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import { ConformanceCheck } from '../types'; +import { getScenarioForResourceAuthorizationServer } from '../scenarios'; +import { createResultDir, formatPrettyChecks } from './utils'; +import { ResourceAuthorizationServerOptions } from '../schemas'; +import { IdPAuthorizationServer } from '../scenarios/ema/auth/helpers/provideIdPAuthorizationServer'; +import { + TRUSTED_IDP_DETAIL, + UNTRUSTED_IDP_DETAIL +} from '../scenarios/ema/resource-authorization-server/support'; + +export async function runResourceAuthorizationServerConformanceTest( + options: ResourceAuthorizationServerOptions, + scenarioName: string, + details: Record, + outputDir?: string +): Promise<{ + checks: ConformanceCheck[]; + resultDir?: string; + scenarioDescription: string; +}> { + let resultDir: string | undefined; + + if (outputDir) { + resultDir = createResultDir( + outputDir, + scenarioName, + 'resource-authorization-server' + ); + await fs.mkdir(resultDir, { recursive: true }); + } + + // Scenario is guaranteed to exist by CLI validation + const scenario = getScenarioForResourceAuthorizationServer(scenarioName)!; + + console.log( + `Running scenario '${scenarioName}' against resource authorization server: ${options.url}` + ); + + const checks = await scenario.run(options, details); + + if (resultDir) { + await fs.writeFile( + path.join(resultDir, 'checks.json'), + JSON.stringify(checks, null, 2) + ); + + console.log(`Results saved to ${resultDir}`); + } + + return { checks, resultDir, scenarioDescription: scenario.description }; +} + +export function printResourceAuthorizationServerResults( + checks: ConformanceCheck[], + scenarioDescription: string, + verbose: boolean = false +): { + passed: number; + failed: number; + denominator: number; + warnings: number; +} { + const denominator = checks.filter( + (c) => c.status === 'SUCCESS' || c.status === 'FAILURE' + ).length; + const passed = checks.filter((c) => c.status === 'SUCCESS').length; + const failed = checks.filter((c) => c.status === 'FAILURE').length; + const warnings = checks.filter((c) => c.status === 'WARNING').length; + + if (verbose) { + console.log(JSON.stringify(checks, null, 2)); + } else { + console.log(`Checks:\n${formatPrettyChecks(checks)}`); + } + + console.log(`\nTest Results:`); + console.log( + `Passed: ${passed}/${denominator}, ${failed} failed, ${warnings} warnings` + ); + + if (failed > 0) { + console.log('\n=== Failed Checks ==='); + checks + .filter((c) => c.status === 'FAILURE') + .forEach((c) => { + console.log(`\n - ${c.name}: ${c.description}`); + if (c.errorMessage) { + console.log(` Error: ${c.errorMessage}`); + } + }); + } + + return { passed, failed, denominator, warnings }; +} + +export function printResourceAuthorizationServerSummary( + allResults: { scenario: string; checks: ConformanceCheck[] }[] +): { totalPassed: number; totalFailed: number } { + console.log('\n\n=== SUMMARY ==='); + let totalPassed = 0; + let totalFailed = 0; + + for (const result of allResults) { + const passed = result.checks.filter((c) => c.status === 'SUCCESS').length; + const failed = result.checks.filter((c) => c.status === 'FAILURE').length; + totalPassed += passed; + totalFailed += failed; + + const status = failed === 0 ? '✓' : '✗'; + console.log( + `${status} ${result.scenario}: ${passed} passed, ${failed} failed` + ); + } + + console.log(`\nTotal: ${totalPassed} passed, ${totalFailed} failed`); + + return { totalPassed, totalFailed }; +} + +/** + * Start the runner-hosted IdP AS instance(s) these scenarios mint ID-JAGs + * with, and package them into the `details` bag the way scenarios expect + * (see `scenarios/ema/resource-authorization-server/support.ts`). The target + * Resource AS must independently be configured (out of band) to trust the + * printed issuer(s) before running scenarios that require them; scenarios + * that don't (e.g. the untrusted-IdP negative test needing only the trusted + * one) SKIP gracefully when a detail is absent. + * + * `options.trustedIdpIssuer`/`untrustedIdpIssuer` let a tester front this + * process with a stable URL instead of the ephemeral localhost one: a + * `http://localhost:PORT`/`http://127.0.0.1:PORT` value binds directly to + * that port (so a locally-running Resource AS can be preconfigured with it + * ahead of time), while any other URL is expected to be fronted by a + * tunnel/proxy forwarding to the printed local address. + */ +export async function startIdps( + options: ResourceAuthorizationServerOptions +): Promise<{ + details: Record; + stop: () => Promise; +}> { + const trustedIdp = await IdPAuthorizationServer.create({ + issuer: options.trustedIdpIssuer + }); + await trustedIdp.start(); + + const untrustedIdp = await IdPAuthorizationServer.create({ + issuer: options.untrustedIdpIssuer + }); + await untrustedIdp.start(); + + console.log('Started runner-hosted IdP Authorization Server(s):'); + console.log(` trusted issuer: ${trustedIdp.issuer}`); + console.log(` local: ${trustedIdp.localUrl}`); + console.log(` untrusted issuer: ${untrustedIdp.issuer}`); + console.log(` local: ${untrustedIdp.localUrl}`); + console.log( + 'The target Resource AS must already be configured to trust the ' + + 'trusted issuer above (and, for scenario 8, know of but not trust the ' + + 'untrusted one) before running scenarios that need them.\n' + ); + + return { + details: { + [TRUSTED_IDP_DETAIL]: trustedIdp, + [UNTRUSTED_IDP_DETAIL]: untrustedIdp + }, + stop: async () => { + await trustedIdp.stop(); + await untrustedIdp.stop(); + } + }; +} diff --git a/src/scenarios/ema/auth/helpers/mockResourceAuthorizationServer.test.ts b/src/scenarios/ema/auth/helpers/mockResourceAuthorizationServer.test.ts new file mode 100644 index 00000000..8c6a5179 --- /dev/null +++ b/src/scenarios/ema/auth/helpers/mockResourceAuthorizationServer.test.ts @@ -0,0 +1,767 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import * as jose from 'jose'; +import { + IdPAuthorizationServer, + createIdPKeyPair, + createIdJag, + ID_JAG_ALG +} from './provideIdPAuthorizationServer'; +import { + MockResourceAuthorizationServer, + validateIdJagForResourceAs, + requestAccessTokenWithIdJag, + introspectToken, + JWT_BEARER_GRANT_TYPE, + ID_JAG_GRANT_PROFILE, + type ResourceAsKeyResolver +} from './mockResourceAuthorizationServer'; + +const MCP_RESOURCE = 'https://mcp.example/'; +const TEST_CLIENT_ID = 'conformance-test-client'; +const TEST_SUBJECT = 'demo-user@example.com'; + +// --------------------------------------------------------------------------- +// Pure validator (Resource-AS processing rules) with a stub key resolver +// --------------------------------------------------------------------------- + +describe('validateIdJagForResourceAs', () => { + const RESOURCE_AS_ISSUER = 'https://resource-as.example'; + const IDP_ISSUER = 'https://trusted-idp.example'; + + async function setup() { + const idpKey = await createIdPKeyPair('idp-key'); + const resolve: ResourceAsKeyResolver = async (issuer) => { + if (issuer === IDP_ISSUER) return [idpKey.publicJwk]; + throw new Error(`no keys for ${issuer}`); + }; + return { idpKey, resolve }; + } + + it('accepts a valid ID-JAG from a trusted IdP', async () => { + const { idpKey, resolve } = await setup(); + const assertion = await createIdJag(idpKey.privateKey, idpKey.kid, { + issuer: IDP_ISSUER, + subject: TEST_SUBJECT, + audience: RESOURCE_AS_ISSUER, + resource: MCP_RESOURCE, + clientId: TEST_CLIENT_ID, + scope: 'mcp.read' + }); + + const result = await validateIdJagForResourceAs(assertion, { + resourceAsIssuer: RESOURCE_AS_ISSUER, + trustedIdpIssuers: [IDP_ISSUER], + resolveIdpKeys: resolve + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.issuer).toBe(IDP_ISSUER); + expect(result.subject).toBe(TEST_SUBJECT); + expect(result.resource).toBe(MCP_RESOURCE); + expect(result.scope).toBe('mcp.read'); + expect(result.clientId).toBe(TEST_CLIENT_ID); + } + }); + + it('rejects an ID-JAG from an untrusted issuer with invalid_grant', async () => { + const { idpKey, resolve } = await setup(); + const assertion = await createIdJag(idpKey.privateKey, idpKey.kid, { + issuer: 'https://untrusted-idp.example', + subject: TEST_SUBJECT, + audience: RESOURCE_AS_ISSUER, + resource: MCP_RESOURCE + }); + + const result = await validateIdJagForResourceAs(assertion, { + resourceAsIssuer: RESOURCE_AS_ISSUER, + trustedIdpIssuers: [IDP_ISSUER], + resolveIdpKeys: resolve + }); + + expect(result).toMatchObject({ ok: false, error: 'invalid_grant' }); + }); + + it('rejects a bad signature (trusted issuer, foreign key) with invalid_grant', async () => { + const { resolve } = await setup(); + const foreignKey = await createIdPKeyPair('foreign'); + const assertion = await createIdJag(foreignKey.privateKey, foreignKey.kid, { + issuer: IDP_ISSUER, + subject: TEST_SUBJECT, + audience: RESOURCE_AS_ISSUER, + resource: MCP_RESOURCE + }); + + const result = await validateIdJagForResourceAs(assertion, { + resourceAsIssuer: RESOURCE_AS_ISSUER, + trustedIdpIssuers: [IDP_ISSUER], + resolveIdpKeys: resolve + }); + + expect(result).toMatchObject({ ok: false, error: 'invalid_grant' }); + }); + + it('rejects a mismatched audience with invalid_grant', async () => { + const { idpKey, resolve } = await setup(); + const assertion = await createIdJag(idpKey.privateKey, idpKey.kid, { + issuer: IDP_ISSUER, + subject: TEST_SUBJECT, + audience: 'https://other-resource-as.example', + resource: MCP_RESOURCE + }); + + const result = await validateIdJagForResourceAs(assertion, { + resourceAsIssuer: RESOURCE_AS_ISSUER, + trustedIdpIssuers: [IDP_ISSUER], + resolveIdpKeys: resolve + }); + + expect(result).toMatchObject({ ok: false, error: 'invalid_grant' }); + }); + + it('rejects an expired ID-JAG with invalid_grant', async () => { + const { idpKey, resolve } = await setup(); + const assertion = await createIdJag(idpKey.privateKey, idpKey.kid, { + issuer: IDP_ISSUER, + subject: TEST_SUBJECT, + audience: RESOURCE_AS_ISSUER, + resource: MCP_RESOURCE, + expiresIn: '-60s' + }); + + const result = await validateIdJagForResourceAs(assertion, { + resourceAsIssuer: RESOURCE_AS_ISSUER, + trustedIdpIssuers: [IDP_ISSUER], + resolveIdpKeys: resolve + }); + + expect(result).toMatchObject({ ok: false, error: 'invalid_grant' }); + }); + + it('rejects a JWT with the wrong typ header with invalid_grant', async () => { + const { idpKey, resolve } = await setup(); + const notAnIdJag = await new jose.SignJWT({ resource: MCP_RESOURCE }) + .setProtectedHeader({ alg: ID_JAG_ALG, typ: 'JWT', kid: idpKey.kid }) + .setIssuer(IDP_ISSUER) + .setSubject(TEST_SUBJECT) + .setAudience(RESOURCE_AS_ISSUER) + .setIssuedAt() + .setExpirationTime('5m') + .setJti('x') + .sign(idpKey.privateKey); + + const result = await validateIdJagForResourceAs(notAnIdJag, { + resourceAsIssuer: RESOURCE_AS_ISSUER, + trustedIdpIssuers: [IDP_ISSUER], + resolveIdpKeys: resolve + }); + + expect(result).toMatchObject({ ok: false, error: 'invalid_grant' }); + }); + + it('rejects an ID-JAG missing the resource claim with invalid_target', async () => { + const { idpKey, resolve } = await setup(); + const assertion = await createIdJag(idpKey.privateKey, idpKey.kid, { + issuer: IDP_ISSUER, + subject: TEST_SUBJECT, + audience: RESOURCE_AS_ISSUER + }); + + const result = await validateIdJagForResourceAs(assertion, { + resourceAsIssuer: RESOURCE_AS_ISSUER, + trustedIdpIssuers: [IDP_ISSUER], + resolveIdpKeys: resolve + }); + + expect(result).toMatchObject({ ok: false, error: 'invalid_target' }); + }); + + it('rejects a malformed assertion with invalid_request', async () => { + const { resolve } = await setup(); + const result = await validateIdJagForResourceAs('not-a-jwt', { + resourceAsIssuer: RESOURCE_AS_ISSUER, + trustedIdpIssuers: [IDP_ISSUER], + resolveIdpKeys: resolve + }); + expect(result).toMatchObject({ ok: false, error: 'invalid_request' }); + }); +}); + +// --------------------------------------------------------------------------- +// Hosted Resource AS driven end-to-end against real IdP AS servers +// --------------------------------------------------------------------------- + +describe('MockResourceAuthorizationServer', () => { + let trustedIdp: IdPAuthorizationServer | null = null; + let untrustedIdp: IdPAuthorizationServer | null = null; + let resourceAs: MockResourceAuthorizationServer | null = null; + + afterEach(async () => { + await resourceAs?.stop(); + await trustedIdp?.stop(); + await untrustedIdp?.stop(); + resourceAs = null; + trustedIdp = null; + untrustedIdp = null; + }); + + async function boot() { + trustedIdp = await IdPAuthorizationServer.create(); + await trustedIdp.start(); + untrustedIdp = await IdPAuthorizationServer.create(); + await untrustedIdp.start(); + resourceAs = await MockResourceAuthorizationServer.create({ + trustedIdpIssuers: [trustedIdp.issuer] + }); + await resourceAs.start(); + return { trustedIdp, untrustedIdp, resourceAs }; + } + + it('advertises the id-jag grant profile and jwt-bearer grant in metadata', async () => { + const { resourceAs } = await boot(); + const metadata = resourceAs.getMetadata(); + expect(metadata.issuer).toBe(resourceAs.issuer); + expect(metadata.token_endpoint).toBe(resourceAs.tokenEndpoint); + expect(metadata.grant_types_supported).toContain(JWT_BEARER_GRANT_TYPE); + expect(metadata.authorization_grant_profiles_supported).toContain( + ID_JAG_GRANT_PROFILE + ); + }); + + it('issues an access token audience-restricted to the resource for a valid ID-JAG', async () => { + const { trustedIdp, resourceAs } = await boot(); + const assertion = await trustedIdp.issueIdJag({ + subject: TEST_SUBJECT, + audience: resourceAs.issuer, + resource: MCP_RESOURCE, + clientId: TEST_CLIENT_ID, + scope: 'mcp.read mcp.write' + }); + + const response = await requestAccessTokenWithIdJag( + resourceAs.tokenEndpoint, + { assertion, clientId: TEST_CLIENT_ID } + ); + + expect(response.statusCode).toBe(200); + expect(response.body.token_type).toBe('Bearer'); + expect(response.body.scope).toBe('mcp.read mcp.write'); + expect(typeof response.body.access_token).toBe('string'); + + const payload = await resourceAs.verifyAccessToken( + response.body.access_token as string, + MCP_RESOURCE + ); + expect(payload.aud).toBe(MCP_RESOURCE); + expect(payload.sub).toBe(TEST_SUBJECT); + expect(payload.iss).toBe(resourceAs.issuer); + }); + + it('rejects an ID-JAG from an untrusted IdP', async () => { + const { untrustedIdp, resourceAs } = await boot(); + const assertion = await untrustedIdp.issueIdJag({ + subject: TEST_SUBJECT, + audience: resourceAs.issuer, + resource: MCP_RESOURCE + }); + + const response = await requestAccessTokenWithIdJag( + resourceAs.tokenEndpoint, + { assertion } + ); + + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('invalid_grant'); + }); + + it('rejects an ID-JAG that claims a trusted issuer but is signed by another key', async () => { + const { trustedIdp, resourceAs } = await boot(); + // Forge the trusted issuer while signing with a foreign key; the Resource AS + // fetches the real trusted IdP keys, so the signature must not verify. + const foreignKey = await createIdPKeyPair('foreign'); + const assertion = await createIdJag(foreignKey.privateKey, foreignKey.kid, { + issuer: trustedIdp.issuer, + subject: TEST_SUBJECT, + audience: resourceAs.issuer, + resource: MCP_RESOURCE + }); + + const response = await requestAccessTokenWithIdJag( + resourceAs.tokenEndpoint, + { assertion } + ); + + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('invalid_grant'); + }); + + it('rejects an ID-JAG addressed to a different audience', async () => { + const { trustedIdp, resourceAs } = await boot(); + const assertion = await trustedIdp.issueIdJag({ + subject: TEST_SUBJECT, + audience: 'https://other-resource-as.example', + resource: MCP_RESOURCE + }); + + const response = await requestAccessTokenWithIdJag( + resourceAs.tokenEndpoint, + { assertion } + ); + + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('invalid_grant'); + }); + + it('rejects an ID-JAG without a resource claim as invalid_target', async () => { + const { trustedIdp, resourceAs } = await boot(); + const assertion = await trustedIdp.issueIdJag({ + subject: TEST_SUBJECT, + audience: resourceAs.issuer + }); + + const response = await requestAccessTokenWithIdJag( + resourceAs.tokenEndpoint, + { assertion } + ); + + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('invalid_target'); + }); + + it('rejects an unsupported grant type', async () => { + const { trustedIdp, resourceAs } = await boot(); + const assertion = await trustedIdp.issueIdJag({ + subject: TEST_SUBJECT, + audience: resourceAs.issuer, + resource: MCP_RESOURCE + }); + + const response = await requestAccessTokenWithIdJag( + resourceAs.tokenEndpoint, + { assertion, grantType: 'authorization_code' } + ); + + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('unsupported_grant_type'); + }); + + it('rejects a request missing the assertion parameter', async () => { + const { resourceAs } = await boot(); + const response = await requestAccessTokenWithIdJag( + resourceAs.tokenEndpoint, + { assertion: '' } + ); + + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('invalid_request'); + }); + + it('honours addTrustedIdp for issuers discovered after construction', async () => { + trustedIdp = await IdPAuthorizationServer.create(); + await trustedIdp.start(); + resourceAs = await MockResourceAuthorizationServer.create(); + await resourceAs.start(); + resourceAs.addTrustedIdp(trustedIdp.issuer); + + const assertion = await trustedIdp.issueIdJag({ + subject: TEST_SUBJECT, + audience: resourceAs.issuer, + resource: MCP_RESOURCE + }); + const response = await requestAccessTokenWithIdJag( + resourceAs.tokenEndpoint, + { assertion } + ); + + expect(response.statusCode).toBe(200); + }); +}); + +describe('MockResourceAuthorizationServer registration', () => { + let trustedIdp: IdPAuthorizationServer | null = null; + let resourceAs: MockResourceAuthorizationServer | null = null; + + afterEach(async () => { + await resourceAs?.stop(); + await trustedIdp?.stop(); + resourceAs = null; + trustedIdp = null; + }); + + async function bootWith( + options: Parameters[0] = {} + ) { + trustedIdp = await IdPAuthorizationServer.create(); + await trustedIdp.start(); + resourceAs = await MockResourceAuthorizationServer.create({ + trustedIdpIssuers: [trustedIdp.issuer], + ...options + }); + await resourceAs.start(); + return { trustedIdp, resourceAs }; + } + + it('registerClient returns a secret and enforces client_secret_basic', async () => { + const { trustedIdp, resourceAs } = await bootWith(); + const secret = resourceAs.registerClient(TEST_CLIENT_ID); + expect(secret).toBe(resourceAs.getClientSecret(TEST_CLIENT_ID)); + + const assertion = await trustedIdp.issueIdJag({ + subject: TEST_SUBJECT, + audience: resourceAs.issuer, + resource: MCP_RESOURCE, + clientId: TEST_CLIENT_ID + }); + + const authed = await requestAccessTokenWithIdJag(resourceAs.tokenEndpoint, { + assertion, + clientId: TEST_CLIENT_ID, + clientSecret: secret + }); + expect(authed.statusCode).toBe(200); + + const noCreds = await requestAccessTokenWithIdJag( + resourceAs.tokenEndpoint, + { + assertion + } + ); + expect(noCreds.statusCode).toBe(401); + expect(noCreds.body.error).toBe('invalid_client'); + + const badSecret = await requestAccessTokenWithIdJag( + resourceAs.tokenEndpoint, + { assertion, clientId: TEST_CLIENT_ID, clientSecret: 'wrong-secret' } + ); + expect(badSecret.statusCode).toBe(401); + expect(badSecret.body.error).toBe('invalid_client'); + }); + + it('registerClient accepts a caller-provided secret', async () => { + const { resourceAs } = await bootWith(); + const secret = resourceAs.registerClient(TEST_CLIENT_ID, 'fixed-secret'); + expect(secret).toBe('fixed-secret'); + expect(resourceAs.getClientSecret(TEST_CLIENT_ID)).toBe('fixed-secret'); + }); + + it('registerUser returns a user id usable as the ID-JAG sub', async () => { + const { trustedIdp, resourceAs } = await bootWith(); + const userId = resourceAs.registerUser('alice'); + expect(userId).toBe(resourceAs.getUserId('alice')); + + const assertion = await trustedIdp.issueIdJag({ + subject: userId, + audience: resourceAs.issuer, + resource: MCP_RESOURCE + }); + const response = await requestAccessTokenWithIdJag( + resourceAs.tokenEndpoint, + { assertion } + ); + expect(response.statusCode).toBe(200); + + const payload = await resourceAs.verifyAccessToken( + response.body.access_token as string, + MCP_RESOURCE + ); + expect(payload.sub).toBe(userId); + }); + + it('linkIdpSubject maps the ID-JAG sub to the linked Resource AS user id', async () => { + const { trustedIdp, resourceAs } = await bootWith(); + const userId = resourceAs.registerUser('alice'); + const idpSub = 'idp-alice-001'; + resourceAs.linkIdpSubject(idpSub, userId); + + const assertion = await trustedIdp.issueIdJag({ + subject: idpSub, + audience: resourceAs.issuer, + resource: MCP_RESOURCE + }); + const response = await requestAccessTokenWithIdJag( + resourceAs.tokenEndpoint, + { assertion } + ); + expect(response.statusCode).toBe(200); + + const payload = await resourceAs.verifyAccessToken( + response.body.access_token as string, + MCP_RESOURCE + ); + expect(payload.sub).toBe(userId); + expect(payload.sub).not.toBe(idpSub); + }); + + it('registerScope records recognised scopes', async () => { + const { resourceAs } = await bootWith(); + resourceAs.registerScope('mcp.read'); + resourceAs.registerScope('mcp.write'); + expect(resourceAs.getRegisteredScopes()).toEqual(['mcp.read', 'mcp.write']); + }); + + it('rejects an ID-JAG requesting an unregistered scope with invalid_scope', async () => { + const { trustedIdp, resourceAs } = await bootWith(); + resourceAs.registerScope('mcp.read'); + + const accepted = await trustedIdp.issueIdJag({ + subject: TEST_SUBJECT, + audience: resourceAs.issuer, + resource: MCP_RESOURCE, + scope: 'mcp.read' + }); + const acceptedResponse = await requestAccessTokenWithIdJag( + resourceAs.tokenEndpoint, + { assertion: accepted } + ); + expect(acceptedResponse.statusCode).toBe(200); + + const rejected = await trustedIdp.issueIdJag({ + subject: TEST_SUBJECT, + audience: resourceAs.issuer, + resource: MCP_RESOURCE, + scope: 'mcp.read mcp.admin' + }); + const rejectedResponse = await requestAccessTokenWithIdJag( + resourceAs.tokenEndpoint, + { assertion: rejected } + ); + expect(rejectedResponse.statusCode).toBe(400); + expect(rejectedResponse.body.error).toBe('invalid_scope'); + }); + + it('only accepts ID-JAGs whose resource is a registered trusted MCP Server', async () => { + const { trustedIdp, resourceAs } = await bootWith(); + resourceAs.registerTrustedMcpServer(MCP_RESOURCE); + expect(resourceAs.getTrustedMcpServers()).toEqual([MCP_RESOURCE]); + + const trusted = await trustedIdp.issueIdJag({ + subject: TEST_SUBJECT, + audience: resourceAs.issuer, + resource: MCP_RESOURCE + }); + const trustedResponse = await requestAccessTokenWithIdJag( + resourceAs.tokenEndpoint, + { assertion: trusted } + ); + expect(trustedResponse.statusCode).toBe(200); + + const untrusted = await trustedIdp.issueIdJag({ + subject: TEST_SUBJECT, + audience: resourceAs.issuer, + resource: 'https://other-mcp.example/' + }); + const untrustedResponse = await requestAccessTokenWithIdJag( + resourceAs.tokenEndpoint, + { assertion: untrusted } + ); + expect(untrustedResponse.statusCode).toBe(400); + expect(untrustedResponse.body.error).toBe('invalid_target'); + }); + + it('registerTrustedIdp gates the accepted ID-JAG issuers', async () => { + trustedIdp = await IdPAuthorizationServer.create(); + await trustedIdp.start(); + resourceAs = await MockResourceAuthorizationServer.create(); + await resourceAs.start(); + + const assertion = await trustedIdp.issueIdJag({ + subject: TEST_SUBJECT, + audience: resourceAs.issuer, + resource: MCP_RESOURCE + }); + + const before = await requestAccessTokenWithIdJag(resourceAs.tokenEndpoint, { + assertion + }); + expect(before.statusCode).toBe(400); + expect(before.body.error).toBe('invalid_grant'); + + resourceAs.registerTrustedIdp(trustedIdp.issuer); + const after = await requestAccessTokenWithIdJag(resourceAs.tokenEndpoint, { + assertion + }); + expect(after.statusCode).toBe(200); + }); +}); + +describe('MockResourceAuthorizationServer token introspection', () => { + let trustedIdp: IdPAuthorizationServer | null = null; + let resourceAs: MockResourceAuthorizationServer | null = null; + + afterEach(async () => { + await resourceAs?.stop(); + await trustedIdp?.stop(); + resourceAs = null; + trustedIdp = null; + }); + + async function boot( + options: Parameters[0] = {} + ) { + trustedIdp = await IdPAuthorizationServer.create(); + await trustedIdp.start(); + resourceAs = await MockResourceAuthorizationServer.create({ + trustedIdpIssuers: [trustedIdp.issuer], + ...options + }); + await resourceAs.start(); + return { trustedIdp, resourceAs }; + } + + async function issueToken( + trustedIdp: IdPAuthorizationServer, + resourceAs: MockResourceAuthorizationServer, + overrides: Record = {} + ): Promise { + const assertion = await trustedIdp.issueIdJag({ + subject: TEST_SUBJECT, + audience: resourceAs.issuer, + resource: MCP_RESOURCE, + clientId: TEST_CLIENT_ID, + scope: 'mcp.read', + ...overrides + }); + const response = await requestAccessTokenWithIdJag( + resourceAs.tokenEndpoint, + { assertion } + ); + expect(response.statusCode).toBe(200); + return response.body.access_token as string; + } + + it('advertises the introspection endpoint in its metadata', async () => { + const { resourceAs } = await boot(); + const metadata = resourceAs.getMetadata(); + expect(metadata.introspection_endpoint).toBe( + resourceAs.introspectionEndpoint + ); + expect(metadata.introspection_endpoint_auth_methods_supported).toContain( + 'client_secret_basic' + ); + }); + + it('reports an active access token with its claims', async () => { + const { trustedIdp, resourceAs } = await boot(); + const token = await issueToken(trustedIdp, resourceAs); + + const response = await introspectToken(resourceAs.introspectionEndpoint, { + token + }); + + expect(response.statusCode).toBe(200); + expect(response.body.active).toBe(true); + expect(response.body.token_type).toBe('Bearer'); + expect(response.body.scope).toBe('mcp.read'); + expect(response.body.client_id).toBe(TEST_CLIENT_ID); + expect(response.body.sub).toBe(TEST_SUBJECT); + expect(response.body.aud).toBe(MCP_RESOURCE); + expect(response.body.iss).toBe(resourceAs.issuer); + expect(typeof response.body.exp).toBe('number'); + expect(typeof response.body.iat).toBe('number'); + expect(typeof response.body.jti).toBe('string'); + }); + + it('reports active:false for an unknown or malformed token', async () => { + const { resourceAs } = await boot(); + const response = await introspectToken(resourceAs.introspectionEndpoint, { + token: 'not-a-token' + }); + expect(response.statusCode).toBe(200); + expect(response.body.active).toBe(false); + expect(Object.keys(response.body)).toEqual(['active']); + }); + + it('reports active:false for a token signed by another key', async () => { + const { trustedIdp, resourceAs } = await boot(); + // A token minted by a different Resource AS must not introspect as active. + const other = await MockResourceAuthorizationServer.create({ + trustedIdpIssuers: [trustedIdp.issuer] + }); + await other.start(); + try { + const foreignToken = await issueToken(trustedIdp, other); + const response = await introspectToken(resourceAs.introspectionEndpoint, { + token: foreignToken + }); + expect(response.body.active).toBe(false); + } finally { + await other.stop(); + } + }); + + it('reports active:false for an expired token', async () => { + // A negative lifetime mints an already-expired access token; with zero clock + // tolerance introspection must report it inactive. + const { trustedIdp, resourceAs } = await boot({ + accessTokenLifetimeSeconds: -10, + clockToleranceSeconds: 0 + }); + const token = await issueToken(trustedIdp, resourceAs); + + const response = await introspectToken(resourceAs.introspectionEndpoint, { + token + }); + expect(response.body.active).toBe(false); + }); + + it('includes the registered username for the token subject', async () => { + const { trustedIdp, resourceAs } = await boot(); + const userId = resourceAs.registerUser('alice'); + const token = await issueToken(trustedIdp, resourceAs, { subject: userId }); + + const response = await introspectToken(resourceAs.introspectionEndpoint, { + token + }); + expect(response.body.active).toBe(true); + expect(response.body.sub).toBe(userId); + expect(response.body.username).toBe('alice'); + }); + + it('requires client authentication once a client is registered', async () => { + const { trustedIdp, resourceAs } = await boot(); + const secret = resourceAs.registerClient(TEST_CLIENT_ID); + const token = await issueTokenAuthed(trustedIdp, resourceAs, secret); + + const unauth = await introspectToken(resourceAs.introspectionEndpoint, { + token + }); + expect(unauth.statusCode).toBe(401); + expect(unauth.body.error).toBe('invalid_client'); + + const authed = await introspectToken(resourceAs.introspectionEndpoint, { + token, + clientId: TEST_CLIENT_ID, + clientSecret: secret + }); + expect(authed.statusCode).toBe(200); + expect(authed.body.active).toBe(true); + }); + + async function issueTokenAuthed( + trustedIdp: IdPAuthorizationServer, + resourceAs: MockResourceAuthorizationServer, + secret: string + ): Promise { + const assertion = await trustedIdp.issueIdJag({ + subject: TEST_SUBJECT, + audience: resourceAs.issuer, + resource: MCP_RESOURCE, + clientId: TEST_CLIENT_ID + }); + const response = await requestAccessTokenWithIdJag( + resourceAs.tokenEndpoint, + { assertion, clientId: TEST_CLIENT_ID, clientSecret: secret } + ); + expect(response.statusCode).toBe(200); + return response.body.access_token as string; + } + + it('returns invalid_request when the token parameter is missing', async () => { + const { resourceAs } = await boot(); + const response = await introspectToken(resourceAs.introspectionEndpoint, { + token: '' + }); + expect(response.statusCode).toBe(400); + expect(response.body.error).toBe('invalid_request'); + }); +}); diff --git a/src/scenarios/ema/auth/helpers/mockResourceAuthorizationServer.ts b/src/scenarios/ema/auth/helpers/mockResourceAuthorizationServer.ts new file mode 100644 index 00000000..a9581328 --- /dev/null +++ b/src/scenarios/ema/auth/helpers/mockResourceAuthorizationServer.ts @@ -0,0 +1,959 @@ +/** + * Mock **Resource Authorization Server** for the Enterprise-Managed + * Authorization (EMA) conformance tests (ISSUE-470). A real Resource AS can't + * be stood up as a test target, so this module provides a spec-shaped stand-in + * that the test runner drives while simulating an MCP Client. + * + * It implements the Resource-AS side of the Identity Assertion JWT + * Authorization Grant (ID-JAG) flow: + * - hosts authorization-server metadata (well-known URI) advertising the + * `urn:ietf:params:oauth:grant-profile:id-jag` grant profile and the + * `jwt-bearer` grant type (Discovery, EMA §6), + * - exposes a token endpoint that accepts a `jwt-bearer` grant carrying an + * ID-JAG assertion, validates it against a configured set of trusted IdP + * Authorization Servers (fetching their `jwks_uri` to verify the + * signature), and + * - on success issues an access token audience-restricted to the MCP Server + * named by the ID-JAG `resource` claim (EMA §5.1). + * + * Spec references: + * - Enterprise-Managed Authorization + * https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization.mdx + * - Identity Assertion JWT Authorization Grant (draft-04) + * https://www.ietf.org/archive/id/draft-ietf-oauth-identity-assertion-authz-grant-04.html + * - RFC 7523 (JWT Profile for OAuth 2.0 Authorization Grants) + */ +import express, { type Request, type Response } from 'express'; +import type { Server } from 'node:http'; +import * as jose from 'jose'; +import type { JWK } from 'jose'; +import { request } from 'undici'; +import { + ID_JAG_ALG, + ID_JAG_TYP, + OAUTH_AS_WELL_KNOWN, + createIdPKeyPair, + fetchIdPServerMetadata, + fetchJwks, + type IdPKeyPair +} from './provideIdPAuthorizationServer'; +import type { ResourceAuthorizationServerUnderTest } from './resourceAuthorizationServerTarget'; + +/** RFC 7523 grant type the MCP Client uses to present an ID-JAG to the Resource AS. */ +export const JWT_BEARER_GRANT_TYPE = + 'urn:ietf:params:oauth:grant-type:jwt-bearer'; + +/** Grant-profile identifier a Resource AS advertises to signal ID-JAG support (EMA §6). */ +export const ID_JAG_GRANT_PROFILE = + 'urn:ietf:params:oauth:grant-profile:id-jag'; + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// --------------------------------------------------------------------------- +// ID-JAG validation (Resource-AS processing rules) +// --------------------------------------------------------------------------- + +/** Resolves the signing keys (JWK Set) published by a given IdP issuer. */ +export type ResourceAsKeyResolver = (idpIssuer: string) => Promise; + +export interface ValidateIdJagOptions { + /** This Resource AS's issuer identifier; the ID-JAG `aud` MUST match it. */ + resourceAsIssuer: string; + /** IdP issuer identifiers this Resource AS trusts. */ + trustedIdpIssuers: string[]; + /** + * MCP Server identifiers this Resource AS trusts. When non-empty, the ID-JAG + * `resource` claim (the MCP Server the access token is minted for) MUST be + * one of them. In ID-JAG the MCP Server is the `resource` claim — the JWT + * `aud` is the Resource AS itself. + */ + trustedMcpServers?: string[]; + /** + * Whether the ID-JAG `resource` claim is mandatory. Defaults to true. The + * `resource` claim is OPTIONAL in EMA §4.3, so set false to accept grants + * without it (the issued access token is then not audience-restricted). + */ + requireResourceClaim?: boolean; + /** + * Scopes this Resource AS recognises. When non-empty, every space-delimited + * value in the ID-JAG `scope` claim MUST be one of them, else the grant is + * rejected with `invalid_scope` (RFC 6749 §5.2). + */ + registeredScopes?: string[]; + /** Fetches the signing keys for a trusted IdP issuer. */ + resolveIdpKeys: ResourceAsKeyResolver; + /** Leeway for `exp`/`iat` in seconds. Defaults to 5. */ + clockToleranceSeconds?: number; +} + +export interface IdJagValidationSuccess { + ok: true; + issuer: string; + subject: string; + /** MCP Server resource identifier the access token is restricted to, if present. */ + resource?: string; + scope?: string; + clientId?: string; + payload: jose.JWTPayload; +} + +export interface IdJagValidationFailure { + ok: false; + /** OAuth 2.0 token error code (RFC 6749 §5.2 / RFC 8707). */ + error: string; + errorDescription: string; +} + +export type IdJagValidationResult = + | IdJagValidationSuccess + | IdJagValidationFailure; + +function validationFailure( + error: string, + errorDescription: string +): IdJagValidationFailure { + return { ok: false, error, errorDescription }; +} + +/** + * Validate an ID-JAG the way a Resource AS must (EMA §5.1 / + * draft-ietf-oauth-identity-assertion-authz-grant §4.4.1): + * 1. the assertion is a well-formed JWT typed `oauth-id-jag+jwt` signed with ES256, + * 2. its `iss` names a trusted IdP, + * 3. its signature verifies against that IdP's published keys, + * 4. its `aud` equals this Resource AS's issuer identifier and it is unexpired, + * 5. it carries a `resource` claim identifying the MCP Server. + * + * Returned as a discriminated result rather than thrown so the token endpoint + * can map failures onto OAuth error responses. Exported so tests can exercise + * the rules directly with a stub key resolver. + */ +export async function validateIdJagForResourceAs( + assertion: string, + options: ValidateIdJagOptions +): Promise { + let header: jose.ProtectedHeaderParameters; + try { + header = jose.decodeProtectedHeader(assertion); + } catch { + return validationFailure( + 'invalid_request', + 'Assertion is not a well-formed JWT' + ); + } + if (header.typ !== ID_JAG_TYP) { + return validationFailure( + 'invalid_grant', + `Assertion "typ" header must be "${ID_JAG_TYP}"` + ); + } + if (header.alg !== ID_JAG_ALG) { + return validationFailure( + 'invalid_grant', + `Assertion "alg" header must be "${ID_JAG_ALG}"` + ); + } + + let unverified: jose.JWTPayload; + try { + unverified = jose.decodeJwt(assertion); + } catch { + return validationFailure( + 'invalid_request', + 'Assertion payload is not a valid JWT' + ); + } + const issuer = + typeof unverified.iss === 'string' ? unverified.iss : undefined; + if (!issuer) { + return validationFailure( + 'invalid_grant', + 'Assertion is missing the "iss" claim' + ); + } + if (!options.trustedIdpIssuers.includes(issuer)) { + return validationFailure( + 'invalid_grant', + `Assertion issuer "${issuer}" is not a trusted IdP` + ); + } + + let keys: JWK[]; + try { + keys = await options.resolveIdpKeys(issuer); + } catch (error) { + return validationFailure( + 'invalid_grant', + `Unable to resolve signing keys for issuer "${issuer}": ${toErrorMessage(error)}` + ); + } + if (keys.length === 0) { + return validationFailure( + 'invalid_grant', + `Issuer "${issuer}" published no signing keys` + ); + } + + let payload: jose.JWTPayload; + try { + const jwks = jose.createLocalJWKSet({ keys }); + ({ payload } = await jose.jwtVerify(assertion, jwks, { + algorithms: [ID_JAG_ALG], + typ: ID_JAG_TYP, + issuer, + audience: options.resourceAsIssuer, + clockTolerance: options.clockToleranceSeconds ?? 5 + })); + } catch (error) { + return validationFailure( + 'invalid_grant', + `ID-JAG verification failed: ${toErrorMessage(error)}` + ); + } + + for (const claim of ['sub', 'jti', 'exp', 'iat'] as const) { + if (payload[claim] === undefined) { + return validationFailure( + 'invalid_grant', + `ID-JAG is missing required claim "${claim}"` + ); + } + } + + const resource = + typeof payload.resource === 'string' ? payload.resource : undefined; + if (!resource) { + if (options.requireResourceClaim ?? true) { + return validationFailure( + 'invalid_target', + 'ID-JAG is missing the "resource" claim identifying the MCP Server' + ); + } + } else if ( + options.trustedMcpServers && + options.trustedMcpServers.length > 0 && + !options.trustedMcpServers.includes(resource) + ) { + return validationFailure( + 'invalid_target', + `MCP Server "${resource}" is not a trusted MCP Server` + ); + } + + const scope = typeof payload.scope === 'string' ? payload.scope : undefined; + if ( + options.registeredScopes && + options.registeredScopes.length > 0 && + scope !== undefined + ) { + const unknownScopes = scope + .split(' ') + .filter((s) => s.length > 0 && !options.registeredScopes!.includes(s)); + if (unknownScopes.length > 0) { + return validationFailure( + 'invalid_scope', + `Requested scope(s) not recognised: ${unknownScopes.join(' ')}` + ); + } + } + + return { + ok: true, + issuer, + subject: payload.sub as string, + resource, + scope, + clientId: + typeof payload.client_id === 'string' ? payload.client_id : undefined, + payload + }; +} + +// --------------------------------------------------------------------------- +// Resource AS metadata + host +// --------------------------------------------------------------------------- + +export interface ResourceServerMetadata { + issuer: string; + token_endpoint: string; + introspection_endpoint: string; + jwks_uri: string; + grant_types_supported: string[]; + authorization_grant_profiles_supported: string[]; + token_endpoint_auth_methods_supported: string[]; + introspection_endpoint_auth_methods_supported: string[]; +} + +export interface MockResourceAuthorizationServerOptions { + /** IdP issuer identifiers this Resource AS trusts. */ + trustedIdpIssuers?: string[]; + /** MCP Server URLs this Resource AS trusts (matched against the ID-JAG `resource`). */ + trustedMcpServers?: string[]; + /** Whether the ID-JAG `resource` claim is mandatory. Defaults to true. */ + requireResourceClaim?: boolean; + /** Reuse an existing ES256 key pair for signing access tokens. */ + signingKeyPair?: IdPKeyPair; + /** Well-known suffix for the metadata endpoint. Defaults to RFC 8414's. */ + wellKnownPath?: string; + /** Access-token lifetime in seconds. Defaults to 3600. */ + accessTokenLifetimeSeconds?: number; + /** Leeway for ID-JAG `exp`/`iat` in seconds. Defaults to 5. */ + clockToleranceSeconds?: number; + /** + * Rewrite the metadata document served at the well-known URI. Receives the + * default (conformant) metadata and returns the object to serve — negative + * tests use this to drop or malform fields and produce a Resource AS that + * fails discovery. + */ + metadataTransform?: ( + defaults: ResourceServerMetadata + ) => Record; +} + +export interface AccessTokenRequestParams { + assertion: string; + clientId?: string; + clientSecret?: string; + resource?: string; + scope?: string; + /** Override the grant type (for negative tests). Defaults to `jwt-bearer`. */ + grantType?: string; + /** + * How to present client credentials. Defaults to `client_secret_basic` when a + * `clientSecret` is supplied. + */ + clientAuthMethod?: 'client_secret_basic' | 'client_secret_post'; +} + +export interface TokenEndpointResponse { + statusCode: number; + contentType?: string; + headers: Record; + body: Record; +} + +/** + * POST a `jwt-bearer` grant carrying an ID-JAG assertion to a Resource AS token + * endpoint, as an MCP Client would. Returns the status code and parsed body. + */ +export async function requestAccessTokenWithIdJag( + tokenEndpoint: string, + params: AccessTokenRequestParams +): Promise { + const form = new URLSearchParams(); + form.set('grant_type', params.grantType ?? JWT_BEARER_GRANT_TYPE); + form.set('assertion', params.assertion); + if (params.resource) form.set('resource', params.resource); + if (params.scope) form.set('scope', params.scope); + + const headers: Record = { + 'content-type': 'application/x-www-form-urlencoded' + }; + + const useBasicAuth = + params.clientId !== undefined && + params.clientSecret !== undefined && + params.clientAuthMethod !== 'client_secret_post'; + + if (useBasicAuth) { + const credentials = Buffer.from( + `${params.clientId}:${params.clientSecret}` + ).toString('base64'); + headers['authorization'] = `Basic ${credentials}`; + } else { + if (params.clientId) form.set('client_id', params.clientId); + if (params.clientSecret) form.set('client_secret', params.clientSecret); + } + + const response = await request(tokenEndpoint, { + method: 'POST', + headers, + body: form.toString() + }); + const contentTypeHeader = response.headers['content-type']; + const contentType = Array.isArray(contentTypeHeader) + ? contentTypeHeader[0] + : contentTypeHeader; + const body = (await response.body.json()) as Record; + return { + statusCode: response.statusCode, + contentType, + headers: response.headers, + body + }; +} + +export interface IntrospectionRequestParams { + token: string; + /** RFC 7662 `token_type_hint` (e.g. `access_token`). */ + tokenTypeHint?: string; + clientId?: string; + clientSecret?: string; + /** Defaults to `client_secret_basic` when a `clientSecret` is provided. */ + clientAuthMethod?: 'client_secret_basic' | 'client_secret_post'; +} + +export interface IntrospectionEndpointResponse { + statusCode: number; + contentType?: string; + headers: Record; + body: Record; +} + +/** + * POST a token to a Resource AS introspection endpoint (RFC 7662 §2.1) and + * return the status code and parsed introspection response. + */ +export async function introspectToken( + introspectionEndpoint: string, + params: IntrospectionRequestParams +): Promise { + const form = new URLSearchParams(); + form.set('token', params.token); + if (params.tokenTypeHint) form.set('token_type_hint', params.tokenTypeHint); + + const headers: Record = { + 'content-type': 'application/x-www-form-urlencoded' + }; + + const useBasicAuth = + params.clientId !== undefined && + params.clientSecret !== undefined && + params.clientAuthMethod !== 'client_secret_post'; + + if (useBasicAuth) { + const credentials = Buffer.from( + `${params.clientId}:${params.clientSecret}` + ).toString('base64'); + headers['authorization'] = `Basic ${credentials}`; + } else { + if (params.clientId) form.set('client_id', params.clientId); + if (params.clientSecret) form.set('client_secret', params.clientSecret); + } + + const response = await request(introspectionEndpoint, { + method: 'POST', + headers, + body: form.toString() + }); + const contentTypeHeader = response.headers['content-type']; + const contentType = Array.isArray(contentTypeHeader) + ? contentTypeHeader[0] + : contentTypeHeader; + const body = (await response.body.json()) as Record; + return { + statusCode: response.statusCode, + contentType, + headers: response.headers, + body + }; +} + +/** + * A localhost Resource Authorization Server that stands in for a real one. It + * hosts its metadata and token endpoints, validates presented ID-JAGs against + * its trusted IdPs, and issues audience-restricted access tokens. + */ +export class MockResourceAuthorizationServer implements ResourceAuthorizationServerUnderTest { + private readonly signingKey: IdPKeyPair; + private readonly wellKnownPath: string; + private readonly accessTokenLifetimeSeconds: number; + private readonly clockToleranceSeconds: number; + private readonly trustedIdpIssuers: string[]; + private readonly trustedMcpServers: Set; + private readonly requireResourceClaim: boolean; + private readonly clients = new Map(); + private readonly users = new Map(); + private readonly idpSubjectLinks = new Map(); + private readonly scopes = new Set(); + private readonly metadataTransform?: ( + defaults: ResourceServerMetadata + ) => Record; + private readonly jwksCache = new Map(); + private httpServer: Server | null = null; + private baseUrl = ''; + + private constructor( + signingKey: IdPKeyPair, + trustedIdpIssuers: string[], + wellKnownPath: string, + accessTokenLifetimeSeconds: number, + clockToleranceSeconds: number, + trustedMcpServers: string[], + requireResourceClaim: boolean, + metadataTransform?: ( + defaults: ResourceServerMetadata + ) => Record + ) { + this.signingKey = signingKey; + this.trustedIdpIssuers = trustedIdpIssuers; + this.wellKnownPath = wellKnownPath; + this.accessTokenLifetimeSeconds = accessTokenLifetimeSeconds; + this.clockToleranceSeconds = clockToleranceSeconds; + this.trustedMcpServers = new Set(trustedMcpServers); + this.requireResourceClaim = requireResourceClaim; + this.metadataTransform = metadataTransform; + } + + static async create( + options: MockResourceAuthorizationServerOptions = {} + ): Promise { + const signingKey = + options.signingKeyPair ?? (await createIdPKeyPair('resource-as-es256-1')); + return new MockResourceAuthorizationServer( + signingKey, + [...(options.trustedIdpIssuers ?? [])], + options.wellKnownPath ?? OAUTH_AS_WELL_KNOWN, + options.accessTokenLifetimeSeconds ?? 3600, + options.clockToleranceSeconds ?? 5, + [...(options.trustedMcpServers ?? [])], + options.requireResourceClaim ?? true, + options.metadataTransform + ); + } + + /** Add an IdP issuer to the trust list (issuer URLs are known only after the IdP starts). */ + addTrustedIdp(issuer: string): void { + if (!this.trustedIdpIssuers.includes(issuer)) { + this.trustedIdpIssuers.push(issuer); + } + } + + /** Register a trusted IdP AS by issuer URL (alias of {@link addTrustedIdp}). */ + registerTrustedIdp(issuer: string): void { + this.addTrustedIdp(issuer); + } + + /** + * Register a trusted MCP Server by URL. Once at least one is registered, the + * Resource AS only accepts ID-JAGs whose `resource` (the MCP Server the + * access token is minted for) is one of the registered servers. + */ + registerTrustedMcpServer(url: string): void { + this.trustedMcpServers.add(url); + } + + /** + * Register a client for `client_secret_basic` authentication and return its + * secret. Once any client is registered, the token endpoint requires a valid + * client_secret_basic credential. + */ + registerClient(clientId: string, clientSecret?: string): string { + const secret = clientSecret ?? `secret_${crypto.randomUUID()}`; + this.clients.set(clientId, secret); + return secret; + } + + /** Register a user and return its id, registered with this Resource AS. */ + registerUser(username: string, userId?: string): string { + const id = userId ?? `user_${crypto.randomUUID()}`; + this.users.set(username, id); + return id; + } + + /** + * Link an ID-JAG `sub` (a user id registered with a trusted IdP) to a user id + * registered with this Resource AS. When a validated ID-JAG's `sub` matches a + * linked idpSub, the issued access token's `sub` claim uses the linked + * Resource AS user id rather than echoing the ID-JAG value verbatim. + */ + linkIdpSubject(idpSub: string, resourceAsUserId: string): void { + this.idpSubjectLinks.set(idpSub, resourceAsUserId); + } + + /** Register a scope the Resource AS recognises. */ + registerScope(scope: string): void { + this.scopes.add(scope); + } + + /** The secret registered for a client id, if any. */ + getClientSecret(clientId: string): string | undefined { + return this.clients.get(clientId); + } + + /** The user id registered for a username, if any. */ + getUserId(username: string): string | undefined { + return this.users.get(username); + } + + getRegisteredScopes(): string[] { + return [...this.scopes]; + } + + getTrustedIdpIssuers(): string[] { + return [...this.trustedIdpIssuers]; + } + + getTrustedMcpServers(): string[] { + return [...this.trustedMcpServers]; + } + + async start(): Promise { + const app = express(); + app.use(express.urlencoded({ extended: false })); + + app.get(`/${this.wellKnownPath}`, (_req: Request, res: Response) => { + res.type('application/json').json(this.getServedMetadata()); + }); + + app.get('/jwks', (_req: Request, res: Response) => { + res.type('application/json').json({ keys: [this.signingKey.publicJwk] }); + }); + + app.post('/token', (req: Request, res: Response) => { + void this.handleTokenRequest(req, res); + }); + + app.post('/introspect', (req: Request, res: Response) => { + void this.handleIntrospectionRequest(req, res); + }); + + this.httpServer = app.listen(0); + await new Promise((resolve, reject) => { + this.httpServer!.once('listening', resolve); + this.httpServer!.once('error', reject); + }); + const address = this.httpServer.address(); + if (!address || typeof address === 'string') { + throw new Error('Resource AS failed to bind to a TCP port'); + } + this.baseUrl = `http://localhost:${address.port}`; + return this.baseUrl; + } + + async stop(): Promise { + if (this.httpServer) { + const server = this.httpServer; + await new Promise((resolve) => { + server.closeAllConnections?.(); + server.close(() => resolve()); + }); + this.httpServer = null; + } + this.baseUrl = ''; + this.jwksCache.clear(); + } + + get issuer(): string { + if (!this.baseUrl) { + throw new Error('Resource AS has not been started'); + } + return this.baseUrl; + } + + get metadataUrl(): string { + return `${this.issuer}/${this.wellKnownPath}`; + } + + get tokenEndpoint(): string { + return `${this.issuer}/token`; + } + + get introspectionEndpoint(): string { + return `${this.issuer}/introspect`; + } + + get jwksUrl(): string { + return `${this.issuer}/jwks`; + } + + getMetadata(): ResourceServerMetadata { + return { + issuer: this.issuer, + token_endpoint: this.tokenEndpoint, + introspection_endpoint: this.introspectionEndpoint, + jwks_uri: this.jwksUrl, + grant_types_supported: [JWT_BEARER_GRANT_TYPE], + authorization_grant_profiles_supported: [ID_JAG_GRANT_PROFILE], + token_endpoint_auth_methods_supported: [ + 'client_secret_basic', + 'client_secret_post', + 'none' + ], + introspection_endpoint_auth_methods_supported: [ + 'client_secret_basic', + 'client_secret_post' + ] + }; + } + + /** The metadata document actually served, after any configured transform. */ + getServedMetadata(): Record { + const defaults = this.getMetadata(); + return this.metadataTransform + ? this.metadataTransform(defaults) + : { ...defaults }; + } + + /** Verify an access token this Resource AS issued (for test introspection). */ + async verifyAccessToken( + token: string, + expectedAudience?: string + ): Promise { + const key = await jose.importJWK(this.signingKey.publicJwk, ID_JAG_ALG); + const { payload } = await jose.jwtVerify(token, key, { + algorithms: [ID_JAG_ALG], + issuer: this.issuer, + audience: expectedAudience + }); + return payload; + } + + /** + * Introspect an access token this Resource AS issued (RFC 7662 §2.2). Returns + * `{ active: true, ... }` with the token's claims when it verifies and is + * unexpired, otherwise `{ active: false }`. + */ + async introspect(token: string): Promise> { + let payload: jose.JWTPayload; + try { + const key = await jose.importJWK(this.signingKey.publicJwk, ID_JAG_ALG); + ({ payload } = await jose.jwtVerify(token, key, { + algorithms: [ID_JAG_ALG], + issuer: this.issuer, + clockTolerance: this.clockToleranceSeconds + })); + } catch { + return { active: false }; + } + + const response: Record = { + active: true, + token_type: 'Bearer' + }; + if (typeof payload.scope === 'string') response.scope = payload.scope; + if (typeof payload.client_id === 'string') { + response.client_id = payload.client_id; + } + if (typeof payload.sub === 'string') { + response.sub = payload.sub; + const username = this.usernameForUserId(payload.sub); + if (username) response.username = username; + } + if (payload.aud !== undefined) response.aud = payload.aud; + if (payload.iss !== undefined) response.iss = payload.iss; + if (payload.exp !== undefined) response.exp = payload.exp; + if (payload.iat !== undefined) response.iat = payload.iat; + if (payload.jti !== undefined) response.jti = payload.jti; + return response; + } + + private usernameForUserId(userId: string): string | undefined { + for (const [username, id] of this.users) { + if (id === userId) return username; + } + return undefined; + } + + /** Resolve a trusted IdP's signing keys via its metadata `jwks_uri`, cached per issuer. */ + private resolveIdpKeys: ResourceAsKeyResolver = async (idpIssuer) => { + const cached = this.jwksCache.get(idpIssuer); + if (cached) { + return cached; + } + const metadata = await fetchIdPServerMetadata(idpIssuer); + const jwksUri = metadata.body.jwks_uri; + if (!jwksUri || typeof jwksUri !== 'string') { + throw new Error(`IdP metadata for "${idpIssuer}" has no jwks_uri`); + } + const { keys } = await fetchJwks(jwksUri); + this.jwksCache.set(idpIssuer, keys); + return keys; + }; + + /** + * Authenticate the client via `client_secret_post` or `client_secret_basic`. + * When no clients are registered, authentication is skipped (open token + * endpoint). Returns the authenticated client id on success. + */ + private authenticateClient( + req: Request + ): + | { ok: true; clientId?: string } + | { ok: false; error: string; errorDescription: string } { + if (this.clients.size === 0) { + return { ok: true }; + } + // client_secret_post: credentials in the form body (OpenID Connect Core §9). + const body = (req.body ?? {}) as Record; + if ( + typeof body.client_id === 'string' || + typeof body.client_secret === 'string' + ) { + const clientId = typeof body.client_id === 'string' ? body.client_id : ''; + const clientSecret = + typeof body.client_secret === 'string' ? body.client_secret : ''; + const expected = this.clients.get(clientId); + if (expected === undefined || expected !== clientSecret) { + return { + ok: false, + error: 'invalid_client', + errorDescription: 'Unknown client or invalid client secret' + }; + } + return { ok: true, clientId }; + } + // client_secret_basic: credentials in the Authorization header (RFC 6749 §2.3.1). + const header = req.headers['authorization']; + if (typeof header !== 'string' || !header.startsWith('Basic ')) { + return { + ok: false, + error: 'invalid_client', + errorDescription: + 'client_secret_post or client_secret_basic authentication is required' + }; + } + const decoded = Buffer.from( + header.slice('Basic '.length), + 'base64' + ).toString('utf8'); + const separator = decoded.indexOf(':'); + if (separator < 0) { + return { + ok: false, + error: 'invalid_client', + errorDescription: 'Malformed Basic authorization header' + }; + } + const clientId = decoded.slice(0, separator); + const clientSecret = decoded.slice(separator + 1); + const expected = this.clients.get(clientId); + if (expected === undefined || expected !== clientSecret) { + return { + ok: false, + error: 'invalid_client', + errorDescription: 'Unknown client or invalid client secret' + }; + } + return { ok: true, clientId }; + } + + private async handleTokenRequest(req: Request, res: Response): Promise { + const body = (req.body ?? {}) as Record; + const grantType = body.grant_type; + if (grantType !== JWT_BEARER_GRANT_TYPE) { + res + .status(400) + .set('Cache-Control', 'no-store') + .json({ + error: 'unsupported_grant_type', + error_description: `Only "${JWT_BEARER_GRANT_TYPE}" is supported` + }); + return; + } + + const auth = this.authenticateClient(req); + if (!auth.ok) { + res.status(401).set('Cache-Control', 'no-store').json({ + error: auth.error, + error_description: auth.errorDescription + }); + return; + } + + const assertion = body.assertion; + if (typeof assertion !== 'string' || assertion.length === 0) { + res.status(400).set('Cache-Control', 'no-store').json({ + error: 'invalid_request', + error_description: 'Missing "assertion" parameter' + }); + return; + } + + const result = await validateIdJagForResourceAs(assertion, { + resourceAsIssuer: this.issuer, + trustedIdpIssuers: this.trustedIdpIssuers, + trustedMcpServers: [...this.trustedMcpServers], + requireResourceClaim: this.requireResourceClaim, + registeredScopes: [...this.scopes], + resolveIdpKeys: this.resolveIdpKeys, + clockToleranceSeconds: this.clockToleranceSeconds + }); + + if (!result.ok) { + res.status(400).set('Cache-Control', 'no-store').json({ + error: result.error, + error_description: result.errorDescription + }); + return; + } + + const clientId = + auth.clientId ?? + (typeof body.client_id === 'string' ? body.client_id : result.clientId); + const accessToken = await this.issueAccessToken(result, clientId); + + res + .status(200) + .set('Cache-Control', 'no-store') + .json({ + token_type: 'Bearer', + access_token: accessToken, + expires_in: this.accessTokenLifetimeSeconds, + ...(result.scope ? { scope: result.scope } : {}) + }); + } + + /** RFC 7662 introspection endpoint: authenticate the caller, then introspect. */ + private async handleIntrospectionRequest( + req: Request, + res: Response + ): Promise { + const auth = this.authenticateClient(req); + if (!auth.ok) { + res.status(401).set('Cache-Control', 'no-store').json({ + error: auth.error, + error_description: auth.errorDescription + }); + return; + } + + const body = (req.body ?? {}) as Record; + const token = body.token; + if (typeof token !== 'string' || token.length === 0) { + res.status(400).set('Cache-Control', 'no-store').json({ + error: 'invalid_request', + error_description: 'Missing "token" parameter' + }); + return; + } + + const introspection = await this.introspect(token); + res.status(200).set('Cache-Control', 'no-store').json(introspection); + } + + /** Mint an access token audience-restricted to the ID-JAG `resource` (EMA §5.1). */ + private async issueAccessToken( + result: IdJagValidationSuccess, + clientId?: string + ): Promise { + const claims: Record = {}; + if (result.scope !== undefined) claims.scope = result.scope; + if (clientId !== undefined) claims.client_id = clientId; + + // Map the ID-JAG's IdP-registered subject to this Resource AS's own user + // id, if linked; otherwise echo the ID-JAG subject verbatim. + const subject = this.idpSubjectLinks.get(result.subject) ?? result.subject; + + let builder = new jose.SignJWT(claims) + .setProtectedHeader({ + alg: ID_JAG_ALG, + typ: 'at+jwt', + kid: this.signingKey.kid + }) + .setIssuer(this.issuer) + .setSubject(subject) + .setIssuedAt() + .setExpirationTime(`${this.accessTokenLifetimeSeconds}s`) + .setJti(crypto.randomUUID()); + + // Only audience-restrict when the grant identified an MCP Server. + if (result.resource !== undefined) { + builder = builder.setAudience(result.resource); + } + + return builder.sign(this.signingKey.privateKey); + } +} diff --git a/src/scenarios/ema/auth/helpers/provideIdPAuthorizationServer.test.ts b/src/scenarios/ema/auth/helpers/provideIdPAuthorizationServer.test.ts new file mode 100644 index 00000000..0ae474aa --- /dev/null +++ b/src/scenarios/ema/auth/helpers/provideIdPAuthorizationServer.test.ts @@ -0,0 +1,303 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { webcrypto } from 'node:crypto'; +import * as jose from 'jose'; +import type { JWK } from 'jose'; +import { + ID_JAG_ALG, + ID_JAG_TYP, + OAUTH_AS_WELL_KNOWN, + TOKEN_EXCHANGE_GRANT_TYPE, + createIdPKeyPair, + verifyIdPKeyPair, + createIdJag, + verifyIdJag, + fetchIdPServerMetadata, + fetchJwks, + IdPAuthorizationServer +} from './provideIdPAuthorizationServer'; + +/** + * Independent ES256 verification via Node's native WebCrypto — a different code + * path from jose's verifier, so a signing bug can't be masked by symmetric use + * of a single library. + */ +async function verifyEs256Independently( + jwt: string, + publicJwk: JWK +): Promise { + const [h, p, s] = jwt.split('.'); + const key = await webcrypto.subtle.importKey( + 'jwk', + publicJwk as JsonWebKey, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['verify'] + ); + const signature = new Uint8Array(Buffer.from(s, 'base64url')); + const data = new TextEncoder().encode(`${h}.${p}`); + return webcrypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + signature, + data + ); +} + +describe('createIdPKeyPair / verifyIdPKeyPair', () => { + it('creates an ES256 (EC P-256) key pair annotated for a JWK Set', async () => { + const kp = await createIdPKeyPair(); + expect(kp.publicJwk.kty).toBe('EC'); + expect(kp.publicJwk.crv).toBe('P-256'); + expect(kp.publicJwk.alg).toBe(ID_JAG_ALG); + expect(kp.publicJwk.use).toBe('sig'); + expect(kp.publicJwk.kid).toBe(kp.kid); + // Public JWK must not carry the private component. + expect((kp.publicJwk as Record).d).toBeUndefined(); + }); + + it('honours a custom kid', async () => { + const kp = await createIdPKeyPair('trusted-idp-key'); + expect(kp.kid).toBe('trusted-idp-key'); + expect(kp.publicJwk.kid).toBe('trusted-idp-key'); + }); + + it('verifies a freshly generated key pair round-trips', async () => { + const kp = await createIdPKeyPair(); + expect(await verifyIdPKeyPair(kp)).toBe(true); + }); + + it('rejects a key pair whose public JWK does not match the private key', async () => { + const a = await createIdPKeyPair(); + const b = await createIdPKeyPair(); + const mismatched = { ...a, publicJwk: b.publicJwk }; + expect(await verifyIdPKeyPair(mismatched)).toBe(false); + }); +}); + +describe('createIdJag / verifyIdJag', () => { + it('signs an ID-JAG with the id-jag type header and required claims', async () => { + const kp = await createIdPKeyPair(); + const idJag = await createIdJag(kp.privateKey, kp.kid, { + issuer: 'https://idp.example', + subject: 'U123', + audience: 'https://resource-as.example/', + resource: 'https://mcp.example/', + clientId: 'client-abc', + scope: 'mcp.read mcp.write', + email: 'user@example.com' + }); + + const header = jose.decodeProtectedHeader(idJag); + expect(header.typ).toBe(ID_JAG_TYP); + expect(header.alg).toBe(ID_JAG_ALG); + expect(header.kid).toBe(kp.kid); + + const payload = jose.decodeJwt(idJag); + expect(payload.iss).toBe('https://idp.example'); + expect(payload.sub).toBe('U123'); + expect(payload.aud).toBe('https://resource-as.example/'); + expect(payload.resource).toBe('https://mcp.example/'); + expect(payload.client_id).toBe('client-abc'); + expect(payload.scope).toBe('mcp.read mcp.write'); + expect(payload.email).toBe('user@example.com'); + expect(typeof payload.jti).toBe('string'); + expect(typeof payload.iat).toBe('number'); + expect(typeof payload.exp).toBe('number'); + }); + + it('produces a signature verifiable by an independent ES256 verifier', async () => { + const kp = await createIdPKeyPair(); + const idJag = await createIdJag(kp.privateKey, kp.kid, { + issuer: 'https://idp.example', + subject: 'U123', + audience: 'https://resource-as.example/' + }); + expect(await verifyEs256Independently(idJag, kp.publicJwk)).toBe(true); + }); + + it('verifyIdJag accepts a valid grant and returns its claims', async () => { + const kp = await createIdPKeyPair(); + const idJag = await createIdJag(kp.privateKey, kp.kid, { + issuer: 'https://idp.example', + subject: 'U123', + audience: 'https://resource-as.example/' + }); + const { header, payload } = await verifyIdJag(idJag, kp.publicJwk, { + issuer: 'https://idp.example', + audience: 'https://resource-as.example/' + }); + expect(header.typ).toBe(ID_JAG_TYP); + expect(payload.sub).toBe('U123'); + }); + + it('verifyIdJag rejects a grant signed by a different key', async () => { + const signer = await createIdPKeyPair(); + const other = await createIdPKeyPair(); + const idJag = await createIdJag(signer.privateKey, signer.kid, { + issuer: 'https://idp.example', + subject: 'U123', + audience: 'https://resource-as.example/' + }); + await expect(verifyIdJag(idJag, other.publicJwk)).rejects.toThrow(); + }); + + it('verifyIdJag rejects an expired grant', async () => { + const kp = await createIdPKeyPair(); + const idJag = await createIdJag(kp.privateKey, kp.kid, { + issuer: 'https://idp.example', + subject: 'U123', + audience: 'https://resource-as.example/', + expiresIn: '-60s' + }); + await expect(verifyIdJag(idJag, kp.publicJwk)).rejects.toThrow(); + }); + + it('verifyIdJag rejects a JWT with the wrong typ header', async () => { + const kp = await createIdPKeyPair(); + const notAnIdJag = await new jose.SignJWT({ sub: 'U123' }) + .setProtectedHeader({ alg: ID_JAG_ALG, typ: 'JWT', kid: kp.kid }) + .setIssuer('https://idp.example') + .setAudience('https://resource-as.example/') + .setIssuedAt() + .setExpirationTime('5m') + .setJti('x') + .sign(kp.privateKey); + await expect(verifyIdJag(notAnIdJag, kp.publicJwk)).rejects.toThrow(); + }); +}); + +describe('IdPAuthorizationServer', () => { + let server: IdPAuthorizationServer | null = null; + + afterEach(async () => { + await server?.stop(); + server = null; + }); + + it('hosts server metadata at the well-known URI retrievable via HTTP GET', async () => { + server = await IdPAuthorizationServer.create(); + const issuer = await server.start(); + + const metadata = await fetchIdPServerMetadata(issuer); + expect(metadata.statusCode).toBe(200); + expect(metadata.contentType).toContain('application/json'); + expect(metadata.body.issuer).toBe(issuer); + expect(metadata.body.jwks_uri).toBe(`${issuer}/jwks`); + expect(metadata.body.token_endpoint).toBe(`${issuer}/token`); + expect(metadata.body.grant_types_supported).toContain( + TOKEN_EXCHANGE_GRANT_TYPE + ); + }); + + it('derives the issuer by removing the well-known suffix from the metadata URL', async () => { + server = await IdPAuthorizationServer.create(); + const issuer = await server.start(); + expect(server.metadataUrl).toBe(`${issuer}/${OAUTH_AS_WELL_KNOWN}`); + expect(server.metadataUrl.replace(`/${OAUTH_AS_WELL_KNOWN}`, '')).toBe( + issuer + ); + }); + + it('serves the signing key at jwks_uri and verifies its own ID-JAG', async () => { + server = await IdPAuthorizationServer.create(); + await server.start(); + + const jwks = await fetchJwks(server.jwksUrl); + expect(jwks.statusCode).toBe(200); + expect(jwks.keys).toHaveLength(1); + const [key] = jwks.keys; + expect(key.kty).toBe('EC'); + expect((key as Record).d).toBeUndefined(); + + const idJag = await server.issueIdJag({ + subject: 'U123', + audience: 'https://resource-as.example/', + resource: 'https://mcp.example/' + }); + const { payload } = await verifyIdJag(idJag, key, { + issuer: server.issuer, + audience: 'https://resource-as.example/' + }); + expect(payload.sub).toBe('U123'); + expect(payload.iss).toBe(server.issuer); + }); + + it('throws when the issuer is read before start', async () => { + server = await IdPAuthorizationServer.create(); + expect(() => server!.issuer).toThrow(); + }); + + it('reuses a provided key pair', async () => { + const kp = await createIdPKeyPair('shared-key'); + server = await IdPAuthorizationServer.create({ keyPair: kp }); + await server.start(); + const jwks = await fetchJwks(server.jwksUrl); + expect(jwks.keys[0].kid).toBe('shared-key'); + }); + + it('advertises a configured issuer while binding to a local port', async () => { + // Trailing slash is normalised; the local server still serves the routes. + server = await IdPAuthorizationServer.create({ + issuer: 'https://idp.example.com/' + }); + await server.start(); + + expect(server.issuer).toBe('https://idp.example.com'); + expect(server.localUrl).toMatch(/^http:\/\/localhost:\d+$/); + expect(server.jwksUrl).toBe('https://idp.example.com/jwks'); + expect(server.tokenEndpoint).toBe('https://idp.example.com/token'); + + // The metadata served at the bound address advertises the configured issuer. + const metadata = await fetchIdPServerMetadata(server.localUrl); + expect(metadata.body.issuer).toBe('https://idp.example.com'); + expect(metadata.body.jwks_uri).toBe('https://idp.example.com/jwks'); + expect(metadata.body.token_endpoint).toBe('https://idp.example.com/token'); + + // The ID-JAG iss matches the configured issuer, and the key at the bound + // jwks endpoint verifies it. + const idJag = await server.issueIdJag({ + subject: 'U123', + audience: 'https://resource-as.example/', + resource: 'https://mcp.example/' + }); + const jwks = await fetchJwks(`${server.localUrl}/jwks`); + const { payload } = await verifyIdJag(idJag, jwks.keys[0], { + issuer: 'https://idp.example.com', + audience: 'https://resource-as.example/' + }); + expect(payload.iss).toBe('https://idp.example.com'); + }); + + it('exposes the configured issuer before start', async () => { + server = await IdPAuthorizationServer.create({ + issuer: 'https://idp.example.com' + }); + expect(server.issuer).toBe('https://idp.example.com'); + }); + + it('issueIdJagWithUnpublishedKey signs with a key absent from jwks_uri', async () => { + server = await IdPAuthorizationServer.create(); + await server.start(); + + const idJag = await server.issueIdJagWithUnpublishedKey({ + subject: 'U123', + audience: 'https://resource-as.example/', + resource: 'https://mcp.example/' + }); + + // iss still names this IdP; only the signing key is unpublished. + const unverified = jose.decodeJwt(idJag); + expect(unverified.iss).toBe(server.issuer); + + const jwks = await fetchJwks(server.jwksUrl); + expect(jwks.keys.map((k) => k.kid)).not.toContain( + jose.decodeProtectedHeader(idJag).kid + ); + await expect( + verifyIdJag(idJag, jwks.keys[0], { + issuer: server.issuer, + audience: 'https://resource-as.example/' + }) + ).rejects.toThrow(); + }); +}); diff --git a/src/scenarios/ema/auth/helpers/provideIdPAuthorizationServer.ts b/src/scenarios/ema/auth/helpers/provideIdPAuthorizationServer.ts new file mode 100644 index 00000000..2b513e10 --- /dev/null +++ b/src/scenarios/ema/auth/helpers/provideIdPAuthorizationServer.ts @@ -0,0 +1,446 @@ +/** + * Groundwork for the Enterprise-Managed Authorization (EMA) conformance tests + * (ISSUE-470). This module implements a self-contained **IdP Authorization + * Server** that the test runner controls while simulating an MCP Client. + * + * The IdP AS provides the building blocks the runner needs to drive a Resource + * Authorization Server through the ID-JAG flow: + * - an ES256 JWS key pair (with a helper to prove it round-trips), + * - creation and signing of an Identity Assertion JWT Authorization Grant + * (ID-JAG) plus verification of its signature and claims, + * - an HTTP server hosting the two endpoints the issue calls out — the + * authorization-server metadata endpoint (well-known URI) and the JWK Set + * endpoint (`jwks_uri`) — plus GET helpers to retrieve them. + * + * Spec references: + * - Enterprise-Managed Authorization + * https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization.mdx + * - Identity Assertion JWT Authorization Grant (draft-04) + * https://www.ietf.org/archive/id/draft-ietf-oauth-identity-assertion-authz-grant-04.html + * - RFC 8414 (Authorization Server Metadata) + */ +import express, { type Request, type Response } from 'express'; +import type { Server } from 'node:http'; +import * as jose from 'jose'; +import type { CryptoKey, JWK } from 'jose'; +import { request } from 'undici'; + +/** JWS algorithm used for the IdP signing key and the ID-JAG. */ +export const ID_JAG_ALG = 'ES256'; + +/** `typ` header of an ID-JAG (draft-ietf-oauth-identity-assertion-authz-grant §3.1). */ +export const ID_JAG_TYP = 'oauth-id-jag+jwt'; + +/** OAuth 2.0 token type identifier for an issued ID-JAG. */ +export const ID_JAG_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id-jag'; + +/** RFC 8693 token-exchange grant type used to request an ID-JAG from the IdP. */ +export const TOKEN_EXCHANGE_GRANT_TYPE = + 'urn:ietf:params:oauth:grant-type:token-exchange'; + +/** RFC 8414 well-known suffix for OAuth authorization-server metadata. */ +export const OAUTH_AS_WELL_KNOWN = '.well-known/oauth-authorization-server'; + +// --------------------------------------------------------------------------- +// Key pairs (JWS / ES256) +// --------------------------------------------------------------------------- + +export interface IdPKeyPair { + publicKey: CryptoKey; + privateKey: CryptoKey; + /** Public key as a JWK, annotated with `kid`, `alg` and `use` for the JWK Set. */ + publicJwk: JWK; + kid: string; +} + +/** + * Create an ES256 JWS key pair for the IdP. The public key is exported as a + * JWK carrying the metadata (`kid`, `alg`, `use`) needed to publish it in a + * JWK Set. + */ +export async function createIdPKeyPair( + kid: string = 'idp-es256-1' +): Promise { + const { publicKey, privateKey } = await jose.generateKeyPair(ID_JAG_ALG, { + extractable: true + }); + const publicJwk: JWK = { + ...(await jose.exportJWK(publicKey)), + kid, + alg: ID_JAG_ALG, + use: 'sig' + }; + return { publicKey, privateKey, publicJwk, kid }; +} + +/** + * Prove a key pair is internally consistent: sign a probe token with the + * private key and verify it with the exported public JWK. Returns true only + * when the round-trip succeeds. Used to test freshly generated key pairs. + */ +export async function verifyIdPKeyPair(keyPair: IdPKeyPair): Promise { + try { + const probe = await new jose.SignJWT({ probe: true }) + .setProtectedHeader({ alg: ID_JAG_ALG, kid: keyPair.kid }) + .setIssuedAt() + .sign(keyPair.privateKey); + const publicKey = await jose.importJWK(keyPair.publicJwk, ID_JAG_ALG); + await jose.jwtVerify(probe, publicKey, { algorithms: [ID_JAG_ALG] }); + return true; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// ID-JAG (Identity Assertion JWT Authorization Grant) +// --------------------------------------------------------------------------- + +export interface CreateIdJagInput { + /** IdP issuer identifier (`iss`). */ + issuer: string; + /** End-user subject identifier (`sub`). */ + subject: string; + /** Resource AS issuer identifier (`aud`). */ + audience: string; + /** MCP Server resource identifier (`resource`); MUST be set if present per EMA §4.3. */ + resource?: string; + /** MCP Client's `client_id` at the Resource AS. */ + clientId?: string; + /** Space-delimited scopes (`scope`). */ + scope?: string; + /** End-user email (`email`). */ + email?: string; + /** JWT id (`jti`); a random UUID is used when omitted. */ + jwtId?: string; + /** jose duration string for `exp`; defaults to `5m`. Use a negative offset for expired tokens. */ + expiresIn?: string; + /** Override `iat` (seconds since epoch). */ + issuedAt?: number; + /** Extra claims merged before the reserved claims above are applied. */ + additionalClaims?: Record; +} + +/** + * Create and sign an ID-JAG with the supplied private key. The token carries + * the `oauth-id-jag+jwt` type header and the claims defined in + * draft-ietf-oauth-identity-assertion-authz-grant §3.1. + */ +export async function createIdJag( + privateKey: CryptoKey, + kid: string, + input: CreateIdJagInput +): Promise { + const { + issuer, + subject, + audience, + resource, + clientId, + scope, + email, + jwtId = crypto.randomUUID(), + expiresIn = '5m', + issuedAt, + additionalClaims + } = input; + + const payload: Record = { ...(additionalClaims ?? {}) }; + if (resource !== undefined) payload.resource = resource; + if (clientId !== undefined) payload.client_id = clientId; + if (scope !== undefined) payload.scope = scope; + if (email !== undefined) payload.email = email; + + let builder = new jose.SignJWT(payload) + .setProtectedHeader({ alg: ID_JAG_ALG, typ: ID_JAG_TYP, kid }) + .setIssuer(issuer) + .setSubject(subject) + .setAudience(audience) + .setExpirationTime(expiresIn) + .setJti(jwtId); + + builder = + issuedAt !== undefined + ? builder.setIssuedAt(issuedAt) + : builder.setIssuedAt(); + + return builder.sign(privateKey); +} + +export interface VerifyIdJagOptions { + /** Require this `iss` value. */ + issuer?: string; + /** Require this `aud` value. */ + audience?: string; +} + +export interface VerifiedIdJag { + header: jose.ProtectedHeaderParameters; + payload: jose.JWTPayload; +} + +/** + * Verify an ID-JAG's signature against a public JWK and check that its type + * header and claims are well-formed. Throws when the signature is invalid, the + * `typ` header is wrong, or a required claim is missing. + */ +export async function verifyIdJag( + idJag: string, + publicJwk: JWK, + options: VerifyIdJagOptions = {} +): Promise { + const publicKey = await jose.importJWK(publicJwk, ID_JAG_ALG); + const { protectedHeader, payload } = await jose.jwtVerify(idJag, publicKey, { + algorithms: [ID_JAG_ALG], + typ: ID_JAG_TYP, + issuer: options.issuer, + audience: options.audience + }); + + for (const claim of ['sub', 'aud', 'iss', 'exp', 'iat', 'jti'] as const) { + if (payload[claim] === undefined) { + throw new Error(`ID-JAG is missing required claim "${claim}"`); + } + } + + return { header: protectedHeader, payload }; +} + +// --------------------------------------------------------------------------- +// IdP Authorization Server metadata + host +// --------------------------------------------------------------------------- + +export interface IdPServerMetadata { + issuer: string; + token_endpoint: string; + jwks_uri: string; + grant_types_supported: string[]; + token_endpoint_auth_methods_supported: string[]; + response_types_supported: string[]; + id_token_signing_alg_values_supported: string[]; +} + +export interface FetchedMetadata { + statusCode: number; + contentType?: string; + body: IdPServerMetadata & Record; +} + +/** + * Retrieve an IdP AS metadata document by HTTP GET on its well-known URI, as an + * MCP Client / Resource AS would. Returns the status code, content type and the + * parsed JSON body. + */ +export async function fetchIdPServerMetadata( + issuer: string, + wellKnownPath: string = OAUTH_AS_WELL_KNOWN +): Promise { + const url = `${issuer.replace(/\/$/, '')}/${wellKnownPath}`; + const response = await request(url, { method: 'GET' }); + const contentTypeHeader = response.headers['content-type']; + const contentType = Array.isArray(contentTypeHeader) + ? contentTypeHeader[0] + : contentTypeHeader; + const body = (await response.body.json()) as IdPServerMetadata & + Record; + return { statusCode: response.statusCode, contentType, body }; +} + +export interface FetchedJwks { + statusCode: number; + keys: JWK[]; +} + +/** Retrieve a JWK Set by HTTP GET on its `jwks_uri`. */ +export async function fetchJwks(jwksUri: string): Promise { + const response = await request(jwksUri, { method: 'GET' }); + const body = (await response.body.json()) as { keys?: JWK[] }; + return { statusCode: response.statusCode, keys: body.keys ?? [] }; +} + +export interface IdPAuthorizationServerOptions { + /** Reuse an existing key pair; a fresh ES256 pair is generated otherwise. */ + keyPair?: IdPKeyPair; + /** Well-known suffix for the metadata endpoint. Defaults to RFC 8414's. */ + wellKnownPath?: string; + /** + * Issuer identifier to advertise instead of the bound localhost address. Set + * this to a stable, externally reachable URL when a real Resource AS must + * fetch the IdP's metadata and jwks_uri (e.g. a tunnel/proxy that forwards to + * {@link IdPAuthorizationServer.localUrl}). It flows into the metadata + * `issuer`, `token_endpoint` and `jwks_uri`, and into the ID-JAG `iss`. + * + * When this names an explicit port on a loopback host (`http://localhost:PORT` + * or `http://127.0.0.1:PORT`), {@link IdPAuthorizationServer.start} binds + * directly to that port instead of an ephemeral one, so a locally-running + * Resource AS can be preconfigured with a fixed, stable address without + * needing a tunnel/proxy. + */ + issuer?: string; +} + +/** + * A localhost IdP Authorization Server that hosts the two endpoints the test + * runner needs: the authorization-server metadata endpoint (well-known URI) and + * the JWK Set endpoint (`jwks_uri`). It also mints ID-JAGs signed with its own + * key so the runner can present valid — or deliberately invalid — grants. + */ +export class IdPAuthorizationServer { + private readonly keyPair: IdPKeyPair; + private readonly wellKnownPath: string; + private readonly issuerOverride?: string; + private httpServer: Server | null = null; + private baseUrl = ''; + + private constructor( + keyPair: IdPKeyPair, + wellKnownPath: string, + issuerOverride?: string + ) { + this.keyPair = keyPair; + this.wellKnownPath = wellKnownPath; + this.issuerOverride = issuerOverride; + } + + static async create( + options: IdPAuthorizationServerOptions = {} + ): Promise { + const keyPair = options.keyPair ?? (await createIdPKeyPair()); + return new IdPAuthorizationServer( + keyPair, + options.wellKnownPath ?? OAUTH_AS_WELL_KNOWN, + options.issuer?.replace(/\/$/, '') + ); + } + + /** + * Port named by a loopback issuer override (e.g. `http://localhost:9464`), + * or undefined if the override is absent, non-loopback, or has no explicit + * port — in which case {@link start} binds an ephemeral port instead. + */ + private loopbackPort(): number | undefined { + if (!this.issuerOverride) { + return undefined; + } + let url: URL; + try { + url = new URL(this.issuerOverride); + } catch { + return undefined; + } + const isLoopback = + url.hostname === 'localhost' || + url.hostname === '127.0.0.1' || + url.hostname === '::1'; + return isLoopback && url.port ? Number(url.port) : undefined; + } + + /** Start listening (on the loopback port named by the issuer override, if any, otherwise an ephemeral port) and return the issuer identifier. */ + async start(): Promise { + const app = express(); + + app.get(`/${this.wellKnownPath}`, (_req: Request, res: Response) => { + res.type('application/json').json(this.getMetadata()); + }); + + app.get('/jwks', (_req: Request, res: Response) => { + res.type('application/json').json({ keys: [this.keyPair.publicJwk] }); + }); + + this.httpServer = app.listen(this.loopbackPort() ?? 0); + await new Promise((resolve, reject) => { + this.httpServer!.once('listening', resolve); + this.httpServer!.once('error', reject); + }); + const address = this.httpServer.address(); + if (!address || typeof address === 'string') { + throw new Error('IdP AS failed to bind to a TCP port'); + } + this.baseUrl = `http://localhost:${address.port}`; + return this.baseUrl; + } + + async stop(): Promise { + if (this.httpServer) { + const server = this.httpServer; + await new Promise((resolve) => { + server.closeAllConnections?.(); + server.close(() => resolve()); + }); + this.httpServer = null; + } + this.baseUrl = ''; + } + + /** The actual bound localhost address; only valid after {@link start}. */ + get localUrl(): string { + if (!this.baseUrl) { + throw new Error('IdP AS has not been started'); + } + return this.baseUrl; + } + + /** + * Issuer identifier: the configured override when set, otherwise the bound + * localhost address (which requires {@link start}). + */ + get issuer(): string { + return this.issuerOverride ?? this.localUrl; + } + + get keyPairRef(): IdPKeyPair { + return this.keyPair; + } + + get metadataUrl(): string { + return `${this.issuer}/${this.wellKnownPath}`; + } + + get jwksUrl(): string { + return `${this.issuer}/jwks`; + } + + get tokenEndpoint(): string { + return `${this.issuer}/token`; + } + + /** The metadata document served at the well-known URI. */ + getMetadata(): IdPServerMetadata { + return { + issuer: this.issuer, + token_endpoint: this.tokenEndpoint, + jwks_uri: this.jwksUrl, + grant_types_supported: [TOKEN_EXCHANGE_GRANT_TYPE], + token_endpoint_auth_methods_supported: ['client_secret_basic'], + response_types_supported: ['code'], + id_token_signing_alg_values_supported: [ID_JAG_ALG] + }; + } + + /** Mint an ID-JAG signed with this IdP's private key. */ + async issueIdJag( + input: Omit & { issuer?: string } + ): Promise { + return createIdJag(this.keyPair.privateKey, this.keyPair.kid, { + ...input, + issuer: input.issuer ?? this.issuer + }); + } + + /** + * Mint an ID-JAG the same way {@link issueIdJag} does, except signed with a + * freshly generated key that this IdP never adds to its own JWK Set (it is + * not served at {@link jwksUrl}). Models an IdP that itself issues a token + * whose signature a Resource AS cannot verify against the IdP's published + * key — e.g. an unpropagated key rotation — for negative testing. + */ + async issueIdJagWithUnpublishedKey( + input: Omit & { issuer?: string } + ): Promise { + const unpublishedKey = await createIdPKeyPair('idp-unpublished-es256'); + return createIdJag(unpublishedKey.privateKey, unpublishedKey.kid, { + ...input, + issuer: input.issuer ?? this.issuer + }); + } +} diff --git a/src/scenarios/ema/auth/helpers/resourceAuthorizationServerTarget.ts b/src/scenarios/ema/auth/helpers/resourceAuthorizationServerTarget.ts new file mode 100644 index 00000000..40ed8862 --- /dev/null +++ b/src/scenarios/ema/auth/helpers/resourceAuthorizationServerTarget.ts @@ -0,0 +1,31 @@ +/** + * The Resource Authorization Server (Resource AS) surface the EMA + * conformance scenarios drive (ISSUE-470). + * + * Scenarios target a *general* Resource AS through this interface instead of + * the concrete {@link MockResourceAuthorizationServer}, so the same scenario + * can run against any Resource AS that satisfies the contract. Tests inject the + * mock; a future runner could inject an adapter over a real Resource AS. + * + * The caller owns the target's lifecycle (create/start/stop): the target must + * be started and its endpoints reachable before it is handed to a scenario. + */ +export interface ResourceAuthorizationServerUnderTest { + /** This Resource AS's issuer identifier (the ID-JAG `aud` must match it). */ + readonly issuer: string; + /** Token endpoint that accepts the `jwt-bearer` ID-JAG grant. */ + readonly tokenEndpoint: string; + /** RFC 7662 introspection endpoint. */ + readonly introspectionEndpoint: string; + + /** Provision a trusted IdP AS by issuer URL (ID-JAGs it signs are accepted). */ + registerTrustedIdp(issuer: string): void; + /** Provision a user and return its id, registered with this Resource AS. */ + registerUser(username: string): string; + /** Provision a client and return the `client_secret_basic` secret to present. */ + registerClient(clientId: string): string; + /** Provision a trusted MCP Server the ID-JAG `resource` claim may name. */ + registerTrustedMcpServer(url: string): void; + /** Provision a scope the Resource AS recognises. */ + registerScope(scope: string): void; +} diff --git a/src/scenarios/ema/auth/spec-references.ts b/src/scenarios/ema/auth/spec-references.ts new file mode 100644 index 00000000..944918f5 --- /dev/null +++ b/src/scenarios/ema/auth/spec-references.ts @@ -0,0 +1,44 @@ +import { SpecReference } from '../../../types'; + +export const SpecReferences: { [key: string]: SpecReference } = { + EMA: { + id: 'MCP-Enterprise-Managed-Authorization', + url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization.mdx' + }, + EMA_DISCOVERY: { + id: 'MCP-Enterprise-Managed-Authorization-Discovery', + url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization.mdx' + }, + ID_JAG_DISCOVERY: { + id: 'draft-ietf-oauth-identity-assertion-authz-grant-04-discovery', + url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-identity-assertion-authz-grant-04.html#section-7.2' + }, + RFC_7523: { + id: 'RFC-7523', + url: 'https://www.rfc-editor.org/rfc/rfc7523.html' + }, + RFC_6749_TOKEN_RESPONSE: { + id: 'RFC-6749-5.1', + url: 'https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1' + }, + RFC_7521_RESPONSE: { + id: 'RFC-7521-5.2', + url: 'https://www.rfc-editor.org/rfc/rfc7521.html#section-5.2' + }, + RFC_7662_INTROSPECTION: { + id: 'RFC-7662-2.2', + url: 'https://www.rfc-editor.org/rfc/rfc7662.html#section-2.2' + }, + RFC_8707_INVALID_TARGET: { + id: 'RFC-8707-2.1', + url: 'https://www.rfc-editor.org/rfc/rfc8707.html#section-2.1' + }, + RFC_6749_ERROR_RESPONSE: { + id: 'RFC-6749-5.2', + url: 'https://www.rfc-editor.org/rfc/rfc6749.html#section-5.2' + }, + RFC_7521_INVALID_GRANT: { + id: 'RFC-7521-4.1.1', + url: 'https://www.rfc-editor.org/rfc/rfc7521.html#section-4.1.1' + } +}; diff --git a/src/scenarios/ema/resource-authorization-server/resource-authorization-error-path.test.ts b/src/scenarios/ema/resource-authorization-server/resource-authorization-error-path.test.ts new file mode 100644 index 00000000..aeb10280 --- /dev/null +++ b/src/scenarios/ema/resource-authorization-server/resource-authorization-error-path.test.ts @@ -0,0 +1,328 @@ +import { describe, it, expect } from 'vitest'; +import { + ResourceServerErrorPathScenario, + ResourceServerInvalidScopeScenario, + ResourceServerInvalidSignatureScenario, + ResourceServerUntrustedIdpScenario +} from './resource-authorization-error-path'; +import { createHarness } from './test-harness'; + +const CHECK_IDS = [ + 'resource-as-error-invalid-target-status', + 'resource-as-error-invalid-target-code' +]; + +const CHECK_IDS_INVALID_SCOPE = [ + 'resource-as-error-invalid-scope-status', + 'resource-as-error-invalid-scope-code' +]; + +const CHECK_IDS_INVALID_SIGNATURE = [ + 'resource-as-error-invalid-signature-status', + 'resource-as-error-invalid-signature-code' +]; + +const CHECK_IDS_UNTRUSTED_IDP = [ + 'resource-as-error-untrusted-idp-status', + 'resource-as-error-untrusted-idp-code' +]; + +describe('ResourceServerErrorPathScenario', () => { + it('has a stable name and EMA extension source', () => { + const scenario = new ResourceServerErrorPathScenario(); + expect(scenario.name).toBe('ema/resource-authorization-server/error-path'); + expect(scenario.source).toEqual({ + extensionId: 'io.modelcontextprotocol/enterprise-managed-authorization' + }); + }); + + it('passes every check against a configured Resource AS', async () => { + const harness = await createHarness({ registerTrustedMcpServer: true }); + try { + const scenario = new ResourceServerErrorPathScenario(); + const checks = await scenario.run(harness.options, harness.details); + + expect(checks.map((c) => c.id)).toEqual(CHECK_IDS); + for (const check of checks) { + expect( + check.status, + `${check.id} failed: ${check.errorMessage ?? ''}` + ).toBe('SUCCESS'); + } + } finally { + await harness.stop(); + } + }); + + it('rejects the untrusted resource with 400 invalid_target', async () => { + const harness = await createHarness({ registerTrustedMcpServer: true }); + try { + const scenario = new ResourceServerErrorPathScenario(); + const checks = await scenario.run(harness.options, harness.details); + + const statusCheck = checks.find( + (c) => c.id === 'resource-as-error-invalid-target-status' + ); + expect(statusCheck?.details?.statusCode).toBe(400); + + const errorCheck = checks.find( + (c) => c.id === 'resource-as-error-invalid-target-code' + ); + expect(errorCheck?.details?.error).toBe('invalid_target'); + } finally { + await harness.stop(); + } + }); + + it('skips when the untrustedMcpServer setting is missing', async () => { + const scenario = new ResourceServerErrorPathScenario(); + const checks = await scenario.run( + { + url: 'https://resource-as.example.com', + clientId: 'mcp-client', + clientSecret: 'secret', + idpSub: 'idp-user-123' + }, + {} + ); + expect(checks).toHaveLength(1); + expect(checks[0].status).toBe('SKIPPED'); + }); +}); + +describe('ResourceServerInvalidScopeScenario', () => { + it('has a stable name and EMA extension source', () => { + const scenario = new ResourceServerInvalidScopeScenario(); + expect(scenario.name).toBe( + 'ema/resource-authorization-server/error-path-invalid-scope' + ); + expect(scenario.source).toEqual({ + extensionId: 'io.modelcontextprotocol/enterprise-managed-authorization' + }); + }); + + it('passes every check against a configured Resource AS', async () => { + const harness = await createHarness({ + registerTrustedMcpServer: true, + registerScope: true + }); + try { + const scenario = new ResourceServerInvalidScopeScenario(); + const checks = await scenario.run(harness.options, harness.details); + + expect(checks.map((c) => c.id)).toEqual(CHECK_IDS_INVALID_SCOPE); + for (const check of checks) { + expect( + check.status, + `${check.id} failed: ${check.errorMessage ?? ''}` + ).toBe('SUCCESS'); + } + } finally { + await harness.stop(); + } + }); + + it('rejects the unregistered scope with 400 invalid_scope', async () => { + const harness = await createHarness({ + registerTrustedMcpServer: true, + registerScope: true + }); + try { + const scenario = new ResourceServerInvalidScopeScenario(); + const checks = await scenario.run(harness.options, harness.details); + + const statusCheck = checks.find( + (c) => c.id === 'resource-as-error-invalid-scope-status' + ); + expect(statusCheck?.details?.statusCode).toBe(400); + + const errorCheck = checks.find( + (c) => c.id === 'resource-as-error-invalid-scope-code' + ); + expect(errorCheck?.details?.error).toBe('invalid_scope'); + } finally { + await harness.stop(); + } + }); +}); + +describe('ResourceServerInvalidSignatureScenario', () => { + it('has a stable name and EMA extension source', () => { + const scenario = new ResourceServerInvalidSignatureScenario(); + expect(scenario.name).toBe( + 'ema/resource-authorization-server/error-path-invalid-signature' + ); + expect(scenario.source).toEqual({ + extensionId: 'io.modelcontextprotocol/enterprise-managed-authorization' + }); + }); + + it('passes every check against a configured Resource AS', async () => { + const harness = await createHarness({ registerTrustedMcpServer: true }); + try { + const scenario = new ResourceServerInvalidSignatureScenario(); + const checks = await scenario.run(harness.options, harness.details); + + expect(checks.map((c) => c.id)).toEqual(CHECK_IDS_INVALID_SIGNATURE); + for (const check of checks) { + expect( + check.status, + `${check.id} failed: ${check.errorMessage ?? ''}` + ).toBe('SUCCESS'); + } + } finally { + await harness.stop(); + } + }); + + it('rejects the unverifiable signature with 400 invalid_grant', async () => { + const harness = await createHarness({ registerTrustedMcpServer: true }); + try { + const scenario = new ResourceServerInvalidSignatureScenario(); + const checks = await scenario.run(harness.options, harness.details); + + const statusCheck = checks.find( + (c) => c.id === 'resource-as-error-invalid-signature-status' + ); + expect(statusCheck?.details?.statusCode).toBe(400); + + const errorCheck = checks.find( + (c) => c.id === 'resource-as-error-invalid-signature-code' + ); + expect(errorCheck?.details?.error).toBe('invalid_grant'); + } finally { + await harness.stop(); + } + }); + + it('skips when a required setting is missing', async () => { + const scenario = new ResourceServerInvalidSignatureScenario(); + const checks = await scenario.run( + { url: 'https://resource-as.example.com' }, + {} + ); + expect(checks).toHaveLength(1); + expect(checks[0].status).toBe('SKIPPED'); + }); + + it('does not require a registered user (idpSub)', async () => { + const harness = await createHarness({ registerTrustedMcpServer: true }); + try { + const optionsWithoutSub = { ...harness.options }; + delete optionsWithoutSub.idpSub; + const scenario = new ResourceServerInvalidSignatureScenario(); + const checks = await scenario.run(optionsWithoutSub, harness.details); + + expect(checks.map((c) => c.id)).toEqual(CHECK_IDS_INVALID_SIGNATURE); + for (const check of checks) { + expect( + check.status, + `${check.id} failed: ${check.errorMessage ?? ''}` + ).toBe('SUCCESS'); + } + } finally { + await harness.stop(); + } + }); +}); + +describe('ResourceServerUntrustedIdpScenario', () => { + it('has a stable name and EMA extension source', () => { + const scenario = new ResourceServerUntrustedIdpScenario(); + expect(scenario.name).toBe( + 'ema/resource-authorization-server/error-path-untrusted-idp' + ); + expect(scenario.source).toEqual({ + extensionId: 'io.modelcontextprotocol/enterprise-managed-authorization' + }); + }); + + it('passes every check against a configured Resource AS', async () => { + const harness = await createHarness({ + registerTrustedMcpServer: true, + provisionUntrustedIdp: true + }); + try { + const scenario = new ResourceServerUntrustedIdpScenario(); + const checks = await scenario.run(harness.options, harness.details); + + expect(checks.map((c) => c.id)).toEqual(CHECK_IDS_UNTRUSTED_IDP); + for (const check of checks) { + expect( + check.status, + `${check.id} failed: ${check.errorMessage ?? ''}` + ).toBe('SUCCESS'); + } + } finally { + await harness.stop(); + } + }); + + it('rejects the untrusted issuer with 400 invalid_grant', async () => { + const harness = await createHarness({ + registerTrustedMcpServer: true, + provisionUntrustedIdp: true + }); + try { + const scenario = new ResourceServerUntrustedIdpScenario(); + const checks = await scenario.run(harness.options, harness.details); + + const statusCheck = checks.find( + (c) => c.id === 'resource-as-error-untrusted-idp-status' + ); + expect(statusCheck?.details?.statusCode).toBe(400); + + const errorCheck = checks.find( + (c) => c.id === 'resource-as-error-untrusted-idp-code' + ); + expect(errorCheck?.details?.error).toBe('invalid_grant'); + } finally { + await harness.stop(); + } + }); + + it('skips when a required setting is missing', async () => { + const scenario = new ResourceServerUntrustedIdpScenario(); + const checks = await scenario.run( + { url: 'https://resource-as.example.com' }, + {} + ); + expect(checks).toHaveLength(1); + expect(checks[0].status).toBe('SKIPPED'); + }); + + it('skips when no untrusted IdP AS is supplied via details', async () => { + const harness = await createHarness({ registerTrustedMcpServer: true }); + try { + const scenario = new ResourceServerUntrustedIdpScenario(); + const checks = await scenario.run(harness.options, harness.details); + expect(checks).toHaveLength(1); + expect(checks[0].status).toBe('SKIPPED'); + } finally { + await harness.stop(); + } + }); + + it('does not require a registered user (idpSub)', async () => { + const harness = await createHarness({ + registerTrustedMcpServer: true, + provisionUntrustedIdp: true + }); + try { + const optionsWithoutSub = { ...harness.options }; + delete optionsWithoutSub.idpSub; + const scenario = new ResourceServerUntrustedIdpScenario(); + const checks = await scenario.run(optionsWithoutSub, harness.details); + + expect(checks.map((c) => c.id)).toEqual(CHECK_IDS_UNTRUSTED_IDP); + for (const check of checks) { + expect( + check.status, + `${check.id} failed: ${check.errorMessage ?? ''}` + ).toBe('SUCCESS'); + } + } finally { + await harness.stop(); + } + }); +}); diff --git a/src/scenarios/ema/resource-authorization-server/resource-authorization-error-path.ts b/src/scenarios/ema/resource-authorization-server/resource-authorization-error-path.ts new file mode 100644 index 00000000..05d8db00 --- /dev/null +++ b/src/scenarios/ema/resource-authorization-server/resource-authorization-error-path.ts @@ -0,0 +1,631 @@ +/** + * Error-path scenarios for the Resource-AS side of Enterprise-Managed + * Authorization (ISSUE-470). + * + * Scenario 5 ({@link ResourceServerErrorPathScenario}): an ID-JAG whose + * `resource` claim names an untrusted MCP Server is rejected with an RFC 8707 + * §2.1 `invalid_target` error (HTTP 400). + * + * Scenario 6 ({@link ResourceServerInvalidScopeScenario}): an ID-JAG with a + * trusted `resource` but an unrecognised `scope` is rejected with an RFC 6749 + * §5.2 `invalid_scope` error (HTTP 400). + * + * Scenario 7 ({@link ResourceServerInvalidSignatureScenario}): the trusted IdP + * itself creates and signs an otherwise well-formed ID-JAG, but with a key it + * never publishes at its own `jwks_uri`. Signature verification must fail, so + * the Resource AS rejects it with an RFC 7521 §4.1.1 `invalid_grant` error + * (HTTP 400). + * + * Scenario 8 ({@link ResourceServerUntrustedIdpScenario}): an otherwise + * well-formed and correctly signed ID-JAG whose `iss` names an IdP the + * Resource AS does not trust is rejected with an RFC 7521 §4.1.1 + * `invalid_grant` error (HTTP 400). + * + * These scenarios target a Resource AS named by {@link + * ResourceAuthorizationServerOptions}: they discover the token endpoint from the + * issuer's server metadata. The runner plays the MCP Client and hosts the + * Trusted IdP AS, handed over through `details` (see {@link getIdp}). + */ +import { randomUUID } from 'node:crypto'; +import type { + ConformanceCheck, + ScenarioForResourceAuthorizationServer, + ScenarioSource +} from '../../../types'; +import type { ResourceAuthorizationServerOptions } from '../../../schemas'; +import type { IdPAuthorizationServer } from '../auth/helpers/provideIdPAuthorizationServer'; +import { requestAccessTokenWithIdJag } from '../auth/helpers/mockResourceAuthorizationServer'; +import { + TRUSTED_IDP_DETAIL, + UNTRUSTED_IDP_DETAIL, + getIdp, + discoverResourceAs, + type ResourceAsEndpoints +} from './support'; +import { SpecReferences as SPEC_REFERENCES } from '../auth/spec-references'; + +const EMA_SOURCE: ScenarioSource = { + extensionId: 'io.modelcontextprotocol/enterprise-managed-authorization' +}; + +/** Client credentials + IdP-registered subject the error-path flows carry in the ID-JAG. */ +interface ClientContext { + clientId: string; + clientSecret: string; + idpSub: string; +} + +function skippedCheck( + id: string, + name: string, + reason: string +): ConformanceCheck { + return { + id, + name, + description: reason, + status: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: reason, + specReferences: [SPEC_REFERENCES.EMA] + }; +} + +function failureCheck( + id: string, + name: string, + error: unknown +): ConformanceCheck { + return { + id, + name, + description: 'Resource AS ID-JAG error-path flow failed to run', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: error instanceof Error ? error.message : String(error), + specReferences: [SPEC_REFERENCES.EMA] + }; +} + +/** Resolve the client credentials + IdP-registered subject every error-path flow needs. */ +function resolveClientContext( + options: ResourceAuthorizationServerOptions +): ClientContext | { missing: string } { + if (!options.clientId) return { missing: 'clientId' }; + if (!options.clientSecret) return { missing: 'clientSecret' }; + if (!options.idpSub) return { missing: 'idpSub' }; + return { + clientId: options.clientId, + clientSecret: options.clientSecret, + idpSub: options.idpSub + }; +} + +/** + * Resolve the client credentials the invalid-signature flow needs. No + * registered user is required: signature verification fails before the + * Resource AS ever inspects the `sub` claim, so a placeholder subject is fine. + */ +function resolveSignatureClientContext( + options: ResourceAuthorizationServerOptions +): ClientContext | { missing: string } { + if (!options.clientId) return { missing: 'clientId' }; + if (!options.clientSecret) return { missing: 'clientSecret' }; + return { + clientId: options.clientId, + clientSecret: options.clientSecret, + idpSub: options.idpSub ?? 'ema-invalid-signature-test-subject' + }; +} + +/** + * Scenario 5: a `resource` claim naming an untrusted MCP Server is rejected + * with `invalid_target` (RFC 8707 §2.1). + */ +export class ResourceServerErrorPathScenario implements ScenarioForResourceAuthorizationServer { + name = 'ema/resource-authorization-server/error-path'; + readonly source = EMA_SOURCE; + description = + 'EMA: a Resource AS rejects an ID-JAG whose resource claim names an untrusted MCP Server with a 400 invalid_target error (RFC 8707 §2.1).'; + + async run( + options: ResourceAuthorizationServerOptions, + details: Record + ): Promise { + try { + const client = resolveClientContext(options); + if ('missing' in client) { + return [ + skippedCheck( + 'resource-as-error-invalid-target-status', + 'ResourceAsErrorInvalidTargetStatus', + `error-path requires the "${client.missing}" setting` + ) + ]; + } + if (!options.untrustedMcpServer) { + return [ + skippedCheck( + 'resource-as-error-invalid-target-status', + 'ResourceAsErrorInvalidTargetStatus', + 'error-path requires the "untrustedMcpServer" setting' + ) + ]; + } + const idp = getIdp(details, TRUSTED_IDP_DETAIL); + if (!idp) { + return [ + skippedCheck( + 'resource-as-error-invalid-target-status', + 'ResourceAsErrorInvalidTargetStatus', + 'error-path requires a trusted IdP AS supplied via details' + ) + ]; + } + const endpoints = await discoverResourceAs(options.url); + return await this.runFlow( + idp, + endpoints, + client, + options.untrustedMcpServer + ); + } catch (error) { + return [ + failureCheck( + 'resource-as-error-invalid-target-status', + 'ResourceAsErrorInvalidTargetStatus', + error + ) + ]; + } + } + + private async runFlow( + idp: IdPAuthorizationServer, + endpoints: ResourceAsEndpoints, + client: ClientContext, + untrustedMcpServer: string + ): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + // The ID-JAG names an MCP Server that is NOT trusted by the Resource AS. + const idJag = await idp.issueIdJag({ + subject: client.idpSub, + audience: endpoints.issuer, + clientId: client.clientId, + resource: untrustedMcpServer + }); + + const response = await requestAccessTokenWithIdJag( + endpoints.tokenEndpoint, + { + assertion: idJag, + clientId: client.clientId, + clientSecret: client.clientSecret, + clientAuthMethod: 'client_secret_post' + } + ); + + const statusOk = response.statusCode === 400; + checks.push({ + id: 'resource-as-error-invalid-target-status', + name: 'ResourceAsErrorInvalidTargetStatus', + description: + 'Resource AS responds 400 Bad Request when the ID-JAG resource names an untrusted MCP Server (RFC 8707 §2.1)', + status: statusOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: statusOk + ? undefined + : `Expected HTTP 400, got ${response.statusCode} with body ${JSON.stringify(response.body)}`, + specReferences: [SPEC_REFERENCES.RFC_8707_INVALID_TARGET], + details: { statusCode: response.statusCode } + }); + + const errorOk = response.body.error === 'invalid_target'; + checks.push({ + id: 'resource-as-error-invalid-target-code', + name: 'ResourceAsErrorInvalidTargetCode', + description: + 'Resource AS error response uses error="invalid_target" (RFC 8707 §2.1)', + status: errorOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: errorOk + ? undefined + : `Expected error "invalid_target", got ${JSON.stringify(response.body.error)}`, + specReferences: [ + SPEC_REFERENCES.RFC_8707_INVALID_TARGET, + SPEC_REFERENCES.EMA + ], + details: { + error: response.body.error, + error_description: response.body.error_description + } + }); + + return checks; + } +} + +/** + * Scenario 6: an ID-JAG with a valid (trusted) `resource` claim but a `scope` + * the Resource AS does not recognise is rejected with `invalid_scope` + * (RFC 6749 §5.2). The scenario requests a random, almost-certainly-unregistered + * scope so it does not depend on the configured (registered) scope. + */ +export class ResourceServerInvalidScopeScenario implements ScenarioForResourceAuthorizationServer { + name = 'ema/resource-authorization-server/error-path-invalid-scope'; + readonly source = EMA_SOURCE; + description = + 'EMA: a Resource AS rejects an ID-JAG whose scope claim is not among the registered scopes with a 400 invalid_scope error (RFC 6749 §5.2).'; + + async run( + options: ResourceAuthorizationServerOptions, + details: Record + ): Promise { + try { + const client = resolveClientContext(options); + if ('missing' in client) { + return [ + skippedCheck( + 'resource-as-error-invalid-scope-status', + 'ResourceAsErrorInvalidScopeStatus', + `error-path-invalid-scope requires the "${client.missing}" setting` + ) + ]; + } + if (!options.trustedMcpServer) { + return [ + skippedCheck( + 'resource-as-error-invalid-scope-status', + 'ResourceAsErrorInvalidScopeStatus', + 'error-path-invalid-scope requires the "trustedMcpServer" setting' + ) + ]; + } + const idp = getIdp(details, TRUSTED_IDP_DETAIL); + if (!idp) { + return [ + skippedCheck( + 'resource-as-error-invalid-scope-status', + 'ResourceAsErrorInvalidScopeStatus', + 'error-path-invalid-scope requires a trusted IdP AS supplied via details' + ) + ]; + } + const endpoints = await discoverResourceAs(options.url); + return await this.runFlow( + idp, + endpoints, + client, + options.trustedMcpServer + ); + } catch (error) { + return [ + failureCheck( + 'resource-as-error-invalid-scope-status', + 'ResourceAsErrorInvalidScopeStatus', + error + ) + ]; + } + } + + private async runFlow( + idp: IdPAuthorizationServer, + endpoints: ResourceAsEndpoints, + client: ClientContext, + trustedMcpServer: string + ): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + // A scope the Resource AS is not expected to recognise. + const unregisteredScope = `urn:mcp-conformance:unregistered-scope:${randomUUID()}`; + + // The ID-JAG names the trusted MCP Server but requests an unregistered scope. + const idJag = await idp.issueIdJag({ + subject: client.idpSub, + audience: endpoints.issuer, + clientId: client.clientId, + resource: trustedMcpServer, + scope: unregisteredScope + }); + + const response = await requestAccessTokenWithIdJag( + endpoints.tokenEndpoint, + { + assertion: idJag, + clientId: client.clientId, + clientSecret: client.clientSecret, + clientAuthMethod: 'client_secret_post' + } + ); + + const statusOk = response.statusCode === 400; + checks.push({ + id: 'resource-as-error-invalid-scope-status', + name: 'ResourceAsErrorInvalidScopeStatus', + description: + 'Resource AS responds 400 Bad Request when the ID-JAG scope is not recognised (RFC 6749 §5.2)', + status: statusOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: statusOk + ? undefined + : `Expected HTTP 400, got ${response.statusCode} with body ${JSON.stringify(response.body)}`, + specReferences: [SPEC_REFERENCES.RFC_6749_ERROR_RESPONSE], + details: { statusCode: response.statusCode } + }); + + const errorOk = response.body.error === 'invalid_scope'; + checks.push({ + id: 'resource-as-error-invalid-scope-code', + name: 'ResourceAsErrorInvalidScopeCode', + description: + 'Resource AS error response uses error="invalid_scope" (RFC 6749 §5.2)', + status: errorOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: errorOk + ? undefined + : `Expected error "invalid_scope", got ${JSON.stringify(response.body.error)}`, + specReferences: [ + SPEC_REFERENCES.RFC_6749_ERROR_RESPONSE, + SPEC_REFERENCES.EMA + ], + details: { + error: response.body.error, + error_description: response.body.error_description + } + }); + + return checks; + } +} + +/** + * Scenario 7: the trusted IdP itself creates and signs a well-formed ID-JAG + * (`iss` names the trusted IdP, `client_id` is the registered client), but + * with a key it never publishes at its own `jwks_uri`. The Resource AS fetches + * the IdP's real JWK Set, cannot verify the signature, and must reject it with + * `invalid_grant` (RFC 7521 §4.1.1). + */ +export class ResourceServerInvalidSignatureScenario implements ScenarioForResourceAuthorizationServer { + name = 'ema/resource-authorization-server/error-path-invalid-signature'; + readonly source = EMA_SOURCE; + description = + 'EMA: a Resource AS rejects an ID-JAG whose iss names a trusted IdP but whose signature was made with a key absent from that IdP jwks_uri, with a 400 invalid_grant error (RFC 7521 §4.1.1).'; + + async run( + options: ResourceAuthorizationServerOptions, + details: Record + ): Promise { + try { + const client = resolveSignatureClientContext(options); + if ('missing' in client) { + return [ + skippedCheck( + 'resource-as-error-invalid-signature-status', + 'ResourceAsErrorInvalidSignatureStatus', + `error-path-invalid-signature requires the "${client.missing}" setting` + ) + ]; + } + const idp = getIdp(details, TRUSTED_IDP_DETAIL); + if (!idp) { + return [ + skippedCheck( + 'resource-as-error-invalid-signature-status', + 'ResourceAsErrorInvalidSignatureStatus', + 'error-path-invalid-signature requires a trusted IdP AS supplied via details' + ) + ]; + } + const endpoints = await discoverResourceAs(options.url); + return await this.runFlow( + idp, + endpoints, + client, + options.trustedMcpServer + ); + } catch (error) { + return [ + failureCheck( + 'resource-as-error-invalid-signature-status', + 'ResourceAsErrorInvalidSignatureStatus', + error + ) + ]; + } + } + + private async runFlow( + idp: IdPAuthorizationServer, + endpoints: ResourceAsEndpoints, + client: ClientContext, + trustedMcpServer: string | undefined + ): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + // The trusted IdP itself signs a valid-looking ID-JAG with a key it never + // adds to its own JWK Set, so the Resource AS cannot verify it against + // jwks_uri even though iss correctly names a trusted IdP. + const idJag = await idp.issueIdJagWithUnpublishedKey({ + subject: client.idpSub, + audience: endpoints.issuer, + clientId: client.clientId, + resource: trustedMcpServer + }); + + const response = await requestAccessTokenWithIdJag( + endpoints.tokenEndpoint, + { + assertion: idJag, + clientId: client.clientId, + clientSecret: client.clientSecret, + clientAuthMethod: 'client_secret_post' + } + ); + + const statusOk = response.statusCode === 400; + checks.push({ + id: 'resource-as-error-invalid-signature-status', + name: 'ResourceAsErrorInvalidSignatureStatus', + description: + 'Resource AS responds 400 Bad Request when the ID-JAG signature cannot be verified against the IdP jwks_uri (RFC 7521 §4.1.1)', + status: statusOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: statusOk + ? undefined + : `Expected HTTP 400, got ${response.statusCode} with body ${JSON.stringify(response.body)}`, + specReferences: [SPEC_REFERENCES.RFC_7521_INVALID_GRANT], + details: { statusCode: response.statusCode } + }); + + const errorOk = response.body.error === 'invalid_grant'; + checks.push({ + id: 'resource-as-error-invalid-signature-code', + name: 'ResourceAsErrorInvalidSignatureCode', + description: + 'Resource AS error response uses error="invalid_grant" for an unverifiable ID-JAG signature (RFC 7521 §4.1.1)', + status: errorOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: errorOk + ? undefined + : `Expected error "invalid_grant", got ${JSON.stringify(response.body.error)}`, + specReferences: [ + SPEC_REFERENCES.RFC_7521_INVALID_GRANT, + SPEC_REFERENCES.EMA + ], + details: { + error: response.body.error, + error_description: response.body.error_description + } + }); + + return checks; + } +} + +/** + * Scenario 8: an otherwise well-formed ID-JAG, correctly signed with its + * issuing IdP's own published key, but that IdP is not one the Resource AS + * trusts. Rejected with `invalid_grant` (RFC 7521 §4.1.1) purely on `iss` + * trust, independent of signature validity. + */ +export class ResourceServerUntrustedIdpScenario implements ScenarioForResourceAuthorizationServer { + name = 'ema/resource-authorization-server/error-path-untrusted-idp'; + readonly source = EMA_SOURCE; + description = + 'EMA: a Resource AS rejects an ID-JAG issued by an IdP it does not trust, with a 400 invalid_grant error (RFC 7521 §4.1.1).'; + + async run( + options: ResourceAuthorizationServerOptions, + details: Record + ): Promise { + try { + const client = resolveSignatureClientContext(options); + if ('missing' in client) { + return [ + skippedCheck( + 'resource-as-error-untrusted-idp-status', + 'ResourceAsErrorUntrustedIdpStatus', + `error-path-untrusted-idp requires the "${client.missing}" setting` + ) + ]; + } + const idp = getIdp(details, UNTRUSTED_IDP_DETAIL); + if (!idp) { + return [ + skippedCheck( + 'resource-as-error-untrusted-idp-status', + 'ResourceAsErrorUntrustedIdpStatus', + 'error-path-untrusted-idp requires an untrusted IdP AS supplied via details' + ) + ]; + } + const endpoints = await discoverResourceAs(options.url); + return await this.runFlow( + idp, + endpoints, + client, + options.trustedMcpServer + ); + } catch (error) { + return [ + failureCheck( + 'resource-as-error-untrusted-idp-status', + 'ResourceAsErrorUntrustedIdpStatus', + error + ) + ]; + } + } + + private async runFlow( + idp: IdPAuthorizationServer, + endpoints: ResourceAsEndpoints, + client: ClientContext, + trustedMcpServer: string | undefined + ): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + // Legitimately signed by the untrusted IdP's own published key; only its + // iss is not one the Resource AS trusts. + const idJag = await idp.issueIdJag({ + subject: client.idpSub, + audience: endpoints.issuer, + clientId: client.clientId, + resource: trustedMcpServer + }); + + const response = await requestAccessTokenWithIdJag( + endpoints.tokenEndpoint, + { + assertion: idJag, + clientId: client.clientId, + clientSecret: client.clientSecret, + clientAuthMethod: 'client_secret_post' + } + ); + + const statusOk = response.statusCode === 400; + checks.push({ + id: 'resource-as-error-untrusted-idp-status', + name: 'ResourceAsErrorUntrustedIdpStatus', + description: + 'Resource AS responds 400 Bad Request when the ID-JAG issuer is not a trusted IdP (RFC 7521 §4.1.1)', + status: statusOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: statusOk + ? undefined + : `Expected HTTP 400, got ${response.statusCode} with body ${JSON.stringify(response.body)}`, + specReferences: [SPEC_REFERENCES.RFC_7521_INVALID_GRANT], + details: { statusCode: response.statusCode } + }); + + const errorOk = response.body.error === 'invalid_grant'; + checks.push({ + id: 'resource-as-error-untrusted-idp-code', + name: 'ResourceAsErrorUntrustedIdpCode', + description: + 'Resource AS error response uses error="invalid_grant" for an untrusted ID-JAG issuer (RFC 7521 §4.1.1)', + status: errorOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: errorOk + ? undefined + : `Expected error "invalid_grant", got ${JSON.stringify(response.body.error)}`, + specReferences: [ + SPEC_REFERENCES.RFC_7521_INVALID_GRANT, + SPEC_REFERENCES.EMA + ], + details: { + error: response.body.error, + error_description: response.body.error_description + } + }); + + return checks; + } +} diff --git a/src/scenarios/ema/resource-authorization-server/resource-authorization-happy-path.test.ts b/src/scenarios/ema/resource-authorization-server/resource-authorization-happy-path.test.ts new file mode 100644 index 00000000..ff046abe --- /dev/null +++ b/src/scenarios/ema/resource-authorization-server/resource-authorization-happy-path.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect } from 'vitest'; +import { + ResourceServerHappyPathScenario, + ResourceServerHappyPathWithResourceScenario, + ResourceServerHappyPathWithScopeScenario +} from './resource-authorization-happy-path'; +import { + createHarness, + TEST_CLIENT_ID, + TEST_SCOPE, + TEST_TRUSTED_MCP_SERVER +} from './test-harness'; + +const CHECK_IDS = [ + 'resource-as-happy-token-response', + 'resource-as-happy-token-cache-control', + 'resource-as-happy-no-refresh-token', + 'resource-as-happy-introspection-active', + 'resource-as-happy-introspection-client-id', + 'resource-as-happy-introspection-sub' +]; + +const CHECK_IDS_WITH_RESOURCE = [ + 'resource-as-happy-with-resource-token-response', + 'resource-as-happy-with-resource-token-cache-control', + 'resource-as-happy-with-resource-no-refresh-token', + 'resource-as-happy-with-resource-introspection-active', + 'resource-as-happy-with-resource-introspection-client-id', + 'resource-as-happy-with-resource-introspection-sub', + 'resource-as-happy-with-resource-introspection-aud-format', + 'resource-as-happy-with-resource-introspection-aud-value' +]; + +const CHECK_IDS_WITH_SCOPE = [ + 'resource-as-happy-with-scope-token-response', + 'resource-as-happy-with-scope-token-cache-control', + 'resource-as-happy-with-scope-no-refresh-token', + 'resource-as-happy-with-scope-introspection-active', + 'resource-as-happy-with-scope-introspection-client-id', + 'resource-as-happy-with-scope-introspection-sub', + 'resource-as-happy-with-scope-introspection-aud-format', + 'resource-as-happy-with-scope-introspection-aud-value', + 'resource-as-happy-with-scope-introspection-scope' +]; + +describe('ResourceServerHappyPathScenario', () => { + it('has a stable name and EMA extension source', () => { + const scenario = new ResourceServerHappyPathScenario(); + expect(scenario.name).toBe('ema/resource-authorization-server/happy-path'); + expect(scenario.source).toEqual({ + extensionId: 'io.modelcontextprotocol/enterprise-managed-authorization' + }); + }); + + it('passes every check against a configured Resource AS', async () => { + const harness = await createHarness({ requireResourceClaim: false }); + try { + const scenario = new ResourceServerHappyPathScenario(); + const checks = await scenario.run(harness.options, harness.details); + + expect(checks.map((c) => c.id)).toEqual(CHECK_IDS); + for (const check of checks) { + expect( + check.status, + `${check.id} failed: ${check.errorMessage ?? ''}` + ).toBe('SUCCESS'); + } + } finally { + await harness.stop(); + } + }); + + it('introspection reports the configured client_id and sub', async () => { + const harness = await createHarness({ requireResourceClaim: false }); + try { + const scenario = new ResourceServerHappyPathScenario(); + const checks = await scenario.run(harness.options, harness.details); + + const clientCheck = checks.find( + (c) => c.id === 'resource-as-happy-introspection-client-id' + ); + expect(clientCheck?.details?.client_id).toBe(TEST_CLIENT_ID); + + const subCheck = checks.find( + (c) => c.id === 'resource-as-happy-introspection-sub' + ); + expect(subCheck?.status).toBe('SUCCESS'); + expect(subCheck?.details?.sub).toBe(harness.options.sub); + } finally { + await harness.stop(); + } + }); + + it('skips when a required setting is missing', async () => { + const scenario = new ResourceServerHappyPathScenario(); + const checks = await scenario.run( + { url: 'https://resource-as.example.com' }, + {} + ); + expect(checks).toHaveLength(1); + expect(checks[0].status).toBe('SKIPPED'); + }); +}); + +describe('ResourceServerHappyPathWithResourceScenario', () => { + it('has a stable name and EMA extension source', () => { + const scenario = new ResourceServerHappyPathWithResourceScenario(); + expect(scenario.name).toBe( + 'ema/resource-authorization-server/happy-path-with-resource' + ); + expect(scenario.source).toEqual({ + extensionId: 'io.modelcontextprotocol/enterprise-managed-authorization' + }); + }); + + it('passes every check against a configured Resource AS', async () => { + const harness = await createHarness({ registerTrustedMcpServer: true }); + try { + const scenario = new ResourceServerHappyPathWithResourceScenario(); + const checks = await scenario.run(harness.options, harness.details); + + expect(checks.map((c) => c.id)).toEqual(CHECK_IDS_WITH_RESOURCE); + for (const check of checks) { + expect( + check.status, + `${check.id} failed: ${check.errorMessage ?? ''}` + ).toBe('SUCCESS'); + } + } finally { + await harness.stop(); + } + }); + + it('introspection reports a single-valued aud equal to the trusted MCP Server', async () => { + const harness = await createHarness({ registerTrustedMcpServer: true }); + try { + const scenario = new ResourceServerHappyPathWithResourceScenario(); + const checks = await scenario.run(harness.options, harness.details); + + const audValueCheck = checks.find( + (c) => + c.id === 'resource-as-happy-with-resource-introspection-aud-value' + ); + expect(audValueCheck?.status).toBe('SUCCESS'); + expect(audValueCheck?.details?.aud).toBe(TEST_TRUSTED_MCP_SERVER); + } finally { + await harness.stop(); + } + }); +}); + +describe('ResourceServerHappyPathWithScopeScenario', () => { + it('has a stable name and EMA extension source', () => { + const scenario = new ResourceServerHappyPathWithScopeScenario(); + expect(scenario.name).toBe( + 'ema/resource-authorization-server/happy-path-with-scope' + ); + expect(scenario.source).toEqual({ + extensionId: 'io.modelcontextprotocol/enterprise-managed-authorization' + }); + }); + + it('passes every check against a configured Resource AS', async () => { + const harness = await createHarness({ + registerTrustedMcpServer: true, + registerScope: true + }); + try { + const scenario = new ResourceServerHappyPathWithScopeScenario(); + const checks = await scenario.run(harness.options, harness.details); + + expect(checks.map((c) => c.id)).toEqual(CHECK_IDS_WITH_SCOPE); + for (const check of checks) { + expect( + check.status, + `${check.id} failed: ${check.errorMessage ?? ''}` + ).toBe('SUCCESS'); + } + } finally { + await harness.stop(); + } + }); + + it('introspection reports the configured scope', async () => { + const harness = await createHarness({ + registerTrustedMcpServer: true, + registerScope: true + }); + try { + const scenario = new ResourceServerHappyPathWithScopeScenario(); + const checks = await scenario.run(harness.options, harness.details); + + const scopeCheck = checks.find( + (c) => c.id === 'resource-as-happy-with-scope-introspection-scope' + ); + expect(scopeCheck?.status).toBe('SUCCESS'); + expect(scopeCheck?.details?.scope).toBe(TEST_SCOPE); + } finally { + await harness.stop(); + } + }); +}); diff --git a/src/scenarios/ema/resource-authorization-server/resource-authorization-happy-path.ts b/src/scenarios/ema/resource-authorization-server/resource-authorization-happy-path.ts new file mode 100644 index 00000000..efe63834 --- /dev/null +++ b/src/scenarios/ema/resource-authorization-server/resource-authorization-happy-path.ts @@ -0,0 +1,908 @@ +/** + * Successful-path scenarios for the Resource-AS side of Enterprise-Managed + * Authorization (ISSUE-470): + * - Scenario 2 ({@link ResourceServerHappyPathScenario}): ID-JAG with no + * `resource` claim. + * - Scenario 3 ({@link ResourceServerHappyPathWithResourceScenario}): ID-JAG + * carrying a `resource` claim naming a trusted MCP Server, which the issued + * access token is audience-restricted to. + * - Scenario 4 ({@link ResourceServerHappyPathWithScopeScenario}): Scenario 3 + * plus a `scope`. + * + * Each scenario targets a Resource AS named by {@link + * ResourceAuthorizationServerOptions}: it discovers the token and introspection + * endpoints from the issuer's server metadata, then drives the ID-JAG flow. The + * runner plays the MCP Client and hosts the Trusted IdP AS, handed over through + * `details` (see {@link getIdp}); it mints an ID-JAG, exchanges it for an access + * token (client_secret_post), and introspects that token. + * + * The `resource` claim is OPTIONAL (EMA §4.3), so a Resource AS must return a + * successful OAuth token response (RFC 6749 §5.1 / RFC 7521 §5.2) and a + * successful introspection response (RFC 7662 §2.2) either way. + */ +import type { + ConformanceCheck, + ScenarioForResourceAuthorizationServer, + ScenarioSource +} from '../../../types'; +import type { ResourceAuthorizationServerOptions } from '../../../schemas'; +import type { IdPAuthorizationServer } from '../auth/helpers/provideIdPAuthorizationServer'; +import { + requestAccessTokenWithIdJag, + introspectToken, + type TokenEndpointResponse, + type IntrospectionEndpointResponse +} from '../auth/helpers/mockResourceAuthorizationServer'; +import { + TRUSTED_IDP_DETAIL, + getIdp, + discoverResourceAs, + type ResourceAsEndpoints +} from './support'; +import { SpecReferences as SPEC_REFERENCES } from '../auth/spec-references'; + +const EMA_SOURCE: ScenarioSource = { + extensionId: 'io.modelcontextprotocol/enterprise-managed-authorization' +}; + +/** Resource-AS settings the ID-JAG happy-path flow requires from the config. */ +interface FlowContext { + clientId: string; + clientSecret: string; + /** User id registered with the trusted IdP; carried as the ID-JAG `sub` claim. */ + idpSub: string; + /** User id registered with the target Resource AS; expected in the issued access token's `sub` claim. */ + sub: string; + /** MCP Server the ID-JAG names (`resource`); omitted for the no-resource scenario. */ + resource?: string; + /** OAuth scope the ID-JAG requests (`scope`); omitted when not exercising scope. */ + scope?: string; +} + +function headerValue( + headers: Record, + name: string +): string | undefined { + const value = headers[name]; + return Array.isArray(value) ? value[0] : value; +} + +function skippedCheck( + id: string, + name: string, + reason: string +): ConformanceCheck { + return { + id, + name, + description: reason, + status: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: reason, + specReferences: [SPEC_REFERENCES.EMA] + }; +} + +function failureCheck( + id: string, + name: string, + error: unknown +): ConformanceCheck { + return { + id, + name, + description: 'Resource AS ID-JAG happy-path flow failed to run', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: error instanceof Error ? error.message : String(error), + specReferences: [SPEC_REFERENCES.EMA] + }; +} + +/** + * Resolve the config fields every happy-path flow needs (`clientId`, + * `clientSecret`, `idpSub`, `sub`) plus any scenario-specific extras. Returns + * the missing field name when a required value is absent. + */ +function resolveFlowContext( + options: ResourceAuthorizationServerOptions, + extras: { resource?: boolean; scope?: boolean } = {} +): FlowContext | { missing: string } { + if (!options.clientId) return { missing: 'clientId' }; + if (!options.clientSecret) return { missing: 'clientSecret' }; + if (!options.idpSub) return { missing: 'idpSub' }; + if (!options.sub) return { missing: 'sub' }; + if (extras.resource && !options.trustedMcpServer) { + return { missing: 'trustedMcpServer' }; + } + if (extras.scope && !options.scope) return { missing: 'scope' }; + return { + clientId: options.clientId, + clientSecret: options.clientSecret, + idpSub: options.idpSub, + sub: options.sub, + resource: extras.resource ? options.trustedMcpServer : undefined, + scope: extras.scope ? options.scope : undefined + }; +} + +/** + * Mint an ID-JAG, exchange it for an access token, and introspect that token + * against the discovered endpoints. Shared by all three happy-path scenarios. + */ +async function requestAndIntrospect( + idp: IdPAuthorizationServer, + endpoints: ResourceAsEndpoints, + flow: FlowContext +): Promise<{ + tokenResponse: TokenEndpointResponse; + introspection?: IntrospectionEndpointResponse; + accessToken?: string; +}> { + const idJag = await idp.issueIdJag({ + subject: flow.idpSub, + audience: endpoints.issuer, + clientId: flow.clientId, + resource: flow.resource, + scope: flow.scope + }); + + const tokenResponse = await requestAccessTokenWithIdJag( + endpoints.tokenEndpoint, + { + assertion: idJag, + clientId: flow.clientId, + clientSecret: flow.clientSecret, + clientAuthMethod: 'client_secret_post' + } + ); + + const accessToken = + typeof tokenResponse.body.access_token === 'string' + ? tokenResponse.body.access_token + : undefined; + + let introspection: IntrospectionEndpointResponse | undefined; + if (accessToken) { + introspection = await introspectToken(endpoints.introspectionEndpoint, { + token: accessToken, + clientId: flow.clientId, + clientSecret: flow.clientSecret, + clientAuthMethod: 'client_secret_post' + }); + } + + return { tokenResponse, introspection, accessToken }; +} + +/** + * Scenario 2: successful ID-JAG exchange and introspection without a `resource` + * claim. The target Resource AS must be configured to not require a `resource` + * claim (EMA §4.3). + */ +export class ResourceServerHappyPathScenario implements ScenarioForResourceAuthorizationServer { + name = 'ema/resource-authorization-server/happy-path'; + readonly source = EMA_SOURCE; + description = + 'EMA: a Resource AS accepts an ID-JAG without a resource claim, returns an RFC 6749 §5.1 token response (no refresh token), and introspects the issued access token (client_id + sub).'; + + async run( + options: ResourceAuthorizationServerOptions, + details: Record + ): Promise { + try { + const flow = resolveFlowContext(options); + if ('missing' in flow) { + return [ + skippedCheck( + 'resource-as-happy-token-response', + 'ResourceAsHappyTokenResponse', + `happy-path requires the "${flow.missing}" setting` + ) + ]; + } + const idp = getIdp(details, TRUSTED_IDP_DETAIL); + if (!idp) { + return [ + skippedCheck( + 'resource-as-happy-token-response', + 'ResourceAsHappyTokenResponse', + 'happy-path requires a trusted IdP AS supplied via details' + ) + ]; + } + const endpoints = await discoverResourceAs(options.url); + return await this.runFlow(idp, endpoints, flow); + } catch (error) { + return [ + failureCheck( + 'resource-as-happy-token-response', + 'ResourceAsHappyTokenResponse', + error + ) + ]; + } + } + + private async runFlow( + idp: IdPAuthorizationServer, + endpoints: ResourceAsEndpoints, + flow: FlowContext + ): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + const { tokenResponse, introspection, accessToken } = + await requestAndIntrospect(idp, endpoints, flow); + + this.checkTokenResponse(checks, tokenResponse, timestamp); + this.checkIntrospection( + checks, + accessToken ? introspection : undefined, + flow, + timestamp + ); + + return checks; + } + + private checkTokenResponse( + checks: ConformanceCheck[], + response: TokenEndpointResponse, + timestamp: () => string + ): void { + const accessToken = + typeof response.body.access_token === 'string' + ? response.body.access_token + : undefined; + const tokenTypeOk = + typeof response.body.token_type === 'string' && + response.body.token_type.length > 0; + const jsonContentType = + typeof response.contentType === 'string' && + response.contentType.toLowerCase().includes('application/json'); + const successOk = + response.statusCode === 200 && + accessToken !== undefined && + tokenTypeOk && + jsonContentType; + + checks.push({ + id: 'resource-as-happy-token-response', + name: 'ResourceAsHappyTokenResponse', + description: + 'Resource AS returns a successful access token response with access_token and token_type (RFC 6749 §5.1 / RFC 7521 §5.2)', + status: successOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: successOk + ? undefined + : `Expected 200 JSON token response with access_token and token_type, got status ${response.statusCode}, body ${JSON.stringify(response.body)}`, + specReferences: [ + SPEC_REFERENCES.RFC_6749_TOKEN_RESPONSE, + SPEC_REFERENCES.RFC_7521_RESPONSE, + SPEC_REFERENCES.EMA + ], + details: { + statusCode: response.statusCode, + contentType: response.contentType, + token_type: response.body.token_type + } + }); + + // RFC 6749 §5.1: successful token responses must not be cached. + const cacheControl = headerValue(response.headers, 'cache-control'); + const cacheControlOk = + typeof cacheControl === 'string' && + cacheControl.toLowerCase().includes('no-store'); + checks.push({ + id: 'resource-as-happy-token-cache-control', + name: 'ResourceAsHappyTokenCacheControl', + description: + 'Resource AS token response sets Cache-Control: no-store (RFC 6749 §5.1)', + status: cacheControlOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: cacheControlOk + ? undefined + : `Expected Cache-Control: no-store, got ${cacheControl ?? '(missing)'}`, + specReferences: [SPEC_REFERENCES.RFC_6749_TOKEN_RESPONSE], + details: { cacheControl } + }); + + // The flow uses no refresh token; the response must not include one. + const hasRefreshToken = response.body.refresh_token !== undefined; + checks.push({ + id: 'resource-as-happy-no-refresh-token', + name: 'ResourceAsHappyNoRefreshToken', + description: + 'Resource AS token response does not include a refresh_token for the ID-JAG grant', + status: hasRefreshToken ? 'FAILURE' : 'SUCCESS', + timestamp: timestamp(), + errorMessage: hasRefreshToken + ? `Unexpected refresh_token in the token response: ${JSON.stringify(response.body.refresh_token)}` + : undefined, + specReferences: [SPEC_REFERENCES.RFC_6749_TOKEN_RESPONSE], + details: { refresh_token_present: hasRefreshToken } + }); + } + + private checkIntrospection( + checks: ConformanceCheck[], + response: IntrospectionEndpointResponse | undefined, + flow: FlowContext, + timestamp: () => string + ): void { + const activeOk = + response !== undefined && + response.statusCode === 200 && + response.body.active === true; + checks.push({ + id: 'resource-as-happy-introspection-active', + name: 'ResourceAsHappyIntrospectionActive', + description: + 'Resource AS returns a successful introspection response marking the access token active (RFC 7662 §2.2)', + status: activeOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: activeOk + ? undefined + : `Expected 200 introspection response with active=true, got ${response ? `status ${response.statusCode}, body ${JSON.stringify(response.body)}` : '(no introspection performed)'}`, + specReferences: [SPEC_REFERENCES.RFC_7662_INTROSPECTION], + details: response + ? { statusCode: response.statusCode, active: response.body.active } + : {} + }); + + const clientIdOk = response?.body.client_id === flow.clientId; + checks.push({ + id: 'resource-as-happy-introspection-client-id', + name: 'ResourceAsHappyIntrospectionClientId', + description: `Introspection response "client_id" equals the configured client "${flow.clientId}" (RFC 7662 §2.2)`, + status: clientIdOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: clientIdOk + ? undefined + : `Expected client_id "${flow.clientId}", got ${JSON.stringify(response?.body.client_id)}`, + specReferences: [SPEC_REFERENCES.RFC_7662_INTROSPECTION], + details: { client_id: response?.body.client_id } + }); + + // The access token's sub is the Resource AS's own id for the user identified + // by the ID-JAG's sub claim (flow.idpSub), which may differ from it. + const subOk = response?.body.sub === flow.sub; + checks.push({ + id: 'resource-as-happy-introspection-sub', + name: 'ResourceAsHappyIntrospectionSub', + description: + 'Introspection response "sub" equals the configured Resource AS user id linked to the ID-JAG subject (RFC 7662 §2.2)', + status: subOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: subOk + ? undefined + : `Expected sub "${flow.sub}", got ${JSON.stringify(response?.body.sub)}`, + specReferences: [SPEC_REFERENCES.RFC_7662_INTROSPECTION], + details: { sub: response?.body.sub, username: response?.body.username } + }); + } +} + +/** + * Scenario 3: successful ID-JAG exchange and introspection with a `resource` + * claim naming a trusted MCP Server. The issued access token is + * audience-restricted to that MCP Server (EMA §5.1), so introspection reports a + * single-valued `aud` equal to the trusted MCP Server URL. + */ +export class ResourceServerHappyPathWithResourceScenario implements ScenarioForResourceAuthorizationServer { + name = 'ema/resource-authorization-server/happy-path-with-resource'; + readonly source = EMA_SOURCE; + description = + 'EMA: a Resource AS accepts an ID-JAG carrying a resource claim for a trusted MCP Server, returns an RFC 6749 §5.1 token response (no refresh token), and introspects the issued access token (client_id, sub, and single-valued aud = the MCP Server).'; + + async run( + options: ResourceAuthorizationServerOptions, + details: Record + ): Promise { + try { + const flow = resolveFlowContext(options, { resource: true }); + if ('missing' in flow) { + return [ + skippedCheck( + 'resource-as-happy-with-resource-token-response', + 'ResourceAsHappyWithResourceTokenResponse', + `happy-path-with-resource requires the "${flow.missing}" setting` + ) + ]; + } + const idp = getIdp(details, TRUSTED_IDP_DETAIL); + if (!idp) { + return [ + skippedCheck( + 'resource-as-happy-with-resource-token-response', + 'ResourceAsHappyWithResourceTokenResponse', + 'happy-path-with-resource requires a trusted IdP AS supplied via details' + ) + ]; + } + const endpoints = await discoverResourceAs(options.url); + return await this.runFlow(idp, endpoints, flow); + } catch (error) { + return [ + failureCheck( + 'resource-as-happy-with-resource-token-response', + 'ResourceAsHappyWithResourceTokenResponse', + error + ) + ]; + } + } + + private async runFlow( + idp: IdPAuthorizationServer, + endpoints: ResourceAsEndpoints, + flow: FlowContext + ): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + const { tokenResponse, introspection, accessToken } = + await requestAndIntrospect(idp, endpoints, flow); + + this.checkTokenResponse(checks, tokenResponse, timestamp); + this.checkIntrospection( + checks, + accessToken ? introspection : undefined, + flow, + timestamp + ); + + return checks; + } + + private checkTokenResponse( + checks: ConformanceCheck[], + response: TokenEndpointResponse, + timestamp: () => string + ): void { + const accessToken = + typeof response.body.access_token === 'string' + ? response.body.access_token + : undefined; + const tokenTypeOk = + typeof response.body.token_type === 'string' && + response.body.token_type.length > 0; + const jsonContentType = + typeof response.contentType === 'string' && + response.contentType.toLowerCase().includes('application/json'); + const successOk = + response.statusCode === 200 && + accessToken !== undefined && + tokenTypeOk && + jsonContentType; + + checks.push({ + id: 'resource-as-happy-with-resource-token-response', + name: 'ResourceAsHappyWithResourceTokenResponse', + description: + 'Resource AS returns a successful access token response with access_token and token_type (RFC 6749 §5.1 / RFC 7521 §5.2)', + status: successOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: successOk + ? undefined + : `Expected 200 JSON token response with access_token and token_type, got status ${response.statusCode}, body ${JSON.stringify(response.body)}`, + specReferences: [ + SPEC_REFERENCES.RFC_6749_TOKEN_RESPONSE, + SPEC_REFERENCES.RFC_7521_RESPONSE, + SPEC_REFERENCES.EMA + ], + details: { + statusCode: response.statusCode, + contentType: response.contentType, + token_type: response.body.token_type + } + }); + + const cacheControl = headerValue(response.headers, 'cache-control'); + const cacheControlOk = + typeof cacheControl === 'string' && + cacheControl.toLowerCase().includes('no-store'); + checks.push({ + id: 'resource-as-happy-with-resource-token-cache-control', + name: 'ResourceAsHappyWithResourceTokenCacheControl', + description: + 'Resource AS token response sets Cache-Control: no-store (RFC 6749 §5.1)', + status: cacheControlOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: cacheControlOk + ? undefined + : `Expected Cache-Control: no-store, got ${cacheControl ?? '(missing)'}`, + specReferences: [SPEC_REFERENCES.RFC_6749_TOKEN_RESPONSE], + details: { cacheControl } + }); + + const hasRefreshToken = response.body.refresh_token !== undefined; + checks.push({ + id: 'resource-as-happy-with-resource-no-refresh-token', + name: 'ResourceAsHappyWithResourceNoRefreshToken', + description: + 'Resource AS token response does not include a refresh_token for the ID-JAG grant', + status: hasRefreshToken ? 'FAILURE' : 'SUCCESS', + timestamp: timestamp(), + errorMessage: hasRefreshToken + ? `Unexpected refresh_token in the token response: ${JSON.stringify(response.body.refresh_token)}` + : undefined, + specReferences: [SPEC_REFERENCES.RFC_6749_TOKEN_RESPONSE], + details: { refresh_token_present: hasRefreshToken } + }); + } + + private checkIntrospection( + checks: ConformanceCheck[], + response: IntrospectionEndpointResponse | undefined, + flow: FlowContext, + timestamp: () => string + ): void { + const activeOk = + response !== undefined && + response.statusCode === 200 && + response.body.active === true; + checks.push({ + id: 'resource-as-happy-with-resource-introspection-active', + name: 'ResourceAsHappyWithResourceIntrospectionActive', + description: + 'Resource AS returns a successful introspection response marking the access token active (RFC 7662 §2.2)', + status: activeOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: activeOk + ? undefined + : `Expected 200 introspection response with active=true, got ${response ? `status ${response.statusCode}, body ${JSON.stringify(response.body)}` : '(no introspection performed)'}`, + specReferences: [SPEC_REFERENCES.RFC_7662_INTROSPECTION], + details: response + ? { statusCode: response.statusCode, active: response.body.active } + : {} + }); + + const clientIdOk = response?.body.client_id === flow.clientId; + checks.push({ + id: 'resource-as-happy-with-resource-introspection-client-id', + name: 'ResourceAsHappyWithResourceIntrospectionClientId', + description: `Introspection response "client_id" equals the configured client "${flow.clientId}" (RFC 7662 §2.2)`, + status: clientIdOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: clientIdOk + ? undefined + : `Expected client_id "${flow.clientId}", got ${JSON.stringify(response?.body.client_id)}`, + specReferences: [SPEC_REFERENCES.RFC_7662_INTROSPECTION], + details: { client_id: response?.body.client_id } + }); + + // The access token's sub is the Resource AS's own id for the user identified + // by the ID-JAG's sub claim (flow.idpSub), which may differ from it. + const subOk = response?.body.sub === flow.sub; + checks.push({ + id: 'resource-as-happy-with-resource-introspection-sub', + name: 'ResourceAsHappyWithResourceIntrospectionSub', + description: + 'Introspection response "sub" equals the configured Resource AS user id linked to the ID-JAG subject (RFC 7662 §2.2)', + status: subOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: subOk + ? undefined + : `Expected sub "${flow.sub}", got ${JSON.stringify(response?.body.sub)}`, + specReferences: [SPEC_REFERENCES.RFC_7662_INTROSPECTION], + details: { sub: response?.body.sub, username: response?.body.username } + }); + + // `aud` must be a string or a single-element array (RFC 7662 §2.2 / RFC 7519). + const aud = response?.body.aud; + let audValues: string[] | undefined; + if (typeof aud === 'string') { + audValues = [aud]; + } else if ( + Array.isArray(aud) && + aud.length === 1 && + typeof aud[0] === 'string' + ) { + audValues = aud as string[]; + } + const audFormatOk = audValues !== undefined; + checks.push({ + id: 'resource-as-happy-with-resource-introspection-aud-format', + name: 'ResourceAsHappyWithResourceIntrospectionAudFormat', + description: + 'Introspection response "aud" is a string or a single-valued array (RFC 7662 §2.2)', + status: audFormatOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: audFormatOk + ? undefined + : `Expected "aud" to be a string or single-element array, got ${JSON.stringify(aud)}`, + specReferences: [SPEC_REFERENCES.RFC_7662_INTROSPECTION], + details: { aud } + }); + + // The single audience value must be the trusted MCP Server (EMA §5.1). + const audValueOk = audValues?.[0] === flow.resource; + checks.push({ + id: 'resource-as-happy-with-resource-introspection-aud-value', + name: 'ResourceAsHappyWithResourceIntrospectionAudValue', + description: `Introspection response "aud" equals the configured trusted MCP Server "${flow.resource}" (EMA §5.1)`, + status: audValueOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: audValueOk + ? undefined + : `Expected aud "${flow.resource}", got ${JSON.stringify(aud)}`, + specReferences: [ + SPEC_REFERENCES.RFC_7662_INTROSPECTION, + SPEC_REFERENCES.EMA + ], + details: { aud } + }); + } +} + +/** + * Scenario 4: successful ID-JAG exchange and introspection with both a + * `resource` claim naming a trusted MCP Server and a `scope`. Extends Scenario 3 + * by requesting a registered scope and asserting the introspection response + * carries it back. + */ +export class ResourceServerHappyPathWithScopeScenario implements ScenarioForResourceAuthorizationServer { + name = 'ema/resource-authorization-server/happy-path-with-scope'; + readonly source = EMA_SOURCE; + description = + 'EMA: a Resource AS accepts an ID-JAG carrying a resource claim and a scope, returns an RFC 6749 §5.1 token response (no refresh token), and introspects the issued access token (client_id, sub, single-valued aud = the MCP Server, and scope).'; + + async run( + options: ResourceAuthorizationServerOptions, + details: Record + ): Promise { + try { + const flow = resolveFlowContext(options, { resource: true, scope: true }); + if ('missing' in flow) { + return [ + skippedCheck( + 'resource-as-happy-with-scope-token-response', + 'ResourceAsHappyWithScopeTokenResponse', + `happy-path-with-scope requires the "${flow.missing}" setting` + ) + ]; + } + const idp = getIdp(details, TRUSTED_IDP_DETAIL); + if (!idp) { + return [ + skippedCheck( + 'resource-as-happy-with-scope-token-response', + 'ResourceAsHappyWithScopeTokenResponse', + 'happy-path-with-scope requires a trusted IdP AS supplied via details' + ) + ]; + } + const endpoints = await discoverResourceAs(options.url); + return await this.runFlow(idp, endpoints, flow); + } catch (error) { + return [ + failureCheck( + 'resource-as-happy-with-scope-token-response', + 'ResourceAsHappyWithScopeTokenResponse', + error + ) + ]; + } + } + + private async runFlow( + idp: IdPAuthorizationServer, + endpoints: ResourceAsEndpoints, + flow: FlowContext + ): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + const { tokenResponse, introspection, accessToken } = + await requestAndIntrospect(idp, endpoints, flow); + + this.checkTokenResponse(checks, tokenResponse, timestamp); + this.checkIntrospection( + checks, + accessToken ? introspection : undefined, + flow, + timestamp + ); + + return checks; + } + + private checkTokenResponse( + checks: ConformanceCheck[], + response: TokenEndpointResponse, + timestamp: () => string + ): void { + const accessToken = + typeof response.body.access_token === 'string' + ? response.body.access_token + : undefined; + const tokenTypeOk = + typeof response.body.token_type === 'string' && + response.body.token_type.length > 0; + const jsonContentType = + typeof response.contentType === 'string' && + response.contentType.toLowerCase().includes('application/json'); + const successOk = + response.statusCode === 200 && + accessToken !== undefined && + tokenTypeOk && + jsonContentType; + + checks.push({ + id: 'resource-as-happy-with-scope-token-response', + name: 'ResourceAsHappyWithScopeTokenResponse', + description: + 'Resource AS returns a successful access token response with access_token and token_type (RFC 6749 §5.1 / RFC 7521 §5.2)', + status: successOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: successOk + ? undefined + : `Expected 200 JSON token response with access_token and token_type, got status ${response.statusCode}, body ${JSON.stringify(response.body)}`, + specReferences: [ + SPEC_REFERENCES.RFC_6749_TOKEN_RESPONSE, + SPEC_REFERENCES.RFC_7521_RESPONSE, + SPEC_REFERENCES.EMA + ], + details: { + statusCode: response.statusCode, + contentType: response.contentType, + token_type: response.body.token_type + } + }); + + const cacheControl = headerValue(response.headers, 'cache-control'); + const cacheControlOk = + typeof cacheControl === 'string' && + cacheControl.toLowerCase().includes('no-store'); + checks.push({ + id: 'resource-as-happy-with-scope-token-cache-control', + name: 'ResourceAsHappyWithScopeTokenCacheControl', + description: + 'Resource AS token response sets Cache-Control: no-store (RFC 6749 §5.1)', + status: cacheControlOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: cacheControlOk + ? undefined + : `Expected Cache-Control: no-store, got ${cacheControl ?? '(missing)'}`, + specReferences: [SPEC_REFERENCES.RFC_6749_TOKEN_RESPONSE], + details: { cacheControl } + }); + + const hasRefreshToken = response.body.refresh_token !== undefined; + checks.push({ + id: 'resource-as-happy-with-scope-no-refresh-token', + name: 'ResourceAsHappyWithScopeNoRefreshToken', + description: + 'Resource AS token response does not include a refresh_token for the ID-JAG grant', + status: hasRefreshToken ? 'FAILURE' : 'SUCCESS', + timestamp: timestamp(), + errorMessage: hasRefreshToken + ? `Unexpected refresh_token in the token response: ${JSON.stringify(response.body.refresh_token)}` + : undefined, + specReferences: [SPEC_REFERENCES.RFC_6749_TOKEN_RESPONSE], + details: { refresh_token_present: hasRefreshToken } + }); + } + + private checkIntrospection( + checks: ConformanceCheck[], + response: IntrospectionEndpointResponse | undefined, + flow: FlowContext, + timestamp: () => string + ): void { + const activeOk = + response !== undefined && + response.statusCode === 200 && + response.body.active === true; + checks.push({ + id: 'resource-as-happy-with-scope-introspection-active', + name: 'ResourceAsHappyWithScopeIntrospectionActive', + description: + 'Resource AS returns a successful introspection response marking the access token active (RFC 7662 §2.2)', + status: activeOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: activeOk + ? undefined + : `Expected 200 introspection response with active=true, got ${response ? `status ${response.statusCode}, body ${JSON.stringify(response.body)}` : '(no introspection performed)'}`, + specReferences: [SPEC_REFERENCES.RFC_7662_INTROSPECTION], + details: response + ? { statusCode: response.statusCode, active: response.body.active } + : {} + }); + + const clientIdOk = response?.body.client_id === flow.clientId; + checks.push({ + id: 'resource-as-happy-with-scope-introspection-client-id', + name: 'ResourceAsHappyWithScopeIntrospectionClientId', + description: `Introspection response "client_id" equals the configured client "${flow.clientId}" (RFC 7662 §2.2)`, + status: clientIdOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: clientIdOk + ? undefined + : `Expected client_id "${flow.clientId}", got ${JSON.stringify(response?.body.client_id)}`, + specReferences: [SPEC_REFERENCES.RFC_7662_INTROSPECTION], + details: { client_id: response?.body.client_id } + }); + + // The access token's sub is the Resource AS's own id for the user identified + // by the ID-JAG's sub claim (flow.idpSub), which may differ from it. + const subOk = response?.body.sub === flow.sub; + checks.push({ + id: 'resource-as-happy-with-scope-introspection-sub', + name: 'ResourceAsHappyWithScopeIntrospectionSub', + description: + 'Introspection response "sub" equals the configured Resource AS user id linked to the ID-JAG subject (RFC 7662 §2.2)', + status: subOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: subOk + ? undefined + : `Expected sub "${flow.sub}", got ${JSON.stringify(response?.body.sub)}`, + specReferences: [SPEC_REFERENCES.RFC_7662_INTROSPECTION], + details: { sub: response?.body.sub, username: response?.body.username } + }); + + const aud = response?.body.aud; + let audValues: string[] | undefined; + if (typeof aud === 'string') { + audValues = [aud]; + } else if ( + Array.isArray(aud) && + aud.length === 1 && + typeof aud[0] === 'string' + ) { + audValues = aud as string[]; + } + const audFormatOk = audValues !== undefined; + checks.push({ + id: 'resource-as-happy-with-scope-introspection-aud-format', + name: 'ResourceAsHappyWithScopeIntrospectionAudFormat', + description: + 'Introspection response "aud" is a string or a single-valued array (RFC 7662 §2.2)', + status: audFormatOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: audFormatOk + ? undefined + : `Expected "aud" to be a string or single-element array, got ${JSON.stringify(aud)}`, + specReferences: [SPEC_REFERENCES.RFC_7662_INTROSPECTION], + details: { aud } + }); + + const audValueOk = audValues?.[0] === flow.resource; + checks.push({ + id: 'resource-as-happy-with-scope-introspection-aud-value', + name: 'ResourceAsHappyWithScopeIntrospectionAudValue', + description: `Introspection response "aud" equals the configured trusted MCP Server "${flow.resource}" (EMA §5.1)`, + status: audValueOk ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: audValueOk + ? undefined + : `Expected aud "${flow.resource}", got ${JSON.stringify(aud)}`, + specReferences: [ + SPEC_REFERENCES.RFC_7662_INTROSPECTION, + SPEC_REFERENCES.EMA + ], + details: { aud } + }); + + // `scope` is space-delimited (RFC 7662 §2.2); it must include the requested scope. + const scope = response?.body.scope; + const scopeIncluded = + typeof scope === 'string' && + flow.scope !== undefined && + scope.split(' ').includes(flow.scope); + checks.push({ + id: 'resource-as-happy-with-scope-introspection-scope', + name: 'ResourceAsHappyWithScopeIntrospectionScope', + description: `Introspection response "scope" includes the configured scope "${flow.scope}" (RFC 7662 §2.2)`, + status: scopeIncluded ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: scopeIncluded + ? undefined + : `Expected "scope" to include "${flow.scope}", got ${JSON.stringify(scope)}`, + specReferences: [SPEC_REFERENCES.RFC_7662_INTROSPECTION], + details: { scope } + }); + } +} diff --git a/src/scenarios/ema/resource-authorization-server/resource-authorization-metadata.test.ts b/src/scenarios/ema/resource-authorization-server/resource-authorization-metadata.test.ts new file mode 100644 index 00000000..92930d25 --- /dev/null +++ b/src/scenarios/ema/resource-authorization-server/resource-authorization-metadata.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { + ResourceServerMetadataScenario, + checkResourceServerMetadata +} from './resource-authorization-metadata'; +import { + MockResourceAuthorizationServer, + ID_JAG_GRANT_PROFILE, + JWT_BEARER_GRANT_TYPE +} from '../auth/helpers/mockResourceAuthorizationServer'; + +const CHECK_IDS = [ + 'resource-as-metadata-grant-profiles-supported', + 'resource-as-metadata-id-jag-grant-profile', + 'resource-as-metadata-grant-types-supported', + 'resource-as-metadata-jwt-bearer-grant-type' +]; + +function statusOf(checks: ReturnType) { + return Object.fromEntries(checks.map((c) => [c.id, c.status])); +} + +describe('checkResourceServerMetadata', () => { + it('passes for well-formed metadata using array claims', () => { + const checks = checkResourceServerMetadata({ + authorization_grant_profiles_supported: [ID_JAG_GRANT_PROFILE], + grant_types_supported: [JWT_BEARER_GRANT_TYPE, 'authorization_code'] + }); + expect(checks.map((c) => c.id)).toEqual(CHECK_IDS); + expect(checks.every((c) => c.status === 'SUCCESS')).toBe(true); + }); + + it('accepts a string form of authorization_grant_profiles_supported', () => { + const checks = checkResourceServerMetadata({ + authorization_grant_profiles_supported: ID_JAG_GRANT_PROFILE, + grant_types_supported: [JWT_BEARER_GRANT_TYPE] + }); + const status = statusOf(checks); + expect(status['resource-as-metadata-grant-profiles-supported']).toBe( + 'SUCCESS' + ); + expect(status['resource-as-metadata-id-jag-grant-profile']).toBe('SUCCESS'); + }); + + it('fails when authorization_grant_profiles_supported is missing', () => { + const checks = checkResourceServerMetadata({ + grant_types_supported: [JWT_BEARER_GRANT_TYPE] + }); + const status = statusOf(checks); + expect(status['resource-as-metadata-grant-profiles-supported']).toBe( + 'FAILURE' + ); + expect(status['resource-as-metadata-id-jag-grant-profile']).toBe('FAILURE'); + }); + + it('fails when authorization_grant_profiles_supported is not a string/array', () => { + const checks = checkResourceServerMetadata({ + authorization_grant_profiles_supported: 42, + grant_types_supported: [JWT_BEARER_GRANT_TYPE] + }); + expect( + statusOf(checks)['resource-as-metadata-grant-profiles-supported'] + ).toBe('FAILURE'); + }); + + it('fails when the id-jag profile is not advertised', () => { + const checks = checkResourceServerMetadata({ + authorization_grant_profiles_supported: ['urn:example:other-profile'], + grant_types_supported: [JWT_BEARER_GRANT_TYPE] + }); + const status = statusOf(checks); + expect(status['resource-as-metadata-grant-profiles-supported']).toBe( + 'SUCCESS' + ); + expect(status['resource-as-metadata-id-jag-grant-profile']).toBe('FAILURE'); + }); + + it('fails when grant_types_supported is missing', () => { + const checks = checkResourceServerMetadata({ + authorization_grant_profiles_supported: [ID_JAG_GRANT_PROFILE] + }); + const status = statusOf(checks); + expect(status['resource-as-metadata-grant-types-supported']).toBe( + 'FAILURE' + ); + expect(status['resource-as-metadata-jwt-bearer-grant-type']).toBe( + 'FAILURE' + ); + }); + + it('fails when grant_types_supported is a string rather than an array', () => { + const checks = checkResourceServerMetadata({ + authorization_grant_profiles_supported: [ID_JAG_GRANT_PROFILE], + grant_types_supported: JWT_BEARER_GRANT_TYPE + }); + expect(statusOf(checks)['resource-as-metadata-grant-types-supported']).toBe( + 'FAILURE' + ); + }); + + it('fails when the jwt-bearer grant type is not advertised', () => { + const checks = checkResourceServerMetadata({ + authorization_grant_profiles_supported: [ID_JAG_GRANT_PROFILE], + grant_types_supported: ['authorization_code'] + }); + const status = statusOf(checks); + expect(status['resource-as-metadata-grant-types-supported']).toBe( + 'SUCCESS' + ); + expect(status['resource-as-metadata-jwt-bearer-grant-type']).toBe( + 'FAILURE' + ); + }); +}); + +describe('ResourceServerMetadataScenario', () => { + it('has a stable name and EMA extension source', () => { + const scenario = new ResourceServerMetadataScenario(); + expect(scenario.name).toBe('ema/resource-authorization-server/metadata'); + expect(scenario.source).toEqual({ + extensionId: 'io.modelcontextprotocol/enterprise-managed-authorization' + }); + }); + + it('passes every check against a configured Resource AS', async () => { + const resourceAs = await MockResourceAuthorizationServer.create(); + await resourceAs.start(); + try { + const scenario = new ResourceServerMetadataScenario(); + const checks = await scenario.run({ url: resourceAs.issuer }, {}); + + expect(checks.map((c) => c.id)).toEqual(CHECK_IDS); + for (const check of checks) { + expect( + check.status, + `${check.id} failed: ${check.errorMessage ?? ''}` + ).toBe('SUCCESS'); + } + } finally { + await resourceAs.stop(); + } + }); +}); + +describe('ResourceServerMetadataScenario against a misconfigured Resource AS', () => { + let resourceAs: MockResourceAuthorizationServer | null = null; + + afterEach(async () => { + await resourceAs?.stop(); + resourceAs = null; + }); + + it('fails discovery when the Resource AS omits ID-JAG support from its metadata', async () => { + // Serve metadata that advertises neither the id-jag grant profile nor the + // jwt-bearer grant type — a Resource AS that does not support the profile. + resourceAs = await MockResourceAuthorizationServer.create({ + metadataTransform: (defaults) => ({ + issuer: defaults.issuer, + token_endpoint: defaults.token_endpoint, + jwks_uri: defaults.jwks_uri, + grant_types_supported: ['authorization_code'], + token_endpoint_auth_methods_supported: + defaults.token_endpoint_auth_methods_supported + }) + }); + await resourceAs.start(); + + const scenario = new ResourceServerMetadataScenario(); + const checks = await scenario.run({ url: resourceAs.issuer }, {}); + const status = Object.fromEntries(checks.map((c) => [c.id, c.status])); + + // The absent authorization_grant_profiles_supported claim fails both + // profile checks; the present-but-insufficient grant_types_supported passes + // the array check but fails the jwt-bearer membership check. + expect(status['resource-as-metadata-grant-profiles-supported']).toBe( + 'FAILURE' + ); + expect(status['resource-as-metadata-id-jag-grant-profile']).toBe('FAILURE'); + expect(status['resource-as-metadata-grant-types-supported']).toBe( + 'SUCCESS' + ); + expect(status['resource-as-metadata-jwt-bearer-grant-type']).toBe( + 'FAILURE' + ); + expect(checks.some((c) => c.status === 'FAILURE')).toBe(true); + }); + + it('fails every check when the metadata drops both claims entirely', async () => { + resourceAs = await MockResourceAuthorizationServer.create({ + metadataTransform: (defaults) => ({ + issuer: defaults.issuer, + token_endpoint: defaults.token_endpoint, + jwks_uri: defaults.jwks_uri + }) + }); + await resourceAs.start(); + + const scenario = new ResourceServerMetadataScenario(); + const checks = await scenario.run({ url: resourceAs.issuer }, {}); + + expect(checks.map((c) => c.id)).toEqual(CHECK_IDS); + expect(checks.every((c) => c.status === 'FAILURE')).toBe(true); + }); +}); diff --git a/src/scenarios/ema/resource-authorization-server/resource-authorization-metadata.ts b/src/scenarios/ema/resource-authorization-server/resource-authorization-metadata.ts new file mode 100644 index 00000000..cd6acfd2 --- /dev/null +++ b/src/scenarios/ema/resource-authorization-server/resource-authorization-metadata.ts @@ -0,0 +1,164 @@ +/** + * Scenario 1 for the Resource-AS side of Enterprise-Managed Authorization + * (ISSUE-470): verify a Resource Authorization Server's server metadata + * advertises ID-JAG support. + * + * A real Resource AS is not available as a test target, so the runner drives + * the mock Resource AS (`../auth/helpers/mockResourceAuthorizationServer`), + * retrieves its metadata over HTTP, and checks that it declares the ID-JAG + * grant profile and the JWT-bearer grant type (EMA §6 Discovery / + * draft-ietf-oauth-identity-assertion-authz-grant §7.2). + */ +import { request } from 'undici'; +import type { + ConformanceCheck, + ScenarioForResourceAuthorizationServer, + ScenarioSource +} from '../../../types'; +import type { ResourceAuthorizationServerOptions } from '../../../schemas'; +import { + ID_JAG_GRANT_PROFILE, + JWT_BEARER_GRANT_TYPE +} from '../auth/helpers/mockResourceAuthorizationServer'; +import { SpecReferences as SPEC_REFERENCES } from '../auth/spec-references'; + +const EMA_SOURCE: ScenarioSource = { + extensionId: 'io.modelcontextprotocol/enterprise-managed-authorization' +}; + +/** + * Normalize a metadata claim that may be a single string or an array of + * strings into a string list. Returns undefined when the claim is absent or + * not a string / array-of-strings. + */ +function asStringList(claim: unknown): string[] | undefined { + if (typeof claim === 'string') { + return [claim]; + } + if (Array.isArray(claim) && claim.every((v) => typeof v === 'string')) { + return claim as string[]; + } + return undefined; +} + +/** + * Run the four Resource-AS metadata checks against a parsed metadata document. + * Exported so tests can exercise the logic without an HTTP round-trip. + */ +export function checkResourceServerMetadata( + body: Record +): ConformanceCheck[] { + const checks: ConformanceCheck[] = []; + const timestamp = () => new Date().toISOString(); + + // 1. authorization_grant_profiles_supported is present (string or array). + const grantProfilesClaim = body.authorization_grant_profiles_supported; + const grantProfiles = asStringList(grantProfilesClaim); + const grantProfilesPresent = grantProfiles !== undefined; + checks.push({ + id: 'resource-as-metadata-grant-profiles-supported', + name: 'ResourceAsMetadataGrantProfilesSupported', + description: + 'Resource AS metadata includes "authorization_grant_profiles_supported" as a string or array of strings (EMA §6)', + status: grantProfilesPresent ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: grantProfilesPresent + ? undefined + : `Missing or non-string "authorization_grant_profiles_supported": ${JSON.stringify(grantProfilesClaim)}`, + specReferences: [ + SPEC_REFERENCES.EMA_DISCOVERY, + SPEC_REFERENCES.ID_JAG_DISCOVERY + ], + details: { authorization_grant_profiles_supported: grantProfilesClaim } + }); + + // 2. authorization_grant_profiles_supported includes the ID-JAG profile. + const idJagProfileIncluded = + grantProfiles?.includes(ID_JAG_GRANT_PROFILE) ?? false; + checks.push({ + id: 'resource-as-metadata-id-jag-grant-profile', + name: 'ResourceAsMetadataIdJagGrantProfile', + description: `Resource AS metadata "authorization_grant_profiles_supported" includes "${ID_JAG_GRANT_PROFILE}" (EMA §6)`, + status: idJagProfileIncluded ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: idJagProfileIncluded + ? undefined + : `"authorization_grant_profiles_supported" does not include "${ID_JAG_GRANT_PROFILE}": ${JSON.stringify(grantProfilesClaim)}`, + specReferences: [ + SPEC_REFERENCES.EMA_DISCOVERY, + SPEC_REFERENCES.ID_JAG_DISCOVERY + ], + details: { authorization_grant_profiles_supported: grantProfilesClaim } + }); + + // 3. grant_types_supported is present and an array of strings. + const grantTypesClaim = body.grant_types_supported; + const grantTypesIsArray = + Array.isArray(grantTypesClaim) && + grantTypesClaim.every((v) => typeof v === 'string'); + checks.push({ + id: 'resource-as-metadata-grant-types-supported', + name: 'ResourceAsMetadataGrantTypesSupported', + description: + 'Resource AS metadata includes "grant_types_supported" as an array of strings (RFC 8414 §2)', + status: grantTypesIsArray ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: grantTypesIsArray + ? undefined + : `Missing or non-array "grant_types_supported": ${JSON.stringify(grantTypesClaim)}`, + specReferences: [SPEC_REFERENCES.RFC_7523], + details: { grant_types_supported: grantTypesClaim } + }); + + // 4. grant_types_supported includes the JWT-bearer grant type. + const jwtBearerIncluded = + grantTypesIsArray && + (grantTypesClaim as string[]).includes(JWT_BEARER_GRANT_TYPE); + checks.push({ + id: 'resource-as-metadata-jwt-bearer-grant-type', + name: 'ResourceAsMetadataJwtBearerGrantType', + description: `Resource AS metadata "grant_types_supported" includes "${JWT_BEARER_GRANT_TYPE}" (RFC 7523)`, + status: jwtBearerIncluded ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + errorMessage: jwtBearerIncluded + ? undefined + : `"grant_types_supported" does not include "${JWT_BEARER_GRANT_TYPE}": ${JSON.stringify(grantTypesClaim)}`, + specReferences: [SPEC_REFERENCES.RFC_7523], + details: { grant_types_supported: grantTypesClaim } + }); + + return checks; +} + +async function fetchResourceServerMetadata( + metadataUrl: string +): Promise> { + const response = await request(metadataUrl, { method: 'GET' }); + return (await response.body.json()) as Record; +} + +/** + * Scenario 1: verify the Resource AS server metadata. + * + * Discovers the target Resource AS from {@link + * ResourceAuthorizationServerOptions.url} (well-known URI), retrieves its + * metadata over HTTP, and validates the ID-JAG discovery fields. + */ +export class ResourceServerMetadataScenario implements ScenarioForResourceAuthorizationServer { + name = 'ema/resource-authorization-server/metadata'; + readonly source = EMA_SOURCE; + description = + 'EMA: the Resource AS server metadata advertises the id-jag grant profile (authorization_grant_profiles_supported) and the jwt-bearer grant type (grant_types_supported).'; + + async run( + options: ResourceAuthorizationServerOptions, + _details: Record + ): Promise { + const base = options.url.endsWith('/') + ? options.url.slice(0, -1) + : options.url; + const metadataUrl = `${base}/.well-known/oauth-authorization-server`; + const body = await fetchResourceServerMetadata(metadataUrl); + return checkResourceServerMetadata(body); + } +} diff --git a/src/scenarios/ema/resource-authorization-server/support.ts b/src/scenarios/ema/resource-authorization-server/support.ts new file mode 100644 index 00000000..9a9b66f7 --- /dev/null +++ b/src/scenarios/ema/resource-authorization-server/support.ts @@ -0,0 +1,72 @@ +/** + * Shared helpers for the Resource-AS EMA scenarios (ISSUE-470). A scenario is + * driven by a {@link ResourceAuthorizationServerOptions} settings object naming + * a target Resource AS, plus a `details` bag through which the runner hands over + * the live IdP Authorization Server(s) it hosts (the runner plays the Trusted — + * and, for negative tests, an Untrusted — IdP AS). The IdP objects carry the + * signing keys the scenario needs to mint ID-JAGs, which the settings (issuer + * URLs only) cannot convey. + */ +import { request } from 'undici'; +import { IdPAuthorizationServer } from '../auth/helpers/provideIdPAuthorizationServer'; + +/** `details` key under which the runner supplies the trusted IdP AS. */ +export const TRUSTED_IDP_DETAIL = 'trustedIdp'; +/** `details` key under which the runner supplies the untrusted IdP AS. */ +export const UNTRUSTED_IDP_DETAIL = 'untrustedIdp'; + +/** Pull a runner-hosted IdP AS out of the scenario `details` bag, if present. */ +export function getIdp( + details: Record, + key: string +): IdPAuthorizationServer | undefined { + const value = details[key]; + return value instanceof IdPAuthorizationServer ? value : undefined; +} + +export interface ResourceAsEndpoints { + /** Issuer identifier the ID-JAG `aud` must match (metadata `issuer`). */ + issuer: string; + tokenEndpoint: string; + introspectionEndpoint: string; + metadata: Record; +} + +function trimTrailingSlash(url: string): string { + return url.endsWith('/') ? url.slice(0, -1) : url; +} + +/** + * Fetch a Resource AS's server metadata from its issuer URL (well-known URI) + * and resolve the token and introspection endpoints the ID-JAG flow needs. + * Throws when the document is unreachable or omits a required endpoint. + */ +export async function discoverResourceAs( + issuerUrl: string +): Promise { + const metadataUrl = `${trimTrailingSlash(issuerUrl)}/.well-known/oauth-authorization-server`; + const response = await request(metadataUrl, { method: 'GET' }); + if (response.statusCode !== 200) { + throw new Error( + `Resource AS metadata endpoint ${metadataUrl} returned ${response.statusCode}` + ); + } + const metadata = (await response.body.json()) as Record; + const issuer = + typeof metadata.issuer === 'string' + ? metadata.issuer + : trimTrailingSlash(issuerUrl); + const tokenEndpoint = metadata.token_endpoint; + const introspectionEndpoint = metadata.introspection_endpoint; + if (typeof tokenEndpoint !== 'string') { + throw new Error( + `Resource AS metadata at ${metadataUrl} is missing "token_endpoint"` + ); + } + if (typeof introspectionEndpoint !== 'string') { + throw new Error( + `Resource AS metadata at ${metadataUrl} is missing "introspection_endpoint"` + ); + } + return { issuer, tokenEndpoint, introspectionEndpoint, metadata }; +} diff --git a/src/scenarios/ema/resource-authorization-server/test-harness.ts b/src/scenarios/ema/resource-authorization-server/test-harness.ts new file mode 100644 index 00000000..d19b3eba --- /dev/null +++ b/src/scenarios/ema/resource-authorization-server/test-harness.ts @@ -0,0 +1,110 @@ +/** + * Test harness for the Resource-AS EMA scenarios. It stands up the runner-side + * pieces a human tester would provision for a real target — a Trusted IdP AS and + * a Resource AS with a registered client, user, trusted MCP Server and scope — + * then packages them as the {@link ResourceAuthorizationServerOptions} config + * plus the `details` bag (carrying the live IdP) that a scenario consumes. + */ +import { + MockResourceAuthorizationServer, + type MockResourceAuthorizationServerOptions +} from '../auth/helpers/mockResourceAuthorizationServer'; +import { IdPAuthorizationServer } from '../auth/helpers/provideIdPAuthorizationServer'; +import type { ResourceAuthorizationServerOptions } from '../../../schemas'; +import { TRUSTED_IDP_DETAIL, UNTRUSTED_IDP_DETAIL } from './support'; + +export const TEST_CLIENT_ID = 'mcp-client'; +export const TEST_USERNAME = 'Alice'; +export const TEST_IDP_SUB = 'idp-alice-001'; +export const TEST_TRUSTED_MCP_SERVER = 'https://mcp.example/'; +export const TEST_UNTRUSTED_MCP_SERVER = 'https://other-mcp.example/'; +export const TEST_SCOPE = 'mcp.read'; + +export interface HarnessProvisioning { + /** Whether the mock Resource AS requires the ID-JAG `resource` claim. Defaults to true. */ + requireResourceClaim?: boolean; + /** Register {@link TEST_TRUSTED_MCP_SERVER} as a trusted MCP Server. */ + registerTrustedMcpServer?: boolean; + /** Register {@link TEST_SCOPE} so the Resource AS enforces its scope set. */ + registerScope?: boolean; + /** Provision a second IdP AS that the Resource AS never registers as trusted. */ + provisionUntrustedIdp?: boolean; + /** Extra options passed to the mock Resource AS (e.g. metadataTransform). */ + mockOptions?: MockResourceAuthorizationServerOptions; +} + +export interface Harness { + idp: IdPAuthorizationServer; + /** Present only when `provisioning.provisionUntrustedIdp` is set. */ + untrustedIdp?: IdPAuthorizationServer; + resourceAs: MockResourceAuthorizationServer; + options: ResourceAuthorizationServerOptions; + details: Record; + stop(): Promise; +} + +/** + * Provision the IdP + Resource AS and return the config, details and a teardown. + * The caller owns the lifecycle: always `await harness.stop()` in a finally. + */ +export async function createHarness( + provisioning: HarnessProvisioning = {} +): Promise { + const idp = await IdPAuthorizationServer.create(); + await idp.start(); + + const untrustedIdp = provisioning.provisionUntrustedIdp + ? await IdPAuthorizationServer.create() + : undefined; + if (untrustedIdp) { + await untrustedIdp.start(); + } + + const resourceAs = await MockResourceAuthorizationServer.create({ + requireResourceClaim: provisioning.requireResourceClaim ?? true, + ...provisioning.mockOptions + }); + await resourceAs.start(); + + resourceAs.registerTrustedIdp(idp.issuer); + const sub = resourceAs.registerUser(TEST_USERNAME); + resourceAs.linkIdpSubject(TEST_IDP_SUB, sub); + const clientSecret = resourceAs.registerClient(TEST_CLIENT_ID); + if (provisioning.registerTrustedMcpServer) { + resourceAs.registerTrustedMcpServer(TEST_TRUSTED_MCP_SERVER); + } + if (provisioning.registerScope) { + resourceAs.registerScope(TEST_SCOPE); + } + + const options: ResourceAuthorizationServerOptions = { + url: resourceAs.issuer, + clientId: TEST_CLIENT_ID, + clientSecret, + sub, + idpSub: TEST_IDP_SUB, + trustedIdpIssuer: idp.issuer, + untrustedIdpIssuer: untrustedIdp?.issuer, + trustedMcpServer: TEST_TRUSTED_MCP_SERVER, + untrustedMcpServer: TEST_UNTRUSTED_MCP_SERVER, + scope: TEST_SCOPE + }; + + const details: Record = { [TRUSTED_IDP_DETAIL]: idp }; + if (untrustedIdp) { + details[UNTRUSTED_IDP_DETAIL] = untrustedIdp; + } + + return { + idp, + untrustedIdp, + resourceAs, + options, + details, + async stop() { + await resourceAs.stop(); + await idp.stop(); + await untrustedIdp?.stop(); + } + }; +} diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 03cf55f5..7825b22c 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -2,6 +2,7 @@ import { Scenario, ClientScenario, ClientScenarioForAuthorizationServer, + ScenarioForResourceAuthorizationServer, ScenarioSource, SpecVersion, DatedSpecVersion, @@ -113,6 +114,20 @@ import { listMetadataScenarios } from './client/auth/discovery-metadata'; import { AuthorizationServerMetadataEndpointScenario } from './authorization-server/authorization-server-metadata'; import { AuthorizationCodeGrantScenario } from './authorization-server/authorization-code-grant'; +// EMA Resource Authorization Server scenarios (ISSUE-470) +import { ResourceServerMetadataScenario } from './ema/resource-authorization-server/resource-authorization-metadata'; +import { + ResourceServerHappyPathScenario, + ResourceServerHappyPathWithResourceScenario, + ResourceServerHappyPathWithScopeScenario +} from './ema/resource-authorization-server/resource-authorization-happy-path'; +import { + ResourceServerErrorPathScenario, + ResourceServerInvalidScopeScenario, + ResourceServerInvalidSignatureScenario, + ResourceServerUntrustedIdpScenario +} from './ema/resource-authorization-server/resource-authorization-error-path'; + import { HttpStandardHeadersScenario } from './client/http-standard-headers'; import { HttpCustomHeadersScenario, @@ -293,6 +308,30 @@ export const clientScenariosForAuthorizationServer = new Map< ]) ); +// All scenarios for the EMA Resource Authorization Server (ISSUE-470) +const allScenariosListForResourceAuthorizationServer: ScenarioForResourceAuthorizationServer[] = + [ + new ResourceServerMetadataScenario(), + new ResourceServerHappyPathScenario(), + new ResourceServerHappyPathWithResourceScenario(), + new ResourceServerHappyPathWithScopeScenario(), + new ResourceServerErrorPathScenario(), + new ResourceServerInvalidScopeScenario(), + new ResourceServerInvalidSignatureScenario(), + new ResourceServerUntrustedIdpScenario() + ]; + +// Scenarios map for the EMA Resource Authorization Server - built from list +export const resourceAuthorizationServerScenarios = new Map< + string, + ScenarioForResourceAuthorizationServer +>( + allScenariosListForResourceAuthorizationServer.map((scenario) => [ + scenario.name, + scenario + ]) +); + // All client test scenarios (core + backcompat + extensions) const scenariosList: Scenario[] = [ new InitializeScenario(), @@ -388,6 +427,16 @@ export function listClientScenariosForAuthorizationServer(): string[] { return Array.from(clientScenariosForAuthorizationServer.keys()); } +export function getScenarioForResourceAuthorizationServer( + name: string +): ScenarioForResourceAuthorizationServer | undefined { + return resourceAuthorizationServerScenarios.get(name); +} + +export function listScenariosForResourceAuthorizationServer(): string[] { + return Array.from(resourceAuthorizationServerScenarios.keys()); +} + // All client-testing scenarios that target the draft spec, derived from the // declared `source.introducedIn` rather than a hand-maintained list (covers // both the auth draft scenarios and the non-auth ones, e.g. SEP-2243/2575). @@ -484,7 +533,8 @@ export function getScenarioSpecVersions( const s = scenarios.get(name) ?? clientScenarios.get(name) ?? - clientScenariosForAuthorizationServer.get(name); + clientScenariosForAuthorizationServer.get(name) ?? + resourceAuthorizationServerScenarios.get(name); if (!s) return undefined; if ('extensionId' in s.source) return ['extension']; const result: ScenarioSpecTag[] = []; diff --git a/src/schemas.ts b/src/schemas.ts index 040aa2e3..6dbff360 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -2,7 +2,8 @@ import { z } from 'zod'; import { getScenario, getClientScenario, - getClientScenarioForAuthorizationServer + getClientScenarioForAuthorizationServer, + getScenarioForResourceAuthorizationServer } from './scenarios'; // Client command options schema @@ -69,6 +70,70 @@ export type AuthorizationServerOptions = z.infer< typeof AuthorizationServerOptionsSchema >; +// Resource authorization server (EMA / ID-JAG) command options schema. +// +// Settings a human tester provides to point a scenario at a *real* target +// Resource AS they have pre-provisioned. `url` is the only field every scenario +// needs (it discovers the token/introspection endpoints from the issuer's +// server metadata); each scenario validates the additional fields it requires +// and skips when they are absent, so the remaining fields are schema-optional. +export const ResourceAuthorizationServerOptionsSchema = z.object({ + // [1] Issuer URL of the target Resource AS. The runner GETs its server + // metadata (well-known URL derived from the issuer) to learn the token and + // introspection endpoints. Required by every scenario. + url: z.string().url('Invalid resource authorization server URL'), + scenario: z + .string() + .min(1, 'Scenario cannot be empty') + .refine( + (scenario) => + getScenarioForResourceAuthorizationServer(scenario) !== undefined, + { + error: (iss) => `Unknown scenario '${iss.input}'` + } + ) + .optional(), + // [2] client_id of the MCP Client the tester registered with the Resource AS. + clientId: z.string().min(1, 'Client id cannot be empty').optional(), + // [3] Client secret for that MCP Client (client_secret_post authentication). + clientSecret: z.string().min(1, 'Client secret cannot be empty').optional(), + // [4] Issuer URL of an IdP AS the target Resource AS trusts (accepts ID-JAGs + // whose `iss` matches). + trustedIdpIssuer: z.string().url('Invalid trusted IdP issuer URL').optional(), + // [5] Issuer URL of an IdP AS the target Resource AS does not trust (rejects + // ID-JAGs whose `iss` matches). + untrustedIdpIssuer: z + .string() + .url('Invalid untrusted IdP issuer URL') + .optional(), + // [6] URL of an MCP Server the Resource AS trusts; used as the ID-JAG + // `resource`. Optional — only needed for scenarios exercising `resource`. + trustedMcpServer: z.string().url('Invalid trusted MCP server URL').optional(), + // [7] URL of an MCP Server the Resource AS does not trust; used as the ID-JAG + // `resource`. Optional — only needed for scenarios exercising `resource`. + untrustedMcpServer: z + .string() + .url('Invalid untrusted MCP server URL') + .optional(), + // [8] OAuth 2.0 scope the Resource AS recognises; used as the ID-JAG `scope`. + // Optional — only needed for scenarios exercising `scope`. + scope: z.string().min(1, 'Scope cannot be empty').optional(), + // [9] User id registered with the target Resource AS; the Resource AS is + // expected to place this value in the `sub` claim of the access token it + // issues for the linked IdP-registered user (see `idpSub`). Optional — + // only needed for scenarios exercising `sub`. + sub: z.string().min(1, 'Subject cannot be empty').optional(), + // [10] User id registered with the trusted IdP AS for that same person; + // used as the ID-JAG `sub` claim. The IdP and the Resource AS may assign the + // same human different ids, so this is intentionally distinct from `sub` + // above. Optional — only needed for scenarios exercising `sub`. + idpSub: z.string().min(1, 'Subject cannot be empty').optional() +}); + +export type ResourceAuthorizationServerOptions = z.infer< + typeof ResourceAuthorizationServerOptionsSchema +>; + // Interactive command options schema export const InteractiveOptionsSchema = z.object({ scenario: z diff --git a/src/types.ts b/src/types.ts index 5960945b..e46516e1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,9 @@ import type { RunContext } from './connection'; import type { ScenarioContext } from './mock-server'; -import type { AuthorizationServerOptions } from './schemas'; +import type { + AuthorizationServerOptions, + ResourceAuthorizationServerOptions +} from './schemas'; export type CheckStatus = | 'SUCCESS' @@ -158,3 +161,13 @@ export interface ClientScenarioForAuthorizationServer { details: Record ): Promise; } + +export interface ScenarioForResourceAuthorizationServer { + name: string; + description: string; + source: ScenarioSource; + run( + options: ResourceAuthorizationServerOptions, + details: Record + ): Promise; +}