From 0b2d21bb0876a9bc99e4319f19ded5f6db3978db Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:40:41 +0000 Subject: [PATCH 01/15] client/dpop: pin the SEP-1932 extension spec URL to a commit instead of a personal working branch --- src/scenarios/client/auth/spec-references.ts | 4 +++- src/seps/sep-1932.yaml | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 972417bd..351e1e29 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -121,7 +121,9 @@ export const SpecReferences: { [key: string]: SpecReference } = { }, DPOP_EXTENSION: { id: 'MCP-DPoP-Extension', - url: 'https://github.com/modelcontextprotocol/ext-auth/blob/pieterkas-dpop-extension/specification/draft/dpop-extension.mdx' + // Commit-pinned so reports don't dead-link when the working branch moves; + // TODO: switch to the main-branch path once the doc merges into ext-auth. + url: 'https://github.com/modelcontextprotocol/ext-auth/blob/f2f94539fe4dc28aa53b7caed88f929aba02aec6/specification/draft/dpop-extension.mdx' }, RFC_9449_PROOF_SYNTAX: { id: 'RFC-9449-dpop-proof-jwt-syntax', diff --git a/src/seps/sep-1932.yaml b/src/seps/sep-1932.yaml index c3c650f3..4c340f1a 100644 --- a/src/seps/sep-1932.yaml +++ b/src/seps/sep-1932.yaml @@ -1,5 +1,7 @@ sep: 1932 -spec_url: https://github.com/modelcontextprotocol/ext-auth/blob/pieterkas-dpop-extension/specification/draft/dpop-extension.mdx +# Commit-pinned so the link survives the working branch moving; switch to the +# main-branch path once the extension doc merges into ext-auth. +spec_url: https://github.com/modelcontextprotocol/ext-auth/blob/f2f94539fe4dc28aa53b7caed88f929aba02aec6/specification/draft/dpop-extension.mdx requirements: - check: sep-1932-client-dpop-auth-scheme text: 'When making requests to protected MCP server resources, clients MUST include the access token using the `Authorization` header with the `DPoP` authentication scheme as specified in RFC 9449 Section 7.1' From 1e31849efa61480324821a9440ac5b002582fe8b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:42:12 +0000 Subject: [PATCH 02/15] client/dpop: attribute an unverifiable access token to the token, not the proof --- src/scenarios/client/auth/dpop.ts | 6 ++++- .../client/auth/helpers/dpopResourceAuth.ts | 22 ++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts index e993369d..0e435373 100644 --- a/src/scenarios/client/auth/dpop.ts +++ b/src/scenarios/client/auth/dpop.ts @@ -326,7 +326,11 @@ export class DPoPClientScenario implements Scenario { errorMessage = 'Client never presented an access token to the MCP server'; } else if (!this.obs.allProofsWellFormed) { status = 'FAILURE'; - errorMessage = `DPoP proof was missing or malformed: ${this.obs.proofError}`; + // Attribute the failure to the right layer: a defect in the proof itself + // vs. an access token the proof cannot be validated against. + errorMessage = this.obs.proofError + ? `DPoP proof was missing or malformed: ${this.obs.proofError}` + : `DPoP proof could not be validated against the presented access token: ${this.obs.tokenError}`; } else if (this.obs.replayDetected) { status = 'FAILURE'; errorMessage = diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts index 33f44be4..88561d29 100644 --- a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts @@ -32,7 +32,10 @@ export interface DpopClientObservations { jtisSeen: string[]; replayDetected: boolean; allProofsWellFormed: boolean; + /** First defect in a proof itself (malformed/mismatched claims, bad signature). */ proofError?: string; + /** First defect in the presented access token (e.g. not a DPoP-bound JWT). */ + tokenError?: string; /** The judge issued a `use_dpop_nonce` challenge (RFC 9449 §9). */ rsNonceChallengeIssued: boolean; /** The client retried the request carrying the correct nonce. */ @@ -103,7 +106,8 @@ export function createDpopResourceAuth( ); if (!result.ok) { obs.allProofsWellFormed = false; - obs.proofError ??= result.error; + if (result.tokenProblem) obs.tokenError ??= result.error; + else obs.proofError ??= result.error; next(); return; } @@ -191,7 +195,12 @@ export async function validateResourceProof( token: string, method: string, resourceUrl: string -): Promise<{ ok: true; jti: string } | { ok: false; error: string }> { +): Promise< + | { ok: true; jti: string } + // `tokenProblem` marks defects in the presented access token, as opposed to + // the proof itself, so the scenario can attribute the failure accurately. + | { ok: false; error: string; tokenProblem?: boolean } +> { if (!proof) return { ok: false, error: 'missing DPoP proof header' }; // RFC 9449 §4.2: at most one DPoP header. A single proof JWT has no comma, so // a comma means duplicate headers were sent (Node joins them with ", "). @@ -272,7 +281,14 @@ export async function validateResourceProof( }; } } catch { - return { ok: false, error: 'access token is not a decodable JWT' }; + // The token, not the proof, is at fault (e.g. an opaque Bearer token from + // a proof-less token request). RFC 9449 §6.2 permits non-JWT bound tokens + // via introspection, but this self-contained harness only mints JWTs. + return { + ok: false, + error: 'presented access token is not a DPoP-bound JWT', + tokenProblem: true + }; } return { ok: true, jti: claims.jti }; From 70dd6f83d378c64f9d0a9bbf74d5d28ea6480ca1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:43:16 +0000 Subject: [PATCH 03/15] client/dpop: reference client sends RFC 8707 resource, validates state/iss, and surfaces the OAuth error body --- .../typescript/helpers/dpopClientFlow.ts | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/examples/clients/typescript/helpers/dpopClientFlow.ts b/examples/clients/typescript/helpers/dpopClientFlow.ts index f4cb81ff..ef512b3c 100644 --- a/examples/clients/typescript/helpers/dpopClientFlow.ts +++ b/examples/clients/typescript/helpers/dpopClientFlow.ts @@ -55,6 +55,9 @@ export async function runDpopClient( ); const prm = await (await fetch(prmUrl.toString())).json(); const authServerUrl: string = prm.authorization_servers[0]; + // RFC 8707 resource indicator — sent in both the authorization request and + // the token request, per the MCP authorization spec. + const resource: string = prm.resource; // 2. Authorization server metadata. const asMeta = await ( @@ -96,7 +99,8 @@ export async function runDpopClient( state, redirect_uri: REDIRECT_URI, code_challenge: codeChallenge, - code_challenge_method: 'S256' + code_challenge_method: 'S256', + resource }).toString()}`; const authorizeResponse = await request(authorizeUrl, { method: 'GET' }); await authorizeResponse.body.text().catch(() => undefined); @@ -105,8 +109,20 @@ export async function runDpopClient( if (!locationStr) { throw new Error('Authorization endpoint did not redirect with a code'); } - const code = new URL(locationStr).searchParams.get('code'); + const callbackParams = new URL(locationStr).searchParams; + const code = callbackParams.get('code'); if (!code) throw new Error('No authorization code in redirect'); + // CSRF binding: the callback must echo our state (OAuth 2.1 §7.1). + if (callbackParams.get('state') !== state) { + throw new Error('Authorization response state does not match the request'); + } + // RFC 9207: when the AS returns iss, it must equal the metadata issuer. + const callbackIss = callbackParams.get('iss'); + if (callbackIss !== null && callbackIss !== asMeta.issuer) { + throw new Error( + `Authorization response iss "${callbackIss}" does not match the metadata issuer "${asMeta.issuer}"` + ); + } // 5. Token request with a DPoP proof → DPoP-bound access token. The broken // `sendTokenRequestProof:false` variant omits the proof, so the AS issues an @@ -116,7 +132,8 @@ export async function runDpopClient( code, redirect_uri: REDIRECT_URI, code_verifier: codeVerifier, - client_id: clientId + client_id: clientId, + resource }).toString(); const requestToken = async (nonce?: string): Promise => { const headers: Record = { @@ -156,7 +173,17 @@ export async function runDpopClient( } } if (!tokenResponse.ok) { - throw new Error(`Token request failed: HTTP ${tokenResponse.status}`); + // Surface the OAuth error body — error_description carries the actionable + // detail (e.g. which DPoP proof claim the AS rejected). + const body = await tokenResponse + .json() + .catch(() => ({}) as { error?: string; error_description?: string }); + const detail = [body.error, body.error_description] + .filter(Boolean) + .join(': '); + throw new Error( + `Token request failed: HTTP ${tokenResponse.status}${detail ? ` (${detail})` : ''}` + ); } const tokenBody = await tokenResponse.json(); const accessToken: string = tokenBody.access_token; From 0c9e6e400f13f38e1149bfb5d6c91cb280a96283 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:45:08 +0000 Subject: [PATCH 04/15] =?UTF-8?q?client/dpop:=20drop=20the=20trailing-slas?= =?UTF-8?q?h=20htu=20leniency;=20normalize=20strictly=20per=20RFC=203986/9?= =?UTF-8?q?449=20=C2=A74.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/auth/helpers/createAuthServer.ts | 13 ++++++------- .../auth/helpers/dpopResourceAuth.test.ts | 18 ++++++++++++++++-- .../client/auth/helpers/dpopResourceAuth.ts | 13 ++++++------- .../validateDpopProofAtTokenEndpoint.test.ts | 19 +++++++++++++++++-- 4 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 162f93aa..79ce6fd8 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -44,16 +44,15 @@ const DPOP_ASYMMETRIC_ALGS = [ ]; /** - * Canonicalize an `htu` for comparison per RFC 9449 §4.3 (RFC 3986 scheme-based - * normalization): lowercase scheme/host, drop the default port, ignore a - * trailing slash. Query/fragment are rejected by the caller (RFC 9449 §4.2), - * not silently stripped here. + * Canonicalize an `htu` for comparison per RFC 9449 §4.3 (RFC 3986 + * syntax-based normalization): lowercase scheme/host, drop the default port, + * empty path → `/`. No leniency beyond that — `/mcp/` and `/mcp` are distinct + * URIs. Query/fragment are rejected by the caller (RFC 9449 §4.2), not + * silently stripped here. */ function normalizeHtu(u: string): string { try { - const url = new URL(u); - // Tolerate a single trailing slash only; `/mcp//` is a distinct path. - return `${url.protocol}//${url.host}${url.pathname.replace(/\/$/, '')}`; + return new URL(u).href; } catch { return u; } diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts index 8d20cf7a..840b069f 100644 --- a/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts @@ -39,18 +39,32 @@ describe('validateResourceProof — accepts a well-formed resource proof', () => expect(result.ok).toBe(true); }); - it('accepts an htu that differs only by normalization (trailing slash)', async () => { + it('accepts an htu that differs only by RFC 3986 normalization (case, default port)', async () => { const kp = await generateDpopKeyPair(); const token = await boundToken(kp); const proof = await buildDpopProof({ keyPair: kp, htm: 'POST', - htu: `${RESOURCE}/`, + htu: 'HTTPS://MCP.EXAMPLE.COM:443/mcp', accessToken: token }); const result = await validateResourceProof(proof, token, 'POST', RESOURCE); expect(result.ok).toBe(true); }); + + it('rejects an htu with a spurious trailing slash (a distinct URI per RFC 3986)', async () => { + const kp = await generateDpopKeyPair(); + const token = await boundToken(kp); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: `${RESOURCE}/`, + accessToken: token + }); + const result = await validateResourceProof(proof, token, 'POST', RESOURCE); + expect(result.ok).toBe(false); + expect(result.ok ? '' : result.error).toMatch(/htu/); + }); }); describe('validateResourceProof — rejects each single defect', () => { diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts index 88561d29..4b03c211 100644 --- a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts @@ -169,16 +169,15 @@ function splitAuthorization(authorization: string): { } /** - * Canonicalize an `htu` for comparison per RFC 9449 §4.3 (RFC 3986 scheme-based - * normalization): lowercase scheme/host, drop the default port, ignore a - * trailing slash. Query/fragment are rejected by the caller (RFC 9449 §4.2), - * not silently stripped here. + * Canonicalize an `htu` for comparison per RFC 9449 §4.3 (RFC 3986 + * syntax-based normalization): lowercase scheme/host, drop the default port, + * empty path → `/`. No leniency beyond that — `/mcp/` and `/mcp` are distinct + * URIs. Query/fragment are rejected by the caller (RFC 9449 §4.2), not + * silently stripped here. */ function normalizeHtu(u: string): string { try { - const url = new URL(u); - // Tolerate a single trailing slash only; `/mcp//` is a distinct path. - return `${url.protocol}//${url.host}${url.pathname.replace(/\/$/, '')}`; + return new URL(u).href; } catch { return u; } diff --git a/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts b/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts index e4a23102..f76fb7c8 100644 --- a/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts +++ b/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts @@ -30,12 +30,12 @@ describe('validateDpopProofAtTokenEndpoint — accepts a valid token-request pro expect(result.ok ? result.jkt : '').toBe(kp.thumbprint); }); - it('accepts an htu differing only by a single trailing slash', async () => { + it('accepts an htu differing only by RFC 3986 normalization (case, default port)', async () => { const kp = await generateDpopKeyPair(); const proof = await buildDpopProof({ keyPair: kp, htm: 'POST', - htu: `${TOKEN_ENDPOINT}/` + htu: 'HTTPS://AUTH.EXAMPLE.COM:443/token' }); const result = await validateDpopProofAtTokenEndpoint( proof, @@ -43,6 +43,21 @@ describe('validateDpopProofAtTokenEndpoint — accepts a valid token-request pro ); expect(result.ok).toBe(true); }); + + it('rejects an htu with a spurious trailing slash (a distinct URI per RFC 3986)', async () => { + const kp = await generateDpopKeyPair(); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: `${TOKEN_ENDPOINT}/` + }); + const result = await validateDpopProofAtTokenEndpoint( + proof, + TOKEN_ENDPOINT + ); + expect(result.ok).toBe(false); + expect(result.ok ? '' : result.error).toMatch(/htu/); + }); }); describe('validateDpopProofAtTokenEndpoint — rejects each single defect', () => { From d1270693f3b4aa90049afb8d070d011e4bb1eae2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:45:34 +0000 Subject: [PATCH 05/15] client/dpop: reject DPoP sub-options on a DPoP-disabled test AS instead of silently ignoring them --- src/scenarios/client/auth/helpers/createAuthServer.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 79ce6fd8..29a9cd2f 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -280,6 +280,12 @@ export function createAuthServer( const dpopEnabled = dpopSigningAlgValuesSupported !== undefined || dpopMisbehavior !== undefined; + // Sub-options without DPoP enabled would be a silent no-op — fail fast. + if (!dpopEnabled && (dpopRequireNonce || dpopTokenRequestObs)) { + throw new Error( + 'dpopRequireNonce/dpopTokenRequestObs require DPoP to be enabled (set dpopSigningAlgValuesSupported or dpopMisbehavior)' + ); + } // Records whether the client presented a valid DPoP proof at its // authorization_code token request (RFC 9449 §5) into the caller's observation From eca0c8de7745a4f30efdcfce886d65475b6a99a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:48:29 +0000 Subject: [PATCH 06/15] =?UTF-8?q?client/dpop:=20share=20one=20DPoP=20alg?= =?UTF-8?q?=20list;=20advertise=20what=20is=20enforced=20and=20reject=20un?= =?UTF-8?q?advertised=20algs=20(RFC=209449=20=C2=A75.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scenarios/client/auth/dpop.ts | 5 ++- .../client/auth/helpers/createAuthServer.ts | 40 ++++++++++--------- src/scenarios/client/auth/helpers/dpopAlgs.ts | 17 ++++++++ .../auth/helpers/dpopResourceAuth.test.ts | 2 +- .../client/auth/helpers/dpopResourceAuth.ts | 17 ++------ .../validateDpopProofAtTokenEndpoint.test.ts | 21 +++++++++- 6 files changed, 67 insertions(+), 35 deletions(-) create mode 100644 src/scenarios/client/auth/helpers/dpopAlgs.ts diff --git a/src/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts index 0e435373..fd2e91b4 100644 --- a/src/scenarios/client/auth/dpop.ts +++ b/src/scenarios/client/auth/dpop.ts @@ -17,6 +17,7 @@ import { newDpopClientObservations, type DpopClientObservations } from './helpers/dpopResourceAuth'; +import { DPOP_ASYMMETRIC_ALGS } from './helpers/dpopAlgs'; import { SpecReferences } from './spec-references'; const PRM_PATH = '/.well-known/oauth-protected-resource/mcp'; @@ -175,7 +176,9 @@ export class DPoPClientScenario implements Scenario { this.tokenReqObs = newTokenReqObs(); const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { - dpopSigningAlgValuesSupported: ['ES256'], + // Advertise exactly what the validators enforce, so a client honoring + // RFC 9449 §5.1 alg negotiation is graded the same as one that doesn't. + dpopSigningAlgValuesSupported: DPOP_ASYMMETRIC_ALGS, dpopTokenRequestObs: this.tokenReqObs, dpopRequireNonce: this.requireNonce }); diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 29a9cd2f..3797bc9b 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -7,6 +7,7 @@ import { createRequestLogger } from '../../../request-logger'; import { SpecReferences } from '../spec-references'; import { MockTokenVerifier } from './mockTokenVerifier'; import * as jose from 'jose'; +import { DPOP_ASYMMETRIC_ALGS } from './dpopAlgs'; import { generateIssuerKey, mintDpopBoundToken, @@ -29,20 +30,6 @@ function computeS256Challenge(codeVerifier: string): string { // Fixed nonce the test AS hands out when `dpopRequireNonce` is set (RFC 9449 §8). const AS_DPOP_NONCE = 'conformance-as-dpop-nonce'; -// Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3, §11.6). -const DPOP_ASYMMETRIC_ALGS = [ - 'ES256', - 'ES384', - 'ES512', - 'RS256', - 'RS384', - 'RS512', - 'PS256', - 'PS384', - 'PS512', - 'EdDSA' -]; - /** * Canonicalize an `htu` for comparison per RFC 9449 §4.3 (RFC 3986 * syntax-based normalization): lowercase scheme/host, drop the default port, @@ -62,12 +49,22 @@ function normalizeHtu(u: string): string { * Validate a DPoP proof presented at the token endpoint (the token-request * subset of RFC 9449 §4.3). Hand-rolled with jose primitives — deliberately an * INDEPENDENT code path from the suite's proof builder, so a shared bug - * surfaces rather than hides. Returns the JWK thumbprint to bind on success. + * surfaces rather than hides. (Kept as a deliberate two-copy contract with + * validateResourceProof in dpopResourceAuth.ts — apply fixes to both.) + * Returns the JWK thumbprint to bind on success. + * + * `advertisedAlgs` is the AS's `dpop_signing_alg_values_supported`; proofs + * signed with an alg outside it are rejected (RFC 9449 §5.1), with an + * asymmetric-only floor (§11.6) regardless of what is advertised. */ export async function validateDpopProofAtTokenEndpoint( proof: string, - tokenEndpointUrl: string + tokenEndpointUrl: string, + advertisedAlgs: string[] = DPOP_ASYMMETRIC_ALGS ): Promise<{ ok: true; jkt: string } | { ok: false; error: string }> { + const acceptedAlgs = advertisedAlgs.filter((a) => + DPOP_ASYMMETRIC_ALGS.includes(a) + ); let header: jose.ProtectedHeaderParameters; try { header = jose.decodeProtectedHeader(proof); @@ -80,6 +77,12 @@ export async function validateDpopProofAtTokenEndpoint( if (!header.alg || !DPOP_ASYMMETRIC_ALGS.includes(header.alg)) { return { ok: false, error: 'alg must be a supported asymmetric algorithm' }; } + if (!acceptedAlgs.includes(header.alg)) { + return { + ok: false, + error: `alg ${header.alg} is not among the advertised dpop_signing_alg_values_supported (${advertisedAlgs.join(', ')})` + }; + } const jwk = header.jwk as jose.JWK | undefined; if (!jwk) { return { ok: false, error: 'missing jwk header parameter' }; @@ -91,7 +94,7 @@ export async function validateDpopProofAtTokenEndpoint( try { const key = await jose.importJWK(jwk, header.alg); claims = ( - await jose.jwtVerify(proof, key, { algorithms: DPOP_ASYMMETRIC_ALGS }) + await jose.jwtVerify(proof, key, { algorithms: acceptedAlgs }) ).payload; } catch { return { ok: false, error: 'DPoP proof signature does not verify' }; @@ -607,7 +610,8 @@ export function createAuthServer( if (proof) { const result = await validateDpopProofAtTokenEndpoint( proof, - tokenEndpointUrl + tokenEndpointUrl, + dpopSigningAlgValuesSupported ); if (!result.ok) { recordTokenRequestProof(grantType, false, result.error); diff --git a/src/scenarios/client/auth/helpers/dpopAlgs.ts b/src/scenarios/client/auth/helpers/dpopAlgs.ts new file mode 100644 index 00000000..c8c6bb02 --- /dev/null +++ b/src/scenarios/client/auth/helpers/dpopAlgs.ts @@ -0,0 +1,17 @@ +/** + * Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3, §11.6). + * Single source of truth shared by the test AS metadata and both proof + * validators, so what is advertised and what is enforced cannot drift. + */ +export const DPOP_ASYMMETRIC_ALGS = [ + 'ES256', + 'ES384', + 'ES512', + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'EdDSA' +]; diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts index 840b069f..cd62c406 100644 --- a/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts @@ -25,7 +25,7 @@ async function boundToken( }); } -describe('validateResourceProof — accepts a well-formed resource proof', () => { +describe('validateResourceProof — baseline acceptance and htu normalization', () => { it('accepts a valid proof bound to the presented token', async () => { const kp = await generateDpopKeyPair(); const token = await boundToken(kp); diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts index 4b03c211..8d147fd6 100644 --- a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts @@ -1,24 +1,11 @@ import type { Request, Response, NextFunction, RequestHandler } from 'express'; import * as jose from 'jose'; import { createHash } from 'node:crypto'; +import { DPOP_ASYMMETRIC_ALGS } from './dpopAlgs'; /** Fixed nonce the judge hands out when `requireNonce` is set (RFC 9449 §9). */ const RS_DPOP_NONCE = 'conformance-rs-dpop-nonce'; -/** Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3 / §11.6). */ -const DPOP_ASYMMETRIC_ALGS = [ - 'ES256', - 'ES384', - 'ES512', - 'RS256', - 'RS384', - 'RS512', - 'PS256', - 'PS384', - 'PS512', - 'EdDSA' -]; - /** * Accumulated observations of how the client presented its access token and * per-request DPoP proof. The scenario turns these into the two @@ -188,6 +175,8 @@ function normalizeHtu(u: string): string { * (RFC 9449 §4.3, resource-request subset): well-formed `dpop+jwt`, asymmetric * alg, embedded public jwk, htm/htu match, `ath` binds the token, signature * verifies, and the proof key's thumbprint matches the token's `cnf.jkt`. + * (Kept as a deliberate two-copy contract with validateDpopProofAtTokenEndpoint + * in createAuthServer.ts — apply fixes to both.) */ export async function validateResourceProof( proof: string | undefined, diff --git a/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts b/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts index f76fb7c8..bbe30d28 100644 --- a/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts +++ b/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts @@ -14,7 +14,7 @@ const TOKEN_ENDPOINT = 'https://auth.example.com/token'; * cover the token-request subset of RFC 9449 §4.3 (no `ath`/`cnf` — there is no * access token yet at the token request). */ -describe('validateDpopProofAtTokenEndpoint — accepts a valid token-request proof', () => { +describe('validateDpopProofAtTokenEndpoint — baseline acceptance, htu normalization, alg negotiation', () => { it('accepts a well-formed proof and returns the JWK thumbprint', async () => { const kp = await generateDpopKeyPair(); const proof = await buildDpopProof({ @@ -44,6 +44,25 @@ describe('validateDpopProofAtTokenEndpoint — accepts a valid token-request pro expect(result.ok).toBe(true); }); + it('rejects a proof signed with an alg outside the advertised list (RFC 9449 §5.1)', async () => { + const kp = await generateDpopKeyPair(); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: TOKEN_ENDPOINT + }); + // Proof is ES256; the AS advertises RS256 only. + const result = await validateDpopProofAtTokenEndpoint( + proof, + TOKEN_ENDPOINT, + ['RS256'] + ); + expect(result.ok).toBe(false); + expect(result.ok ? '' : result.error).toMatch( + /dpop_signing_alg_values_supported/ + ); + }); + it('rejects an htu with a spurious trailing slash (a distinct URI per RFC 3986)', async () => { const kp = await generateDpopKeyPair(); const proof = await buildDpopProof({ From ea37354554a9cb340f830efdc17a1ad037b68832 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:49:02 +0000 Subject: [PATCH 07/15] client/dpop: pin expectedSuccessSlugs on the baseline negative tests (single-defect isolation) --- src/scenarios/client/auth/index.test.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 57ff27ac..8ab389b1 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -356,15 +356,25 @@ describe('WIF JWT-bearer negative tests', () => { describe('DPoP client negative tests (SEP-1932)', () => { test('auth/dpop: client presents the token with the Bearer scheme', async () => { const runner = new InlineClientRunner(dpopBearerClient); + // expectedSuccessSlugs pins single-defect isolation: expectedFailureSlugs + // alone only asserts a subset of the actual failures. await runClientAgainstScenario(runner, 'auth/dpop', { - expectedFailureSlugs: ['sep-1932-client-dpop-auth-scheme'] + expectedFailureSlugs: ['sep-1932-client-dpop-auth-scheme'], + expectedSuccessSlugs: [ + 'sep-1932-client-token-request-proof', + 'sep-1932-client-fresh-proof' + ] }); }); test('auth/dpop: client reuses a DPoP proof across requests', async () => { const runner = new InlineClientRunner(dpopReplayClient); await runClientAgainstScenario(runner, 'auth/dpop', { - expectedFailureSlugs: ['sep-1932-client-fresh-proof'] + expectedFailureSlugs: ['sep-1932-client-fresh-proof'], + expectedSuccessSlugs: [ + 'sep-1932-client-dpop-auth-scheme', + 'sep-1932-client-token-request-proof' + ] }); }); @@ -377,7 +387,8 @@ describe('DPoP client negative tests (SEP-1932)', () => { expectedFailureSlugs: [ 'sep-1932-client-token-request-proof', 'sep-1932-client-fresh-proof' - ] + ], + expectedSuccessSlugs: ['sep-1932-client-dpop-auth-scheme'] }); }); From 2436e53757b67ef2b6745f81b6603bf91695cfd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:54:26 +0000 Subject: [PATCH 08/15] client/dpop: resource judge verifies the presented token against the AS issuer key and the jkt bound at the token endpoint --- src/scenarios/client/auth/dpop.ts | 14 +- .../client/auth/helpers/createAuthServer.ts | 26 +++- .../auth/helpers/dpopResourceAuth.test.ts | 123 ++++++++++++++++-- .../client/auth/helpers/dpopResourceAuth.ts | 76 ++++++++--- 4 files changed, 206 insertions(+), 33 deletions(-) diff --git a/src/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts index fd2e91b4..3ff77247 100644 --- a/src/scenarios/client/auth/dpop.ts +++ b/src/scenarios/client/auth/dpop.ts @@ -18,6 +18,7 @@ import { type DpopClientObservations } from './helpers/dpopResourceAuth'; import { DPOP_ASYMMETRIC_ALGS } from './helpers/dpopAlgs'; +import { generateIssuerKey } from './helpers/dpopToken'; import { SpecReferences } from './spec-references'; const PRM_PATH = '/.well-known/oauth-protected-resource/mcp'; @@ -175,12 +176,16 @@ export class DPoPClientScenario implements Scenario { this.obs = newDpopClientObservations(); this.tokenReqObs = newTokenReqObs(); + // The scenario owns the issuer key: the AS mints tokens with it and the + // resource judge verifies presented tokens against it. + const issuerKey = await generateIssuerKey(); const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { // Advertise exactly what the validators enforce, so a client honoring // RFC 9449 §5.1 alg negotiation is graded the same as one that doesn't. dpopSigningAlgValuesSupported: DPOP_ASYMMETRIC_ALGS, dpopTokenRequestObs: this.tokenReqObs, - dpopRequireNonce: this.requireNonce + dpopRequireNonce: this.requireNonce, + dpopIssuerKey: issuerKey }); await this.authServer.start(authApp); @@ -194,7 +199,12 @@ export class DPoPClientScenario implements Scenario { this.obs, () => `${this.server.getUrl()}/mcp`, () => `${this.server.getUrl()}${PRM_PATH}`, - this.requireNonce + this.requireNonce, + { + issuerKey, + getIssuer: () => this.authServer.getUrl(), + getBoundJkt: () => this.tokenReqObs.jkt + } ) } ); diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 3797bc9b..3470d011 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -93,9 +93,8 @@ export async function validateDpopProofAtTokenEndpoint( let claims: jose.JWTPayload; try { const key = await jose.importJWK(jwk, header.alg); - claims = ( - await jose.jwtVerify(proof, key, { algorithms: acceptedAlgs }) - ).payload; + claims = (await jose.jwtVerify(proof, key, { algorithms: acceptedAlgs })) + .payload; } catch { return { ok: false, error: 'DPoP proof signature does not verify' }; } @@ -154,6 +153,11 @@ export interface DpopTokenRequestObservation { asNonceChallengeIssued: boolean; /** The client retried the token request carrying the correct nonce. */ asNonceHonored: boolean; + /** + * `cnf.jkt` bound into the most recently issued token, so the resource judge + * can assert the token presented at the MCP server is the one issued here. + */ + jkt?: string; } export interface AuthServerOptions { @@ -209,6 +213,12 @@ export interface AuthServerOptions { | 'unbound-token'; /** Sink for the DPoP token-request observation; see the interface docstring. */ dpopTokenRequestObs?: DpopTokenRequestObservation; + /** + * Issuer key for minting DPoP-bound tokens. Supply it when the scenario also + * needs to VERIFY the issued tokens (e.g. the resource judge); lazily + * generated when omitted. + */ + dpopIssuerKey?: TokenIssuerKey; /** * When true, the token endpoint requires a DPoP nonce (RFC 9449 §8): a * proof-bearing request without the correct `nonce` claim is answered with @@ -277,8 +287,9 @@ export function createAuthServer( let lastAuthorizationScopes: string[] = []; // Track PKCE code_challenge for verification in token request let storedCodeChallenge: string | undefined; - // Lazily-created issuer key for minting DPoP-bound JWT access tokens. - let dpopIssuerKey: TokenIssuerKey | undefined; + // Issuer key for minting DPoP-bound JWT access tokens (caller-supplied or + // lazily created). + let dpopIssuerKey: TokenIssuerKey | undefined = options.dpopIssuerKey; // DPoP behaviour is active only when the caller opts in (any DPoP option). const dpopEnabled = dpopSigningAlgValuesSupported !== undefined || @@ -668,6 +679,11 @@ export function createAuthServer( if (!dpopIssuerKey) { dpopIssuerKey = await generateIssuerKey(); } + // Record the bound thumbprint so the resource judge can assert the + // token presented at the MCP server is the one issued here. + if (grantType === 'authorization_code' && dpopTokenRequestObs) { + dpopTokenRequestObs.jkt = result.jkt; + } const boundToken = await mintDpopBoundToken({ issuerKey: dpopIssuerKey, issuer: resolveIssuer(), diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts index cd62c406..9064eb1d 100644 --- a/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts @@ -1,21 +1,40 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import { generateDpopKeyPair, buildDpopProof, type DpopKeyPair } from './dpopProof'; -import { generateIssuerKey, mintDpopBoundToken } from './dpopToken'; +import { + generateIssuerKey, + mintDpopBoundToken, + type TokenIssuerKey +} from './dpopToken'; import { validateResourceProof } from './dpopResourceAuth'; const ISSUER = 'https://auth.example.com'; const RESOURCE = 'https://mcp.example.com/mcp'; +// One issuer key for the whole file: the validator verifies presented tokens +// against the issuer key, so minting and validation must share it. +let issuerKey: TokenIssuerKey; +beforeAll(async () => { + issuerKey = await generateIssuerKey(); +}); + +/** The token-binding argument matching {@link boundToken}'s issuer. */ +function binding(boundJkt?: string): { + issuerKey: TokenIssuerKey; + issuer: string; + boundJkt?: string; +} { + return { issuerKey, issuer: ISSUER, ...(boundJkt ? { boundJkt } : {}) }; +} + /** Mint a DPoP-bound token for `kp`'s key, optionally bound to a foreign key. */ async function boundToken( kp: DpopKeyPair, jktOverride?: string ): Promise { - const issuerKey = await generateIssuerKey(); return mintDpopBoundToken({ issuerKey, issuer: ISSUER, @@ -35,7 +54,13 @@ describe('validateResourceProof — baseline acceptance and htu normalization', htu: RESOURCE, accessToken: token }); - const result = await validateResourceProof(proof, token, 'POST', RESOURCE); + const result = await validateResourceProof( + proof, + token, + 'POST', + RESOURCE, + binding(kp.thumbprint) + ); expect(result.ok).toBe(true); }); @@ -48,7 +73,13 @@ describe('validateResourceProof — baseline acceptance and htu normalization', htu: 'HTTPS://MCP.EXAMPLE.COM:443/mcp', accessToken: token }); - const result = await validateResourceProof(proof, token, 'POST', RESOURCE); + const result = await validateResourceProof( + proof, + token, + 'POST', + RESOURCE, + binding() + ); expect(result.ok).toBe(true); }); @@ -61,7 +92,13 @@ describe('validateResourceProof — baseline acceptance and htu normalization', htu: `${RESOURCE}/`, accessToken: token }); - const result = await validateResourceProof(proof, token, 'POST', RESOURCE); + const result = await validateResourceProof( + proof, + token, + 'POST', + RESOURCE, + binding() + ); expect(result.ok).toBe(false); expect(result.ok ? '' : result.error).toMatch(/htu/); }); @@ -76,7 +113,13 @@ describe('validateResourceProof — rejects each single defect', () => { const kp = (build.keyPair as DpopKeyPair) ?? (await generateDpopKeyPair()); const token = await boundToken(kp, tokenJktOverride); const proof = await buildDpopProof({ ...build, keyPair: kp }); - const result = await validateResourceProof(proof, token, method, RESOURCE); + const result = await validateResourceProof( + proof, + token, + method, + RESOURCE, + binding() + ); expect(result.ok).toBe(false); return result.ok ? '' : result.error; } @@ -88,7 +131,8 @@ describe('validateResourceProof — rejects each single defect', () => { undefined, token, 'POST', - RESOURCE + RESOURCE, + binding() ); expect(result.ok).toBe(false); }); @@ -206,11 +250,69 @@ describe('validateResourceProof — rejects each single defect', () => { htu: RESOURCE, accessToken: token }); - const result = await validateResourceProof(proof, token, 'POST', RESOURCE); + const result = await validateResourceProof( + proof, + token, + 'POST', + RESOURCE, + binding() + ); expect(result.ok).toBe(false); expect(result.ok ? '' : result.error).toMatch(/cnf\.jkt/); }); + it('rejects a token signed by a key other than the test AS issuer key', async () => { + const kp = await generateDpopKeyPair(); + // Self-minted lookalike: correct claims, wrong issuer key. + const rogueIssuer = await generateIssuerKey(); + const token = await mintDpopBoundToken({ + issuerKey: rogueIssuer, + issuer: ISSUER, + audience: RESOURCE, + jkt: kp.thumbprint + }); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + accessToken: token + }); + const result = await validateResourceProof( + proof, + token, + 'POST', + RESOURCE, + binding() + ); + expect(result.ok).toBe(false); + expect(result.ok ? '' : result.error).toMatch(/not a valid JWT issued/); + }); + + it('rejects a validly-issued token bound to a different key than the token endpoint bound', async () => { + // The AS bound the token issued at the token endpoint to `original`; the + // client presents a different (validly-issued) token bound to `other`. + const original = await generateDpopKeyPair(); + const other = await generateDpopKeyPair(); + const token = await boundToken(other); + const proof = await buildDpopProof({ + keyPair: other, + htm: 'POST', + htu: RESOURCE, + accessToken: token + }); + const result = await validateResourceProof( + proof, + token, + 'POST', + RESOURCE, + binding(original.thumbprint) + ); + expect(result.ok).toBe(false); + expect(result.ok ? '' : result.error).toMatch( + /not the one issued at the token endpoint/ + ); + }); + it('rejects an htu containing a query string (RFC 9449 §4.2)', async () => { const kp = await generateDpopKeyPair(); expect( @@ -243,7 +345,8 @@ describe('validateResourceProof — rejects each single defect', () => { `${proof}, ${proof}`, token, 'POST', - RESOURCE + RESOURCE, + binding() ); expect(result.ok).toBe(false); expect(result.ok ? '' : result.error).toMatch( diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts index 8d147fd6..e0c9ce03 100644 --- a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts @@ -2,6 +2,7 @@ import type { Request, Response, NextFunction, RequestHandler } from 'express'; import * as jose from 'jose'; import { createHash } from 'node:crypto'; import { DPOP_ASYMMETRIC_ALGS } from './dpopAlgs'; +import type { TokenIssuerKey } from './dpopToken'; /** Fixed nonce the judge hands out when `requireNonce` is set (RFC 9449 §9). */ const RS_DPOP_NONCE = 'conformance-rs-dpop-nonce'; @@ -42,6 +43,18 @@ export function newDpopClientObservations(): DpopClientObservations { }; } +/** + * How the resource judge verifies the presented access token: the test AS + * issuer key (signature/issuer/exp) plus the `cnf.jkt` the AS bound at the + * token endpoint. Getters are lazy — issuer URL and bound jkt only exist once + * the servers are up and a token has been issued. + */ +export interface DpopResourceTokenBinding { + issuerKey: TokenIssuerKey; + getIssuer: () => string; + getBoundJkt: () => string | undefined; +} + /** * Test MCP server DPoP judge (SEP-1932 / RFC 9449), passed to `createServer` * via its `options.authMiddleware` hook. An unauthenticated request gets a @@ -56,7 +69,8 @@ export function createDpopResourceAuth( obs: DpopClientObservations, getResourceUrl: () => string, getPrmUrl: () => string, - requireNonce = false + requireNonce: boolean, + tokenBinding: DpopResourceTokenBinding ): RequestHandler { return async ( req: Request, @@ -89,7 +103,12 @@ export function createDpopResourceAuth( proofValue, token, req.method, - getResourceUrl() + getResourceUrl(), + { + issuerKey: tokenBinding.issuerKey, + issuer: tokenBinding.getIssuer(), + boundJkt: tokenBinding.getBoundJkt() + } ); if (!result.ok) { obs.allProofsWellFormed = false; @@ -175,6 +194,9 @@ function normalizeHtu(u: string): string { * (RFC 9449 §4.3, resource-request subset): well-formed `dpop+jwt`, asymmetric * alg, embedded public jwk, htm/htu match, `ath` binds the token, signature * verifies, and the proof key's thumbprint matches the token's `cnf.jkt`. + * The token itself is verified against the test AS issuer key (signature, + * issuer, exp) and, when `boundJkt` is known, must be the very token issued at + * the token endpoint — not a self-minted lookalike. * (Kept as a deliberate two-copy contract with validateDpopProofAtTokenEndpoint * in createAuthServer.ts — apply fixes to both.) */ @@ -182,7 +204,8 @@ export async function validateResourceProof( proof: string | undefined, token: string, method: string, - resourceUrl: string + resourceUrl: string, + tokenBinding: { issuerKey: TokenIssuerKey; issuer: string; boundJkt?: string } ): Promise< | { ok: true; jti: string } // `tokenProblem` marks defects in the presented access token, as opposed to @@ -257,24 +280,45 @@ export async function validateResourceProof( }; } - // Possession: the proof key must be the key the token is bound to. + // The token itself: signed by the test AS issuer key, right issuer, unexpired. + let tokenClaims: jose.JWTPayload; try { - const tokenClaims = jose.decodeJwt(token); - const cnf = tokenClaims.cnf as { jkt?: unknown } | undefined; - const thumbprint = await jose.calculateJwkThumbprint(jwk, 'sha256'); - if (!cnf || cnf.jkt !== thumbprint) { - return { - ok: false, - error: 'proof key thumbprint does not match the token cnf.jkt' - }; - } + tokenClaims = ( + await jose.jwtVerify(token, tokenBinding.issuerKey.publicKey, { + issuer: tokenBinding.issuer, + algorithms: [tokenBinding.issuerKey.alg] + }) + ).payload; } catch { // The token, not the proof, is at fault (e.g. an opaque Bearer token from - // a proof-less token request). RFC 9449 §6.2 permits non-JWT bound tokens - // via introspection, but this self-contained harness only mints JWTs. + // a proof-less token request, or a self-minted JWT). RFC 9449 §6.2 permits + // non-JWT bound tokens via introspection; this harness only mints JWTs. + return { + ok: false, + error: + 'presented access token is not a valid JWT issued by the test authorization server (signature/issuer/exp)', + tokenProblem: true + }; + } + + // Possession: the proof key must be the key the token is bound to. + const cnf = tokenClaims.cnf as { jkt?: unknown } | undefined; + const thumbprint = await jose.calculateJwkThumbprint(jwk, 'sha256'); + if (!cnf || cnf.jkt !== thumbprint) { + return { + ok: false, + error: 'proof key thumbprint does not match the token cnf.jkt' + }; + } + // And the token must be the one the AS issued at the token endpoint. + if ( + tokenBinding.boundJkt !== undefined && + cnf.jkt !== tokenBinding.boundJkt + ) { return { ok: false, - error: 'presented access token is not a DPoP-bound JWT', + error: + 'presented token is not the one issued at the token endpoint (cnf.jkt differs from the key bound there)', tokenProblem: true }; } From 1ec0b2b546c3313f235b7fbd29a22e944c29f263 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:55:34 +0000 Subject: [PATCH 09/15] =?UTF-8?q?sep-1932:=20link=20untested=20server/AS?= =?UTF-8?q?=20rows=20to=20their=20tracking=20issues;=20scope=20iat-window?= =?UTF-8?q?=20to=20the=20spec's=20unconditional=20statement;=20align=20tok?= =?UTF-8?q?en-binding=20with=20RFC=209449=20=C2=A76?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seps/sep-1932.yaml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/seps/sep-1932.yaml b/src/seps/sep-1932.yaml index 4c340f1a..4858cc94 100644 --- a/src/seps/sep-1932.yaml +++ b/src/seps/sep-1932.yaml @@ -15,24 +15,34 @@ requirements: text: 'When the MCP server responds with `use_dpop_nonce`, the client MUST retry the request with a DPoP proof that includes the supplied `nonce` (RFC 9449 Section 9)' - check: sep-1932-server-validate-proof text: 'MCP servers MUST validate DPoP proofs according to RFC 9449 Section 4.3' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/369' - check: sep-1932-server-iat-window - text: "Implementations conforming to this specification MUST verify that the `iat` claim is within ±5 minutes of the server's current time / MCP servers that are not capable of keeping state or performing global `jti` tracking MUST provide DPoP proof replay protection by enforcing short `iat` acceptance windows of ±5 minutes and standard RFC 9449 claim validation" + text: "Implementations conforming to this specification MUST verify that the `iat` claim is within ±5 minutes of the server's current time" + issue: 'https://github.com/modelcontextprotocol/conformance/issues/369' - check: sep-1932-server-reject-401 text: 'If any validation step fails, the MCP server MUST reject the request with an HTTP 401 response and include appropriate error information in the `WWW-Authenticate` header as specified in RFC 9449 Section 7.1' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/369' - check: sep-1932-as-metadata-alg-values 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' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/370' - check: sep-1932-as-no-none-alg text: 'Only asymmetric signature algorithms are permitted; the `none` algorithm MUST NOT be included' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/370' - 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' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/370' - 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)" + text: "When issuing a DPoP-bound access token, the authorization server MUST associate the token with the public key from the client's DPoP proof such that the resource server can verify the binding — for JWT access tokens via a `cnf` claim carrying the JWK SHA-256 thumbprint (`jkt`) of that key; token introspection is also permitted (RFC 9449 Section 6) — and MUST set the token response `token_type` to `DPoP` (RFC 9449 Section 5)" + issue: 'https://github.com/modelcontextprotocol/conformance/issues/370' - 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' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/369' - check: sep-1932-server-nonce text: 'For high-security environments, MCP servers SHOULD implement server-provided nonces as described in RFC 9449 Section 9 to further limit proof lifetime, even if it is stateless' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/369' - 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' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/369' - 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.' @@ -48,3 +58,6 @@ requirements: excluded: 'Client implementation detail, not observable on the wire.' - text: 'MCP servers requiring maximum replay protection SHOULD implement `jti` tracking despite the operational complexity' excluded: 'Explicitly optional (servers are NOT required to track jti) and stateful/internal; only indirectly observable via replay.' + - text: 'MCP servers that is not capable of keeping state or perform global `jti` tracking MUST provide DPoP proof replay protection by enforcing short `iat` acceptance windows of ±5 minutes and standard RFC 9449 claim validation' + excluded: 'Conditional on whether the server keeps state, which is not wire-observable; the unconditional ±5-minute `iat` window it prescribes is covered by sep-1932-server-iat-window.' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/369' From 0fb53222236865cd731f2ce5f345daee15be717c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:56:27 +0000 Subject: [PATCH 10/15] =?UTF-8?q?client/dpop:=20include=20observed=20vs=20?= =?UTF-8?q?expected=20values=20(and=20the=20=C2=B1300s=20window)=20in=20ht?= =?UTF-8?q?m/htu/iat=20rejection=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/auth/helpers/createAuthServer.ts | 26 ++++++++++++----- .../client/auth/helpers/dpopResourceAuth.ts | 29 +++++++++++++------ 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 3470d011..678c8d62 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -102,10 +102,13 @@ export async function validateDpopProofAtTokenEndpoint( return { ok: false, error: 'missing jti claim' }; } if (claims.htm !== 'POST') { - return { ok: false, error: 'htm does not match POST' }; + return { + ok: false, + error: `htm "${claims.htm}" does not match the token request method "POST"` + }; } if (typeof claims.htu !== 'string') { - return { ok: false, error: 'htu does not match the token endpoint' }; + return { ok: false, error: 'missing or non-string htu claim' }; } if (claims.htu.includes('?') || claims.htu.includes('#')) { return { @@ -114,13 +117,20 @@ export async function validateDpopProofAtTokenEndpoint( }; } if (normalizeHtu(claims.htu) !== normalizeHtu(tokenEndpointUrl)) { - return { ok: false, error: 'htu does not match the token endpoint' }; + return { + ok: false, + error: `htu "${claims.htu}" does not match the token endpoint "${tokenEndpointUrl}" (compared after RFC 9449 §4.3 normalization)` + }; + } + if (typeof claims.iat !== 'number') { + return { ok: false, error: 'missing or non-numeric iat claim' }; } - if ( - typeof claims.iat !== 'number' || - Math.abs(Math.floor(Date.now() / 1000) - claims.iat) > 300 - ) { - return { ok: false, error: 'iat outside the acceptable window' }; + const iatSkew = Math.floor(Date.now() / 1000) - claims.iat; + if (Math.abs(iatSkew) > 300) { + return { + ok: false, + error: `iat ${claims.iat} is ${Math.abs(iatSkew)}s ${iatSkew > 0 ? 'behind' : 'ahead of'} server time; allowed window ±300s` + }; } const jkt = await jose.calculateJwkThumbprint(jwk, 'sha256'); return { ok: true, jkt }; diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts index e0c9ce03..7d74c054 100644 --- a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts @@ -247,10 +247,14 @@ export async function validateResourceProof( } if (typeof claims.jti !== 'string') return { ok: false, error: 'missing jti claim' }; - if (claims.htm !== method) - return { ok: false, error: 'htm does not match the request method' }; + if (claims.htm !== method) { + return { + ok: false, + error: `htm "${claims.htm}" does not match the request method "${method}"` + }; + } if (typeof claims.htu !== 'string') { - return { ok: false, error: 'htu does not match the request URI' }; + return { ok: false, error: 'missing or non-string htu claim' }; } if (claims.htu.includes('?') || claims.htu.includes('#')) { return { @@ -259,13 +263,20 @@ export async function validateResourceProof( }; } if (normalizeHtu(claims.htu) !== normalizeHtu(resourceUrl)) { - return { ok: false, error: 'htu does not match the request URI' }; + return { + ok: false, + error: `htu "${claims.htu}" does not match the request URI "${resourceUrl}" (compared after RFC 9449 §4.3 normalization)` + }; } - if ( - typeof claims.iat !== 'number' || - Math.abs(Math.floor(Date.now() / 1000) - claims.iat) > 300 - ) { - return { ok: false, error: 'iat outside the acceptable window' }; + if (typeof claims.iat !== 'number') { + return { ok: false, error: 'missing or non-numeric iat claim' }; + } + const iatSkew = Math.floor(Date.now() / 1000) - claims.iat; + if (Math.abs(iatSkew) > 300) { + return { + ok: false, + error: `iat ${claims.iat} is ${Math.abs(iatSkew)}s ${iatSkew > 0 ? 'behind' : 'ahead of'} server time; allowed window ±300s` + }; } // Recompute ath and the thumbprint inline (jose + node crypto) rather than // via the proof builder's helpers, so this validator stays a fully From dafc722b4bd1f1a90b8bd914d77c2cd20a368321 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:57:36 +0000 Subject: [PATCH 11/15] =?UTF-8?q?client/dpop:=20advertise=20dpop=5Fbound?= =?UTF-8?q?=5Faccess=5Ftokens=5Frequired=20and=20signing=20algs=20in=20the?= =?UTF-8?q?=20PRM=20(RFC=209728=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scenarios/client/auth/dpop.ts | 8 +++++++- .../client/auth/helpers/createServer.ts | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts index 3ff77247..83606539 100644 --- a/src/scenarios/client/auth/dpop.ts +++ b/src/scenarios/client/auth/dpop.ts @@ -205,7 +205,13 @@ export class DPoPClientScenario implements Scenario { getIssuer: () => this.authServer.getUrl(), getBoundJkt: () => this.tokenReqObs.jkt } - ) + ), + // RFC 9728 §2: tell discovery-driven clients this resource expects + // DPoP-bound tokens (the wire-level enablement signal for DPoP). + prmDpop: { + signingAlgValuesSupported: DPOP_ASYMMETRIC_ALGS, + boundAccessTokensRequired: true + } } ); await this.server.start(app); diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index cd32d21b..6e10a6e3 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -30,6 +30,14 @@ export interface ServerOptions { tokenVerifier?: MockTokenVerifier; /** Override the resource field in PRM response (for testing resource mismatch) */ prmResourceOverride?: string; + /** + * DPoP signals for the PRM document (RFC 9728 §2), set by the DPoP scenarios + * so a discovery-driven client can learn the resource expects DPoP. + */ + prmDpop?: { + signingAlgValuesSupported: string[]; + boundAccessTokensRequired: boolean; + }; } export function createServer( @@ -46,7 +54,8 @@ export function createServer( includePrmInWwwAuth = true, includeScopeInWwwAuth = false, tokenVerifier, - prmResourceOverride + prmResourceOverride, + prmDpop } = options; // Factory: create a fresh Server per request to avoid "Already connected" errors // after the v1.26.0 security fix (GHSA-345p-7cg4-v4c7) @@ -139,6 +148,13 @@ export function createServer( prmResponse.scopes_supported = scopesSupported; } + if (prmDpop) { + prmResponse.dpop_signing_alg_values_supported = + prmDpop.signingAlgValuesSupported; + prmResponse.dpop_bound_access_tokens_required = + prmDpop.boundAccessTokensRequired; + } + res.json(prmResponse); }); } From 666b3aca6395f8ee7de77e9928e9c383985d8885 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:59:29 +0000 Subject: [PATCH 12/15] =?UTF-8?q?client/dpop:=20generate=20DPoP=20private?= =?UTF-8?q?=20keys=20non-extractable=20by=20default=20(RFC=209449=20=C2=A7?= =?UTF-8?q?11);=20negative=20variants=20opt=20in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scenarios/client/auth/helpers/dpopProof.test.ts | 3 ++- src/scenarios/client/auth/helpers/dpopProof.ts | 12 +++++++++--- .../client/auth/helpers/dpopResourceAuth.test.ts | 3 ++- .../helpers/validateDpopProofAtTokenEndpoint.test.ts | 3 ++- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/scenarios/client/auth/helpers/dpopProof.test.ts b/src/scenarios/client/auth/helpers/dpopProof.test.ts index e0d1efcd..0301f5d0 100644 --- a/src/scenarios/client/auth/helpers/dpopProof.test.ts +++ b/src/scenarios/client/auth/helpers/dpopProof.test.ts @@ -162,7 +162,8 @@ describe('DPoP proof helper — invalid variants isolate exactly one defect (Lay }); it('embedPrivateKey leaks the private scalar into the jwk header', async () => { - const kp = await generateDpopKeyPair(); + // embedPrivateKey must export the private JWK, so opt into extractability. + const kp = await generateDpopKeyPair('ES256', { extractable: true }); const jwt = await buildDpopProof({ keyPair: kp, ...base, diff --git a/src/scenarios/client/auth/helpers/dpopProof.ts b/src/scenarios/client/auth/helpers/dpopProof.ts index 5ebc8687..8ed319b0 100644 --- a/src/scenarios/client/auth/helpers/dpopProof.ts +++ b/src/scenarios/client/auth/helpers/dpopProof.ts @@ -29,12 +29,18 @@ export interface DpopKeyPair { thumbprint: string; } -/** Generate an asymmetric key pair for DPoP proofs (default ES256 / P-256). */ +/** + * Generate an asymmetric key pair for DPoP proofs (default ES256 / P-256). + * The private key is non-extractable by default (RFC 9449 §11 guidance — + * production clients should do the same); pass `extractable: true` only for + * the negative variants that must export it (e.g. `embedPrivateKey`). + */ export async function generateDpopKeyPair( - alg: string = DEFAULT_ALG + alg: string = DEFAULT_ALG, + { extractable = false }: { extractable?: boolean } = {} ): Promise { const { publicKey, privateKey } = await jose.generateKeyPair(alg, { - extractable: true + extractable }); const publicJwk = await jose.exportJWK(publicKey); const thumbprint = await jose.calculateJwkThumbprint(publicJwk, 'sha256'); diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts index 9064eb1d..199fbab2 100644 --- a/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts @@ -162,7 +162,8 @@ describe('validateResourceProof — rejects each single defect', () => { }); it('rejects a private key embedded in the jwk header', async () => { - const kp = await generateDpopKeyPair(); + // embedPrivateKey must export the private JWK, so opt into extractability. + const kp = await generateDpopKeyPair('ES256', { extractable: true }); expect( await expectRejected({ keyPair: kp, diff --git a/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts b/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts index bbe30d28..0a5e3c44 100644 --- a/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts +++ b/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts @@ -138,7 +138,8 @@ describe('validateDpopProofAtTokenEndpoint — rejects each single defect', () = }); it('rejects a private key embedded in the jwk header', async () => { - const kp = await generateDpopKeyPair(); + // embedPrivateKey must export the private JWK, so opt into extractability. + const kp = await generateDpopKeyPair('ES256', { extractable: true }); expect( await expectRejected({ keyPair: kp, From 047bf542f0306fcfd3644c936d30e12eb9bdc961 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:59:51 +0000 Subject: [PATCH 13/15] client/dpop: fix dangling docstring references in dpopProof.ts --- src/scenarios/client/auth/helpers/dpopProof.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/scenarios/client/auth/helpers/dpopProof.ts b/src/scenarios/client/auth/helpers/dpopProof.ts index 8ed319b0..4ce0b5f9 100644 --- a/src/scenarios/client/auth/helpers/dpopProof.ts +++ b/src/scenarios/client/auth/helpers/dpopProof.ts @@ -11,8 +11,8 @@ import { createHash, randomBytes } from 'node:crypto'; * deliberately-malformed variants (one defect at a time) for the negative checks. * * Correctness of this module is anchored to published RFC test vectors in - * `proof.test.ts` (RFC 9449 §4 examples), not to the conformance servers that - * consume it — see the validation strategy in the project notes. + * `dpopProof.test.ts` (RFC 9449 §4 examples), not to the conformance servers + * that consume it — so the builder and the validators cannot share a bug. */ const DEFAULT_ALG = 'ES256'; From 6fee0e21413f5b03f0cd91392a0aaa52fb4b8232 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 15:01:27 +0000 Subject: [PATCH 14/15] client/dpop: observe DPoP proofs on GET /mcp and answer 405 (Streamable HTTP, no SSE stream) --- src/scenarios/client/auth/dpop.ts | 5 +++- .../client/auth/helpers/createServer.ts | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts index 83606539..6fc98865 100644 --- a/src/scenarios/client/auth/dpop.ts +++ b/src/scenarios/client/auth/dpop.ts @@ -211,7 +211,10 @@ export class DPoPClientScenario implements Scenario { prmDpop: { signingAlgValuesSupported: DPOP_ASYMMETRIC_ALGS, boundAccessTokensRequired: true - } + }, + // SEP-1932's per-request-proof requirement is not POST-scoped, so + // observe proofs on GET /mcp too (answered 405 — no SSE stream here). + observeGetMcp: true } ); await this.server.start(app); diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index 6e10a6e3..632a7c34 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -38,6 +38,11 @@ export interface ServerOptions { signingAlgValuesSupported: string[]; boundAccessTokensRequired: boolean; }; + /** + * Run `authMiddleware` on GET /mcp too, then answer 405 (this server offers + * no SSE stream). Lets the DPoP judge observe non-POST proofs. + */ + observeGetMcp?: boolean; } export function createServer( @@ -213,6 +218,25 @@ export function createServer( }); }); + // Streamable HTTP: this server offers no SSE stream, so GET /mcp is 405 — + // but the judge middleware runs first so non-POST proofs are observed too. + if (options.observeGetMcp && options.authMiddleware) { + const judge = options.authMiddleware; + app.get('/mcp', (req: Request, res: Response, next: NextFunction) => { + judge(req, res, (err?: unknown) => { + if (err) return next(err); + res + .status(405) + .set('Allow', 'POST') + .json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method Not Allowed' }, + id: null + }); + }); + }); + } + // Stateless lifecycle for the /mcp route: shared SEP-2575 validation + // server/discover from mock-server/stateless, then the same tools handlers // as createMcpServer. Bearer-auth middleware and PRM route above are From 724563bdad56a5c795e07409d5d4103a57cf993e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 15:02:42 +0000 Subject: [PATCH 15/15] client/dpop: document the judge's observe-don't-enforce posture (real servers MUST 401) --- src/scenarios/client/auth/helpers/dpopResourceAuth.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts index 7d74c054..5e93e4fc 100644 --- a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts @@ -62,6 +62,11 @@ export interface DpopResourceTokenBinding { * per-request proof) and allowed through so the MCP session can complete and * multiple requests can be examined for proof freshness. * + * Observe-don't-enforce: an INVALID proof is recorded and the request is still + * served 200 (so more evidence accrues) — a real DPoP resource server MUST + * reject it with 401 per RFC 9449 §7.2. The check report, not the wire + * behaviour, is the conformance signal here. + * * Proof validation is hand-rolled with jose — deliberately an INDEPENDENT code * path from the suite's proof builder, so a shared bug surfaces rather than hides. */