From 62807b9fdf6420b53ec916dc9c91278ed344d8cd Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Wed, 23 Sep 2026 07:17:31 -0700 Subject: [PATCH 1/2] feat(sep-1932): require clients to bind the authorization code with dpop_jkt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEP-1932 adopts RFC 9449 §10, so a client binds its authorization code to its DPoP key and the test authorization server rejects a mismatched thumbprint. Co-authored-by: Cursor --- .../typescript/auth-test-dpop-no-jkt.ts | 21 +++++ .../typescript/auth-test-dpop-wrong-jkt.ts | 23 +++++ examples/clients/typescript/auth-test-dpop.ts | 9 +- .../typescript/helpers/dpopClientFlow.ts | 24 ++++- src/scenarios/client/auth/dpop.ts | 50 ++++++++++- .../auth/helpers/createAuthServer.test.ts | 89 +++++++++++++++++++ .../client/auth/helpers/createAuthServer.ts | 38 ++++++++ src/scenarios/client/auth/index.test.ts | 53 ++++++++++- src/scenarios/client/auth/spec-references.ts | 4 + src/seps/sep-1932.yaml | 5 ++ 10 files changed, 306 insertions(+), 10 deletions(-) create mode 100644 examples/clients/typescript/auth-test-dpop-no-jkt.ts create mode 100644 examples/clients/typescript/auth-test-dpop-wrong-jkt.ts create mode 100644 src/scenarios/client/auth/helpers/createAuthServer.test.ts diff --git a/examples/clients/typescript/auth-test-dpop-no-jkt.ts b/examples/clients/typescript/auth-test-dpop-no-jkt.ts new file mode 100644 index 00000000..ac8d2b9d --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-no-jkt.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * DPoP client that omits `dpop_jkt` on the authorization request. Isolates a + * WARNING of sep-1932-client-dpop-jkt (SEP-1932 / RFC 9449 §10). + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'DPoP', + freshProofPerRequest: true, + sendTokenRequestProof: true, + handleAsNonce: true, + handleRsNonce: true, + sendDpopJkt: false + }); +} + +runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-jkt '); diff --git a/examples/clients/typescript/auth-test-dpop-wrong-jkt.ts b/examples/clients/typescript/auth-test-dpop-wrong-jkt.ts new file mode 100644 index 00000000..dc5e3a8a --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-wrong-jkt.ts @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * Broken DPoP client: sends a `dpop_jkt` that does not match the DPoP proof + * key used at the token endpoint. Isolates a FAILURE of + * sep-1932-client-dpop-jkt; the test AS rejects the token request with 400 + * `invalid_grant` (RFC 9449 §10). + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'DPoP', + freshProofPerRequest: true, + sendTokenRequestProof: true, + handleAsNonce: true, + handleRsNonce: true, + wrongDpopJkt: true + }); +} + +runAsCli(runClient, import.meta.url, 'auth-test-dpop-wrong-jkt '); diff --git a/examples/clients/typescript/auth-test-dpop.ts b/examples/clients/typescript/auth-test-dpop.ts index feabfa74..40ffcaff 100644 --- a/examples/clients/typescript/auth-test-dpop.ts +++ b/examples/clients/typescript/auth-test-dpop.ts @@ -4,8 +4,10 @@ import { runDpopClient } from './helpers/dpopClientFlow'; import { runAsCli } from './helpers/cliRunner'; /** - * Well-behaved DPoP client (SEP-1932 / RFC 9449): presents the DPoP-bound token - * with the `DPoP` Authorization scheme and a fresh proof on every MCP request. + * Well-behaved DPoP client (SEP-1932 / RFC 9449): binds the authorization code + * to its DPoP key via `dpop_jkt` (RFC 9449 §10), then presents the DPoP-bound + * token with the `DPoP` Authorization scheme and a fresh proof on every MCP + * request. */ export async function runClient(serverUrl: string): Promise { await runDpopClient(serverUrl, { @@ -13,7 +15,8 @@ export async function runClient(serverUrl: string): Promise { freshProofPerRequest: true, sendTokenRequestProof: true, handleAsNonce: true, - handleRsNonce: true + handleRsNonce: true, + sendDpopJkt: true }); } diff --git a/examples/clients/typescript/helpers/dpopClientFlow.ts b/examples/clients/typescript/helpers/dpopClientFlow.ts index f4cb81ff..fa58e158 100644 --- a/examples/clients/typescript/helpers/dpopClientFlow.ts +++ b/examples/clients/typescript/helpers/dpopClientFlow.ts @@ -29,6 +29,10 @@ import { logger } from './logger'; * challenge (RFC 9449 §8); fails sep-1932-client-as-nonce * - `handleRsNonce:false` → ignores the MCP server's `use_dpop_nonce` * challenge (RFC 9449 §9); fails sep-1932-client-rs-nonce + * - `sendDpopJkt:false` → omits dpop_jkt on the authorization request; + * warns sep-1932-client-dpop-jkt (SEP-1932 / RFC 9449 §10) + * - `wrongDpopJkt:true` → sends a dpop_jkt that does not match the + * token-request proof key; fails sep-1932-client-dpop-jkt (AS returns 400) */ export interface DpopClientOptions { scheme: 'DPoP' | 'Bearer'; @@ -38,6 +42,10 @@ export interface DpopClientOptions { handleAsNonce: boolean; /** Retry an MCP request with the server-supplied nonce on a use_dpop_nonce challenge (RFC 9449 §9). */ handleRsNonce: boolean; + /** Include dpop_jkt on the authorization request (RFC 9449 §10). Default true. */ + sendDpopJkt?: boolean; + /** Send a dpop_jkt that does not match the token-request proof key. */ + wrongDpopJkt?: boolean; } const REDIRECT_URI = 'http://127.0.0.1:9876/callback'; @@ -90,14 +98,26 @@ export async function runDpopClient( const codeChallenge = createHash('sha256') .update(codeVerifier) .digest('base64url'); - const authorizeUrl = `${authorizationEndpoint}?${new URLSearchParams({ + const authorizeParams = new URLSearchParams({ response_type: 'code', client_id: clientId, state, redirect_uri: REDIRECT_URI, code_challenge: codeChallenge, code_challenge_method: 'S256' - }).toString()}`; + }); + // RFC 9449 §10: bind the authorization code to this DPoP key. Default on so + // the compliant fixture and the other single-defect variants send a matching + // dpop_jkt unless they are the dedicated omit/mismatch clients. + if (options.sendDpopJkt !== false) { + authorizeParams.set( + 'dpop_jkt', + options.wrongDpopJkt + ? (await generateDpopKeyPair()).thumbprint + : keyPair.thumbprint + ); + } + const authorizeUrl = `${authorizationEndpoint}?${authorizeParams.toString()}`; const authorizeResponse = await request(authorizeUrl, { method: 'GET' }); await authorizeResponse.body.text().catch(() => undefined); const location = authorizeResponse.headers['location']; diff --git a/src/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts index 8b16ba5a..4408acf0 100644 --- a/src/scenarios/client/auth/dpop.ts +++ b/src/scenarios/client/auth/dpop.ts @@ -76,9 +76,25 @@ const CHECK_DEFS: Record< SpecReferences.DPOP_EXTENSION, SpecReferences.RFC_9449_RS_NONCE ] + }, + 'sep-1932-client-dpop-jkt': { + name: 'DpopAuthCodeBinding', + description: + 'Client binds the authorization code to its DPoP key via the dpop_jkt authorization request parameter (RFC 9449 §10)', + specReferences: [ + SpecReferences.SEP_1932_DPOP, + SpecReferences.DPOP_EXTENSION, + SpecReferences.RFC_9449_DPOP_JKT + ] } }; +/** + * SEP-1932 requires the client to bind the authorization code to its DPoP key + * with `dpop_jkt` (RFC 9449 §10). Omission is a SHOULD miss. + */ +const DPOP_JKT_NOT_SENT_STATUS: CheckStatus = 'WARNING'; + /** * Scenario: DPoP sender-constrained tokens — MCP client (SEP-1932 / RFC 9449). * @@ -91,15 +107,16 @@ const CHECK_DEFS: Record< * * - `auth/dpop` (`requireNonce = false`) — the common, nonce-less baseline. * Neither the AS nor the MCP server issues a nonce challenge; the client - * completes the flow with plain proofs. Emits three checks: + * completes the flow with plain proofs. Emits four checks: * · token acquisition — a valid DPoP proof at the token request, obtaining * a sender-constrained token (RFC 9449 §5); + * · the authorization code is bound to the DPoP key via `dpop_jkt` (§10); * · the token is presented with the `DPoP` Authorization scheme (§7.1); * · a fresh, well-formed DPoP proof accompanies each request (unique `jti`). * * - `auth/dpop-nonce` (`requireNonce = true`) — the AS and MCP server both * require a server-provided nonce (§8/§9), exercising the client's nonce - * handling. Emits the three baseline checks plus two more: + * handling. Emits the four baseline checks plus two more: * · the client retries the token request with the AS-supplied nonce (§8); * · the client retries the MCP request with the server-supplied nonce (§9). */ @@ -108,7 +125,8 @@ function newTokenReqObs(): DpopTokenRequestObservation { recorded: false, validProof: false, asNonceChallengeIssued: false, - asNonceHonored: false + asNonceHonored: false, + dpopJktMatched: false }; } @@ -185,6 +203,7 @@ export class DPoPClientScenario implements Scenario { const checks: ConformanceCheck[] = [ ...shared, this.tokenRequestProofCheck(), + this.dpopJktCheck(), this.authSchemeCheck(), this.freshProofCheck() ]; @@ -244,6 +263,31 @@ export class DPoPClientScenario implements Scenario { ); } + private dpopJktCheck(): ConformanceCheck { + const sent = this.tokenReqObs.dpopJktSent; + const matched = this.tokenReqObs.dpopJktMatched; + let status: CheckStatus; + let errorMessage: string | undefined; + if (sent !== undefined && matched) { + status = 'SUCCESS'; + } else if (sent !== undefined) { + status = 'FAILURE'; + errorMessage = + 'Client sent dpop_jkt but it does not match the DPoP proof key used at the token endpoint'; + } else { + status = DPOP_JKT_NOT_SENT_STATUS; + errorMessage = + 'Client did not send dpop_jkt on the authorization request to bind the authorization code to its DPoP key'; + } + return this.build('sep-1932-client-dpop-jkt', status, { + errorMessage, + details: { + dpopJktSent: sent, + dpopJktMatched: matched + } + }); + } + private tokenRequestProofCheck(): ConformanceCheck { let status: CheckStatus; let errorMessage: string | undefined; diff --git a/src/scenarios/client/auth/helpers/createAuthServer.test.ts b/src/scenarios/client/auth/helpers/createAuthServer.test.ts new file mode 100644 index 00000000..d72b88c2 --- /dev/null +++ b/src/scenarios/client/auth/helpers/createAuthServer.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import type { ConformanceCheck } from '../../../../types'; +import { testScenarioContext } from '../../../../mock-server/testing'; +import { generateDpopKeyPair, buildDpopProof } from './dpopProof'; +import { + createAuthServer, + type DpopTokenRequestObservation +} from './createAuthServer'; +import { ServerLifecycle } from './serverLifecycle'; + +function newObs(): DpopTokenRequestObservation { + return { + recorded: false, + validProof: false, + asNonceChallengeIssued: false, + asNonceHonored: false, + dpopJktMatched: false + }; +} + +describe('createAuthServer — RFC 9449 §10 dpop_jkt binding', () => { + it('rejects authorization_code with 400 invalid_grant when dpop_jkt does not match the proof key', async () => { + const checks: ConformanceCheck[] = []; + const lifecycle = new ServerLifecycle(); + const obs = newObs(); + const app = createAuthServer( + testScenarioContext(), + checks, + lifecycle.getUrl, + { + dpopSigningAlgValuesSupported: ['ES256'], + dpopTokenRequestObs: obs, + loggingEnabled: false + } + ); + await lifecycle.start(app); + try { + const proofKp = await generateDpopKeyPair(); + const otherKp = await generateDpopKeyPair(); + const authorizeUrl = `${lifecycle.getUrl()}/authorize?${new URLSearchParams( + { + response_type: 'code', + client_id: 'test', + redirect_uri: 'http://127.0.0.1:9876/callback', + code_challenge: 'x', + code_challenge_method: 'S256', + dpop_jkt: otherKp.thumbprint + } + ).toString()}`; + await fetch(authorizeUrl, { redirect: 'manual' }); + + const tokenEndpoint = `${lifecycle.getUrl()}/token`; + const proof = await buildDpopProof({ + keyPair: proofKp, + htm: 'POST', + htu: tokenEndpoint + }); + const tokenRes = await fetch(tokenEndpoint, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + dpop: proof + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: 'test-auth-code', + redirect_uri: 'http://127.0.0.1:9876/callback', + code_verifier: 'x', + client_id: 'test' + }) + }); + + expect(tokenRes.status).toBe(400); + const body = (await tokenRes.json()) as { + error?: string; + error_description?: string; + }; + expect(body.error).toBe('invalid_grant'); + expect(body.error_description).toBe( + 'dpop_jkt does not match the DPoP proof key' + ); + expect(obs.dpopJktSent).toBe(otherKp.thumbprint); + expect(obs.dpopJktMatched).toBe(false); + expect(obs.validProof).toBe(true); + } finally { + await lifecycle.stop(); + } + }); +}); diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 162f93aa..8aeaa649 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -152,6 +152,17 @@ export interface DpopTokenRequestObservation { asNonceChallengeIssued: boolean; /** The client retried the token request carrying the correct nonce. */ asNonceHonored: boolean; + /** + * `dpop_jkt` from the authorization request, if any (RFC 9449 §10). + * Written only on an authorization_code exchange after a valid proof, + * matching recordTokenRequestProof. + */ + dpopJktSent?: string; + /** + * Whether `dpop_jkt` equals the JWK thumbprint of the token-request proof + * key. Written only on an authorization_code exchange after a valid proof. + */ + dpopJktMatched: boolean; } export interface AuthServerOptions { @@ -275,6 +286,8 @@ export function createAuthServer( let lastAuthorizationScopes: string[] = []; // Track PKCE code_challenge for verification in token request let storedCodeChallenge: string | undefined; + // RFC 9449 §10: dpop_jkt from the authorization request, if the client sent it. + let storedDpopJkt: string | undefined; // Lazily-created issuer key for minting DPoP-bound JWT access tokens. let dpopIssuerKey: TokenIssuerKey | undefined; // DPoP behaviour is active only when the caller opts in (any DPoP option). @@ -426,6 +439,9 @@ export function createAuthServer( | string | undefined; storedCodeChallenge = codeChallenge; + // RFC 9449 §10: capture dpop_jkt so the token endpoint can bind the + // authorization code to the client's DPoP key. + storedDpopJkt = req.query.dpop_jkt as string | undefined; // PKCE: Check code_challenge is present checks.push({ @@ -619,6 +635,28 @@ export function createAuthServer( // authorization_code inside recordTokenRequestProof. recordTokenRequestProof(grantType, true); + // RFC 9449 §10: if the authorization request carried dpop_jkt, the + // token-request proof key MUST match it. RFC 9449 names no error + // code, so invalid_grant by analogy with PKCE failure. Only + // authorization_code grants count, same gating as + // recordTokenRequestProof. + if (grantType === 'authorization_code' && dpopTokenRequestObs) { + dpopTokenRequestObs.dpopJktSent = storedDpopJkt; + dpopTokenRequestObs.dpopJktMatched = + storedDpopJkt !== undefined && storedDpopJkt === result.jkt; + } + if ( + grantType === 'authorization_code' && + storedDpopJkt !== undefined && + storedDpopJkt !== result.jkt + ) { + res.status(400).json({ + error: 'invalid_grant', + error_description: 'dpop_jkt does not match the DPoP proof key' + }); + return; + } + // RFC 9449 §8: require a server-provided nonce. A proof without the // correct nonce is challenged (400 use_dpop_nonce + DPoP-Nonce); the // client is expected to retry with it. The nonce observation is gated diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 41c84ae8..f2b0f536 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -36,6 +36,8 @@ import { runClient as dpopNoAsNonceClient } from '../../../../examples/clients/t import { runClient as dpopNoRsNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-rs-nonce'; import { runClient as dpopNoNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-nonce'; import { runClient as dpopClient } from '../../../../examples/clients/typescript/auth-test-dpop'; +import { runClient as dpopNoJktClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-jkt'; +import { runClient as dpopWrongJktClient } from '../../../../examples/clients/typescript/auth-test-dpop-wrong-jkt'; import { runClient as resourceSlashClient } from '../../../../examples/clients/typescript/auth-test-resource-slash'; import { getHandler } from '../../../../examples/clients/typescript/everything-client'; import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger'; @@ -455,7 +457,10 @@ describe('DPoP client negative tests (SEP-1932)', () => { 'sep-1932-client-fresh-proof', 'sep-1932-client-rs-nonce' ], - expectedSuccessSlugs: ['sep-1932-client-token-request-proof'] + expectedSuccessSlugs: [ + 'sep-1932-client-token-request-proof', + 'sep-1932-client-dpop-jkt' + ] }); }); @@ -468,12 +473,53 @@ describe('DPoP client negative tests (SEP-1932)', () => { expectedFailureSlugs: ['sep-1932-client-rs-nonce'], expectedSuccessSlugs: [ 'sep-1932-client-token-request-proof', + 'sep-1932-client-dpop-jkt', 'sep-1932-client-dpop-auth-scheme', 'sep-1932-client-fresh-proof', 'sep-1932-client-as-nonce' ] }); }); + + test('auth/dpop: client binds the authorization code via dpop_jkt', async () => { + const runner = new InlineClientRunner(dpopClient); + const checks = await runClientAgainstScenario(runner, 'auth/dpop', { + expectedSuccessSlugs: ['sep-1932-client-dpop-jkt'] + }); + expect( + checks.find((c) => c.id === 'sep-1932-client-dpop-jkt')?.status + ).toBe('SUCCESS'); + }); + + test('auth/dpop: client omits dpop_jkt', async () => { + const runner = new InlineClientRunner(dpopNoJktClient); + const checks = await runClientAgainstScenario(runner, 'auth/dpop', { + expectedFailureSlugs: ['sep-1932-client-dpop-jkt'], + expectedSuccessSlugs: [ + 'sep-1932-client-token-request-proof', + 'sep-1932-client-dpop-auth-scheme', + 'sep-1932-client-fresh-proof' + ] + }); + expect( + checks.find((c) => c.id === 'sep-1932-client-dpop-jkt')?.status + ).toBe('WARNING'); + }); + + test('auth/dpop: client sends a mismatched dpop_jkt', async () => { + const runner = new InlineClientRunner(dpopWrongJktClient); + const checks = await runClientAgainstScenario(runner, 'auth/dpop', { + expectedFailureSlugs: [ + 'sep-1932-client-dpop-jkt', + 'sep-1932-client-dpop-auth-scheme', + 'sep-1932-client-fresh-proof' + ], + expectedSuccessSlugs: ['sep-1932-client-token-request-proof'] + }); + expect( + checks.find((c) => c.id === 'sep-1932-client-dpop-jkt')?.status + ).toBe('FAILURE'); + }); }); // DPoP nonce-less baseline (SEP-1932): a client that implements NO nonce @@ -485,7 +531,7 @@ describe('DPoP client nonce-less baseline (SEP-1932)', () => { test('auth/dpop: nonce-incapable client passes the baseline', async () => { const runner = new InlineClientRunner(dpopNoNonceClient); // No expectedFailureSlugs → asserts every emitted check is SUCCESS (the - // three baseline checks; no as-nonce/rs-nonce checks are emitted here). + // four baseline checks; no as-nonce/rs-nonce checks are emitted here). await runClientAgainstScenario(runner, 'auth/dpop'); }); @@ -501,6 +547,9 @@ describe('DPoP client nonce-less baseline (SEP-1932)', () => { expect(count('token-request')).toBe(1); expect(count('pkce-code-verifier-sent')).toBe(1); expect(count('pkce-verifier-matches-challenge')).toBe(1); + expect( + checks.find((c) => c.id === 'sep-1932-client-dpop-jkt')?.status + ).toBe('SUCCESS'); }); }); diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 980c0305..330b3a3c 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -154,5 +154,9 @@ export const SpecReferences: { [key: string]: SpecReference } = { RFC_9449_RS_NONCE: { id: 'RFC-9449-resource-server-provided-nonce', url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-9' + }, + RFC_9449_DPOP_JKT: { + id: 'RFC-9449-authorization-code-binding-to-a-dpop-key', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-10' } }; diff --git a/src/seps/sep-1932.yaml b/src/seps/sep-1932.yaml index c3c650f3..cf6acaf1 100644 --- a/src/seps/sep-1932.yaml +++ b/src/seps/sep-1932.yaml @@ -7,6 +7,11 @@ requirements: text: 'When making requests to protected MCP server resources, clients MUST include a fresh DPoP proof in the `DPoP` header' - check: sep-1932-client-token-request-proof text: 'To obtain a DPoP-bound access token, the client MUST include a DPoP proof in the `DPoP` header of its token request (RFC 9449 Section 5)' + # SEP-1932 requires the client to bind the authorization code to its DPoP key + # with dpop_jkt. The sentence is RFC 9449 §10. + - check: sep-1932-client-dpop-jkt + text: 'When a token request is received, the authorization server computes the JWK Thumbprint of the proof-of-possession public key in the DPoP proof and verifies that it matches the dpop_jkt parameter value in the authorization request. If they do not match, it MUST reject the request.' + url: https://www.rfc-editor.org/rfc/rfc9449.html#section-10 - check: sep-1932-client-as-nonce text: 'When the authorization server responds with `use_dpop_nonce`, the client MUST retry the token request with a DPoP proof that includes the supplied `nonce` (RFC 9449 Section 8)' - check: sep-1932-client-rs-nonce From abc3cb21898d1b1f5195cbe04b06584b623b0f38 Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Thu, 24 Sep 2026 12:34:39 -0700 Subject: [PATCH 2/2] fix(sep-1932): correct dpop_jkt conformance checks Co-authored-by: Cursor --- src/scenarios/client/auth/dpop.ts | 13 +- .../auth/helpers/createAuthServer.test.ts | 134 +++++++++++++----- .../client/auth/helpers/createAuthServer.ts | 46 +++--- src/seps/sep-1932.yaml | 7 +- 4 files changed, 143 insertions(+), 57 deletions(-) diff --git a/src/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts index 4408acf0..4ab7df67 100644 --- a/src/scenarios/client/auth/dpop.ts +++ b/src/scenarios/client/auth/dpop.ts @@ -19,6 +19,7 @@ import { } from './helpers/dpopResourceAuth'; import { SpecReferences } from './spec-references'; import { collapseDuplicateChecks } from '../../../checks/collapse'; +import { notTestable } from '../../untestable'; const PRM_PATH = '/.well-known/oauth-protected-resource/mcp'; @@ -268,7 +269,14 @@ export class DPoPClientScenario implements Scenario { const matched = this.tokenReqObs.dpopJktMatched; let status: CheckStatus; let errorMessage: string | undefined; - if (sent !== undefined && matched) { + let untestable = false; + if (!this.tokenReqObs.recorded || !this.tokenReqObs.validProof) { + status = 'WARNING'; + untestable = true; + errorMessage = notTestable( + 'the client did not present a valid token-endpoint DPoP proof, so dpop_jkt key agreement could not be evaluated' + ); + } else if (sent !== undefined && matched) { status = 'SUCCESS'; } else if (sent !== undefined) { status = 'FAILURE'; @@ -283,7 +291,8 @@ export class DPoPClientScenario implements Scenario { errorMessage, details: { dpopJktSent: sent, - dpopJktMatched: matched + dpopJktMatched: matched, + ...(untestable ? { untestable: true } : {}) } }); } diff --git a/src/scenarios/client/auth/helpers/createAuthServer.test.ts b/src/scenarios/client/auth/helpers/createAuthServer.test.ts index d72b88c2..27495461 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.test.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.test.ts @@ -1,13 +1,19 @@ import { describe, it, expect } from 'vitest'; import type { ConformanceCheck } from '../../../../types'; import { testScenarioContext } from '../../../../mock-server/testing'; -import { generateDpopKeyPair, buildDpopProof } from './dpopProof'; +import { + generateDpopKeyPair, + buildDpopProof, + type DpopKeyPair +} from './dpopProof'; import { createAuthServer, type DpopTokenRequestObservation } from './createAuthServer'; import { ServerLifecycle } from './serverLifecycle'; +const REDIRECT = 'http://127.0.0.1:9876/callback'; + function newObs(): DpopTokenRequestObservation { return { recorded: false, @@ -18,6 +24,49 @@ function newObs(): DpopTokenRequestObservation { }; } +async function requestAuthorizationCode( + base: string, + keyPair: DpopKeyPair +): Promise { + const authorizeUrl = `${base}/authorize?${new URLSearchParams({ + response_type: 'code', + client_id: 'test', + redirect_uri: REDIRECT, + code_challenge: 'x', + code_challenge_method: 'S256', + dpop_jkt: keyPair.thumbprint + }).toString()}`; + const response = await fetch(authorizeUrl, { redirect: 'manual' }); + return new URL(response.headers.get('location')!).searchParams.get('code')!; +} + +async function exchangeAuthorizationCode( + base: string, + code: string, + keyPair: DpopKeyPair +): Promise { + const tokenEndpoint = `${base}/token`; + const proof = await buildDpopProof({ + keyPair, + htm: 'POST', + htu: tokenEndpoint + }); + return fetch(tokenEndpoint, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + dpop: proof + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT, + code_verifier: 'x', + client_id: 'test' + }) + }); +} + describe('createAuthServer — RFC 9449 §10 dpop_jkt binding', () => { it('rejects authorization_code with 400 invalid_grant when dpop_jkt does not match the proof key', async () => { const checks: ConformanceCheck[] = []; @@ -37,38 +86,12 @@ describe('createAuthServer — RFC 9449 §10 dpop_jkt binding', () => { try { const proofKp = await generateDpopKeyPair(); const otherKp = await generateDpopKeyPair(); - const authorizeUrl = `${lifecycle.getUrl()}/authorize?${new URLSearchParams( - { - response_type: 'code', - client_id: 'test', - redirect_uri: 'http://127.0.0.1:9876/callback', - code_challenge: 'x', - code_challenge_method: 'S256', - dpop_jkt: otherKp.thumbprint - } - ).toString()}`; - await fetch(authorizeUrl, { redirect: 'manual' }); - - const tokenEndpoint = `${lifecycle.getUrl()}/token`; - const proof = await buildDpopProof({ - keyPair: proofKp, - htm: 'POST', - htu: tokenEndpoint - }); - const tokenRes = await fetch(tokenEndpoint, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - dpop: proof - }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - code: 'test-auth-code', - redirect_uri: 'http://127.0.0.1:9876/callback', - code_verifier: 'x', - client_id: 'test' - }) - }); + const code = await requestAuthorizationCode(lifecycle.getUrl(), otherKp); + const tokenRes = await exchangeAuthorizationCode( + lifecycle.getUrl(), + code, + proofKp + ); expect(tokenRes.status).toBe(400); const body = (await tokenRes.json()) as { @@ -86,4 +109,49 @@ describe('createAuthServer — RFC 9449 §10 dpop_jkt binding', () => { await lifecycle.stop(); } }); + + it('binds dpop_jkt to each authorization code across overlapping flows', async () => { + const checks: ConformanceCheck[] = []; + const lifecycle = new ServerLifecycle(); + const obs = newObs(); + const app = createAuthServer( + testScenarioContext(), + checks, + lifecycle.getUrl, + { + dpopSigningAlgValuesSupported: ['ES256'], + dpopTokenRequestObs: obs, + loggingEnabled: false + } + ); + await lifecycle.start(app); + try { + const first = await generateDpopKeyPair(); + const second = await generateDpopKeyPair(); + const firstCode = await requestAuthorizationCode( + lifecycle.getUrl(), + first + ); + const secondCode = await requestAuthorizationCode( + lifecycle.getUrl(), + second + ); + + for (const [code, keyPair] of [ + [firstCode, first], + [secondCode, second] + ] as const) { + const response = await exchangeAuthorizationCode( + lifecycle.getUrl(), + code, + keyPair + ); + expect(response.status).toBe(200); + expect(obs.dpopJktSent).toBe(keyPair.thumbprint); + expect(obs.dpopJktMatched).toBe(true); + } + } finally { + await lifecycle.stop(); + } + }); }); diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 8aeaa649..0032f7fc 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -1,5 +1,5 @@ import express, { Request, Response } from 'express'; -import { createHash } from 'crypto'; +import { createHash, randomBytes } from 'crypto'; import type { ConformanceCheck } from '../../../../types'; import type { ScenarioContext } from '../../../../mock-server'; import { isStatefulVersion } from '../../../../connection/select'; @@ -165,6 +165,12 @@ export interface DpopTokenRequestObservation { dpopJktMatched: boolean; } +interface StoredAuthorizationCode { + codeChallenge?: string; + dpopJkt?: string; + scopes: string[]; +} + export interface AuthServerOptions { metadataPath?: string; isOpenIdConfiguration?: boolean; @@ -282,12 +288,9 @@ export function createAuthServer( onRegistrationRequest } = options; - // Track scopes from the most recent authorization request - let lastAuthorizationScopes: string[] = []; - // Track PKCE code_challenge for verification in token request - let storedCodeChallenge: string | undefined; - // RFC 9449 §10: dpop_jkt from the authorization request, if the client sent it. - let storedDpopJkt: string | undefined; + // Authorization-request state is bound to the code it produced. Keeping this + // per-code prevents overlapping flows from overwriting PKCE or DPoP binding. + const authorizationCodes = new Map(); // Lazily-created issuer key for minting DPoP-bound JWT access tokens. let dpopIssuerKey: TokenIssuerKey | undefined; // DPoP behaviour is active only when the caller opts in (any DPoP option). @@ -433,15 +436,10 @@ export function createAuthServer( } }); - // PKCE: Store code_challenge for later verification const codeChallenge = req.query.code_challenge as string | undefined; const codeChallengeMethod = req.query.code_challenge_method as | string | undefined; - storedCodeChallenge = codeChallenge; - // RFC 9449 §10: capture dpop_jkt so the token endpoint can bind the - // authorization code to the client's DPoP key. - storedDpopJkt = req.query.dpop_jkt as string | undefined; // PKCE: Check code_challenge is present checks.push({ @@ -471,9 +469,13 @@ export function createAuthServer( } }); - // Track scopes from authorization request for token issuance const scopeParam = req.query.scope as string | undefined; - lastAuthorizationScopes = scopeParam ? scopeParam.split(' ') : []; + const authorizationCode = randomBytes(32).toString('base64url'); + authorizationCodes.set(authorizationCode, { + codeChallenge, + dpopJkt: req.query.dpop_jkt as string | undefined, + scopes: scopeParam ? scopeParam.split(' ') : [] + }); if (onAuthorizationRequest) { onAuthorizationRequest({ @@ -487,7 +489,7 @@ export function createAuthServer( const redirectUri = req.query.redirect_uri as string; const state = req.query.state as string; const redirectUrl = new URL(redirectUri); - redirectUrl.searchParams.set('code', 'test-auth-code'); + redirectUrl.searchParams.set('code', authorizationCode); if (state) { redirectUrl.searchParams.set('state', state); } @@ -514,6 +516,10 @@ export function createAuthServer( const timestamp = new Date().toISOString(); const requestedScope = req.body.scope; const grantType = req.body.grant_type; + const authorizationCode = req.body.code as string | undefined; + const authorizationCodeState = authorizationCode + ? authorizationCodes.get(authorizationCode) + : undefined; checks.push({ id: 'token-request', @@ -531,6 +537,9 @@ export function createAuthServer( // PKCE: Check code_verifier is present (only for authorization_code grant) const codeVerifier = req.body.code_verifier as string | undefined; if (grantType === 'authorization_code') { + if (dpopTokenRequestObs) { + dpopTokenRequestObs.dpopJktSent = authorizationCodeState?.dpopJkt; + } checks.push({ id: 'pkce-code-verifier-sent', name: 'PKCE Code Verifier', @@ -544,6 +553,7 @@ export function createAuthServer( // PKCE: Validate code_verifier matches code_challenge (S256) // Fail if either is missing + const storedCodeChallenge = authorizationCodeState?.codeChallenge; const computedChallenge = codeVerifier && storedCodeChallenge ? computeS256Challenge(codeVerifier) @@ -608,7 +618,7 @@ export function createAuthServer( const tokenEndpointUrl = `${getAuthBaseUrl()}${authRoutes.token_endpoint}`; const grantedScopes = requestedScope ? requestedScope.split(' ') - : lastAuthorizationScopes; + : (authorizationCodeState?.scopes ?? []); // No proof ⇒ DPoP is not being exercised here; fall through to a Bearer // token. (Requiring a proof is a per-client `dpop_bound_access_tokens` @@ -640,8 +650,8 @@ export function createAuthServer( // code, so invalid_grant by analogy with PKCE failure. Only // authorization_code grants count, same gating as // recordTokenRequestProof. + const storedDpopJkt = authorizationCodeState?.dpopJkt; if (grantType === 'authorization_code' && dpopTokenRequestObs) { - dpopTokenRequestObs.dpopJktSent = storedDpopJkt; dpopTokenRequestObs.dpopJktMatched = storedDpopJkt !== undefined && storedDpopJkt === result.jkt; } @@ -722,7 +732,7 @@ export function createAuthServer( } let token = `test-token-${Date.now()}`; - let scopes: string[] = lastAuthorizationScopes; + let scopes: string[] = authorizationCodeState?.scopes ?? []; if (onTokenRequest) { const result = await onTokenRequest({ diff --git a/src/seps/sep-1932.yaml b/src/seps/sep-1932.yaml index cf6acaf1..220d0fbc 100644 --- a/src/seps/sep-1932.yaml +++ b/src/seps/sep-1932.yaml @@ -7,11 +7,10 @@ requirements: text: 'When making requests to protected MCP server resources, clients MUST include a fresh DPoP proof in the `DPoP` header' - check: sep-1932-client-token-request-proof text: 'To obtain a DPoP-bound access token, the client MUST include a DPoP proof in the `DPoP` header of its token request (RFC 9449 Section 5)' - # SEP-1932 requires the client to bind the authorization code to its DPoP key - # with dpop_jkt. The sentence is RFC 9449 §10. + # Proposed SEP-1932 client guidance; RFC 9449 §10 currently makes this optional. - check: sep-1932-client-dpop-jkt - text: 'When a token request is received, the authorization server computes the JWK Thumbprint of the proof-of-possession public key in the DPoP proof and verifies that it matches the dpop_jkt parameter value in the authorization request. If they do not match, it MUST reject the request.' - url: https://www.rfc-editor.org/rfc/rfc9449.html#section-10 + text: 'Clients using the authorization code grant SHOULD bind the authorization code to their DPoP key using the dpop_jkt authorization request parameter.' + url: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1932 - check: sep-1932-client-as-nonce text: 'When the authorization server responds with `use_dpop_nonce`, the client MUST retry the token request with a DPoP proof that includes the supplied `nonce` (RFC 9449 Section 8)' - check: sep-1932-client-rs-nonce