From 8d8a4301856fd2e03e88dc80c6e62da624ecc4b1 Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:07:12 +0100 Subject: [PATCH 1/8] Add DPoP authorization-server scenario (SEP-1932, #370) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the DPoP client PR (shared foundation: createAuthServer DPoP core + dpopProof/dpopToken helpers). Adds an authorization-server scenario testing DPoP (SEP-1932 / RFC 9449): metadata (dpop_signing_alg_values_supported present, asymmetric-only), token binding (cnf.jkt + token_type=DPoP), and no-proof enforcement when dpop_bound_access_tokens is advertised. Probes a live AS via authorization_code + PKCE (auto-follows a direct redirect, falls back to an interactive callback) and returns four sep-1932-as-* checks (compliant run + four one-defect-isolation misbehaving configs). - authorization-server/dpop.ts (+ acceptance test, spec-references). - dpopToken: adds readTokenBinding() (reads token_type + cnf.jkt back out of a token response) — introduced here because this scenario is its only consumer. Depends only on the shared DPoP foundation; independent of the server PR. Co-Authored-By: Claude Opus 4.8 --- .../auth/spec-references.ts | 21 + .../authorization-server/dpop.test.ts | 144 ++++ src/scenarios/authorization-server/dpop.ts | 712 ++++++++++++++++++ .../client/auth/helpers/dpopToken.test.ts | 86 ++- .../client/auth/helpers/dpopToken.ts | 51 ++ src/scenarios/index.ts | 4 +- src/seps/sep-1932.yaml | 4 +- 7 files changed, 1018 insertions(+), 4 deletions(-) create mode 100644 src/scenarios/authorization-server/dpop.test.ts create mode 100644 src/scenarios/authorization-server/dpop.ts 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..10706b2d --- /dev/null +++ b/src/scenarios/authorization-server/dpop.test.ts @@ -0,0 +1,144 @@ +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 } 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); + }); +}); diff --git a/src/scenarios/authorization-server/dpop.ts b/src/scenarios/authorization-server/dpop.ts new file mode 100644 index 00000000..171a3c92 --- /dev/null +++ b/src/scenarios/authorization-server/dpop.ts @@ -0,0 +1,712 @@ +/** + * 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' +]; + +/** 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)}` + }) + ); + } + } + + /** + * Pick a proof-signing algorithm the harness can produce that the AS also + * advertises (RFC 9449 §5.1). Returns null when the AS advertises a non-empty + * list with no algorithm we support (including a non-empty but malformed list) + * — the caller then SKIPs rather than sending an unadvertised alg (e.g. + * defaulting to ES256) that the AS would legitimately reject and we'd mis-score + * as a binding failure. Only an absent or empty list (itself flagged by the + * metadata check) falls back to ES256 as a best effort to still exercise the + * binding. + */ + private negotiateProofAlg(metadata: Record): string | null { + const advertised = metadata.dpop_signing_alg_values_supported; + if (Array.isArray(advertised) && advertised.length > 0) { + const match = advertised.find( + (a) => typeof a === 'string' && SUPPORTED_PROOF_ALGS.includes(a) + ); + return typeof match === 'string' ? match : null; + } + return 'ES256'; + } + + /** + * 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 03cf55f5..3b5c955b 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -112,6 +112,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 { @@ -279,7 +280,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' From 83760fba3b6d1ef0cd3e8f175e50cc4ffe59f461 Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:52:58 +0100 Subject: [PATCH 2/8] authorization-server/dpop: treat non-array alg-values as SKIP negotiateProofAlg fell back to ES256 for a present-but-non-array dpop_signing_alg_values_supported (e.g. the string "RS256"), contradicting its docstring and risking a token-binding mis-score for that malformed shape. Treat a present-but-non-array value as null (SKIP), like a non-empty list with no supported alg; only an absent/empty list still falls back to ES256. Defensive against malformed metadata; not independently exercised by a fixture (would need a malformed-metadata AS option), consistent with the htu-strip defensive fixes. Co-Authored-By: Claude Opus 4.8 --- src/scenarios/authorization-server/dpop.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/scenarios/authorization-server/dpop.ts b/src/scenarios/authorization-server/dpop.ts index 171a3c92..c59b0b1e 100644 --- a/src/scenarios/authorization-server/dpop.ts +++ b/src/scenarios/authorization-server/dpop.ts @@ -396,8 +396,8 @@ browser login + callback for login-gated servers.`; /** * Pick a proof-signing algorithm the harness can produce that the AS also * advertises (RFC 9449 §5.1). Returns null when the AS advertises a non-empty - * list with no algorithm we support (including a non-empty but malformed list) - * — the caller then SKIPs rather than sending an unadvertised alg (e.g. + * list with no algorithm we support, OR a present-but-non-array (malformed) + * value — the caller then SKIPs rather than sending an unadvertised alg (e.g. * defaulting to ES256) that the AS would legitimately reject and we'd mis-score * as a binding failure. Only an absent or empty list (itself flagged by the * metadata check) falls back to ES256 as a best effort to still exercise the @@ -405,6 +405,15 @@ browser login + callback for login-gated servers.`; */ private negotiateProofAlg(metadata: Record): string | null { const advertised = metadata.dpop_signing_alg_values_supported; + // A present-but-non-array value (e.g. the string "RS256") is malformed + // metadata, not "unspecified" — SKIP rather than fall back to ES256. + if ( + advertised !== undefined && + advertised !== null && + !Array.isArray(advertised) + ) { + return null; + } if (Array.isArray(advertised) && advertised.length > 0) { const match = advertised.find( (a) => typeof a === 'string' && SUPPORTED_PROOF_ALGS.includes(a) From d01b28604a08044885de0e6f350c3ce236ab588b Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:01:40 +0100 Subject: [PATCH 3/8] authorization-server/dpop: fix null alg-values hole; extract + test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-4 non-array guard carved out `null` (advertised !== null), so metadata with "dpop_signing_alg_values_supported": null passed the support gate (which only tests === undefined), skipped the guard, and fell through to the ES256 fallback — the exact binding mis-score the fix targeted. Extract the negotiation to an exported pure function negotiateProofAlg(advertised) and treat ANY present-but-non-array shape (string, null, number, object) as malformed → null (SKIP). Only an empty array still falls back to ES256. Correct the docstring (an absent field never reaches here — the support gate SKIPs upstream). Add unit tests for every shape (array / empty / no-overlap / string / null / number / object), pinning the fix against a silent refactor regression. Co-Authored-By: Claude Opus 4.8 --- .../authorization-server/dpop.test.ts | 26 +++++++- src/scenarios/authorization-server/dpop.ts | 60 ++++++++++--------- 2 files changed, 58 insertions(+), 28 deletions(-) diff --git a/src/scenarios/authorization-server/dpop.test.ts b/src/scenarios/authorization-server/dpop.test.ts index 10706b2d..7d9c8064 100644 --- a/src/scenarios/authorization-server/dpop.test.ts +++ b/src/scenarios/authorization-server/dpop.test.ts @@ -6,7 +6,7 @@ import { import { ServerLifecycle } from '../client/auth/helpers/serverLifecycle'; import { testScenarioContext } from '../../mock-server/testing'; import type { CheckStatus, ConformanceCheck } from '../../types'; -import { DPoPAuthorizationServerScenario } from './dpop'; +import { DPoPAuthorizationServerScenario, negotiateProofAlg } from './dpop'; const ALL_IDS = [ 'sep-1932-as-metadata-alg-values', @@ -142,3 +142,27 @@ describe('DPoPAuthorizationServerScenario — skip conditions', () => { 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', () => { + expect(negotiateProofAlg([])).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 index c59b0b1e..e8aa702e 100644 --- a/src/scenarios/authorization-server/dpop.ts +++ b/src/scenarios/authorization-server/dpop.ts @@ -94,6 +94,37 @@ const SUPPORTED_PROOF_ALGS = [ '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 { @@ -393,34 +424,9 @@ browser login + callback for login-gated servers.`; } } - /** - * Pick a proof-signing algorithm the harness can produce that the AS also - * advertises (RFC 9449 §5.1). Returns null when the AS advertises a non-empty - * list with no algorithm we support, OR a present-but-non-array (malformed) - * value — the caller then SKIPs rather than sending an unadvertised alg (e.g. - * defaulting to ES256) that the AS would legitimately reject and we'd mis-score - * as a binding failure. Only an absent or empty list (itself flagged by the - * metadata check) falls back to ES256 as a best effort to still exercise the - * binding. - */ + /** See the module-level {@link negotiateProofAlg}. */ private negotiateProofAlg(metadata: Record): string | null { - const advertised = metadata.dpop_signing_alg_values_supported; - // A present-but-non-array value (e.g. the string "RS256") is malformed - // metadata, not "unspecified" — SKIP rather than fall back to ES256. - if ( - advertised !== undefined && - advertised !== null && - !Array.isArray(advertised) - ) { - return null; - } - if (Array.isArray(advertised) && advertised.length > 0) { - const match = advertised.find( - (a) => typeof a === 'string' && SUPPORTED_PROOF_ALGS.includes(a) - ); - return typeof match === 'string' ? match : null; - } - return 'ES256'; + return negotiateProofAlg(metadata.dpop_signing_alg_values_supported); } /** From bbffd4e30548fd3d4737fc8f466ad9612799d9ca Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:32:03 +0100 Subject: [PATCH 4/8] authorization-server/dpop: round-6 test polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broaden the negotiateProofAlg fallback test to also assert undefined → ES256 and correct its title ("empty array or absent field") — the contract covers both, though absent is gated upstream in the scenario. Co-Authored-By: Claude Opus 4.8 --- src/scenarios/authorization-server/dpop.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/scenarios/authorization-server/dpop.test.ts b/src/scenarios/authorization-server/dpop.test.ts index 7d9c8064..6ba2091e 100644 --- a/src/scenarios/authorization-server/dpop.test.ts +++ b/src/scenarios/authorization-server/dpop.test.ts @@ -153,8 +153,11 @@ describe('negotiateProofAlg (dpop_signing_alg_values_supported shapes)', () => { expect(negotiateProofAlg(['ES256K'])).toBeNull(); }); - it('falls back to ES256 only for an empty array', () => { + 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)', () => { From 19ea7edc6d16869fefb3aef6064f609929b6f6a8 Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:21:37 +0100 Subject: [PATCH 5/8] authorization-server/dpop: note token-binding provenance in sep-1932.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a one-line comment above the sep-1932-as-token-binding requirement noting that its binding mechanics are defined in RFC 9449 (§6 cnf/jkt thumbprint, §5 token_type: DPoP) — which the SEP builds on rather than restating — so a reader can see where the requirement text derives from. Addresses review feedback on #396; the check id and text are unchanged. Co-Authored-By: Claude Opus 4.8 --- src/seps/sep-1932.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/seps/sep-1932.yaml b/src/seps/sep-1932.yaml index 8a287eb5..676163ca 100644 --- a/src/seps/sep-1932.yaml +++ b/src/seps/sep-1932.yaml @@ -21,6 +21,7 @@ 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' + # Binding mechanics are defined in RFC 9449 (§6 cnf/jkt thumbprint, §5 token_type: DPoP), which the SEP builds on rather than restating. - 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 From a384b56affc571b4e10c42564a2b4b236cbb74b4 Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:29:39 +0100 Subject: [PATCH 6/8] authorization-server/dpop: forward the RFC 8707 resource parameter The DPoP AS scenario drives its own authorization_code + PKCE flow but did not forward the `resource` parameter, unlike authorization-code-grant.ts after #466. Send it on both the authorization request and the token request when supplied (guarded by options.resource, so it's a no-op otherwise). Keeps the two AS scenarios consistent and lets the DPoP binding checks be evaluated cleanly against a resource-enforcing AS. Addresses review feedback on #396. Co-Authored-By: Claude Opus 4.8 --- src/scenarios/authorization-server/dpop.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/scenarios/authorization-server/dpop.ts b/src/scenarios/authorization-server/dpop.ts index e8aa702e..a7a10894 100644 --- a/src/scenarios/authorization-server/dpop.ts +++ b/src/scenarios/authorization-server/dpop.ts @@ -516,6 +516,11 @@ browser login + callback for login-gated servers.`; code_challenge: codeChallenge, code_challenge_method: 'S256' }); + // RFC 8707: forward the resource parameter when supplied, aligning with the + // authorization-code-grant scenario (#466) so a resource-aware AS is exercised. + if (options.resource) { + params.set('resource', options.resource); + } const authorizeUrl = `${metadata.authorization_endpoint}?${params.toString()}`; const responseUrl = await this.resolveAuthorizationResponse( @@ -634,6 +639,10 @@ browser login + callback for login-gated servers.`; code_verifier: codeVerifier, client_id: options.clientId! }); + // RFC 8707: forward the resource parameter when supplied (aligns with #466). + if (options.resource) { + params.set('resource', options.resource); + } const headers: Record = { 'content-type': 'application/x-www-form-urlencoded' }; From e1c4fb9868d88a868a8aff9b7036b3fd796bcc89 Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:06:04 +0100 Subject: [PATCH 7/8] authorization-server/dpop: record refresh-token binding as excluded Public-client refresh-token binding (issue #370) is not exercisable today: the shared conformance test AS (createAuthServer) doesn't issue refresh tokens or handle the refresh_token grant, so a conformant-vs-misbehaving pair can't be built to validate the check under the suite's "prove it passes and fails" rule. Record it as an excluded: row in sep-1932.yaml with this rationale; deferred as a follow-up until the test AS gains refresh support. Addresses review feedback on #396. Co-Authored-By: Claude Opus 4.8 --- src/seps/sep-1932.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/seps/sep-1932.yaml b/src/seps/sep-1932.yaml index 676163ca..46271843 100644 --- a/src/seps/sep-1932.yaml +++ b/src/seps/sep-1932.yaml @@ -33,6 +33,8 @@ requirements: - 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: 'For a public client, an issued refresh token is bound to the same DPoP key (RFC 9449 §5)' + excluded: 'Not exercisable today: the shared conformance test authorization server does not issue refresh tokens or handle the refresh_token grant, so a conformant-vs-misbehaving pair cannot be built to validate the check. Deferred until the test AS gains refresh support.' - 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' From ff169cc4808e1c9f2412ab0edd318e87f3692db1 Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Thu, 24 Sep 2026 17:05:51 -0700 Subject: [PATCH 8/8] authorization-server/dpop: probe invalid proofs and nonce handling An authorization server that binds a token without checking the proof, or that mishandles a DPoP nonce, now fails. Negative probes run on headless redirects and stay not-testable on a login-gated server unless opted in. Co-authored-by: Cursor --- src/index.ts | 9 + .../auth/spec-references.ts | 12 + .../authorization-server/dpop.test.ts | 267 +++++++- src/scenarios/authorization-server/dpop.ts | 619 +++++++++++++++--- .../client/auth/helpers/createAuthServer.ts | 65 +- src/schemas.ts | 8 +- src/seps/sep-1932.yaml | 13 + 7 files changed, 901 insertions(+), 92 deletions(-) diff --git a/src/index.ts b/src/index.ts index b376644e..12dbc804 100644 --- a/src/index.ts +++ b/src/index.ts @@ -777,6 +777,10 @@ program (value) => Number(value), 3000 ) + .option( + '--dpop-negative-probes', + 'Spend extra authorization codes on DPoP negative probes (invalid proofs and a wrong nonce). Headless authorization servers run these automatically; login-gated servers require this flag or MCP_CONFORMANCE_DPOP_NEGATIVE_PROBES=1' + ) .option('-o, --output-dir ', 'Save results to this directory') .option( '--spec-version ', @@ -814,6 +818,11 @@ program console.error('error: must provide --url or --file'); process.exit(1); } + // Absent boolean flags must not clobber a settings-file true. Commander + // may surface an omitted flag as false. + if (options.dpopNegativeProbes !== true) { + delete options.dpopNegativeProbes; + } // CLI flags override file values; undefined CLI values must not clobber file values const merged = { ...fileOptions, diff --git a/src/scenarios/authorization-server/auth/spec-references.ts b/src/scenarios/authorization-server/auth/spec-references.ts index 38a1beec..c10fd492 100644 --- a/src/scenarios/authorization-server/auth/spec-references.ts +++ b/src/scenarios/authorization-server/auth/spec-references.ts @@ -22,6 +22,18 @@ export const SpecReferences: { [key: string]: SpecReference } = { id: 'RFC-9449-authorization-server-metadata', url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-5.1' }, + RFC_9449_PROOF_CHECKS: { + id: 'RFC-9449-checking-dpop-proofs', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-4.3' + }, + RFC_9449_TOKEN_REQUEST: { + id: 'RFC-9449-dpop-access-token-request', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-5' + }, + RFC_9449_AS_NONCE: { + id: 'RFC-9449-authorization-server-provided-nonce', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-8' + }, RFC_9449_PUBLIC_KEY_CONFIRMATION: { id: 'RFC-9449-public-key-confirmation', url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-6' diff --git a/src/scenarios/authorization-server/dpop.test.ts b/src/scenarios/authorization-server/dpop.test.ts index 6ba2091e..64799a1f 100644 --- a/src/scenarios/authorization-server/dpop.test.ts +++ b/src/scenarios/authorization-server/dpop.test.ts @@ -1,12 +1,29 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach } 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 { AuthorizationServerOptions } from '../../schemas'; import type { CheckStatus, ConformanceCheck } from '../../types'; -import { DPoPAuthorizationServerScenario, negotiateProofAlg } from './dpop'; +import { + DPOP_NEGATIVE_PROBES_ENV, + DPoPAuthorizationServerScenario, + dpopNegativeProbesRequested, + judgeInvalidDpopProofResponse, + judgeWrongNonceRejection, + negotiateProofAlg +} from './dpop'; + +/** Pretend the authorize step was an interactive login, even against the fixture AS. */ +class LoginGatedDpopScenario extends DPoPAuthorizationServerScenario { + protected negativeProbesAllowed( + options: AuthorizationServerOptions + ): boolean { + return dpopNegativeProbesRequested(options); + } +} const ALL_IDS = [ 'sep-1932-as-metadata-alg-values', @@ -19,6 +36,9 @@ const statusOf = ( id: string ): CheckStatus | undefined => checks.find((c) => c.id === id)?.status; +const named = (checks: ConformanceCheck[], name: string) => + checks.find((c) => c.name === name); + /** * 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. @@ -28,7 +48,9 @@ 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' + clientId: string | false = 'test-client-id', + scenario: DPoPAuthorizationServerScenario = new DPoPAuthorizationServerScenario(), + scenarioOptions: { dpopNegativeProbes?: boolean } = {} ): Promise { const lifecycle = new ServerLifecycle(); const app = createAuthServer(testScenarioContext(), [], lifecycle.getUrl, { @@ -38,8 +60,13 @@ async function runAgainst( }); await lifecycle.start(app); try { - return await new DPoPAuthorizationServerScenario().run( - { url: lifecycle.getUrl(), port: 45678, clientId: clientId || undefined }, + return await scenario.run( + { + url: lifecycle.getUrl(), + port: 45678, + clientId: clientId || undefined, + ...scenarioOptions + }, {} ); } finally { @@ -55,11 +82,32 @@ const COMPLIANT: Partial = { }; describe('DPoPAuthorizationServerScenario — compliant AS', () => { - it('emits all three sep-1932-as-* checks as SUCCESS', async () => { + it('emits the metadata, binding, and invalid-proof checks as SUCCESS', async () => { const checks = await runAgainst(COMPLIANT); for (const id of ALL_IDS) { expect(statusOf(checks, id)).toBe('SUCCESS'); } + expect(named(checks, 'RejectsTamperedSignature')?.status).toBe('SUCCESS'); + expect(named(checks, 'RejectsWrongHtu')?.status).toBe('SUCCESS'); + // Nonce is optional (RFC 9449 §8 MAY). A server that does not challenge + // does not emit sep-1932-as-nonce. + expect(checks.filter((c) => c.id === 'sep-1932-as-nonce')).toHaveLength(0); + expect(checks.filter((c) => c.status === 'FAILURE')).toHaveLength(0); + }); + + it('records a nonce challenge, a successful retry, and a rejected wrong nonce', async () => { + const checks = await runAgainst({ + ...COMPLIANT, + dpopRequireNonce: true + }); + for (const id of ALL_IDS) { + expect(statusOf(checks, id)).toBe('SUCCESS'); + } + expect(named(checks, 'RejectsTamperedSignature')?.status).toBe('SUCCESS'); + expect(named(checks, 'RejectsWrongHtu')?.status).toBe('SUCCESS'); + expect(named(checks, 'NonceChallengeHeader')?.status).toBe('SUCCESS'); + expect(named(checks, 'NonceRetryAccepted')?.status).toBe('SUCCESS'); + expect(named(checks, 'NonceRejectsWrongValue')?.status).toBe('SUCCESS'); expect(checks.filter((c) => c.status === 'FAILURE')).toHaveLength(0); }); @@ -91,7 +139,7 @@ describe('DPoPAuthorizationServerScenario — one-defect isolation', () => { ] as const; for (const { misbehavior, target } of CASES) { - it(`misbehaving AS (${misbehavior}) fails only ${target}`, async () => { + it(`misbehaving AS (${misbehavior}) fails ${target} and leaves the other original checks SUCCESS`, async () => { const checks = await runAgainst({ ...COMPLIANT, dpopMisbehavior: misbehavior @@ -118,6 +166,9 @@ describe('DPoPAuthorizationServerScenario — skip conditions', () => { 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'); + expect(statusOf(checks, 'sep-1932-as-rejects-invalid-proof')).toBe( + 'SKIPPED' + ); }); it('skips token binding when no advertised proof alg is supported (no ES256 fallback)', async () => { @@ -130,6 +181,9 @@ describe('DPoPAuthorizationServerScenario — skip conditions', () => { 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'); + expect(statusOf(checks, 'sep-1932-as-rejects-invalid-proof')).toBe( + 'SKIPPED' + ); }); it('skips the whole scenario when the AS does not advertise DPoP support', async () => { @@ -139,10 +193,209 @@ describe('DPoPAuthorizationServerScenario — skip conditions', () => { for (const id of ALL_IDS) { expect(statusOf(checks, id)).toBe('SKIPPED'); } + expect(statusOf(checks, 'sep-1932-as-rejects-invalid-proof')).toBe( + 'SKIPPED' + ); + expect(checks.filter((c) => c.id === 'sep-1932-as-nonce')).toHaveLength(0); expect(checks.filter((c) => c.status === 'FAILURE')).toHaveLength(0); }); }); +describe('DPoPAuthorizationServerScenario — negative probes', () => { + it('accept-any-proof fails both invalid-proof probes and leaves binding SUCCESS', async () => { + const checks = await runAgainst({ + ...COMPLIANT, + dpopMisbehavior: 'accept-any-proof' + }); + expect(statusOf(checks, 'sep-1932-as-token-binding')).toBe('SUCCESS'); + expect(statusOf(checks, 'sep-1932-as-metadata-alg-values')).toBe('SUCCESS'); + for (const name of ['RejectsTamperedSignature', 'RejectsWrongHtu']) { + const check = named(checks, name); + expect(check?.status).toBe('FAILURE'); + expect(check?.details).not.toMatchObject({ untestable: true }); + expect(check?.errorMessage).toContain('issued an access token'); + } + }); + + it('does not run invalid-proof probes when binding did not succeed', async () => { + const checks = await runAgainst({ + ...COMPLIANT, + dpopMisbehavior: 'unbound-token' + }); + expect(statusOf(checks, 'sep-1932-as-token-binding')).toBe('FAILURE'); + for (const name of ['RejectsTamperedSignature', 'RejectsWrongHtu']) { + const check = named(checks, name); + expect(check?.status).toBe('FAILURE'); + expect(check?.details).toMatchObject({ untestable: true }); + expect(check?.errorMessage).toContain('Not testable:'); + expect(check?.errorMessage).toContain('rejects everything'); + } + }); + + it('nonce-without-header fails the header check and does not skip it onto binding', async () => { + const checks = await runAgainst({ + ...COMPLIANT, + dpopMisbehavior: 'nonce-without-header' + }); + const header = named(checks, 'NonceChallengeHeader'); + expect(header?.id).toBe('sep-1932-as-nonce'); + expect(header?.status).toBe('FAILURE'); + expect(header?.errorMessage).toContain('without a DPoP-Nonce header'); + // No nonce was supplied, so there is no retry and no wrong-nonce probe. + expect(named(checks, 'NonceRetryAccepted')).toBeUndefined(); + expect(named(checks, 'NonceRejectsWrongValue')).toBeUndefined(); + // Binding stays the existing inconclusive result: use_dpop_nonce is not + // invalid_dpop_proof. The header check is the failure. + expect(statusOf(checks, 'sep-1932-as-token-binding')).toBe('SKIPPED'); + expect( + checks.find((c) => c.id === 'sep-1932-as-token-binding')?.errorMessage + ).toContain('use_dpop_nonce'); + for (const name of ['RejectsTamperedSignature', 'RejectsWrongHtu']) { + expect(named(checks, name)?.details).toMatchObject({ untestable: true }); + } + }); + + it('nonce-accept-any accepts a wrong nonce and still rejects invalid proofs', async () => { + const checks = await runAgainst({ + ...COMPLIANT, + dpopMisbehavior: 'nonce-accept-any' + }); + expect(statusOf(checks, 'sep-1932-as-token-binding')).toBe('SUCCESS'); + expect(named(checks, 'NonceChallengeHeader')?.status).toBe('SUCCESS'); + expect(named(checks, 'NonceRetryAccepted')?.status).toBe('SUCCESS'); + expect(named(checks, 'NonceRejectsWrongValue')?.status).toBe('FAILURE'); + expect(named(checks, 'NonceRejectsWrongValue')?.errorMessage).toContain( + 'wrong nonce' + ); + expect(named(checks, 'RejectsTamperedSignature')?.status).toBe('SUCCESS'); + expect(named(checks, 'RejectsWrongHtu')?.status).toBe('SUCCESS'); + }); + + it('withholds negative probes on a login-gated AS unless opted in', async () => { + const previous = process.env[DPOP_NEGATIVE_PROBES_ENV]; + delete process.env[DPOP_NEGATIVE_PROBES_ENV]; + try { + const checks = await runAgainst( + COMPLIANT, + 'test-client-id', + new LoginGatedDpopScenario() + ); + expect(statusOf(checks, 'sep-1932-as-token-binding')).toBe('SUCCESS'); + for (const name of ['RejectsTamperedSignature', 'RejectsWrongHtu']) { + const check = named(checks, name); + expect(check?.status).toBe('FAILURE'); + expect(check?.details).toMatchObject({ untestable: true }); + expect(check?.errorMessage).toContain('--dpop-negative-probes'); + expect(check?.errorMessage).toContain(DPOP_NEGATIVE_PROBES_ENV); + } + } finally { + if (previous === undefined) delete process.env[DPOP_NEGATIVE_PROBES_ENV]; + else process.env[DPOP_NEGATIVE_PROBES_ENV] = previous; + } + }); + + it('runs negative probes on a login-gated AS when the flag or env opts in', async () => { + const previous = process.env[DPOP_NEGATIVE_PROBES_ENV]; + delete process.env[DPOP_NEGATIVE_PROBES_ENV]; + try { + const flagged = await runAgainst( + COMPLIANT, + 'test-client-id', + new LoginGatedDpopScenario(), + { dpopNegativeProbes: true } + ); + expect(named(flagged, 'RejectsTamperedSignature')?.status).toBe( + 'SUCCESS' + ); + expect(named(flagged, 'RejectsWrongHtu')?.status).toBe('SUCCESS'); + + process.env[DPOP_NEGATIVE_PROBES_ENV] = '1'; + const fromEnv = await runAgainst( + COMPLIANT, + 'test-client-id', + new LoginGatedDpopScenario() + ); + expect(named(fromEnv, 'RejectsTamperedSignature')?.status).toBe( + 'SUCCESS' + ); + } finally { + if (previous === undefined) delete process.env[DPOP_NEGATIVE_PROBES_ENV]; + else process.env[DPOP_NEGATIVE_PROBES_ENV] = previous; + } + }); +}); + +describe('invalid-proof and wrong-nonce grading', () => { + it('grades invalid proofs by the RFC 9449 §5 response', () => { + expect( + judgeInvalidDpopProofResponse( + { statusCode: 400, body: { error: 'invalid_dpop_proof' } }, + 'tampered-signature' + ).status + ).toBe('SUCCESS'); + expect( + judgeInvalidDpopProofResponse( + { statusCode: 200, body: { access_token: 'tok' } }, + 'tampered-signature' + ).status + ).toBe('FAILURE'); + expect( + judgeInvalidDpopProofResponse( + { statusCode: 400, body: { error: 'invalid_request' } }, + 'wrong-htu' + ).status + ).toBe('WARNING'); + expect( + judgeInvalidDpopProofResponse( + { statusCode: 401, body: { error: 'invalid_dpop_proof' } }, + 'wrong-htu' + ).status + ).toBe('SKIPPED'); + expect( + judgeInvalidDpopProofResponse({ statusCode: 500 }, 'wrong-htu').status + ).toBe('SKIPPED'); + }); + + it('treats any token-less 4xx as a wrong-nonce rejection', () => { + expect( + judgeWrongNonceRejection({ + statusCode: 400, + body: { error: 'use_dpop_nonce' } + }).status + ).toBe('SUCCESS'); + expect( + judgeWrongNonceRejection({ + statusCode: 400, + body: { error: 'invalid_dpop_proof' } + }).status + ).toBe('SUCCESS'); + expect( + judgeWrongNonceRejection({ + statusCode: 200, + body: { access_token: 'tok' } + }).status + ).toBe('FAILURE'); + expect(judgeWrongNonceRejection({ statusCode: 500 }).status).toBe( + 'SKIPPED' + ); + }); + + afterEach(() => { + delete process.env[DPOP_NEGATIVE_PROBES_ENV]; + }); + + it('reads the negative-probe opt-in from the flag or the env var', () => { + expect(dpopNegativeProbesRequested({})).toBe(false); + expect(dpopNegativeProbesRequested({ dpopNegativeProbes: true })).toBe( + true + ); + process.env[DPOP_NEGATIVE_PROBES_ENV] = 'true'; + expect(dpopNegativeProbesRequested({})).toBe(true); + process.env[DPOP_NEGATIVE_PROBES_ENV] = '0'; + expect(dpopNegativeProbesRequested({})).toBe(false); + }); +}); + describe('negotiateProofAlg (dpop_signing_alg_values_supported shapes)', () => { it('picks the first supported alg from a non-empty array', () => { expect(negotiateProofAlg(['ES256'])).toBe('ES256'); diff --git a/src/scenarios/authorization-server/dpop.ts b/src/scenarios/authorization-server/dpop.ts index a7a10894..1aa7de62 100644 --- a/src/scenarios/authorization-server/dpop.ts +++ b/src/scenarios/authorization-server/dpop.ts @@ -7,7 +7,13 @@ * - 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). + * the proof key (`cnf.jkt`) with `token_type: DPoP` (RFC 9449 §5–§6); + * - invalid proofs: a tampered signature and an `htu` that is not the token + * endpoint are rejected with HTTP 400 `invalid_dpop_proof` (RFC 9449 §5), + * but only after the binding check succeeded; + * - nonce: when the first exchange is `use_dpop_nonce`, the challenge carries + * `DPoP-Nonce`, the retry with that nonce succeeds, and a wrong nonce is + * rejected (RFC 9449 §4.3 step 10, §8). * * 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 @@ -40,8 +46,112 @@ import { buildDpopProof } from '../client/auth/helpers/dpopProof'; import { readTokenBinding } from '../client/auth/helpers/dpopToken'; +import { untestableCheck } from '../untestable'; import { SpecReferences } from './auth/spec-references'; +/** Env opt-in for negative probes against a login-gated authorization server. */ +export const DPOP_NEGATIVE_PROBES_ENV = 'MCP_CONFORMANCE_DPOP_NEGATIVE_PROBES'; + +/** + * True when the operator asked to spend extra authorization codes on DPoP + * negative probes. Headless servers do not need this; see + * {@link DPoPAuthorizationServerScenario.negativeProbesAllowed}. + */ +export function dpopNegativeProbesRequested(options: { + dpopNegativeProbes?: boolean; +}): boolean { + if (options.dpopNegativeProbes === true) return true; + const raw = process.env[DPOP_NEGATIVE_PROBES_ENV]; + return raw === '1' || raw?.toLowerCase() === 'true'; +} + +/** + * Why a negative probe cannot be attributed when the valid-proof exchange did + * not yield a DPoP-bound token. Same shape as the server scenario's gate: an + * AS that rejects everything must not pass the negatives vacuously. + */ +function gateReason(caseLabel: string): string { + return `authorization server did not issue a DPoP-bound token for a valid proof, so a rejection of the ${caseLabel} case cannot be distinguished from an authorization server that rejects everything`; +} + +/** + * Cost-control reason. The check is a MUST that applies, but each probe spends + * an authorization code. On a login-gated AS that is an interactive login, so + * the probe is not sent unless the operator opts in. Reported via + * {@link untestableCheck} (FAILURE, `details.untestable`) rather than SKIPPED: + * SKIPPED is excluded from pass/fail counts and the expected-failures baseline, + * so a login-gated AS would read as green without these probes. Headless + * redirects run the probes with no opt-in and never hit this reason. + */ +function interactiveProbeReason(caseLabel: string): string { + return `authorization required an interactive login, so the ${caseLabel} probe was not sent; re-run with --dpop-negative-probes or ${DPOP_NEGATIVE_PROBES_ENV}=1 to spend an additional authorization code`; +} + +function issuedAccessToken(body: Record | undefined): boolean { + return typeof body?.access_token === 'string' && body.access_token.length > 0; +} + +/** + * Grade an invalid-proof probe (RFC 9449 §5). + * + * - HTTP 400 `invalid_dpop_proof` → SUCCESS + * - HTTP 200 with an access token → FAILURE (the AS bound a bad proof) + * - HTTP 400 with any other error → WARNING (rejected, wrong code) + * - anything else → SKIPPED, the scenario's existing inconclusive rule + */ +export function judgeInvalidDpopProofResponse( + result: { statusCode: number; body?: Record }, + caseLabel: string +): { status: CheckStatus; errorMessage?: string } { + const error = result.body?.error; + if (result.statusCode === 200 && issuedAccessToken(result.body)) { + return { + status: 'FAILURE', + errorMessage: `Authorization server issued an access token for a ${caseLabel} DPoP proof (HTTP 200)` + }; + } + if (result.statusCode === 400 && error === 'invalid_dpop_proof') { + return { status: 'SUCCESS' }; + } + if (result.statusCode === 400) { + return { + status: 'WARNING', + errorMessage: `Authorization server rejected the ${caseLabel} DPoP proof with HTTP 400 but error=${String(error ?? 'none')}; expected invalid_dpop_proof` + }; + } + return { + status: 'SKIPPED', + errorMessage: `${caseLabel} probe was inconclusive (HTTP ${result.statusCode}, error=${String(error ?? 'none')})` + }; +} + +/** + * Grade a wrong-nonce probe. RFC 9449 §8 says the authorization server MUST + * reject a nonce that does not match one it recently supplied, and names + * `use_dpop_nonce` for that mismatch. Any 4xx that does not issue a token + * counts as a rejection; issuing a token is FAILURE. Other statuses follow + * the scenario's inconclusive rule. + */ +export function judgeWrongNonceRejection(result: { + statusCode: number; + body?: Record; +}): { status: CheckStatus; errorMessage?: string } { + const error = result.body?.error; + if (issuedAccessToken(result.body)) { + return { + status: 'FAILURE', + errorMessage: `Authorization server issued an access token for a DPoP proof with the wrong nonce (HTTP ${result.statusCode})` + }; + } + if (result.statusCode >= 400 && result.statusCode < 500) { + return { status: 'SUCCESS' }; + } + return { + status: 'SKIPPED', + errorMessage: `Wrong-nonce probe was inconclusive (HTTP ${result.statusCode}, error=${String(error ?? 'none')})` + }; +} + const REDIRECT_URI_ORIGIN = 'http://127.0.0.1'; const REDIRECT_URI_PATH = '/callback'; @@ -77,9 +187,43 @@ const CHECK_DEFS: Record< SpecReferences.RFC_9449_PUBLIC_KEY_CONFIRMATION, SpecReferences.DPOP_EXTENSION ] + }, + 'sep-1932-as-rejects-invalid-proof': { + name: 'DpopRejectsInvalidProof', + description: + 'Authorization server rejects an invalid DPoP proof with HTTP 400 invalid_dpop_proof', + specReferences: [ + SpecReferences.RFC_9449_TOKEN_REQUEST, + SpecReferences.RFC_9449_PROOF_CHECKS + ] + }, + 'sep-1932-as-nonce': { + name: 'DpopNonce', + description: + 'Authorization server nonce challenge carries DPoP-Nonce, accepts that nonce, and rejects a wrong one', + specReferences: [ + SpecReferences.RFC_9449_AS_NONCE, + SpecReferences.RFC_9449_PROOF_CHECKS + ] } }; +/** Invalid-proof probes. Each spends its own authorization code. */ +const INVALID_PROOF_CASES = [ + { + caseId: 'tampered-signature', + name: 'RejectsTamperedSignature', + description: + 'Authorization server rejects a DPoP proof with a tampered signature' + }, + { + caseId: 'wrong-htu', + name: 'RejectsWrongHtu', + description: + 'Authorization server rejects a DPoP proof whose htu is not the token endpoint' + } +] as const; + /** Proof-JWS algorithms the harness can generate a key + proof for. */ const SUPPORTED_PROOF_ALGS = [ 'ES256', @@ -149,6 +293,12 @@ interface TokenExchangeResult { export class DPoPAuthorizationServerScenario implements ClientScenarioForAuthorizationServer { name = 'dpop'; readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + /** + * Whether the first authorization step in this run followed a redirect to + * the registered redirect_uri (no interactive login). Later probe + * authorizations must not overwrite it. + */ + private firstAuthorizationHeadless: boolean | undefined; description = `Test DPoP support in the authorization server (SEP-1932 / RFC 9449). **Authorization Server Implementation Requirements:** @@ -159,17 +309,24 @@ export class DPoPAuthorizationServerScenario implements ClientScenarioForAuthori - 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 invalid DPoP proof (tampered signature, or \`htu\` other than the token endpoint) MUST be rejected with HTTP 400 \`invalid_dpop_proof\` (RFC 9449 §5). These probes run only after the binding check succeeded +- When the token endpoint answers with \`use_dpop_nonce\`, the challenge includes a \`DPoP-Nonce\` header, a retry carrying that nonce succeeds, and a proof with a different nonce is rejected (RFC 9449 §4.3 step 10, §8) 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.`; +browser login + callback for login-gated servers. Negative probes each spend +another authorization code. They run automatically on the headless redirect +path. A login-gated server requires \`--dpop-negative-probes\` or +\`MCP_CONFORMANCE_DPOP_NEGATIVE_PROBES=1\`; otherwise those probes are reported +not testable rather than skipped.`; async run( options: AuthorizationServerOptions, _details: Record ): Promise { + this.firstAuthorizationHeadless = undefined; const checks: ConformanceCheck[] = []; let metadata: Record; @@ -183,7 +340,8 @@ browser login + callback for login-gated servers.`; ); for (const id of [ 'sep-1932-as-no-none-alg', - 'sep-1932-as-token-binding' + 'sep-1932-as-token-binding', + 'sep-1932-as-rejects-invalid-proof' ]) { checks.push( this.check(id, 'SKIPPED', { @@ -205,7 +363,8 @@ browser login + callback for login-gated servers.`; for (const id of [ 'sep-1932-as-metadata-alg-values', 'sep-1932-as-no-none-alg', - 'sep-1932-as-token-binding' + 'sep-1932-as-token-binding', + 'sep-1932-as-rejects-invalid-proof' ]) { checks.push(this.check(id, 'SKIPPED', { errorMessage: reason })); } @@ -275,23 +434,27 @@ browser login + callback for login-gated servers.`; checks: ConformanceCheck[] ): Promise { if (!options.clientId) { + const reason = 'Requires a client_id (pass --client-id)'; checks.push( this.check('sep-1932-as-token-binding', 'SKIPPED', { - errorMessage: 'Requires a client_id (pass --client-id)' + errorMessage: reason }) ); + this.skipInvalidProof(reason, checks); return; } if ( typeof metadata.authorization_endpoint !== 'string' || typeof metadata.token_endpoint !== 'string' ) { + const reason = + 'Metadata is missing authorization_endpoint or token_endpoint'; checks.push( this.check('sep-1932-as-token-binding', 'SKIPPED', { - errorMessage: - 'Metadata is missing authorization_endpoint or token_endpoint' + errorMessage: reason }) ); + this.skipInvalidProof(reason, checks); return; } @@ -300,16 +463,18 @@ browser login + callback for login-gated servers.`; // forcing a pointless interactive login only to fail afterwards. const alg = this.negotiateProofAlg(metadata); if (alg === null) { + const reason = + 'Authorization server advertises no DPoP proof algorithm the harness can produce, so token binding cannot be exercised'; 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', + errorMessage: reason, details: { dpop_signing_alg_values_supported: metadata.dpop_signing_alg_values_supported ?? null } }) ); + this.skipInvalidProof(reason, checks); return; } @@ -325,18 +490,20 @@ browser login + callback for login-gated servers.`; options )); } catch (error) { + const reason = `Could not obtain an authorization code: ${this.message(error)}`; checks.push( this.check('sep-1932-as-token-binding', 'SKIPPED', { - errorMessage: `Could not obtain an authorization code: ${this.message(error)}` + errorMessage: reason }) ); + this.skipInvalidProof(reason, checks); return; } // Exchange the code WITH a DPoP proof and inspect the binding. try { const keyPair = await generateDpopKeyPair(alg); - const result = await this.exchangeWithProof( + const exchanged = await this.exchangeWithProof( metadata, options, code, @@ -344,86 +511,373 @@ browser login + callback for login-gated servers.`; keyPair, alg ); + const binding = this.bindingCheckFor( + exchanged.final, + keyPair.thumbprint, + alg + ); + checks.push(binding); - 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 - } - } - ) + const nonceChallenged = + exchanged.first.statusCode === 400 && + exchanged.first.body?.error === 'use_dpop_nonce'; + if (nonceChallenged) { + checks.push(this.nonceHeaderCheck(exchanged.first)); + if (exchanged.first.dpopNonce) { + checks.push(this.nonceRetryCheck(exchanged.final)); + } + } + + await this.finishNegativeProbes({ + bindingSucceeded: binding.status === 'SUCCESS', + metadata, + options, + keyPair, + alg, + suppliedNonce: exchanged.first.dpopNonce, + nonceChallenged, + checks + }); + } catch (error) { + const reason = `Could not complete the DPoP token exchange: ${this.message(error)}`; + checks.push( + this.check('sep-1932-as-token-binding', 'SKIPPED', { + errorMessage: reason + }) + ); + this.skipInvalidProof(reason, checks); + } + } + + /** + * Binding judgment for the (possibly nonce-retried) token response. Messages + * match the pre-negative-probe scenario: only `invalid_dpop_proof` is a + * binding failure; other non-200 outcomes stay inconclusive. + */ + private bindingCheckFor( + result: TokenExchangeResult, + expectedJkt: string, + alg: string + ): ConformanceCheck { + 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'; + return 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 + } + } + ); + } + + 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 = issuedAccessToken(result.body); + if (!hasAccessToken) { + return this.check('sep-1932-as-token-binding', 'FAILURE', { + errorMessage: 'Token response was 200 but carried no access_token', + details: { tokenType: binding.tokenType ?? null } + }); + } + // 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) { + return 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 } + }); + } + const bound = binding.isDpopTokenType && binding.jkt === expectedJkt; + return 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 + } + } + ); + } + + private nonceHeaderCheck(first: TokenExchangeResult): ConformanceCheck { + const header = first.dpopNonce; + const present = typeof header === 'string' && header.length > 0; + return this.check('sep-1932-as-nonce', present ? 'SUCCESS' : 'FAILURE', { + name: 'NonceChallengeHeader', + description: + 'A use_dpop_nonce error response carries a DPoP-Nonce header', + errorMessage: present + ? undefined + : 'Authorization server returned use_dpop_nonce without a DPoP-Nonce header (RFC 9449 §8)', + details: { case: 'nonce-header', dpopNonce: header ?? null } + }); + } + + private nonceRetryCheck(final: TokenExchangeResult): ConformanceCheck { + const accepted = final.statusCode === 200 && issuedAccessToken(final.body); + return this.check('sep-1932-as-nonce', accepted ? 'SUCCESS' : 'FAILURE', { + name: 'NonceRetryAccepted', + description: + 'Retrying the token request with the supplied DPoP nonce is accepted', + errorMessage: accepted + ? undefined + : `Retry with the supplied DPoP nonce was not accepted (HTTP ${final.statusCode}, error=${String(final.body?.error ?? 'none')})`, + details: { + case: 'nonce-retry', + statusCode: final.statusCode, + error: final.body?.error ?? null + } + }); + } + + /** + * Negative probes run only after a successful binding check. Otherwise they + * are reported not-testable so an AS that rejects every proof cannot pass + * them vacuously. Login-gated authorization (no headless redirect) also + * withholds them unless the operator opts in. + */ + private async finishNegativeProbes(args: { + bindingSucceeded: boolean; + metadata: Record; + options: AuthorizationServerOptions; + keyPair: Awaited>; + alg: string; + suppliedNonce: string | undefined; + nonceChallenged: boolean; + checks: ConformanceCheck[]; + }): Promise { + const { + bindingSucceeded, + metadata, + options, + keyPair, + alg, + suppliedNonce, + nonceChallenged, + checks + } = args; + const wrongNonceApplies = nonceChallenged && !!suppliedNonce; + if (!bindingSucceeded) { + this.emitInvalidProofsNotRun(checks, gateReason); + if (wrongNonceApplies) { + this.emitWrongNonceNotRun(checks, gateReason('wrong-nonce')); + } + return; + } + if (!this.negativeProbesAllowed(options)) { + this.emitInvalidProofsNotRun(checks, interactiveProbeReason); + if (wrongNonceApplies) { + this.emitWrongNonceNotRun( + checks, + interactiveProbeReason('wrong-nonce') ); - return; } + return; + } + await this.runInvalidProofProbes(metadata, options, keyPair, alg, checks); + if (wrongNonceApplies) { + await this.runWrongNonceProbe( + metadata, + options, + keyPair, + alg, + suppliedNonce!, + checks + ); + } + } + + /** + * Headless redirects spend authorization codes with no human in the loop, so + * the probes run on their own. An interactive login requires an explicit + * opt-in (CLI flag or env var). + */ + protected negativeProbesAllowed( + options: AuthorizationServerOptions + ): boolean { + return ( + this.firstAuthorizationHeadless === true || + dpopNegativeProbesRequested(options) + ); + } - 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) { + private emitInvalidProofsNotRun( + checks: ConformanceCheck[], + reasonFor: (caseLabel: string) => string + ): void { + const specReferences = + CHECK_DEFS['sep-1932-as-rejects-invalid-proof'].specReferences; + for (const probe of INVALID_PROOF_CASES) { + checks.push( + untestableCheck( + 'sep-1932-as-rejects-invalid-proof', + probe.name, + probe.description, + reasonFor(probe.caseId), + specReferences + ) + ); + } + } + + private emitWrongNonceNotRun( + checks: ConformanceCheck[], + reason: string + ): void { + const def = CHECK_DEFS['sep-1932-as-nonce']; + checks.push( + untestableCheck( + 'sep-1932-as-nonce', + 'NonceRejectsWrongValue', + 'Authorization server rejects a DPoP proof whose nonce does not match the supplied value', + reason, + def.specReferences + ) + ); + } + + private async runInvalidProofProbes( + metadata: Record, + options: AuthorizationServerOptions, + keyPair: Awaited>, + alg: string, + checks: ConformanceCheck[] + ): Promise { + const htu = stripUrlQuery(metadata.token_endpoint); + for (const probe of INVALID_PROOF_CASES) { + try { + const fresh = await this.obtainAuthorizationCode(metadata, options); + const proof = await buildDpopProof( + probe.caseId === 'tampered-signature' + ? { keyPair, htm: 'POST', htu, alg, tamperSignature: true } + : { + keyPair, + htm: 'POST', + htu: 'https://dpop-negative.invalid/not-the-token-endpoint', + alg + } + ); + const result = await this.exchangeCode( + metadata, + options, + fresh.code, + fresh.codeVerifier, + proof + ); + const judged = judgeInvalidDpopProofResponse(result, probe.caseId); 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 } + this.check('sep-1932-as-rejects-invalid-proof', judged.status, { + name: probe.name, + description: probe.description, + errorMessage: judged.errorMessage, + details: { + case: probe.caseId, + statusCode: result.statusCode, + error: result.body?.error ?? 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) { + } catch (error) { 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 } - }) + untestableCheck( + 'sep-1932-as-rejects-invalid-proof', + probe.name, + probe.description, + `could not obtain an authorization code for the ${probe.caseId} probe: ${this.message(error)}`, + CHECK_DEFS['sep-1932-as-rejects-invalid-proof'].specReferences + ) ); - return; } - const bound = - binding.isDpopTokenType && binding.jkt === keyPair.thumbprint; + } + } + + /** + * Single exchange, not {@link exchangeWithProof}: a conformant AS answers a + * bad nonce with `use_dpop_nonce` plus a fresh nonce, and the retry helper + * would then send the correct nonce and hide the rejection. + */ + private async runWrongNonceProbe( + metadata: Record, + options: AuthorizationServerOptions, + keyPair: Awaited>, + alg: string, + suppliedNonce: string, + checks: ConformanceCheck[] + ): Promise { + const description = + 'Authorization server rejects a DPoP proof whose nonce does not match the supplied value'; + try { + const fresh = await this.obtainAuthorizationCode(metadata, options); + const proof = await buildDpopProof({ + keyPair, + htm: 'POST', + htu: stripUrlQuery(metadata.token_endpoint), + alg, + nonce: `${suppliedNonce}-wrong` + }); + const result = await this.exchangeCode( + metadata, + options, + fresh.code, + fresh.codeVerifier, + proof + ); + const judged = judgeWrongNonceRejection(result); 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)', + this.check('sep-1932-as-nonce', judged.status, { + name: 'NonceRejectsWrongValue', + description, + errorMessage: judged.errorMessage, details: { - tokenType: binding.tokenType ?? null, - cnfJkt: binding.jkt ?? null, - expectedJkt: keyPair.thumbprint + case: 'wrong-nonce', + statusCode: result.statusCode, + error: result.body?.error ?? null } }) ); } catch (error) { checks.push( - this.check('sep-1932-as-token-binding', 'SKIPPED', { - errorMessage: `Could not complete the DPoP token exchange: ${this.message(error)}` - }) + untestableCheck( + 'sep-1932-as-nonce', + 'NonceRejectsWrongValue', + description, + `could not obtain an authorization code for the wrong-nonce probe: ${this.message(error)}`, + CHECK_DEFS['sep-1932-as-nonce'].specReferences + ) ); } } + private skipInvalidProof(reason: string, checks: ConformanceCheck[]): void { + checks.push( + this.check('sep-1932-as-rejects-invalid-proof', 'SKIPPED', { + errorMessage: reason + }) + ); + } + /** See the module-level {@link negotiateProofAlg}. */ private negotiateProofAlg(metadata: Record): string | null { return negotiateProofAlg(metadata.dpop_signing_alg_values_supported); @@ -461,7 +915,7 @@ browser login + callback for login-gated servers.`; codeVerifier: string, keyPair: Awaited>, alg: string - ): Promise { + ): Promise<{ first: TokenExchangeResult; final: TokenExchangeResult }> { // 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. @@ -473,12 +927,15 @@ browser login + callback for login-gated servers.`; codeVerifier, await buildDpopProof({ keyPair, htm: 'POST', htu, alg }) ); + // A use_dpop_nonce response with no DPoP-Nonce header is not retried: there + // is no nonce to put in the proof. The caller records that as a nonce-check + // failure. Binding then sees this 400, which stays inconclusive. if ( first.statusCode === 400 && first.body?.error === 'use_dpop_nonce' && first.dpopNonce ) { - return this.exchangeCode( + const final = await this.exchangeCode( metadata, options, code, @@ -491,8 +948,9 @@ browser login + callback for login-gated servers.`; nonce: first.dpopNonce }) ); + return { first, final }; } - return first; + return { first, final: first }; } // ----- authorization_code + PKCE helpers ----- @@ -562,11 +1020,13 @@ browser login + callback for login-gated servers.`; // resolve it against the request URL before matching the redirect_uri. const resolved = new URL(location, authorizeUrl).toString(); if (resolved.startsWith(redirectUri)) { + this.noteAuthorizationPath(true); return resolved; } } // Interactive fallback for login-gated authorization servers. + this.noteAuthorizationPath(false); const callback = startCallbackServer(options.port); try { console.log( @@ -709,10 +1169,19 @@ browser login + callback for login-gated servers.`; // ----- check construction ----- + /** Record only the first authorization step; probe logins must not reset it. */ + private noteAuthorizationPath(headless: boolean): void { + if (this.firstAuthorizationHeadless === undefined) { + this.firstAuthorizationHeadless = headless; + } + } + private check( id: string, status: CheckStatus, opts: { + name?: string; + description?: string; errorMessage?: string; details?: Record; } = {} @@ -720,8 +1189,8 @@ browser login + callback for login-gated servers.`; const def = CHECK_DEFS[id]; return { id, - name: def.name, - description: def.description, + name: opts.name ?? def.name, + description: opts.description ?? def.description, status, timestamp: new Date().toISOString(), specReferences: def.specReferences, diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 162f93aa..f8bfb631 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -125,6 +125,29 @@ export async function validateDpopProofAtTokenEndpoint( return { ok: true, jkt }; } +/** + * `accept-any-proof` misbehaviour: skip {@link validateDpopProofAtTokenEndpoint} + * and bind to the JWK embedded in the presented header anyway. + */ +async function thumbprintOfPresentedProof( + proof: string +): Promise<{ ok: true; jkt: string } | { ok: false; error: string }> { + try { + const header = jose.decodeProtectedHeader(proof); + const jwk = header.jwk; + if (!jwk || typeof jwk !== 'object') { + return { ok: false, error: 'presented proof has no jwk' }; + } + if ((jwk as Record).d !== undefined) { + return { ok: false, error: 'presented jwk contains a private key' }; + } + const jkt = await jose.calculateJwkThumbprint(jwk, 'sha256'); + return { ok: true, jkt }; + } catch { + return { ok: false, error: 'presented proof is not a JWT' }; + } +} + export interface TokenRequestResult { token: string; scopes: string[]; @@ -198,13 +221,19 @@ export interface AuthServerOptions { * - 'omit-alg-values' — drop `dpop_signing_alg_values_supported` entirely * - 'empty-alg-values' — advertise the field as an empty array * - 'include-none' — list `none` among the supported proof algs - * - 'unbound-token' — issue a Bearer token ignoring a valid proof + * - 'unbound-token' — issue a Bearer token ignoring a valid proof + * - 'accept-any-proof' — skip proof validation and bind to the presented jwk + * - 'nonce-without-header' — return use_dpop_nonce without a DPoP-Nonce header + * - 'nonce-accept-any' — challenge when no nonce is present, then accept any value */ dpopMisbehavior?: | 'omit-alg-values' | 'empty-alg-values' | 'include-none' - | 'unbound-token'; + | 'unbound-token' + | 'accept-any-proof' + | 'nonce-without-header' + | 'nonce-accept-any'; /** Sink for the DPoP token-request observation; see the interface docstring. */ dpopTokenRequestObs?: DpopTokenRequestObservation; /** @@ -600,10 +629,10 @@ export function createAuthServer( // client-side check still records that the client failed to ask for a // bound token. if (proof) { - const result = await validateDpopProofAtTokenEndpoint( - proof, - tokenEndpointUrl - ); + const result = + dpopMisbehavior === 'accept-any-proof' + ? await thumbprintOfPresentedProof(proof) + : await validateDpopProofAtTokenEndpoint(proof, tokenEndpointUrl); if (!result.ok) { recordTokenRequestProof(grantType, false, result.error); res.status(400).json({ @@ -624,17 +653,35 @@ export function createAuthServer( // client is expected to retry with it. The nonce observation is gated // on the authorization_code exchange, matching recordTokenRequestProof // (honoring a challenge on a refresh exchange must not satisfy §8). - if (dpopRequireNonce) { + const enforceNonce = + dpopRequireNonce || + dpopMisbehavior === 'nonce-without-header' || + dpopMisbehavior === 'nonce-accept-any'; + if (enforceNonce) { let proofNonce: unknown; try { proofNonce = jose.decodeJwt(proof).nonce; } catch { proofNonce = undefined; } - if (proofNonce !== AS_DPOP_NONCE) { + const hasNonce = + typeof proofNonce === 'string' && proofNonce.length > 0; + // nonce-accept-any / nonce-without-header: any presented nonce is + // enough. The exact-match path is the compliant dpopRequireNonce mode. + const nonceAccepted = + dpopMisbehavior === 'nonce-accept-any' || + dpopMisbehavior === 'nonce-without-header' + ? hasNonce + : proofNonce === AS_DPOP_NONCE; + if (!nonceAccepted) { if (grantType === 'authorization_code' && dpopTokenRequestObs) dpopTokenRequestObs.asNonceChallengeIssued = true; - res.status(400).set('DPoP-Nonce', AS_DPOP_NONCE).json({ + // nonce-without-header: the defect under test is a challenge that + // omits DPoP-Nonce. Every other challenge carries the header. + if (dpopMisbehavior !== 'nonce-without-header') { + res.set('DPoP-Nonce', AS_DPOP_NONCE); + } + res.status(400).json({ error: 'use_dpop_nonce', error_description: 'Authorization server requires a DPoP nonce' }); diff --git a/src/schemas.ts b/src/schemas.ts index b8f67831..c094e805 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -71,7 +71,13 @@ export const AuthorizationServerOptionsSchema = z.object({ .int('Port must be an integer') .min(1, 'Port must be >= 1') .max(65535, 'Port must be <= 65535') - .default(3000) + .default(3000), + /** + * Spend extra authorization codes on DPoP negative probes. Headless + * authorization servers run those probes automatically; login-gated servers + * require this flag or MCP_CONFORMANCE_DPOP_NEGATIVE_PROBES=1. + */ + dpopNegativeProbes: z.boolean().optional() }); export type AuthorizationServerOptions = z.infer< diff --git a/src/seps/sep-1932.yaml b/src/seps/sep-1932.yaml index 46271843..91a864e3 100644 --- a/src/seps/sep-1932.yaml +++ b/src/seps/sep-1932.yaml @@ -24,6 +24,19 @@ requirements: # Binding mechanics are defined in RFC 9449 (§6 cnf/jkt thumbprint, §5 token_type: DPoP), which the SEP builds on rather than restating. - 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)" + # RFC 9449 §5. The SEP does not restate this sentence; it requires conformance to RFC 9449. + # Graded MUST from "MUST contain a valid DPoP proof JWT". A 400 that names some other + # error is WARNING: the prescribed `invalid_dpop_proof` code is stated without its own keyword. + - check: sep-1932-as-rejects-invalid-proof + text: 'The `DPoP` HTTP header field MUST contain a valid DPoP proof JWT. If the DPoP proof is invalid, the authorization server issues an error response per Section 5.2 of [RFC6749] with `invalid_dpop_proof` as the value of the `error` parameter.' + url: https://www.rfc-editor.org/rfc/rfc9449.html#section-5 + # RFC 9449 §4.3 step 10 and §8. The SEP does not restate these sentences. + # Supplying a nonce is MAY, so the check is emitted only after a use_dpop_nonce response. + # The header sentence has no RFC 2119 keyword; it is the specified content of that response. + # A mismatched nonce MUST be rejected (§8, and §4.3 step 10 under "MUST ensure"). + - check: sep-1932-as-nonce + text: 'To validate a DPoP proof, the receiving server MUST ensure the following: If the server provided a nonce value to the client, the `nonce` claim matches the server-provided nonce value. / The authorization server includes a `DPoP-Nonce` HTTP header in the response supplying a nonce value to be used when sending the subsequent request. / If the `nonce` claim in the DPoP proof does not exactly match a nonce recently supplied by the authorization server to the client, the authorization server MUST reject the request.' + url: https://www.rfc-editor.org/rfc/rfc9449.html#section-8 - check: sep-1932-asymmetric-alg-only text: 'Only asymmetric signature algorithms MUST be used for DPoP proofs. Symmetric algorithms and the `none` algorithm MUST NOT be permitted as specified in RFC 9449 Section 11.6' - check: sep-1932-server-nonce