diff --git a/src/scenarios/authorization-server/auth/spec-references.ts b/src/scenarios/authorization-server/auth/spec-references.ts index e89ec868..38a1beec 100644 --- a/src/scenarios/authorization-server/auth/spec-references.ts +++ b/src/scenarios/authorization-server/auth/spec-references.ts @@ -8,5 +8,26 @@ export const SpecReferences: { [key: string]: SpecReference } = { OAUTH_2_1_AUTHORIZATION_CODE_GRANT: { id: 'OAUTH-2.1-authorization-code-grant', url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#section-4.1' + }, + // DPoP (SEP-1932 / RFC 9449) — authorization-server concerns. + SEP_1932_DPOP: { + id: 'SEP-1932-DPoP', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1932' + }, + DPOP_EXTENSION: { + id: 'MCP-DPoP-Extension', + url: 'https://github.com/modelcontextprotocol/ext-auth/blob/pieterkas-dpop-extension/specification/draft/dpop-extension.mdx' + }, + RFC_9449_AS_METADATA: { + id: 'RFC-9449-authorization-server-metadata', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-5.1' + }, + RFC_9449_PUBLIC_KEY_CONFIRMATION: { + id: 'RFC-9449-public-key-confirmation', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-6' + }, + RFC_9449_ALGORITHMS: { + id: 'RFC-9449-dpop-proof-jwt-syntax', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-11.6' } }; diff --git a/src/scenarios/authorization-server/dpop.test.ts b/src/scenarios/authorization-server/dpop.test.ts new file mode 100644 index 00000000..6ba2091e --- /dev/null +++ b/src/scenarios/authorization-server/dpop.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from 'vitest'; +import { + createAuthServer, + type AuthServerOptions +} from '../client/auth/helpers/createAuthServer'; +import { ServerLifecycle } from '../client/auth/helpers/serverLifecycle'; +import { testScenarioContext } from '../../mock-server/testing'; +import type { CheckStatus, ConformanceCheck } from '../../types'; +import { DPoPAuthorizationServerScenario, negotiateProofAlg } from './dpop'; + +const ALL_IDS = [ + 'sep-1932-as-metadata-alg-values', + 'sep-1932-as-no-none-alg', + 'sep-1932-as-token-binding' +] as const; + +const statusOf = ( + checks: ConformanceCheck[], + id: string +): CheckStatus | undefined => checks.find((c) => c.id === id)?.status; + +/** + * Start an in-process test AS (real Express app, no mocks) with the given DPoP + * options, run the scenario against its live URL, and return the emitted checks. + * The AS 302s straight to the redirect_uri, so the scenario auto-follows headless. + */ +async function runAgainst( + dpopOptions: Partial, + // `false` means "send no client_id" — a plain `undefined` would re-trigger the + // default via JS default-parameter semantics. + clientId: string | false = 'test-client-id' +): Promise { + const lifecycle = new ServerLifecycle(); + const app = createAuthServer(testScenarioContext(), [], lifecycle.getUrl, { + loggingEnabled: false, + grantTypesSupported: ['authorization_code', 'refresh_token'], + ...dpopOptions + }); + await lifecycle.start(app); + try { + return await new DPoPAuthorizationServerScenario().run( + { url: lifecycle.getUrl(), port: 45678, clientId: clientId || undefined }, + {} + ); + } finally { + await lifecycle.stop(); + } +} + +// A DPoP-capable AS: advertises an asymmetric alg and issues bound tokens. +// (`dpop_bound_access_tokens` is per-client registration metadata, RFC 9449 +// §5.2 — not an AS option — so it is deliberately not set here.) +const COMPLIANT: Partial = { + dpopSigningAlgValuesSupported: ['ES256'] +}; + +describe('DPoPAuthorizationServerScenario — compliant AS', () => { + it('emits all three sep-1932-as-* checks as SUCCESS', async () => { + const checks = await runAgainst(COMPLIANT); + for (const id of ALL_IDS) { + expect(statusOf(checks, id)).toBe('SUCCESS'); + } + expect(checks.filter((c) => c.status === 'FAILURE')).toHaveLength(0); + }); + + it('binds the issued token to the presented proof key (cnf.jkt matches)', async () => { + const checks = await runAgainst(COMPLIANT); + const binding = checks.find((c) => c.id === 'sep-1932-as-token-binding'); + expect(binding?.status).toBe('SUCCESS'); + const details = binding?.details as { + tokenType: string; + cnfJkt: string; + expectedJkt: string; + }; + expect(details.tokenType).toBe('DPoP'); + expect(details.cnfJkt).toBe(details.expectedJkt); + }); +}); + +// Isolation matrix: each defect fails EXACTLY its target check, the rest stay +// SUCCESS. (`omit-alg-values` is not here — dropping the field means "not a DPoP +// AS", which SKIPs the whole scenario; see the support-gate tests below.) +describe('DPoPAuthorizationServerScenario — one-defect isolation', () => { + const CASES = [ + { + misbehavior: 'empty-alg-values', + target: 'sep-1932-as-metadata-alg-values' + }, + { misbehavior: 'include-none', target: 'sep-1932-as-no-none-alg' }, + { misbehavior: 'unbound-token', target: 'sep-1932-as-token-binding' } + ] as const; + + for (const { misbehavior, target } of CASES) { + it(`misbehaving AS (${misbehavior}) fails only ${target}`, async () => { + const checks = await runAgainst({ + ...COMPLIANT, + dpopMisbehavior: misbehavior + }); + expect(statusOf(checks, target)).toBe('FAILURE'); + for (const id of ALL_IDS.filter((c) => c !== target)) { + expect(statusOf(checks, id)).toBe('SUCCESS'); + } + }); + } + + it('fails the no-none-alg check when a symmetric algorithm is advertised', async () => { + const checks = await runAgainst({ + dpopSigningAlgValuesSupported: ['ES256', 'HS256'] + }); + expect(statusOf(checks, 'sep-1932-as-metadata-alg-values')).toBe('SUCCESS'); + expect(statusOf(checks, 'sep-1932-as-no-none-alg')).toBe('FAILURE'); + }); +}); + +describe('DPoPAuthorizationServerScenario — skip conditions', () => { + it('skips the token-binding check when no client_id is supplied', async () => { + const checks = await runAgainst(COMPLIANT, false); + expect(statusOf(checks, 'sep-1932-as-metadata-alg-values')).toBe('SUCCESS'); + expect(statusOf(checks, 'sep-1932-as-no-none-alg')).toBe('SUCCESS'); + expect(statusOf(checks, 'sep-1932-as-token-binding')).toBe('SKIPPED'); + }); + + it('skips token binding when no advertised proof alg is supported (no ES256 fallback)', async () => { + // ES256K is asymmetric (passes no-none-alg) but not one the harness can + // produce; the scenario must SKIP rather than send an unadvertised ES256 + // proof the AS would reject and mis-score as a binding failure. + const checks = await runAgainst({ + dpopSigningAlgValuesSupported: ['ES256K'] + }); + expect(statusOf(checks, 'sep-1932-as-metadata-alg-values')).toBe('SUCCESS'); + expect(statusOf(checks, 'sep-1932-as-no-none-alg')).toBe('SUCCESS'); + expect(statusOf(checks, 'sep-1932-as-token-binding')).toBe('SKIPPED'); + }); + + it('skips the whole scenario when the AS does not advertise DPoP support', async () => { + // No dpop_signing_alg_values_supported → not a DPoP AS (RFC 9449 §5.1), so + // the DPoP requirements do not apply: every check SKIPs rather than fails. + const checks = await runAgainst({ dpopMisbehavior: 'omit-alg-values' }); + for (const id of ALL_IDS) { + expect(statusOf(checks, id)).toBe('SKIPPED'); + } + expect(checks.filter((c) => c.status === 'FAILURE')).toHaveLength(0); + }); +}); + +describe('negotiateProofAlg (dpop_signing_alg_values_supported shapes)', () => { + it('picks the first supported alg from a non-empty array', () => { + expect(negotiateProofAlg(['ES256'])).toBe('ES256'); + expect(negotiateProofAlg(['RS256', 'ES256'])).toBe('RS256'); + }); + + it('returns null for a non-empty array with no supported alg (→ SKIP)', () => { + expect(negotiateProofAlg(['ES256K'])).toBeNull(); + }); + + it('falls back to ES256 only for an empty array or an absent field', () => { + expect(negotiateProofAlg([])).toBe('ES256'); + // Absent never reaches here in the scenario (the support gate SKIPs upstream), + // but the contract still treats undefined as the empty/best-effort case. + expect(negotiateProofAlg(undefined)).toBe('ES256'); + }); + + it('returns null for a present-but-non-array (malformed) value (→ SKIP)', () => { + // Regression guard: a string or JSON null must NOT fall through to the + // ES256 fallback, which would mis-score token binding. + expect(negotiateProofAlg('RS256')).toBeNull(); + expect(negotiateProofAlg(null)).toBeNull(); + expect(negotiateProofAlg(42)).toBeNull(); + expect(negotiateProofAlg({ 0: 'ES256' })).toBeNull(); + }); +}); diff --git a/src/scenarios/authorization-server/dpop.ts b/src/scenarios/authorization-server/dpop.ts new file mode 100644 index 00000000..e8aa702e --- /dev/null +++ b/src/scenarios/authorization-server/dpop.ts @@ -0,0 +1,727 @@ +/** + * DPoP authorization-server scenario (SEP-1932 / RFC 9449). + * + * The framework acts as a DPoP-capable OAuth client against the authorization + * server under test at `options.url`. It probes: + * + * - metadata: `dpop_signing_alg_values_supported` is advertised (RFC 9449 §5.1) + * and does not include the `none` or symmetric algorithms; + * - token binding: a code exchanged WITH a DPoP proof yields a token bound to + * the proof key (`cnf.jkt`) with `token_type: DPoP` (RFC 9449 §5–§6). + * + * An AS that does not advertise `dpop_signing_alg_values_supported` is not a + * DPoP authorization server (RFC 9449 §5.1 is how support is signalled), so the + * whole scenario SKIPs rather than failing it — the DPoP checks only apply once + * the AS opts in. (`dpop_bound_access_tokens` is per-client registration + * metadata, RFC 9449 §5.2, not an AS capability, so no enforcement check is + * made here.) + * + * Tokens are obtained via the authorization_code + PKCE grant (the MCP grant). + * The authorization step auto-follows a direct redirect to the registered + * redirect_uri (the headless path used by auto-approving/test ASs) and falls + * back to an interactive browser + callback-server wait for login-gated ASs. + * + * Emits the sep-1932-as-* check IDs declared in src/seps/sep-1932.yaml. + */ + +import { + CheckStatus, + ClientScenarioForAuthorizationServer, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION, + SpecReference +} from '../../types'; +import { AuthorizationServerOptions } from '../../schemas'; +import { request } from 'undici'; +import { createHash, randomBytes } from 'crypto'; +import { startCallbackServer } from './auth/helpers/createCallbackServer'; +import { + generateDpopKeyPair, + buildDpopProof +} from '../client/auth/helpers/dpopProof'; +import { readTokenBinding } from '../client/auth/helpers/dpopToken'; +import { SpecReferences } from './auth/spec-references'; + +const REDIRECT_URI_ORIGIN = 'http://127.0.0.1'; +const REDIRECT_URI_PATH = '/callback'; + +/** Static id → (name, description, spec references) for each emitted check. */ +const CHECK_DEFS: Record< + string, + { name: string; description: string; specReferences: SpecReference[] } +> = { + 'sep-1932-as-metadata-alg-values': { + name: 'DpopMetadataAlgValues', + description: + 'Authorization server metadata advertises dpop_signing_alg_values_supported', + specReferences: [ + SpecReferences.SEP_1932_DPOP, + SpecReferences.DPOP_EXTENSION, + SpecReferences.RFC_9449_AS_METADATA + ] + }, + 'sep-1932-as-no-none-alg': { + name: 'DpopNoNoneAlg', + description: + 'dpop_signing_alg_values_supported lists only asymmetric algorithms (no none or symmetric algorithms)', + specReferences: [ + SpecReferences.RFC_9449_AS_METADATA, + SpecReferences.RFC_9449_ALGORITHMS + ] + }, + 'sep-1932-as-token-binding': { + name: 'DpopTokenBinding', + description: + 'Issued access token is bound to the DPoP key (cnf.jkt) with token_type DPoP', + specReferences: [ + SpecReferences.RFC_9449_PUBLIC_KEY_CONFIRMATION, + SpecReferences.DPOP_EXTENSION + ] + } +}; + +/** Proof-JWS algorithms the harness can generate a key + proof for. */ +const SUPPORTED_PROOF_ALGS = [ + 'ES256', + 'ES384', + 'ES512', + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'EdDSA' +]; + +/** + * Pick a proof-signing algorithm the harness can produce that the AS also + * advertises (RFC 9449 §5.1), given `dpop_signing_alg_values_supported`. + * + * Returns null (→ the caller SKIPs the binding check) when the value is a + * non-empty array with no algorithm we support, OR any present-but-non-array + * shape — a string, `null`, number, or object are all malformed metadata, not + * "unspecified", so we must not fall back to ES256 (which the AS would reject, + * mis-scoring binding). Only an EMPTY array falls back to ES256 as a best-effort + * to still exercise the binding (an empty list is itself flagged by the metadata + * check). An absent field never reaches here — the scenario's support gate SKIPs + * the whole scenario upstream — but is treated as the empty case for safety. + */ +export function negotiateProofAlg( + advertised: unknown, + supported: readonly string[] = SUPPORTED_PROOF_ALGS +): string | null { + // Any present-but-non-array value (string / null / number / object) is + // malformed — SKIP rather than fall back to ES256. + if (advertised !== undefined && !Array.isArray(advertised)) { + return null; + } + if (Array.isArray(advertised) && advertised.length > 0) { + const match = advertised.find( + (a) => typeof a === 'string' && supported.includes(a) + ); + return typeof match === 'string' ? match : null; + } + return 'ES256'; +} + +/** Strip query + fragment from a URL for use as an `htu` (RFC 9449 §4.2). */ +function stripUrlQuery(url: string): string { + try { + const u = new URL(url); + return `${u.origin}${u.pathname}`; + } catch { + return url; + } +} + +interface CodeResult { + code: string; + codeVerifier: string; +} + +interface TokenExchangeResult { + statusCode: number; + body: Record | undefined; + dpopNonce?: string; +} + +export class DPoPAuthorizationServerScenario implements ClientScenarioForAuthorizationServer { + name = 'dpop'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = `Test DPoP support in the authorization server (SEP-1932 / RFC 9449). + +**Authorization Server Implementation Requirements:** + +**Endpoints**: \`authorization server metadata\`, \`authorization endpoint\`, \`token endpoint\` + +**Requirements** (checked only when the AS advertises DPoP support): +- Metadata MUST advertise \`dpop_signing_alg_values_supported\` (RFC 9449 §5.1) +- \`dpop_signing_alg_values_supported\` MUST list only asymmetric algorithms (no \`none\` or symmetric algorithms) +- A token issued for a request carrying a DPoP proof MUST be bound to the proof key: \`cnf.jkt\` equals the JWK thumbprint and \`token_type\` is \`DPoP\` (RFC 9449 §5–§6) + +An AS that does not advertise \`dpop_signing_alg_values_supported\` is treated as +not supporting DPoP and the scenario SKIPs. Tokens are obtained via the +authorization_code + PKCE grant. The authorization step auto-follows a direct +redirect to the registered redirect_uri, or falls back to an interactive +browser login + callback for login-gated servers.`; + + async run( + options: AuthorizationServerOptions, + _details: Record + ): Promise { + const checks: ConformanceCheck[] = []; + + let metadata: Record; + try { + metadata = await this.fetchMetadata(options.url); + } catch (error) { + checks.push( + this.check('sep-1932-as-metadata-alg-values', 'FAILURE', { + errorMessage: `Could not fetch authorization server metadata: ${this.message(error)}` + }) + ); + for (const id of [ + 'sep-1932-as-no-none-alg', + 'sep-1932-as-token-binding' + ]) { + checks.push( + this.check(id, 'SKIPPED', { + errorMessage: 'Authorization server metadata unavailable' + }) + ); + } + return checks; + } + + // Support gate (RFC 9449 §5.1): an AS signals DPoP support by advertising + // `dpop_signing_alg_values_supported`. If the field is absent the AS is not + // a DPoP server and the DPoP requirements do not apply, so the scenario + // SKIPs rather than failing. (An empty/invalid value IS a claim of support, + // so it falls through and fails the metadata check below.) + if (metadata.dpop_signing_alg_values_supported === undefined) { + const reason = + 'Authorization server does not advertise dpop_signing_alg_values_supported (not a DPoP authorization server)'; + for (const id of [ + 'sep-1932-as-metadata-alg-values', + 'sep-1932-as-no-none-alg', + 'sep-1932-as-token-binding' + ]) { + checks.push(this.check(id, 'SKIPPED', { errorMessage: reason })); + } + return checks; + } + + this.checkMetadataAlgValues(metadata, checks); + await this.checkTokenEndpointBehaviour(metadata, options, checks); + + return checks; + } + + // ----- metadata checks ----- + + private checkMetadataAlgValues( + metadata: Record, + checks: ConformanceCheck[] + ): void { + const algValues = metadata.dpop_signing_alg_values_supported; + const isNonEmptyArray = Array.isArray(algValues) && algValues.length > 0; + + checks.push( + this.check( + 'sep-1932-as-metadata-alg-values', + isNonEmptyArray ? 'SUCCESS' : 'FAILURE', + { + errorMessage: isNonEmptyArray + ? undefined + : 'Metadata is missing a non-empty dpop_signing_alg_values_supported array', + details: { dpop_signing_alg_values_supported: algValues ?? null } + } + ) + ); + + // RFC 9449 §11.6 / the extension: only asymmetric algorithms are + // permitted — the `none` algorithm and symmetric (HMAC, `HS*`) algorithms + // MUST NOT appear in the advertised list. + const list: unknown[] = Array.isArray(algValues) ? algValues : []; + const forbidden = list.filter( + (a) => + typeof a === 'string' && + (a.toLowerCase() === 'none' || a.toUpperCase().startsWith('HS')) + ); + checks.push( + this.check( + 'sep-1932-as-no-none-alg', + forbidden.length > 0 ? 'FAILURE' : 'SUCCESS', + { + errorMessage: + forbidden.length > 0 + ? `dpop_signing_alg_values_supported MUST list only asymmetric algorithms; found non-asymmetric: ${forbidden.join(', ')}` + : undefined, + details: { + dpop_signing_alg_values_supported: algValues ?? null, + ...(forbidden.length > 0 ? { forbidden } : {}) + } + } + ) + ); + } + + // ----- token-endpoint check (DPoP token binding) ----- + + private async checkTokenEndpointBehaviour( + metadata: Record, + options: AuthorizationServerOptions, + checks: ConformanceCheck[] + ): Promise { + if (!options.clientId) { + checks.push( + this.check('sep-1932-as-token-binding', 'SKIPPED', { + errorMessage: 'Requires a client_id (pass --client-id)' + }) + ); + return; + } + if ( + typeof metadata.authorization_endpoint !== 'string' || + typeof metadata.token_endpoint !== 'string' + ) { + checks.push( + this.check('sep-1932-as-token-binding', 'SKIPPED', { + errorMessage: + 'Metadata is missing authorization_endpoint or token_endpoint' + }) + ); + return; + } + + // Negotiate the proof algorithm BEFORE the (possibly interactive) authorize + // step: if we can't produce one the AS advertises, SKIP now rather than + // forcing a pointless interactive login only to fail afterwards. + const alg = this.negotiateProofAlg(metadata); + if (alg === null) { + checks.push( + this.check('sep-1932-as-token-binding', 'SKIPPED', { + errorMessage: + 'Authorization server advertises no DPoP proof algorithm the harness can produce, so token binding cannot be exercised', + details: { + dpop_signing_alg_values_supported: + metadata.dpop_signing_alg_values_supported ?? null + } + }) + ); + return; + } + + // Acquire the authorization code first, in its OWN try/catch, so a failure + // here is reported as "could not obtain a code" and never conflated with a + // token-exchange or binding problem below (which is what a single wrapping + // catch used to do). + let code: string; + let codeVerifier: string; + try { + ({ code, codeVerifier } = await this.obtainAuthorizationCode( + metadata, + options + )); + } catch (error) { + checks.push( + this.check('sep-1932-as-token-binding', 'SKIPPED', { + errorMessage: `Could not obtain an authorization code: ${this.message(error)}` + }) + ); + return; + } + + // Exchange the code WITH a DPoP proof and inspect the binding. + try { + const keyPair = await generateDpopKeyPair(alg); + const result = await this.exchangeWithProof( + metadata, + options, + code, + codeVerifier, + keyPair, + alg + ); + + if (result.statusCode !== 200) { + // Only a DPoP-specific rejection is a binding failure. Any other token + // error (e.g. the AS wanted client auth we didn't send) is inconclusive + // for the binding requirement, so skip rather than mis-attribute a + // FAILURE against a real third-party AS. + const dpopRejection = result.body?.error === 'invalid_dpop_proof'; + checks.push( + this.check( + 'sep-1932-as-token-binding', + dpopRejection ? 'FAILURE' : 'SKIPPED', + { + errorMessage: dpopRejection + ? `Authorization server rejected a valid DPoP proof (HTTP ${result.statusCode}, error=invalid_dpop_proof)` + : `Could not complete the token exchange for a non-DPoP reason (HTTP ${result.statusCode}, error=${result.body?.error ?? 'none'}); binding is inconclusive`, + details: { + statusCode: result.statusCode, + error: result.body?.error ?? null, + alg + } + } + ) + ); + return; + } + + const binding = readTokenBinding(result.body ?? {}); + // A 200 response with no access_token at all is a plainly broken AS, not + // an "inconclusive/opaque" case — fail it rather than fall into the SKIP + // branch below. + const hasAccessToken = + typeof result.body?.access_token === 'string' && + result.body.access_token.length > 0; + if (!hasAccessToken) { + checks.push( + this.check('sep-1932-as-token-binding', 'FAILURE', { + errorMessage: 'Token response was 200 but carried no access_token', + details: { tokenType: binding.tokenType ?? null } + }) + ); + return; + } + // Only inconclusive when the AS CLAIMS a DPoP binding (token_type=DPoP) + // but the token is opaque: cnf.jkt can't be read off the wire (it may + // still hold, verifiable only via introspection) → documented harness gap + // → SKIP. A non-DPoP token_type is a plain binding failure below, opaque + // or not, so it does not reach here. + if (binding.isDpopTokenType && !binding.accessTokenIsJwt) { + checks.push( + this.check('sep-1932-as-token-binding', 'SKIPPED', { + errorMessage: + 'Issued access token is opaque (not a JWT); its cnf.jkt binding cannot be verified off the wire', + details: { tokenType: binding.tokenType ?? null } + }) + ); + return; + } + const bound = + binding.isDpopTokenType && binding.jkt === keyPair.thumbprint; + checks.push( + this.check('sep-1932-as-token-binding', bound ? 'SUCCESS' : 'FAILURE', { + errorMessage: bound + ? undefined + : 'Issued token is not bound to the DPoP key (expected token_type=DPoP and cnf.jkt to match the proof key)', + details: { + tokenType: binding.tokenType ?? null, + cnfJkt: binding.jkt ?? null, + expectedJkt: keyPair.thumbprint + } + }) + ); + } catch (error) { + checks.push( + this.check('sep-1932-as-token-binding', 'SKIPPED', { + errorMessage: `Could not complete the DPoP token exchange: ${this.message(error)}` + }) + ); + } + } + + /** See the module-level {@link negotiateProofAlg}. */ + private negotiateProofAlg(metadata: Record): string | null { + return negotiateProofAlg(metadata.dpop_signing_alg_values_supported); + } + + /** + * Choose a token-endpoint client-authentication method from the AS's + * advertised methods (RFC 8414 §2: an omitted list defaults to + * client_secret_basic). Mirrors the authorization-code-grant scenario's + * selection; unsupported methods (…_jwt / tls_client_auth) yield null. + */ + private selectTokenAuthMethod( + metadata: Record, + options: AuthorizationServerOptions + ): 'none' | 'client_secret_post' | 'client_secret_basic' | null { + const authMethods: string[] = + metadata.token_endpoint_auth_methods_supported ?? ['client_secret_basic']; + if (!options.clientSecret || authMethods.includes('none')) return 'none'; + if (authMethods.includes('client_secret_post')) return 'client_secret_post'; + if (authMethods.includes('client_secret_basic')) { + return 'client_secret_basic'; + } + return null; + } + + /** + * Exchange the code with a DPoP proof, completing the nonce handshake if the + * AS demands one (RFC 9449 §8): a `400 use_dpop_nonce` + `DPoP-Nonce` response + * is retried once with the supplied nonce before the result is judged. + */ + private async exchangeWithProof( + metadata: Record, + options: AuthorizationServerOptions, + code: string, + codeVerifier: string, + keyPair: Awaited>, + alg: string + ): Promise { + // RFC 9449 §4.2: htu carries no query/fragment, but RFC 6749 permits them in + // the token endpoint URL — strip them so we don't build a proof our own (and + // a conformant AS's) validator would reject. + const htu = stripUrlQuery(metadata.token_endpoint); + const first = await this.exchangeCode( + metadata, + options, + code, + codeVerifier, + await buildDpopProof({ keyPair, htm: 'POST', htu, alg }) + ); + if ( + first.statusCode === 400 && + first.body?.error === 'use_dpop_nonce' && + first.dpopNonce + ) { + return this.exchangeCode( + metadata, + options, + code, + codeVerifier, + await buildDpopProof({ + keyPair, + htm: 'POST', + htu, + alg, + nonce: first.dpopNonce + }) + ); + } + return first; + } + + // ----- authorization_code + PKCE helpers ----- + + private async obtainAuthorizationCode( + metadata: Record, + options: AuthorizationServerOptions + ): Promise { + const state = randomBytes(32).toString('base64url'); + const codeVerifier = randomBytes(32).toString('base64url'); + const codeChallenge = createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + const redirectUri = `${REDIRECT_URI_ORIGIN}:${options.port}${REDIRECT_URI_PATH}`; + + const params = new URLSearchParams({ + response_type: 'code', + client_id: options.clientId!, + state, + redirect_uri: redirectUri, + code_challenge: codeChallenge, + code_challenge_method: 'S256' + }); + const authorizeUrl = `${metadata.authorization_endpoint}?${params.toString()}`; + + const responseUrl = await this.resolveAuthorizationResponse( + authorizeUrl, + redirectUri, + options + ); + const code = this.validateAuthorizationResponse( + responseUrl, + metadata, + redirectUri, + state + ); + return { code, codeVerifier }; + } + + /** + * Auto-follow a direct redirect to the registered redirect_uri (headless + * path); otherwise print the URL and wait for an interactive browser callback. + */ + private async resolveAuthorizationResponse( + authorizeUrl: string, + redirectUri: string, + options: AuthorizationServerOptions + ): Promise { + // undici's request() does not follow redirects, so a 3xx is returned as-is + // with its Location header — exactly what we want to inspect. + const res = await request(authorizeUrl, { method: 'GET' }); + const rawLocation = res.headers['location']; + const location = Array.isArray(rawLocation) ? rawLocation[0] : rawLocation; + await res.body.text().catch(() => undefined); // drain the socket + + if ( + res.statusCode >= 300 && + res.statusCode < 400 && + typeof location === 'string' + ) { + // Location may be relative (RFC 9110 §10.2.2 permits a relative-ref); + // resolve it against the request URL before matching the redirect_uri. + const resolved = new URL(location, authorizeUrl).toString(); + if (resolved.startsWith(redirectUri)) { + return resolved; + } + } + + // Interactive fallback for login-gated authorization servers. + const callback = startCallbackServer(options.port); + try { + console.log( + `Ensure ${redirectUri} is registered as a redirect URI for client '${options.clientId}'.` + ); + console.log( + 'Access the following URL in your browser and complete authentication:' + ); + console.log(authorizeUrl); + console.log('Waiting up to 5 minutes for the authorization callback...'); + return await callback.waitForCallback(300_000); + } finally { + callback.close(); + } + } + + private validateAuthorizationResponse( + responseUrl: string, + metadata: Record, + redirectUri: string, + state: string + ): string { + const url = new URL(responseUrl); + + if (url.searchParams.has('error')) { + const error = url.searchParams.get('error'); + const desc = url.searchParams.get('error_description'); + throw new Error(`Authorization error: ${error} ${desc ?? ''}`.trim()); + } + + const expected = new URL(redirectUri); + if (url.origin !== expected.origin || url.pathname !== expected.pathname) { + throw new Error( + `Unexpected redirect target: ${url.origin}${url.pathname}` + ); + } + + const stateParams = url.searchParams.getAll('state'); + if (stateParams.length !== 1 || stateParams[0] !== state) { + throw new Error( + `Invalid state parameter: ${stateParams.join(',') || 'missing'}` + ); + } + + const code = url.searchParams.getAll('code'); + if (code.length !== 1 || code[0] === '') { + throw new Error(`Invalid code parameter: ${code.join(',') || 'missing'}`); + } + + const iss = url.searchParams.getAll('iss'); + if (iss.length > 0 && (iss.length !== 1 || iss[0] !== metadata.issuer)) { + throw new Error(`Invalid iss parameter: ${iss.join(',')}`); + } + + return code[0]; + } + + private async exchangeCode( + metadata: Record, + options: AuthorizationServerOptions, + code: string, + codeVerifier: string, + proof?: string + ): Promise { + const redirectUri = `${REDIRECT_URI_ORIGIN}:${options.port}${REDIRECT_URI_PATH}`; + const params = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + client_id: options.clientId! + }); + const headers: Record = { + 'content-type': 'application/x-www-form-urlencoded' + }; + if (proof) { + headers['dpop'] = proof; + } + // Client authentication per the AS's advertised methods (RFC 8414 default is + // client_secret_basic when the field is omitted). No secret → public client. + const authMethod = this.selectTokenAuthMethod(metadata, options); + if (authMethod === 'client_secret_basic' && options.clientSecret) { + const credentials = `${encodeURIComponent(options.clientId!)}:${encodeURIComponent(options.clientSecret)}`; + headers['authorization'] = + `Basic ${Buffer.from(credentials).toString('base64')}`; + } else if (authMethod === 'client_secret_post' && options.clientSecret) { + params.set('client_secret', options.clientSecret); + } + + const res = await request(metadata.token_endpoint, { + method: 'POST', + headers, + body: params.toString() + }); + + const rawNonce = res.headers['dpop-nonce']; + const dpopNonce = Array.isArray(rawNonce) ? rawNonce[0] : rawNonce; + + let body: Record | undefined; + try { + body = (await res.body.json()) as Record; + } catch { + await res.body.text().catch(() => undefined); + body = undefined; + } + return { statusCode: res.statusCode, body, dpopNonce }; + } + + // ----- metadata discovery ----- + + private async fetchMetadata(serverUrl: string): Promise> { + for (const url of this.createWellKnownUrls(serverUrl)) { + try { + const res = await request(url, { method: 'GET' }); + if (res.statusCode === 200) { + return (await res.body.json()) as Record; + } + await res.body.text().catch(() => undefined); + } catch { + // Try the next candidate URL. + } + } + throw new Error('No authorization server metadata endpoint returned 200'); + } + + private createWellKnownUrls(serverUrl: string): string[] { + const base = new URL(serverUrl); + const origin = base.origin; + const path = base.pathname.replace(/\/$/, ''); + const urls = new Set(); + urls.add(`${origin}/.well-known/oauth-authorization-server${path}`); + urls.add(`${origin}/.well-known/openid-configuration${path}`); + urls.add(`${origin}${path}/.well-known/openid-configuration`); + return Array.from(urls); + } + + // ----- check construction ----- + + private check( + id: string, + status: CheckStatus, + opts: { + errorMessage?: string; + details?: Record; + } = {} + ): ConformanceCheck { + const def = CHECK_DEFS[id]; + return { + id, + name: def.name, + description: def.description, + status, + timestamp: new Date().toISOString(), + specReferences: def.specReferences, + ...(opts.errorMessage ? { errorMessage: opts.errorMessage } : {}), + ...(opts.details ? { details: opts.details } : {}) + }; + } + + private message(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} diff --git a/src/scenarios/client/auth/helpers/dpopToken.test.ts b/src/scenarios/client/auth/helpers/dpopToken.test.ts index 71038093..7758c469 100644 --- a/src/scenarios/client/auth/helpers/dpopToken.test.ts +++ b/src/scenarios/client/auth/helpers/dpopToken.test.ts @@ -6,7 +6,11 @@ import { buildDpopProof, accessTokenHash } from './dpopProof'; -import { generateIssuerKey, mintDpopBoundToken } from './dpopToken'; +import { + generateIssuerKey, + mintDpopBoundToken, + readTokenBinding +} from './dpopToken'; /** Independent ES256 verification via Node WebCrypto (a different path from jose). */ async function verifyEs256Independently( @@ -156,3 +160,83 @@ describe('DPoP token minter — invalid variants', () => { expect((claims.exp as number) < (claims.iat as number)).toBe(true); }); }); + +describe('readTokenBinding — reads the sender-constraint back out', () => { + const base = { issuer: ISSUER, audience: AUDIENCE }; + + it('reads token_type=DPoP and cnf.jkt from a bound token-endpoint response', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + const access_token = await mintDpopBoundToken({ + issuerKey, + ...base, + jkt: kp.thumbprint + }); + + const binding = readTokenBinding({ access_token, token_type: 'DPoP' }); + expect(binding.isDpopTokenType).toBe(true); + expect(binding.tokenType).toBe('DPoP'); + expect(binding.jkt).toBe(kp.thumbprint); + expect(binding.accessTokenIsJwt).toBe(true); + }); + + it('treats token_type as case-insensitive (RFC 6749 §7.1)', () => { + for (const token_type of ['dpop', 'DPoP', 'DPOP']) { + expect(readTokenBinding({ token_type }).isDpopTokenType).toBe(true); + } + expect(readTokenBinding({ token_type: 'Bearer' }).isDpopTokenType).toBe( + false + ); + }); + + it('reports no jkt for an unbound Bearer response (cnf omitted)', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + const access_token = await mintDpopBoundToken({ + issuerKey, + ...base, + jkt: kp.thumbprint, + omitCnf: true + }); + + const binding = readTokenBinding({ access_token, token_type: 'Bearer' }); + expect(binding.isDpopTokenType).toBe(false); + expect(binding.jkt).toBeUndefined(); + }); + + it('never throws on an opaque (non-JWT) access token', () => { + const binding = readTokenBinding({ + access_token: 'test-token-1700000000000', + token_type: 'Bearer' + }); + expect(binding.jkt).toBeUndefined(); + expect(binding.isDpopTokenType).toBe(false); + // Opaque token → not a JWT, so a missing jkt is inconclusive, not a failure. + expect(binding.accessTokenIsJwt).toBe(false); + }); + + it('handles a response missing both fields', () => { + const binding = readTokenBinding({}); + expect(binding.tokenType).toBeUndefined(); + expect(binding.isDpopTokenType).toBe(false); + expect(binding.jkt).toBeUndefined(); + }); + + it('surfaces the foreign thumbprint when the AS binds to the wrong key', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + const foreign = await generateDpopKeyPair(); + const access_token = await mintDpopBoundToken({ + issuerKey, + ...base, + jkt: kp.thumbprint, + jktOverride: foreign.thumbprint + }); + + // The scenario compares this against its own proof-key thumbprint (kp); + // a mismatch is exactly the failure it must catch. + const binding = readTokenBinding({ access_token, token_type: 'DPoP' }); + expect(binding.jkt).toBe(foreign.thumbprint); + expect(binding.jkt).not.toBe(kp.thumbprint); + }); +}); diff --git a/src/scenarios/client/auth/helpers/dpopToken.ts b/src/scenarios/client/auth/helpers/dpopToken.ts index afe5632f..90e20bfb 100644 --- a/src/scenarios/client/auth/helpers/dpopToken.ts +++ b/src/scenarios/client/auth/helpers/dpopToken.ts @@ -108,3 +108,54 @@ export async function mintDpopBoundToken( .setExpirationTime(exp) .sign(options.issuerKey.privateKey); } + +/** The two DPoP sender-constraint signals carried by a token-endpoint response. */ +export interface TokenBinding { + /** Raw `token_type` from the token-endpoint response, or undefined if absent. */ + tokenType?: string; + /** True when `token_type` is `DPoP` (RFC 6749 §7.1 makes token_type case-insensitive). */ + isDpopTokenType: boolean; + /** `cnf.jkt` bound into the access token (RFC 9449 §6), or undefined if the + * token is opaque / not a JWT / carries no confirmation. */ + jkt?: string; + /** True when the access token parsed as a JWT. When false the token is opaque, + * so `cnf.jkt` cannot be inspected off the wire (the binding may still hold, + * verifiable only via introspection) — a caller must not read a missing `jkt` + * as a binding failure in that case. */ + accessTokenIsJwt: boolean; +} + +/** + * Read the DPoP binding back out of an OAuth token-endpoint response, from the + * perspective of an inspector (the #370 AS scenario). Combines the two signals + * RFC 9449 §5 requires an AS to emit when it issues a bound token: + * 1. `token_type: "DPoP"` in the JSON response, and + * 2. `cnf.jkt` inside the access token. + * Never throws — an opaque (non-JWT) access token yields `jkt: undefined` so a + * caller can distinguish "bound" from "unbound/bearer" without special-casing. + */ +export function readTokenBinding(response: { + access_token?: unknown; + token_type?: unknown; +}): TokenBinding { + const tokenType = + typeof response.token_type === 'string' ? response.token_type : undefined; + const isDpopTokenType = tokenType?.toLowerCase() === 'dpop'; + + let jkt: string | undefined; + let accessTokenIsJwt = false; + if (typeof response.access_token === 'string') { + try { + const claims = jose.decodeJwt(response.access_token); + accessTokenIsJwt = true; + const cnf = claims.cnf as { jkt?: unknown } | undefined; + if (cnf && typeof cnf.jkt === 'string') { + jkt = cnf.jkt; + } + } catch { + // Opaque / non-JWT access token → no readable binding. + } + } + + return { tokenType, isDpopTokenType, jkt, accessTokenIsJwt }; +} diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index db1584ea..fd424d6d 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -111,6 +111,7 @@ import { import { listMetadataScenarios } from './client/auth/discovery-metadata'; import { AuthorizationServerMetadataEndpointScenario } from './authorization-server/authorization-server-metadata'; import { AuthorizationCodeGrantScenario } from './authorization-server/authorization-code-grant'; +import { DPoPAuthorizationServerScenario } from './authorization-server/dpop'; import { HttpStandardHeadersScenario } from './client/http-standard-headers'; import { @@ -276,7 +277,8 @@ const allClientScenariosListForAuthorizationServer: ClientScenarioForAuthorizati [ // Authorization server scenarios new AuthorizationServerMetadataEndpointScenario(), - new AuthorizationCodeGrantScenario() + new AuthorizationCodeGrantScenario(), + new DPoPAuthorizationServerScenario() ]; // Client scenarios map for authorization server - built from list diff --git a/src/seps/sep-1932.yaml b/src/seps/sep-1932.yaml index c3c650f3..8a287eb5 100644 --- a/src/seps/sep-1932.yaml +++ b/src/seps/sep-1932.yaml @@ -21,8 +21,6 @@ requirements: text: 'Authorization servers supporting DPoP MUST include the `dpop_signing_alg_values_supported` field in their Authorization Server Metadata as defined in RFC 9449 Section 5.1. This field MUST contain a JSON array listing the JWS algorithm values supported for DPoP proof JWTs' - check: sep-1932-as-no-none-alg text: 'Only asymmetric signature algorithms are permitted; the `none` algorithm MUST NOT be included' - - check: sep-1932-as-dpop-bound-enforcement - text: 'When `dpop_bound_access_tokens` is set to `true`, the authorization server MUST reject token requests from the client that do not include a valid DPoP proof' - check: sep-1932-as-token-binding text: "When issuing a DPoP-bound access token, the authorization server MUST bind it to the client's DPoP public key by including a `cnf` claim carrying the JWK SHA-256 thumbprint (`jkt`) of that key (RFC 9449 Section 6) and MUST set the token response `token_type` to `DPoP` (RFC 9449 Section 5)" - check: sep-1932-asymmetric-alg-only @@ -32,6 +30,8 @@ requirements: - check: sep-1932-server-audience-validation text: 'MCP servers MUST continue to validate that access tokens were specifically issued for them, even when DPoP is used' + - text: 'When `dpop_bound_access_tokens` is set to `true`, the authorization server MUST reject token requests from the client that do not include a valid DPoP proof' + excluded: 'Enforcement is gated on the `dpop_bound_access_tokens` client-registration metadata (RFC 9449 §5.2), a per-client policy — not exercisable without dynamic client registration, which is out of scope for these DPoP scenarios.' - text: 'Implementations MUST conform to all requirements specified in this extension' excluded: 'Umbrella requirement satisfied by the specific checks above; not separately observable on the wire.' - text: 'Implementations MUST also conform to the baseline authorization requirements'