From 49650f8bb8fcb364b1001e9b397cb71c2112c625 Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Wed, 23 Sep 2026 07:17:31 -0700 Subject: [PATCH 1/4] 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 81f1e008483cc7f8a223e46e1a4f6b4941885ea8 Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Wed, 23 Sep 2026 07:35:57 -0700 Subject: [PATCH 2/4] feat(sep-1932): require a DPoP proof of the bound key on refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 9449 §5 binds a public client's refresh token to the DPoP key from the code exchange. The test authorization server now issues and rotates those tokens, and auth/dpop-refresh checks the client proves the same key. Co-authored-by: Cursor --- .../typescript/auth-test-dpop-reauth.ts | 24 ++ .../auth-test-dpop-refresh-new-key.ts | 28 ++ .../auth-test-dpop-refresh-no-proof.ts | 27 ++ .../typescript/auth-test-dpop-refresh.ts | 22 ++ .../clients/typescript/everything-client.ts | 2 + .../helpers/ConformanceOAuthProvider.ts | 7 +- .../typescript/helpers/dpopClientFlow.ts | 270 ++++++++----- src/scenarios/client/auth/dpop.ts | 110 +++++- .../auth/helpers/createAuthServer.test.ts | 325 +++++++++++++++- .../client/auth/helpers/createAuthServer.ts | 357 +++++++++++++++++- .../client/auth/helpers/dpopResourceAuth.ts | 21 ++ src/scenarios/client/auth/index.test.ts | 111 +++++- src/scenarios/client/auth/index.ts | 5 +- src/seps/sep-1932.yaml | 4 + 14 files changed, 1179 insertions(+), 134 deletions(-) create mode 100644 examples/clients/typescript/auth-test-dpop-reauth.ts create mode 100644 examples/clients/typescript/auth-test-dpop-refresh-new-key.ts create mode 100644 examples/clients/typescript/auth-test-dpop-refresh-no-proof.ts create mode 100644 examples/clients/typescript/auth-test-dpop-refresh.ts diff --git a/examples/clients/typescript/auth-test-dpop-reauth.ts b/examples/clients/typescript/auth-test-dpop-reauth.ts new file mode 100644 index 00000000..c9c007d6 --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-reauth.ts @@ -0,0 +1,24 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * DPoP client that recovers from access-token expiry by running a new + * authorization_code flow instead of refreshing. sep-1932-client-refresh-proof + * is INFO: re-authorization is permitted and is not a DPoP violation. + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'DPoP', + freshProofPerRequest: true, + sendTokenRequestProof: true, + handleAsNonce: true, + handleRsNonce: true, + sendDpopJkt: true, + exerciseRefresh: true, + onExpiry: 'reauthorize' + }); +} + +runAsCli(runClient, import.meta.url, 'auth-test-dpop-reauth '); diff --git a/examples/clients/typescript/auth-test-dpop-refresh-new-key.ts b/examples/clients/typescript/auth-test-dpop-refresh-new-key.ts new file mode 100644 index 00000000..83490727 --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-refresh-new-key.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * Broken DPoP client: refreshes with a DPoP proof for a different key than + * the one bound at the authorization-code exchange. Isolates a FAILURE of + * sep-1932-client-refresh-proof (RFC 9449 §5). + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'DPoP', + freshProofPerRequest: true, + sendTokenRequestProof: true, + handleAsNonce: true, + handleRsNonce: true, + sendDpopJkt: true, + exerciseRefresh: true, + refreshWithNewKey: true + }); +} + +runAsCli( + runClient, + import.meta.url, + 'auth-test-dpop-refresh-new-key ' +); diff --git a/examples/clients/typescript/auth-test-dpop-refresh-no-proof.ts b/examples/clients/typescript/auth-test-dpop-refresh-no-proof.ts new file mode 100644 index 00000000..775ec43b --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-refresh-no-proof.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * Broken DPoP client: refreshes without a DPoP proof. Isolates a FAILURE of + * sep-1932-client-refresh-proof (RFC 9449 §5). + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'DPoP', + freshProofPerRequest: true, + sendTokenRequestProof: true, + handleAsNonce: true, + handleRsNonce: true, + sendDpopJkt: true, + exerciseRefresh: true, + sendRefreshProof: false + }); +} + +runAsCli( + runClient, + import.meta.url, + 'auth-test-dpop-refresh-no-proof ' +); diff --git a/examples/clients/typescript/auth-test-dpop-refresh.ts b/examples/clients/typescript/auth-test-dpop-refresh.ts new file mode 100644 index 00000000..97e6ed3f --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-refresh.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * DPoP client that refreshes an expired access token with a proof for the + * same key bound at the authorization-code exchange (SEP-1932 / RFC 9449 §5). + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'DPoP', + freshProofPerRequest: true, + sendTokenRequestProof: true, + handleAsNonce: true, + handleRsNonce: true, + sendDpopJkt: true, + exerciseRefresh: true + }); +} + +runAsCli(runClient, import.meta.url, 'auth-test-dpop-refresh '); diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index b3ccbed3..1f88d1cb 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -43,6 +43,7 @@ import { import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js'; import { runClient as issValidationClient } from './auth-test-iss-validation.js'; import { runClient as dpopClient } from './auth-test-dpop.js'; +import { runClient as dpopRefreshClient } from './auth-test-dpop-refresh.js'; import { logger } from './helpers/logger.js'; /** @@ -928,6 +929,7 @@ registerScenario( registerScenario('auth/dpop', dpopClient); registerScenario('auth/dpop-nonce', dpopClient); +registerScenario('auth/dpop-refresh', dpopRefreshClient); // ============================================================================ // MRTR client conformance (SEP-2322) diff --git a/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts b/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts index 5dffd3d7..41e15710 100644 --- a/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts +++ b/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts @@ -56,7 +56,12 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { } saveTokens(tokens: OAuthTokens): void { - this._tokens = tokens; + // Drop refresh_token. The SDK's auth() treats a stored refresh token as a + // reason to skip the authorization endpoint, which hides scope step-up and + // retry-limit. DPoP refresh is exercised by dpopClientFlow. + const stored = { ...tokens }; + delete stored.refresh_token; + this._tokens = stored; } async redirectToAuthorization(authorizationUrl: URL): Promise { diff --git a/examples/clients/typescript/helpers/dpopClientFlow.ts b/examples/clients/typescript/helpers/dpopClientFlow.ts index fa58e158..cfea6c17 100644 --- a/examples/clients/typescript/helpers/dpopClientFlow.ts +++ b/examples/clients/typescript/helpers/dpopClientFlow.ts @@ -33,6 +33,14 @@ import { logger } from './logger'; * 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) + * - `exerciseRefresh:true` → after the token's expires_in, makes more + * MCP requests; refreshes (or re-authorizes) first + * - `sendRefreshProof:false` → refresh omits the DPoP proof; fails + * sep-1932-client-refresh-proof + * - `refreshWithNewKey:true` → refresh proof uses a different key; fails + * sep-1932-client-refresh-proof + * - `onExpiry:'reauthorize'` → runs a new authorization_code flow instead + * of refresh; sep-1932-client-refresh-proof is INFO */ export interface DpopClientOptions { scheme: 'DPoP' | 'Bearer'; @@ -46,6 +54,17 @@ export interface DpopClientOptions { sendDpopJkt?: boolean; /** Send a dpop_jkt that does not match the token-request proof key. */ wrongDpopJkt?: boolean; + /** + * Keep the session past access-token expiry and recover. Default false, so + * the nonce-less and nonce postures finish without waiting. + */ + exerciseRefresh?: boolean; + /** Include a DPoP proof on the refresh request. Default true. */ + sendRefreshProof?: boolean; + /** Sign the refresh proof with a new key instead of the bound one. */ + refreshWithNewKey?: boolean; + /** How to recover once the access token expires. Default `refresh`. */ + onExpiry?: 'refresh' | 'reauthorize'; } const REDIRECT_URI = 'http://127.0.0.1:9876/callback'; @@ -91,101 +110,155 @@ export async function runDpopClient( ).json(); const clientId: string = reg.client_id; - // 4. Authorization request (PKCE). The test AS redirects straight to the - // redirect_uri, so read the code from the Location header (no callback needed). - const state = randomBytes(16).toString('base64url'); - const codeVerifier = randomBytes(32).toString('base64url'); - const codeChallenge = createHash('sha256') - .update(codeVerifier) - .digest('base64url'); - const authorizeParams = new URLSearchParams({ - response_type: 'code', - client_id: clientId, - state, - redirect_uri: REDIRECT_URI, - code_challenge: codeChallenge, - code_challenge_method: 'S256' - }); - // 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']; - const locationStr = Array.isArray(location) ? location[0] : location; - if (!locationStr) { - throw new Error('Authorization endpoint did not redirect with a code'); - } - const code = new URL(locationStr).searchParams.get('code'); - if (!code) throw new Error('No authorization code in redirect'); - - // 5. Token request with a DPoP proof → DPoP-bound access token. The broken - // `sendTokenRequestProof:false` variant omits the proof, so the AS issues an - // unbound Bearer token instead. - const tokenReqBody = new URLSearchParams({ - grant_type: 'authorization_code', - code, - redirect_uri: REDIRECT_URI, - code_verifier: codeVerifier, - client_id: clientId - }).toString(); - const requestToken = async (nonce?: string): Promise => { - const headers: Record = { - 'content-type': 'application/x-www-form-urlencoded' + let accessToken = ''; + let refreshToken: string | undefined; + let expiresAt = 0; + + const postToken = async ( + body: string, + proofFor: (nonce?: string) => Promise + ): Promise => { + const send = async (nonce?: string): Promise => { + const headers: Record = { + 'content-type': 'application/x-www-form-urlencoded' + }; + const proof = await proofFor(nonce); + if (proof) headers.dpop = proof; + return fetch(tokenEndpoint, { method: 'POST', headers, body }); }; - if (options.sendTokenRequestProof) { - headers.dpop = await buildDpopProof({ - keyPair, - htm: 'POST', - // RFC 9449 §4.2: htu carries no query/fragment (the token endpoint URL - // may legally have a query, so strip it here). - htu: stripQuery(tokenEndpoint), - ...(nonce ? { nonce } : {}) - }); + let response = await send(); + // RFC 9449 §8: retry only on use_dpop_nonce, not on any 400 that happens + // to carry a DPoP-Nonce header (for example invalid_grant). + const asNonce = response.headers.get('DPoP-Nonce'); + if (response.status === 400 && asNonce && options.handleAsNonce) { + const challenge = await response + .clone() + .json() + .catch(() => ({}) as { error?: string }); + if (challenge?.error === 'use_dpop_nonce') { + response = await send(asNonce); + } } - return fetch(tokenEndpoint, { - method: 'POST', - headers, - body: tokenReqBody + return response; + }; + + const rememberTokens = (body: { + access_token: string; + refresh_token?: string; + expires_in?: number; + token_type?: string; + }): void => { + accessToken = body.access_token; + refreshToken = body.refresh_token; + expiresAt = Date.now() + (body.expires_in ?? 3600) * 1000; + logger.debug(`Obtained ${body.token_type} access token`); + }; + + // Authorization code + PKCE, then the token request. Callable again so a + // client can re-authorize instead of refreshing. + const exchangeAuthorizationCode = async (): Promise => { + const state = randomBytes(16).toString('base64url'); + const codeVerifier = randomBytes(32).toString('base64url'); + const codeChallenge = createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + const authorizeParams = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + state, + redirect_uri: REDIRECT_URI, + code_challenge: codeChallenge, + code_challenge_method: 'S256' }); + // 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']; + const locationStr = Array.isArray(location) ? location[0] : location; + if (!locationStr) { + throw new Error('Authorization endpoint did not redirect with a code'); + } + const code = new URL(locationStr).searchParams.get('code'); + if (!code) throw new Error('No authorization code in redirect'); + + const tokenResponse = await postToken( + new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + code_verifier: codeVerifier, + client_id: clientId + }).toString(), + async (nonce) => { + if (!options.sendTokenRequestProof) return undefined; + return buildDpopProof({ + keyPair, + htm: 'POST', + // RFC 9449 §4.2: htu carries no query/fragment (the token endpoint URL + // may legally have a query, so strip it here). + htu: stripQuery(tokenEndpoint), + ...(nonce ? { nonce } : {}) + }); + } + ); + if (!tokenResponse.ok) { + throw new Error(`Token request failed: HTTP ${tokenResponse.status}`); + } + rememberTokens(await tokenResponse.json()); }; - let tokenResponse = await requestToken(); - // RFC 9449 §8: the AS may answer with `use_dpop_nonce` (HTTP 400 + DPoP-Nonce); - // a conformant client retries the token request with the supplied nonce. Match - // on the `use_dpop_nonce` error code (not merely any 400 carrying a nonce), so - // an unrelated error (e.g. invalid_grant) that an AS proactively decorates with - // a DPoP-Nonce header does not burn the retry and mask the real failure — - // consistent with the resource-side check below. - const asNonce = tokenResponse.headers.get('DPoP-Nonce'); - if (tokenResponse.status === 400 && asNonce && options.handleAsNonce) { - const challenge = await tokenResponse - .clone() - .json() - .catch(() => ({}) as { error?: string }); - if (challenge?.error === 'use_dpop_nonce') { - tokenResponse = await requestToken(asNonce); + + const refreshAccessToken = async (): Promise => { + if (!refreshToken) throw new Error('No refresh token to present'); + const proofKey = options.refreshWithNewKey + ? await generateDpopKeyPair() + : keyPair; + const response = await postToken( + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: clientId + }).toString(), + async (nonce) => { + if (options.sendRefreshProof === false) return undefined; + return buildDpopProof({ + keyPair: proofKey, + htm: 'POST', + htu: stripQuery(tokenEndpoint), + ...(nonce ? { nonce } : {}) + }); + } + ); + if (!response.ok) { + throw new Error(`Refresh request failed: HTTP ${response.status}`); } - } - if (!tokenResponse.ok) { - throw new Error(`Token request failed: HTTP ${tokenResponse.status}`); - } - const tokenBody = await tokenResponse.json(); - const accessToken: string = tokenBody.access_token; - logger.debug(`Obtained ${tokenBody.token_type} access token`); + rememberTokens(await response.json()); + }; - // 6. MCP session — present the token to the resource with a per-request proof. + const recoverFromExpiry = async (): Promise => { + if (options.onExpiry === 'reauthorize') { + await exchangeAuthorizationCode(); + return; + } + await refreshAccessToken(); + }; + + await exchangeAuthorizationCode(); + + // MCP session — present the token to the resource with a per-request proof. // On a `use_dpop_nonce` challenge (RFC 9449 §9) a conformant client retries - // with the server-supplied nonce embedded in the proof, and carries it on - // subsequent requests. + // with the server-supplied nonce embedded in the proof. On expiry it + // refreshes (or re-authorizes) and retries. const mcpUrl = `${serverUrl}`; let reusableProof: string | undefined; let rsNonce: string | undefined; @@ -193,6 +266,10 @@ export async function runDpopClient( input: string | URL, init?: RequestInit ): Promise => { + if (options.exerciseRefresh && expiresAt > 0 && Date.now() >= expiresAt) { + await recoverFromExpiry(); + reusableProof = undefined; + } const method = (init?.method ?? 'POST').toUpperCase(); const htu = stripQuery( typeof input === 'string' ? input : input.toString() @@ -217,16 +294,25 @@ export async function runDpopClient( return fetch(input, { ...init, headers }); }; let res = await attempt(); + const wwwAuthenticate = res.headers.get('WWW-Authenticate') ?? ''; const nonce = res.headers.get('DPoP-Nonce'); if ( res.status === 401 && nonce && options.handleRsNonce && - (res.headers.get('WWW-Authenticate') ?? '').includes('use_dpop_nonce') + wwwAuthenticate.includes('use_dpop_nonce') ) { rsNonce = nonce; reusableProof = undefined; // rebuild the proof carrying the nonce res = await attempt(); + } else if ( + res.status === 401 && + options.exerciseRefresh && + wwwAuthenticate.includes('invalid_token') + ) { + await recoverFromExpiry(); + reusableProof = undefined; + res = await attempt(); } return res; }; @@ -245,6 +331,12 @@ export async function runDpopClient( logger.debug('Listed tools'); await client.callTool({ name: 'test-tool', arguments: {} }); logger.debug('Called tool'); + if (options.exerciseRefresh) { + const waitMs = Math.max(0, expiresAt - Date.now()) + 500; + await new Promise((resolve) => setTimeout(resolve, waitMs)); + await client.callTool({ name: 'test-tool', arguments: {} }); + await client.listTools(); + } await transport.close(); } diff --git a/src/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts index 4408acf0..e74c4e85 100644 --- a/src/scenarios/client/auth/dpop.ts +++ b/src/scenarios/client/auth/dpop.ts @@ -8,6 +8,7 @@ import type { import { ScenarioUrls } from '../../../types'; import { createAuthServer, + type DpopRefreshObservation, type DpopTokenRequestObservation } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; @@ -86,6 +87,16 @@ const CHECK_DEFS: Record< SpecReferences.DPOP_EXTENSION, SpecReferences.RFC_9449_DPOP_JKT ] + }, + 'sep-1932-client-refresh-proof': { + name: 'DpopRefreshProof', + description: + 'Client proves possession of the same DPoP key on refresh that it bound at the authorization-code exchange (RFC 9449 §5)', + specReferences: [ + SpecReferences.SEP_1932_DPOP, + SpecReferences.DPOP_EXTENSION, + SpecReferences.RFC_9449_TOKEN_REQUEST + ] } }; @@ -119,6 +130,11 @@ const DPOP_JKT_NOT_SENT_STATUS: CheckStatus = 'WARNING'; * 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). + * + * - `auth/dpop-refresh` — the access token expires during the session + * (`expires_in` 30). The client must refresh with a DPoP proof for the key + * bound at the code exchange (RFC 9449 §5). Emits the four baseline checks + * plus the refresh-proof check. */ function newTokenReqObs(): DpopTokenRequestObservation { return { @@ -130,6 +146,17 @@ function newTokenReqObs(): DpopTokenRequestObservation { }; } +function newRefreshObs(): DpopRefreshObservation { + return { + seen: false, + proofPresent: false, + proofValid: false, + jktMatched: false + }; +} + +export type DpopClientPosture = 'baseline' | 'nonce' | 'refresh'; + export class DPoPClientScenario implements Scenario { readonly name: string; readonly source = { @@ -142,29 +169,41 @@ export class DPoPClientScenario implements Scenario { private checks: ConformanceCheck[] = []; private obs: DpopClientObservations = newDpopClientObservations(); private tokenReqObs: DpopTokenRequestObservation = newTokenReqObs(); + private refreshObs: DpopRefreshObservation = newRefreshObs(); /** - * @param requireNonce when true (`auth/dpop-nonce`) the test AS and MCP server - * both demand a server-provided nonce (RFC 9449 §8/§9); when false - * (`auth/dpop`) neither challenges and the client completes with plain - * proofs — the common, nonce-less baseline. + * @param posture `baseline` (`auth/dpop`) neither server challenges; + * `nonce` (`auth/dpop-nonce`) both demand a server-provided nonce; + * `refresh` (`auth/dpop-refresh`) the access token expires in 30s so the + * client must refresh with the bound DPoP key. */ - constructor(private readonly requireNonce: boolean) { - this.name = requireNonce ? 'auth/dpop-nonce' : 'auth/dpop'; - this.description = requireNonce - ? 'Tests that an MCP client, when the authorization server and MCP server require a DPoP nonce, retries the token request and the MCP request with the server-supplied nonce (RFC 9449 §8/§9) — on top of requesting a DPoP-bound token and presenting it with the DPoP Authorization scheme and a fresh proof per request (SEP-1932 / RFC 9449 §5, §7.1, §4.2–4.3).' - : 'Tests that an MCP client requests a DPoP-bound access token (a valid DPoP proof at the token request) and presents it using the DPoP Authorization scheme (not Bearer) with a fresh, well-formed DPoP proof on each POST /mcp request, when the server does not require a nonce (SEP-1932 / RFC 9449 §5, §7.1, §4.2–4.3).'; + constructor(private readonly posture: DpopClientPosture) { + this.name = + posture === 'nonce' + ? 'auth/dpop-nonce' + : posture === 'refresh' + ? 'auth/dpop-refresh' + : 'auth/dpop'; + this.description = + posture === 'nonce' + ? 'Tests that an MCP client, when the authorization server and MCP server require a DPoP nonce, retries the token request and the MCP request with the server-supplied nonce (RFC 9449 §8/§9) — on top of requesting a DPoP-bound token and presenting it with the DPoP Authorization scheme and a fresh proof per request (SEP-1932 / RFC 9449 §5, §7.1, §4.2–4.3).' + : posture === 'refresh' + ? 'Tests that an MCP client, after its DPoP-bound access token expires, refreshes it with a DPoP proof for the same key bound at the authorization-code exchange (SEP-1932 / RFC 9449 §5).' + : 'Tests that an MCP client requests a DPoP-bound access token (a valid DPoP proof at the token request) and presents it using the DPoP Authorization scheme (not Bearer) with a fresh, well-formed DPoP proof on each POST /mcp request, when the server does not require a nonce (SEP-1932 / RFC 9449 §5, §7.1, §4.2–4.3).'; } async start(ctx: ScenarioContext): Promise { this.checks = []; this.obs = newDpopClientObservations(); this.tokenReqObs = newTokenReqObs(); + this.refreshObs = newRefreshObs(); const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { dpopSigningAlgValuesSupported: ['ES256'], dpopTokenRequestObs: this.tokenReqObs, - dpopRequireNonce: this.requireNonce + dpopRefreshObs: this.refreshObs, + dpopRequireNonce: this.posture === 'nonce', + ...(this.posture === 'refresh' ? { accessTokenExpiresIn: 30 } : {}) }); await this.authServer.start(authApp); @@ -178,7 +217,7 @@ export class DPoPClientScenario implements Scenario { this.obs, () => `${this.server.getUrl()}/mcp`, () => `${this.server.getUrl()}${PRM_PATH}`, - this.requireNonce + this.posture === 'nonce' ) } ); @@ -197,9 +236,10 @@ export class DPoPClientScenario implements Scenario { // duplicates the shared token-flow checks (token-request, pkce-*); collapse // those. The baseline (`auth/dpop`) is left untouched so genuinely distinct // repeated attempts (e.g. a restarted authorization flow) keep both entries. - const shared = this.requireNonce - ? collapseDuplicateChecks(this.checks) - : this.checks; + const shared = + this.posture === 'nonce' + ? collapseDuplicateChecks(this.checks) + : this.checks; const checks: ConformanceCheck[] = [ ...shared, this.tokenRequestProofCheck(), @@ -210,9 +250,12 @@ export class DPoPClientScenario implements Scenario { // The nonce checks only apply to the nonce-requiring posture: in the // baseline (`auth/dpop`) neither server issues a `use_dpop_nonce` // challenge, so there is no nonce behaviour to assert. - if (this.requireNonce) { + if (this.posture === 'nonce') { checks.push(this.asNonceCheck(), this.rsNonceCheck()); } + if (this.posture === 'refresh') { + checks.push(this.refreshProofCheck()); + } return checks; } @@ -263,6 +306,43 @@ export class DPoPClientScenario implements Scenario { ); } + private refreshProofCheck(): ConformanceCheck { + const refreshed = + this.refreshObs.seen && + this.refreshObs.proofValid && + this.refreshObs.jktMatched; + const reauthorized = + this.tokenReqObs.reauthorizedInsteadOfRefreshing === true; + let status: CheckStatus; + let errorMessage: string | undefined; + if (refreshed) { + status = 'SUCCESS'; + } else if (this.refreshObs.seen) { + status = 'FAILURE'; + errorMessage = + this.refreshObs.error ?? + 'Refresh request did not prove possession of the DPoP key bound at the authorization-code exchange'; + } else if (reauthorized) { + status = 'INFO'; + errorMessage = + 'Client recovered from access-token expiry by running a fresh authorization_code flow instead of refreshing; that is permitted and is not a DPoP violation'; + } else { + status = 'FAILURE'; + errorMessage = + 'Client did not recover from access-token expiry: no refresh_token grant and no new authorization_code exchange'; + } + return this.build('sep-1932-client-refresh-proof', status, { + errorMessage, + details: { + refreshSeen: this.refreshObs.seen, + proofPresent: this.refreshObs.proofPresent, + proofValid: this.refreshObs.proofValid, + jktMatched: this.refreshObs.jktMatched, + reauthorizedInsteadOfRefreshing: reauthorized + } + }); + } + private dpopJktCheck(): ConformanceCheck { const sent = this.tokenReqObs.dpopJktSent; const matched = this.tokenReqObs.dpopJktMatched; diff --git a/src/scenarios/client/auth/helpers/createAuthServer.test.ts b/src/scenarios/client/auth/helpers/createAuthServer.test.ts index d72b88c2..e850d96a 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.test.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.test.ts @@ -1,9 +1,16 @@ import { describe, it, expect } from 'vitest'; +import * as jose from 'jose'; 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 AuthServerOptions, + type DpopRefreshObservation, type DpopTokenRequestObservation } from './createAuthServer'; import { ServerLifecycle } from './serverLifecycle'; @@ -87,3 +94,319 @@ describe('createAuthServer — RFC 9449 §10 dpop_jkt binding', () => { } }); }); + +const REDIRECT = 'http://127.0.0.1:9876/callback'; +const AS_NONCE = 'conformance-as-dpop-nonce'; + +function newRefreshObs(): DpopRefreshObservation { + return { + seen: false, + proofPresent: false, + proofValid: false, + jktMatched: false + }; +} + +async function startServer(options: AuthServerOptions = {}): Promise<{ + lifecycle: ServerLifecycle; + tokenObs: DpopTokenRequestObservation; + refreshObs: DpopRefreshObservation; + base: string; +}> { + const checks: ConformanceCheck[] = []; + const lifecycle = new ServerLifecycle(); + const tokenObs = newObs(); + const refreshObs = newRefreshObs(); + const app = createAuthServer( + testScenarioContext(), + checks, + lifecycle.getUrl, + { + loggingEnabled: false, + dpopSigningAlgValuesSupported: ['ES256'], + dpopTokenRequestObs: tokenObs, + dpopRefreshObs: refreshObs, + ...options + } + ); + const base = await lifecycle.start(app); + return { lifecycle, tokenObs, refreshObs, base }; +} + +async function postToken( + base: string, + body: Record, + proof?: string +): Promise { + const headers: Record = { + 'content-type': 'application/x-www-form-urlencoded' + }; + if (proof) headers.dpop = proof; + return fetch(`${base}/token`, { + method: 'POST', + headers, + body: new URLSearchParams(body) + }); +} + +async function authorizationCode( + base: string, + keyPair: DpopKeyPair | undefined, + nonce?: string +): Promise<{ refreshToken: string; accessToken: string; tokenType: string }> { + const params = new URLSearchParams({ + response_type: 'code', + client_id: 'test', + redirect_uri: REDIRECT, + code_challenge: 'x', + code_challenge_method: 'S256' + }); + if (keyPair) params.set('dpop_jkt', keyPair.thumbprint); + await fetch(`${base}/authorize?${params}`, { redirect: 'manual' }); + const proof = keyPair + ? await buildDpopProof({ + keyPair, + htm: 'POST', + htu: `${base}/token`, + ...(nonce ? { nonce } : {}) + }) + : undefined; + const res = await postToken( + base, + { + grant_type: 'authorization_code', + code: 'test-auth-code', + redirect_uri: REDIRECT, + code_verifier: 'x', + client_id: 'test' + }, + proof + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + access_token: string; + refresh_token: string; + token_type: string; + }; + return { + refreshToken: body.refresh_token, + accessToken: body.access_token, + tokenType: body.token_type + }; +} + +async function refresh( + base: string, + refreshToken: string, + keyPair?: DpopKeyPair, + nonce?: string +): Promise { + const proof = keyPair + ? await buildDpopProof({ + keyPair, + htm: 'POST', + htu: `${base}/token`, + ...(nonce ? { nonce } : {}) + }) + : undefined; + return postToken( + base, + { + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: 'test' + }, + proof + ); +} + +describe('createAuthServer — refresh tokens (RFC 9449 §5)', () => { + it('rotates a bound refresh token when the same DPoP key is presented', async () => { + const server = await startServer(); + try { + const kp = await generateDpopKeyPair(); + const issued = await authorizationCode(server.base, kp); + const res = await refresh(server.base, issued.refreshToken, kp); + expect(res.status).toBe(200); + const body = (await res.json()) as { + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; + }; + expect(body.token_type).toBe('DPoP'); + expect(body.refresh_token).not.toBe(issued.refreshToken); + expect(body.expires_in).toBe(3600); + expect( + (jose.decodeJwt(body.access_token).cnf as { jkt: string }).jkt + ).toBe(kp.thumbprint); + expect(server.refreshObs).toMatchObject({ + seen: true, + proofPresent: true, + proofValid: true, + jktMatched: true + }); + + const reused = await refresh(server.base, issued.refreshToken, kp); + expect(reused.status).toBe(400); + expect(((await reused.json()) as { error: string }).error).toBe( + 'invalid_grant' + ); + } finally { + await server.lifecycle.stop(); + } + }); + + it('rejects a bound refresh that omits the DPoP proof', async () => { + const server = await startServer(); + try { + const kp = await generateDpopKeyPair(); + const issued = await authorizationCode(server.base, kp); + const res = await refresh(server.base, issued.refreshToken); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toBe( + 'invalid_grant' + ); + expect(server.refreshObs.proofPresent).toBe(false); + expect(server.refreshObs.jktMatched).toBe(false); + } finally { + await server.lifecycle.stop(); + } + }); + + it('rejects a bound refresh signed by a different key', async () => { + const server = await startServer(); + try { + const kp = await generateDpopKeyPair(); + const other = await generateDpopKeyPair(); + const issued = await authorizationCode(server.base, kp); + const res = await refresh(server.base, issued.refreshToken, other); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toBe( + 'invalid_grant' + ); + expect(server.refreshObs).toMatchObject({ + proofPresent: true, + proofValid: true, + jktMatched: false + }); + } finally { + await server.lifecycle.stop(); + } + }); + + it('rejects a malformed DPoP proof with invalid_dpop_proof', async () => { + const server = await startServer(); + try { + const kp = await generateDpopKeyPair(); + const issued = await authorizationCode(server.base, kp); + const res = await postToken( + server.base, + { + grant_type: 'refresh_token', + refresh_token: issued.refreshToken, + client_id: 'test' + }, + 'not-a-jwt' + ); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toBe( + 'invalid_dpop_proof' + ); + } finally { + await server.lifecycle.stop(); + } + }); + + it('rotates an unbound refresh token as a Bearer token', async () => { + const server = await startServer(); + try { + const issued = await authorizationCode(server.base, undefined); + expect(issued.tokenType).toBe('Bearer'); + const res = await refresh(server.base, issued.refreshToken); + expect(res.status).toBe(200); + const body = (await res.json()) as { + token_type: string; + refresh_token: string; + }; + expect(body.token_type).toBe('Bearer'); + expect(body.refresh_token).not.toBe(issued.refreshToken); + } finally { + await server.lifecycle.stop(); + } + }); + + it('unbound-refresh accepts a missing proof and a different key', async () => { + const server = await startServer({ dpopMisbehavior: 'unbound-refresh' }); + try { + const kp = await generateDpopKeyPair(); + const other = await generateDpopKeyPair(); + const issued = await authorizationCode(server.base, kp); + const noProof = await refresh(server.base, issued.refreshToken); + expect(noProof.status).toBe(200); + expect( + ((await noProof.json()) as { token_type: string }).token_type + ).toBe('Bearer'); + + const again = await authorizationCode(server.base, kp); + const rebound = await refresh(server.base, again.refreshToken, other); + expect(rebound.status).toBe(200); + const body = (await rebound.json()) as { + access_token: string; + token_type: string; + }; + expect(body.token_type).toBe('DPoP'); + expect( + (jose.decodeJwt(body.access_token).cnf as { jkt: string }).jkt + ).toBe(other.thumbprint); + } finally { + await server.lifecycle.stop(); + } + }); + + it('rebind-on-refresh binds the new access token to the presented key', async () => { + const server = await startServer({ dpopMisbehavior: 'rebind-on-refresh' }); + try { + const kp = await generateDpopKeyPair(); + const other = await generateDpopKeyPair(); + const issued = await authorizationCode(server.base, kp); + const res = await refresh(server.base, issued.refreshToken, other); + expect(res.status).toBe(200); + const body = (await res.json()) as { access_token: string }; + expect( + (jose.decodeJwt(body.access_token).cnf as { jkt: string }).jkt + ).toBe(other.thumbprint); + expect(server.refreshObs.jktMatched).toBe(false); + } finally { + await server.lifecycle.stop(); + } + }); + + it('challenges a refresh for a nonce without recording the §8 observation', async () => { + const server = await startServer({ dpopRequireNonce: true }); + try { + const kp = await generateDpopKeyPair(); + const issued = await authorizationCode(server.base, kp, AS_NONCE); + expect(server.tokenObs.asNonceChallengeIssued).toBe(false); + expect(server.tokenObs.asNonceHonored).toBe(true); + + const challenged = await refresh(server.base, issued.refreshToken, kp); + expect(challenged.status).toBe(400); + expect(((await challenged.json()) as { error: string }).error).toBe( + 'use_dpop_nonce' + ); + expect(server.tokenObs.asNonceChallengeIssued).toBe(false); + + const retried = await refresh( + server.base, + issued.refreshToken, + kp, + AS_NONCE + ); + expect(retried.status).toBe(200); + expect(server.tokenObs.asNonceChallengeIssued).toBe(false); + } finally { + await server.lifecycle.stop(); + } + }); +}); diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 8aeaa649..7d9d0954 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'; @@ -163,6 +163,32 @@ export interface DpopTokenRequestObservation { * key. Written only on an authorization_code exchange after a valid proof. */ dpopJktMatched: boolean; + /** + * A later authorization_code exchange completed after an earlier one had + * already issued a token. The client recovered by re-authorizing rather + * than presenting the refresh token. + */ + reauthorizedInsteadOfRefreshing?: boolean; +} + +/** + * What the client presented on a `refresh_token` grant (RFC 9449 §5). + * Written for bound and unbound refreshes; a refresh never satisfies the + * authorization_code §8 nonce observation. + */ +export interface DpopRefreshObservation { + seen: boolean; + proofPresent: boolean; + proofValid: boolean; + jktMatched: boolean; + error?: string; +} + +interface StoredRefreshToken { + jkt?: string; + scopes: string[]; + resource?: string; + issuedAt: number; } export interface AuthServerOptions { @@ -210,14 +236,28 @@ export interface AuthServerOptions { * - 'empty-alg-values' — advertise the field as an empty array * - 'include-none' — list `none` among the supported proof algs * - 'unbound-token' — issue a Bearer token ignoring a valid proof + * - 'unbound-refresh' — accept a refresh with no proof or any key, and + * issue a token bound to the presented key, or none + * - 'rebind-on-refresh' — bind the new access token to the presented key + * even when it differs from the key bound to the refresh token */ dpopMisbehavior?: | 'omit-alg-values' | 'empty-alg-values' | 'include-none' - | 'unbound-token'; + | 'unbound-token' + | 'unbound-refresh' + | 'rebind-on-refresh'; /** Sink for the DPoP token-request observation; see the interface docstring. */ dpopTokenRequestObs?: DpopTokenRequestObservation; + /** Sink for refresh-grant observations; see DpopRefreshObservation. */ + dpopRefreshObs?: DpopRefreshObservation; + /** + * `expires_in` (seconds) on token responses, and the DPoP access-token + * lifetime. Default 3600. The refresh posture sets this to 30 so a client + * must refresh during the run. + */ + accessTokenExpiresIn?: number; /** * When true, the token endpoint requires a DPoP nonce (RFC 9449 §8): a * proof-bearing request without the correct `nonce` claim is answered with @@ -275,7 +315,9 @@ export function createAuthServer( dpopSigningAlgValuesSupported, dpopMisbehavior, dpopTokenRequestObs, + dpopRefreshObs, dpopRequireNonce = false, + accessTokenExpiresIn = 3600, tokenVerifier, onTokenRequest, onAuthorizationRequest, @@ -317,6 +359,270 @@ export function createAuthServer( if (!valid && detail) dpopTokenRequestObs.error ??= detail; }; + const refreshTokens = new Map(); + let issuedAuthorizationCode = false; + + const issueRefreshToken = (entry: StoredRefreshToken): string => { + const refreshToken = randomBytes(32).toString('base64url'); + refreshTokens.set(refreshToken, entry); + return refreshToken; + }; + + const markAuthorizationCodeIssued = (grantType: string): void => { + if (grantType !== 'authorization_code') return; + if (issuedAuthorizationCode && dpopTokenRequestObs) { + dpopTokenRequestObs.reauthorizedInsteadOfRefreshing = true; + } + issuedAuthorizationCode = true; + }; + + const recordRefresh = (fields: { + proofPresent: boolean; + proofValid: boolean; + jktMatched: boolean; + error?: string; + }): void => { + if (!dpopRefreshObs) return; + dpopRefreshObs.seen = true; + dpopRefreshObs.proofPresent = fields.proofPresent; + dpopRefreshObs.proofValid = fields.proofValid; + dpopRefreshObs.jktMatched = fields.jktMatched; + if (fields.error) dpopRefreshObs.error ??= fields.error; + }; + + const readDpopHeader = ( + req: Request + ): { multiple: true } | { multiple: false; proof?: string } => { + const proofHeader = req.headers['dpop']; + const proofValue = Array.isArray(proofHeader) + ? proofHeader.join(', ') + : proofHeader; + if (typeof proofValue === 'string' && proofValue.includes(',')) { + return { multiple: true }; + } + return { multiple: false, proof: proofValue }; + }; + + const sendTokenResponse = ( + res: Response, + grantType: string, + body: { + accessToken: string; + tokenType: 'Bearer' | 'DPoP'; + scopes: string[]; + /** Exact `scope` string to echo. Omit to join `scopes` when non-empty. */ + scope?: string; + omitScope?: boolean; + jkt?: string; + resource?: string; + } + ): void => { + markAuthorizationCodeIssued(grantType); + const refresh_token = issueRefreshToken({ + ...(body.jkt !== undefined ? { jkt: body.jkt } : {}), + scopes: body.scopes, + ...(body.resource !== undefined ? { resource: body.resource } : {}), + issuedAt: Date.now() + }); + const scope = body.omitScope + ? undefined + : body.scope !== undefined + ? body.scope + : body.scopes.length > 0 + ? body.scopes.join(' ') + : undefined; + res.json({ + access_token: body.accessToken, + token_type: body.tokenType, + expires_in: accessTokenExpiresIn, + refresh_token, + ...(scope !== undefined ? { scope } : {}) + }); + }; + + const handleRefreshGrant = async ( + req: Request, + res: Response + ): Promise => { + const presented = req.body.refresh_token as string | undefined; + const entry = presented ? refreshTokens.get(presented) : undefined; + if (!presented || !entry) { + res.status(400).json({ + error: 'invalid_grant', + error_description: 'unknown or already-rotated refresh token' + }); + return; + } + + const tokenEndpointUrl = `${getAuthBaseUrl()}${routePrefix}/token`; + const header = readDpopHeader(req); + const resource = entry.resource; + + const rotateAndRespond = async ( + jkt: string | undefined, + tokenType: 'Bearer' | 'DPoP' + ): Promise => { + refreshTokens.delete(presented); + if (tokenType === 'DPoP' && jkt) { + if (!dpopIssuerKey) dpopIssuerKey = await generateIssuerKey(); + const accessToken = await mintDpopBoundToken({ + issuerKey: dpopIssuerKey, + issuer: resolveIssuer(), + audience: resource || 'urn:conformance-test-resource', + jkt, + expiresInSeconds: accessTokenExpiresIn, + ...(entry.scopes.length > 0 ? { scope: entry.scopes.join(' ') } : {}) + }); + sendTokenResponse(res, 'refresh_token', { + accessToken, + tokenType: 'DPoP', + scopes: entry.scopes, + jkt, + ...(resource !== undefined ? { resource } : {}) + }); + return; + } + const accessToken = `test-token-${Date.now()}`; + if (tokenVerifier) tokenVerifier.registerToken(accessToken, entry.scopes); + sendTokenResponse(res, 'refresh_token', { + accessToken, + tokenType: 'Bearer', + scopes: entry.scopes, + ...(resource !== undefined ? { resource } : {}) + }); + }; + + if (!entry.jkt) { + recordRefresh({ + proofPresent: !header.multiple && Boolean(header.proof), + proofValid: false, + jktMatched: false + }); + await rotateAndRespond(undefined, 'Bearer'); + return; + } + + if (dpopMisbehavior === 'unbound-refresh') { + let presentedJkt: string | undefined; + if (!header.multiple && header.proof) { + const result = await validateDpopProofAtTokenEndpoint( + header.proof, + tokenEndpointUrl + ); + recordRefresh({ + proofPresent: true, + proofValid: result.ok, + jktMatched: result.ok && result.jkt === entry.jkt, + ...(result.ok ? {} : { error: result.error }) + }); + if (result.ok) presentedJkt = result.jkt; + } else { + recordRefresh({ + proofPresent: false, + proofValid: false, + jktMatched: false, + error: header.multiple + ? 'multiple DPoP proof headers' + : 'no DPoP proof in the refresh request' + }); + } + if (presentedJkt) { + await rotateAndRespond(presentedJkt, 'DPoP'); + } else { + await rotateAndRespond(undefined, 'Bearer'); + } + return; + } + + if (header.multiple) { + recordRefresh({ + proofPresent: true, + proofValid: false, + jktMatched: false, + error: 'multiple DPoP proof headers' + }); + res.status(400).json({ + error: 'invalid_dpop_proof', + error_description: 'Multiple DPoP proof headers' + }); + return; + } + if (!header.proof) { + recordRefresh({ + proofPresent: false, + proofValid: false, + jktMatched: false, + error: 'no DPoP proof in the refresh request' + }); + res.status(400).json({ + error: 'invalid_grant', + error_description: 'DPoP proof required for a bound refresh token' + }); + return; + } + + const result = await validateDpopProofAtTokenEndpoint( + header.proof, + tokenEndpointUrl + ); + if (!result.ok) { + recordRefresh({ + proofPresent: true, + proofValid: false, + jktMatched: false, + error: result.error + }); + res.status(400).json({ + error: 'invalid_dpop_proof', + error_description: result.error + }); + return; + } + + const matched = result.jkt === entry.jkt; + recordRefresh({ + proofPresent: true, + proofValid: true, + jktMatched: matched, + ...(matched + ? {} + : { + error: 'DPoP proof key does not match the refresh token binding' + }) + }); + if (!matched && dpopMisbehavior !== 'rebind-on-refresh') { + res.status(400).json({ + error: 'invalid_grant', + error_description: + 'DPoP proof key does not match the refresh token binding' + }); + return; + } + + // RFC 9449 §8 applies to the refresh grant too. Do not record it on the + // authorization_code nonce observation: honoring a challenge here must + // not satisfy §8. + if (dpopRequireNonce) { + let proofNonce: unknown; + try { + proofNonce = jose.decodeJwt(header.proof).nonce; + } catch { + proofNonce = undefined; + } + if (proofNonce !== AS_DPOP_NONCE) { + res.status(400).set('DPoP-Nonce', AS_DPOP_NONCE).json({ + error: 'use_dpop_nonce', + error_description: 'Authorization server requires a DPoP nonce' + }); + return; + } + } + + const bindJkt = + dpopMisbehavior === 'rebind-on-refresh' ? result.jkt : entry.jkt; + await rotateAndRespond(bindJkt, 'DPoP'); + }; + const authRoutes = { authorization_endpoint: `${routePrefix}/authorize`, token_endpoint: `${routePrefix}/token`, @@ -528,6 +834,11 @@ export function createAuthServer( } }); + if (grantType === 'refresh_token') { + await handleRefreshGrant(req, res); + return; + } + // PKCE: Check code_verifier is present (only for authorization_code grant) const codeVerifier = req.body.code_verifier as string | undefined; if (grantType === 'authorization_code') { @@ -684,12 +995,18 @@ export function createAuthServer( if (dpopMisbehavior === 'unbound-token') { // Misbehaviour: ignore the binding and issue a plain Bearer token. + // The proof was valid, so the refresh token stays bound to that key. const bearer = `test-token-${Date.now()}`; if (tokenVerifier) tokenVerifier.registerToken(bearer, grantedScopes); - res.json({ - access_token: bearer, - token_type: 'Bearer', - expires_in: 3600 + sendTokenResponse(res, grantType, { + accessToken: bearer, + tokenType: 'Bearer', + scopes: grantedScopes, + omitScope: true, + jkt: result.jkt, + ...((req.body.resource as string | undefined) + ? { resource: req.body.resource as string } + : {}) }); return; } @@ -697,19 +1014,22 @@ export function createAuthServer( if (!dpopIssuerKey) { dpopIssuerKey = await generateIssuerKey(); } + const resource = req.body.resource as string | undefined; const boundToken = await mintDpopBoundToken({ issuerKey: dpopIssuerKey, issuer: resolveIssuer(), - audience: - (req.body.resource as string) || 'urn:conformance-test-resource', + audience: resource || 'urn:conformance-test-resource', jkt: result.jkt, + expiresInSeconds: accessTokenExpiresIn, ...(requestedScope && { scope: requestedScope }) }); - res.json({ - access_token: boundToken, - token_type: 'DPoP', - expires_in: 3600, - ...(requestedScope && { scope: requestedScope }) + sendTokenResponse(res, grantType, { + accessToken: boundToken, + tokenType: 'DPoP', + scopes: grantedScopes, + ...(requestedScope ? { scope: requestedScope } : { omitScope: true }), + jkt: result.jkt, + ...(resource ? { resource } : {}) }); return; } @@ -753,11 +1073,12 @@ export function createAuthServer( tokenVerifier.registerToken(token, scopes); } - res.json({ - access_token: token, - token_type: 'Bearer', - expires_in: 3600, - ...(scopes.length > 0 && { scope: scopes.join(' ') }) + const resource = req.body.resource as string | undefined; + sendTokenResponse(res, grantType, { + accessToken: token, + tokenType: 'Bearer', + scopes, + ...(resource ? { resource } : {}) }); }); diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts index 33f44be4..405f925f 100644 --- a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts @@ -89,6 +89,17 @@ export function createDpopResourceAuth( obs.nonDpopSchemeSeen = true; } + if (accessTokenExpired(token)) { + res + .status(401) + .set( + 'WWW-Authenticate', + `DPoP error="invalid_token", resource_metadata="${getPrmUrl()}"` + ) + .json({ error: 'invalid_token' }); + return; + } + const proofHeader = req.headers['dpop']; // Node collapses duplicate DPoP request headers into one comma-joined value; // validateResourceProof rejects a comma (RFC 9449 §4.2 — at most one proof). @@ -164,6 +175,16 @@ function splitAuthorization(authorization: string): { }; } +/** True when the access token is a JWT whose `exp` is in the past. */ +function accessTokenExpired(token: string): boolean { + try { + const exp = jose.decodeJwt(token).exp; + return typeof exp === 'number' && exp <= Math.floor(Date.now() / 1000); + } catch { + return false; + } +} + /** * Canonicalize an `htu` for comparison per RFC 9449 §4.3 (RFC 3986 scheme-based * normalization): lowercase scheme/host, drop the default port, ignore a diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index f2b0f536..da9008d4 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -38,6 +38,10 @@ import { runClient as dpopNoNonceClient } from '../../../../examples/clients/typ 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 dpopRefreshClient } from '../../../../examples/clients/typescript/auth-test-dpop-refresh'; +import { runClient as dpopRefreshNoProofClient } from '../../../../examples/clients/typescript/auth-test-dpop-refresh-no-proof'; +import { runClient as dpopRefreshNewKeyClient } from '../../../../examples/clients/typescript/auth-test-dpop-refresh-new-key'; +import { runClient as dpopReauthClient } from '../../../../examples/clients/typescript/auth-test-dpop-reauth'; 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'; @@ -346,14 +350,20 @@ describe('Negative tests', () => { describe('Client Extension Scenarios', () => { for (const scenario of extensionScenariosList) { - test(`${scenario.name} passes`, async () => { - const clientFn = getHandler(scenario.name); - if (!clientFn) { - throw new Error(`No handler registered for scenario: ${scenario.name}`); - } - const runner = new InlineClientRunner(clientFn); - await runClientAgainstScenario(runner, scenario.name); - }); + test( + `${scenario.name} passes`, + async () => { + const clientFn = getHandler(scenario.name); + if (!clientFn) { + throw new Error( + `No handler registered for scenario: ${scenario.name}` + ); + } + const runner = new InlineClientRunner(clientFn); + await runClientAgainstScenario(runner, scenario.name); + }, + scenario.name === 'auth/dpop-refresh' ? 60_000 : 15_000 + ); } }); @@ -522,6 +532,91 @@ describe('DPoP client negative tests (SEP-1932)', () => { }); }); +const REFRESH_TEST_TIMEOUT = 60_000; + +describe('DPoP client refresh (SEP-1932)', () => { + test( + 'auth/dpop-refresh: client proves the bound key', + async () => { + const runner = new InlineClientRunner(dpopRefreshClient); + const checks = await runClientAgainstScenario( + runner, + 'auth/dpop-refresh', + { expectedSuccessSlugs: ['sep-1932-client-refresh-proof'] } + ); + expect( + checks.find((c) => c.id === 'sep-1932-client-refresh-proof')?.status + ).toBe('SUCCESS'); + }, + REFRESH_TEST_TIMEOUT + ); + + test( + 'auth/dpop-refresh: client omits the DPoP proof on refresh', + async () => { + const runner = new InlineClientRunner(dpopRefreshNoProofClient); + const checks = await runClientAgainstScenario( + runner, + 'auth/dpop-refresh', + { + allowClientError: true, + expectedFailureSlugs: ['sep-1932-client-refresh-proof'], + expectedSuccessSlugs: [ + 'sep-1932-client-token-request-proof', + 'sep-1932-client-dpop-jkt', + 'sep-1932-client-dpop-auth-scheme', + 'sep-1932-client-fresh-proof' + ] + } + ); + expect( + checks.find((c) => c.id === 'sep-1932-client-refresh-proof')?.status + ).toBe('FAILURE'); + }, + REFRESH_TEST_TIMEOUT + ); + + test( + 'auth/dpop-refresh: client refreshes with a different key', + async () => { + const runner = new InlineClientRunner(dpopRefreshNewKeyClient); + const checks = await runClientAgainstScenario( + runner, + 'auth/dpop-refresh', + { + allowClientError: true, + expectedFailureSlugs: ['sep-1932-client-refresh-proof'], + expectedSuccessSlugs: [ + 'sep-1932-client-token-request-proof', + 'sep-1932-client-dpop-jkt', + 'sep-1932-client-dpop-auth-scheme', + 'sep-1932-client-fresh-proof' + ] + } + ); + expect( + checks.find((c) => c.id === 'sep-1932-client-refresh-proof')?.status + ).toBe('FAILURE'); + }, + REFRESH_TEST_TIMEOUT + ); + + test( + 'auth/dpop-refresh: client re-authorizes instead of refreshing', + async () => { + const runner = new InlineClientRunner(dpopReauthClient); + const checks = await runClientAgainstScenario( + runner, + 'auth/dpop-refresh' + ); + expect( + checks.find((c) => c.id === 'sep-1932-client-refresh-proof')?.status + ).toBe('INFO'); + }, + REFRESH_TEST_TIMEOUT + ); +}); + // DPoP nonce-less baseline (SEP-1932): a client that implements NO nonce // handling still completes DPoP successfully when the server does not require a // nonce (the common case — server nonces are OPTIONAL, RFC 9449 §8/§9). The diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 7daec39b..dcaffabc 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -67,8 +67,9 @@ export const extensionScenariosList: Scenario[] = [ new ClientCredentialsJwtScenario(), new ClientCredentialsBasicScenario(), new EnterpriseManagedAuthorizationScenario(), - new DPoPClientScenario(false), // auth/dpop — nonce-less baseline (common case) - new DPoPClientScenario(true), // auth/dpop-nonce — server-required nonce (§8/§9) + new DPoPClientScenario('baseline'), // auth/dpop — nonce-less baseline (common case) + new DPoPClientScenario('nonce'), // auth/dpop-nonce — server-required nonce (§8/§9) + new DPoPClientScenario('refresh'), // auth/dpop-refresh — refresh bound to the DPoP key (§5) new WifJwtBearerScenario() ]; diff --git a/src/seps/sep-1932.yaml b/src/seps/sep-1932.yaml index cf6acaf1..7734b60b 100644 --- a/src/seps/sep-1932.yaml +++ b/src/seps/sep-1932.yaml @@ -12,6 +12,10 @@ requirements: - 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 + # RFC 9449 §5. The SEP-1932 extension doc does not yet contain this sentence. + - check: sep-1932-client-refresh-proof + text: 'When an authorization server supporting DPoP issues a refresh token to a public client that presents a valid DPoP proof at the token endpoint, the refresh token MUST be bound to the respective public key.' + url: https://www.rfc-editor.org/rfc/rfc9449.html#section-5 - 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 5d4c102adecc33dcf0d2c2e21f00235be68b2491 Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Thu, 24 Sep 2026 12:34:39 -0700 Subject: [PATCH 3/4] fix(sep-1932): correct dpop_jkt conformance checks Co-authored-by: Cursor --- src/scenarios/client/auth/dpop.ts | 13 ++- .../auth/helpers/createAuthServer.test.ts | 84 ++++++++++++++++--- .../client/auth/helpers/createAuthServer.ts | 44 ++++++---- src/seps/sep-1932.yaml | 7 +- 4 files changed, 112 insertions(+), 36 deletions(-) diff --git a/src/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts index e74c4e85..d73cbfd5 100644 --- a/src/scenarios/client/auth/dpop.ts +++ b/src/scenarios/client/auth/dpop.ts @@ -20,6 +20,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'; @@ -348,7 +349,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'; @@ -363,7 +371,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 e850d96a..b995c12c 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.test.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.test.ts @@ -54,7 +54,10 @@ describe('createAuthServer — RFC 9449 §10 dpop_jkt binding', () => { dpop_jkt: otherKp.thumbprint } ).toString()}`; - await fetch(authorizeUrl, { redirect: 'manual' }); + const authorizeRes = await fetch(authorizeUrl, { redirect: 'manual' }); + const code = new URL( + authorizeRes.headers.get('location')! + ).searchParams.get('code')!; const tokenEndpoint = `${lifecycle.getUrl()}/token`; const proof = await buildDpopProof({ @@ -70,7 +73,7 @@ describe('createAuthServer — RFC 9449 §10 dpop_jkt binding', () => { }, body: new URLSearchParams({ grant_type: 'authorization_code', - code: 'test-auth-code', + code, redirect_uri: 'http://127.0.0.1:9876/callback', code_verifier: 'x', client_id: 'test' @@ -93,6 +96,44 @@ describe('createAuthServer — RFC 9449 §10 dpop_jkt binding', () => { await lifecycle.stop(); } }); + + it('binds dpop_jkt to each authorization code across overlapping flows', async () => { + const server = await startServer(); + try { + const first = await generateDpopKeyPair(); + const second = await generateDpopKeyPair(); + const firstCode = await requestAuthorizationCode(server.base, first); + const secondCode = await requestAuthorizationCode(server.base, second); + const tokenEndpoint = `${server.base}/token`; + + for (const [code, keyPair] of [ + [firstCode, first], + [secondCode, second] + ] as const) { + const proof = await buildDpopProof({ + keyPair, + htm: 'POST', + htu: tokenEndpoint + }); + const response = await postToken( + server.base, + { + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT, + code_verifier: 'x', + client_id: 'test' + }, + proof + ); + expect(response.status).toBe(200); + expect(server.tokenObs.dpopJktSent).toBe(keyPair.thumbprint); + expect(server.tokenObs.dpopJktMatched).toBe(true); + } + } finally { + await server.lifecycle.stop(); + } + }); }); const REDIRECT = 'http://127.0.0.1:9876/callback'; @@ -153,16 +194,12 @@ async function authorizationCode( base: string, keyPair: DpopKeyPair | undefined, nonce?: string -): Promise<{ refreshToken: string; accessToken: string; tokenType: string }> { - const params = new URLSearchParams({ - response_type: 'code', - client_id: 'test', - redirect_uri: REDIRECT, - code_challenge: 'x', - code_challenge_method: 'S256' - }); - if (keyPair) params.set('dpop_jkt', keyPair.thumbprint); - await fetch(`${base}/authorize?${params}`, { redirect: 'manual' }); +): Promise<{ + refreshToken: string; + accessToken: string; + tokenType: string; +}> { + const code = await requestAuthorizationCode(base, keyPair); const proof = keyPair ? await buildDpopProof({ keyPair, @@ -175,7 +212,7 @@ async function authorizationCode( base, { grant_type: 'authorization_code', - code: 'test-auth-code', + code, redirect_uri: REDIRECT, code_verifier: 'x', client_id: 'test' @@ -195,6 +232,27 @@ async function authorizationCode( }; } +async function requestAuthorizationCode( + base: string, + keyPair: DpopKeyPair | undefined +): Promise { + const params = new URLSearchParams({ + response_type: 'code', + client_id: 'test', + redirect_uri: REDIRECT, + code_challenge: 'x', + code_challenge_method: 'S256' + }); + if (keyPair) params.set('dpop_jkt', keyPair.thumbprint); + const authorizeRes = await fetch(`${base}/authorize?${params}`, { + redirect: 'manual' + }); + const code = new URL(authorizeRes.headers.get('location')!).searchParams.get( + 'code' + )!; + return code; +} + async function refresh( base: string, refreshToken: string, diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 7d9d0954..87281d0e 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -184,6 +184,12 @@ export interface DpopRefreshObservation { error?: string; } +interface StoredAuthorizationCode { + codeChallenge?: string; + dpopJkt?: string; + scopes: string[]; +} + interface StoredRefreshToken { jkt?: string; scopes: string[]; @@ -324,12 +330,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). @@ -739,15 +742,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({ @@ -777,9 +775,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({ @@ -793,7 +795,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); } @@ -820,6 +822,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', @@ -842,6 +848,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', @@ -855,6 +864,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) @@ -919,7 +929,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` @@ -951,8 +961,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; } @@ -1042,7 +1052,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 7734b60b..7abea975 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 # RFC 9449 §5. The SEP-1932 extension doc does not yet contain this sentence. - check: sep-1932-client-refresh-proof text: 'When an authorization server supporting DPoP issues a refresh token to a public client that presents a valid DPoP proof at the token endpoint, the refresh token MUST be bound to the respective public key.' From 7428cf646279c24f45086571b412b73b320e131e Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Thu, 24 Sep 2026 12:34:52 -0700 Subject: [PATCH 4/4] fix(sep-1932): make DPoP refresh checks conditional Co-authored-by: Cursor --- .../typescript/auth-test-dpop-reauth.ts | 24 --- .../typescript/auth-test-dpop-refresh.ts | 22 --- examples/clients/typescript/auth-test-dpop.ts | 6 +- .../clients/typescript/everything-client.ts | 2 - .../helpers/ConformanceOAuthProvider.ts | 7 +- .../typescript/helpers/dpopClientFlow.ts | 42 +---- src/scenarios/client/auth/dpop.ts | 57 +++---- .../auth/helpers/createAuthServer.test.ts | 54 ++++++ .../client/auth/helpers/createAuthServer.ts | 65 ++++---- src/scenarios/client/auth/index.test.ts | 157 +++++++----------- src/scenarios/client/auth/index.ts | 1 - .../client/auth/test_helpers/testClient.ts | 16 +- src/seps/sep-1932.yaml | 2 +- 13 files changed, 190 insertions(+), 265 deletions(-) delete mode 100644 examples/clients/typescript/auth-test-dpop-reauth.ts delete mode 100644 examples/clients/typescript/auth-test-dpop-refresh.ts diff --git a/examples/clients/typescript/auth-test-dpop-reauth.ts b/examples/clients/typescript/auth-test-dpop-reauth.ts deleted file mode 100644 index c9c007d6..00000000 --- a/examples/clients/typescript/auth-test-dpop-reauth.ts +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env node - -import { runDpopClient } from './helpers/dpopClientFlow'; -import { runAsCli } from './helpers/cliRunner'; - -/** - * DPoP client that recovers from access-token expiry by running a new - * authorization_code flow instead of refreshing. sep-1932-client-refresh-proof - * is INFO: re-authorization is permitted and is not a DPoP violation. - */ -export async function runClient(serverUrl: string): Promise { - await runDpopClient(serverUrl, { - scheme: 'DPoP', - freshProofPerRequest: true, - sendTokenRequestProof: true, - handleAsNonce: true, - handleRsNonce: true, - sendDpopJkt: true, - exerciseRefresh: true, - onExpiry: 'reauthorize' - }); -} - -runAsCli(runClient, import.meta.url, 'auth-test-dpop-reauth '); diff --git a/examples/clients/typescript/auth-test-dpop-refresh.ts b/examples/clients/typescript/auth-test-dpop-refresh.ts deleted file mode 100644 index 97e6ed3f..00000000 --- a/examples/clients/typescript/auth-test-dpop-refresh.ts +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env node - -import { runDpopClient } from './helpers/dpopClientFlow'; -import { runAsCli } from './helpers/cliRunner'; - -/** - * DPoP client that refreshes an expired access token with a proof for the - * same key bound at the authorization-code exchange (SEP-1932 / RFC 9449 §5). - */ -export async function runClient(serverUrl: string): Promise { - await runDpopClient(serverUrl, { - scheme: 'DPoP', - freshProofPerRequest: true, - sendTokenRequestProof: true, - handleAsNonce: true, - handleRsNonce: true, - sendDpopJkt: true, - exerciseRefresh: true - }); -} - -runAsCli(runClient, import.meta.url, 'auth-test-dpop-refresh '); diff --git a/examples/clients/typescript/auth-test-dpop.ts b/examples/clients/typescript/auth-test-dpop.ts index 40ffcaff..137788c1 100644 --- a/examples/clients/typescript/auth-test-dpop.ts +++ b/examples/clients/typescript/auth-test-dpop.ts @@ -7,7 +7,8 @@ import { runAsCli } from './helpers/cliRunner'; * 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. + * request. It also uses the optional refresh token with a proof for the same + * key (RFC 9449 §5). */ export async function runClient(serverUrl: string): Promise { await runDpopClient(serverUrl, { @@ -16,7 +17,8 @@ export async function runClient(serverUrl: string): Promise { sendTokenRequestProof: true, handleAsNonce: true, handleRsNonce: true, - sendDpopJkt: true + sendDpopJkt: true, + exerciseRefresh: true }); } diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 1f88d1cb..b3ccbed3 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -43,7 +43,6 @@ import { import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js'; import { runClient as issValidationClient } from './auth-test-iss-validation.js'; import { runClient as dpopClient } from './auth-test-dpop.js'; -import { runClient as dpopRefreshClient } from './auth-test-dpop-refresh.js'; import { logger } from './helpers/logger.js'; /** @@ -929,7 +928,6 @@ registerScenario( registerScenario('auth/dpop', dpopClient); registerScenario('auth/dpop-nonce', dpopClient); -registerScenario('auth/dpop-refresh', dpopRefreshClient); // ============================================================================ // MRTR client conformance (SEP-2322) diff --git a/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts b/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts index 41e15710..5dffd3d7 100644 --- a/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts +++ b/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts @@ -56,12 +56,7 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { } saveTokens(tokens: OAuthTokens): void { - // Drop refresh_token. The SDK's auth() treats a stored refresh token as a - // reason to skip the authorization endpoint, which hides scope step-up and - // retry-limit. DPoP refresh is exercised by dpopClientFlow. - const stored = { ...tokens }; - delete stored.refresh_token; - this._tokens = stored; + this._tokens = tokens; } async redirectToAuthorization(authorizationUrl: URL): Promise { diff --git a/examples/clients/typescript/helpers/dpopClientFlow.ts b/examples/clients/typescript/helpers/dpopClientFlow.ts index cfea6c17..680081b1 100644 --- a/examples/clients/typescript/helpers/dpopClientFlow.ts +++ b/examples/clients/typescript/helpers/dpopClientFlow.ts @@ -33,14 +33,12 @@ import { logger } from './logger'; * 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) - * - `exerciseRefresh:true` → after the token's expires_in, makes more - * MCP requests; refreshes (or re-authorizes) first + * - `exerciseRefresh:true` → uses the issued refresh token, then makes + * more MCP requests with the replacement access token * - `sendRefreshProof:false` → refresh omits the DPoP proof; fails * sep-1932-client-refresh-proof * - `refreshWithNewKey:true` → refresh proof uses a different key; fails * sep-1932-client-refresh-proof - * - `onExpiry:'reauthorize'` → runs a new authorization_code flow instead - * of refresh; sep-1932-client-refresh-proof is INFO */ export interface DpopClientOptions { scheme: 'DPoP' | 'Bearer'; @@ -54,17 +52,12 @@ export interface DpopClientOptions { sendDpopJkt?: boolean; /** Send a dpop_jkt that does not match the token-request proof key. */ wrongDpopJkt?: boolean; - /** - * Keep the session past access-token expiry and recover. Default false, so - * the nonce-less and nonce postures finish without waiting. - */ + /** Use the issued refresh token and continue with the replacement token. */ exerciseRefresh?: boolean; /** Include a DPoP proof on the refresh request. Default true. */ sendRefreshProof?: boolean; /** Sign the refresh proof with a new key instead of the bound one. */ refreshWithNewKey?: boolean; - /** How to recover once the access token expires. Default `refresh`. */ - onExpiry?: 'refresh' | 'reauthorize'; } const REDIRECT_URI = 'http://127.0.0.1:9876/callback'; @@ -112,7 +105,6 @@ export async function runDpopClient( let accessToken = ''; let refreshToken: string | undefined; - let expiresAt = 0; const postToken = async ( body: string, @@ -150,7 +142,6 @@ export async function runDpopClient( }): void => { accessToken = body.access_token; refreshToken = body.refresh_token; - expiresAt = Date.now() + (body.expires_in ?? 3600) * 1000; logger.debug(`Obtained ${body.token_type} access token`); }; @@ -245,20 +236,11 @@ export async function runDpopClient( rememberTokens(await response.json()); }; - const recoverFromExpiry = async (): Promise => { - if (options.onExpiry === 'reauthorize') { - await exchangeAuthorizationCode(); - return; - } - await refreshAccessToken(); - }; - await exchangeAuthorizationCode(); // MCP session — present the token to the resource with a per-request proof. // On a `use_dpop_nonce` challenge (RFC 9449 §9) a conformant client retries - // with the server-supplied nonce embedded in the proof. On expiry it - // refreshes (or re-authorizes) and retries. + // with the server-supplied nonce embedded in the proof. const mcpUrl = `${serverUrl}`; let reusableProof: string | undefined; let rsNonce: string | undefined; @@ -266,10 +248,6 @@ export async function runDpopClient( input: string | URL, init?: RequestInit ): Promise => { - if (options.exerciseRefresh && expiresAt > 0 && Date.now() >= expiresAt) { - await recoverFromExpiry(); - reusableProof = undefined; - } const method = (init?.method ?? 'POST').toUpperCase(); const htu = stripQuery( typeof input === 'string' ? input : input.toString() @@ -305,14 +283,6 @@ export async function runDpopClient( rsNonce = nonce; reusableProof = undefined; // rebuild the proof carrying the nonce res = await attempt(); - } else if ( - res.status === 401 && - options.exerciseRefresh && - wwwAuthenticate.includes('invalid_token') - ) { - await recoverFromExpiry(); - reusableProof = undefined; - res = await attempt(); } return res; }; @@ -332,8 +302,8 @@ export async function runDpopClient( await client.callTool({ name: 'test-tool', arguments: {} }); logger.debug('Called tool'); if (options.exerciseRefresh) { - const waitMs = Math.max(0, expiresAt - Date.now()) + 500; - await new Promise((resolve) => setTimeout(resolve, waitMs)); + await refreshAccessToken(); + reusableProof = undefined; await client.callTool({ name: 'test-tool', arguments: {} }); await client.listTools(); } diff --git a/src/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts index d73cbfd5..029fa047 100644 --- a/src/scenarios/client/auth/dpop.ts +++ b/src/scenarios/client/auth/dpop.ts @@ -92,7 +92,7 @@ const CHECK_DEFS: Record< 'sep-1932-client-refresh-proof': { name: 'DpopRefreshProof', description: - 'Client proves possession of the same DPoP key on refresh that it bound at the authorization-code exchange (RFC 9449 §5)', + 'When using a bound refresh token, the client proves possession of the same DPoP key used to obtain it (RFC 9449 §5)', specReferences: [ SpecReferences.SEP_1932_DPOP, SpecReferences.DPOP_EXTENSION, @@ -117,25 +117,22 @@ const DPOP_JKT_NOT_SENT_STATUS: CheckStatus = 'WARNING'; * RFC 9449 (AS §8 "MAY", RS §9 "can also choose") and the two are mutually * exclusive for a given run: * - * - `auth/dpop` (`requireNonce = false`) — the common, nonce-less baseline. + * - `auth/dpop` (`posture = baseline`) — the common, nonce-less flow. * Neither the AS nor the MCP server issues a nonce challenge; the client - * completes the flow with plain proofs. Emits four checks: + * completes the flow with plain proofs. It also receives a refresh token; + * if it uses that optional token, the refresh proof is checked. Emits: * · 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`). + * · conditional refresh-token proof for the same DPoP key (§5). * - * - `auth/dpop-nonce` (`requireNonce = true`) — the AS and MCP server both + * - `auth/dpop-nonce` (`posture = nonce`) — the AS and MCP server both * require a server-provided nonce (§8/§9), exercising the client's nonce * 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). - * - * - `auth/dpop-refresh` — the access token expires during the session - * (`expires_in` 30). The client must refresh with a DPoP proof for the key - * bound at the code exchange (RFC 9449 §5). Emits the four baseline checks - * plus the refresh-proof check. */ function newTokenReqObs(): DpopTokenRequestObservation { return { @@ -156,7 +153,7 @@ function newRefreshObs(): DpopRefreshObservation { }; } -export type DpopClientPosture = 'baseline' | 'nonce' | 'refresh'; +export type DpopClientPosture = 'baseline' | 'nonce'; export class DPoPClientScenario implements Scenario { readonly name: string; @@ -174,23 +171,14 @@ export class DPoPClientScenario implements Scenario { /** * @param posture `baseline` (`auth/dpop`) neither server challenges; - * `nonce` (`auth/dpop-nonce`) both demand a server-provided nonce; - * `refresh` (`auth/dpop-refresh`) the access token expires in 30s so the - * client must refresh with the bound DPoP key. + * `nonce` (`auth/dpop-nonce`) both demand a server-provided nonce. */ constructor(private readonly posture: DpopClientPosture) { - this.name = - posture === 'nonce' - ? 'auth/dpop-nonce' - : posture === 'refresh' - ? 'auth/dpop-refresh' - : 'auth/dpop'; + this.name = posture === 'nonce' ? 'auth/dpop-nonce' : 'auth/dpop'; this.description = posture === 'nonce' ? 'Tests that an MCP client, when the authorization server and MCP server require a DPoP nonce, retries the token request and the MCP request with the server-supplied nonce (RFC 9449 §8/§9) — on top of requesting a DPoP-bound token and presenting it with the DPoP Authorization scheme and a fresh proof per request (SEP-1932 / RFC 9449 §5, §7.1, §4.2–4.3).' - : posture === 'refresh' - ? 'Tests that an MCP client, after its DPoP-bound access token expires, refreshes it with a DPoP proof for the same key bound at the authorization-code exchange (SEP-1932 / RFC 9449 §5).' - : 'Tests that an MCP client requests a DPoP-bound access token (a valid DPoP proof at the token request) and presents it using the DPoP Authorization scheme (not Bearer) with a fresh, well-formed DPoP proof on each POST /mcp request, when the server does not require a nonce (SEP-1932 / RFC 9449 §5, §7.1, §4.2–4.3).'; + : 'Tests that an MCP client requests a DPoP-bound access token, presents it using the DPoP Authorization scheme with a fresh proof on each request, and, if it uses the optional refresh token, proves possession of the same key (SEP-1932 / RFC 9449 §5, §7.1, §4.2–4.3).'; } async start(ctx: ScenarioContext): Promise { @@ -203,8 +191,13 @@ export class DPoPClientScenario implements Scenario { dpopSigningAlgValuesSupported: ['ES256'], dpopTokenRequestObs: this.tokenReqObs, dpopRefreshObs: this.refreshObs, + issueRefreshTokens: true, dpopRequireNonce: this.posture === 'nonce', - ...(this.posture === 'refresh' ? { accessTokenExpiresIn: 30 } : {}) + onRegistrationRequest: () => ({ + clientId: 'conformance-dpop-public-client', + clientSecret: undefined, + tokenEndpointAuthMethod: 'none' + }) }); await this.authServer.start(authApp); @@ -246,7 +239,8 @@ export class DPoPClientScenario implements Scenario { this.tokenRequestProofCheck(), this.dpopJktCheck(), this.authSchemeCheck(), - this.freshProofCheck() + this.freshProofCheck(), + ...(this.posture === 'baseline' ? [this.refreshProofCheck()] : []) ]; // The nonce checks only apply to the nonce-requiring posture: in the // baseline (`auth/dpop`) neither server issues a `use_dpop_nonce` @@ -254,9 +248,6 @@ export class DPoPClientScenario implements Scenario { if (this.posture === 'nonce') { checks.push(this.asNonceCheck(), this.rsNonceCheck()); } - if (this.posture === 'refresh') { - checks.push(this.refreshProofCheck()); - } return checks; } @@ -312,8 +303,6 @@ export class DPoPClientScenario implements Scenario { this.refreshObs.seen && this.refreshObs.proofValid && this.refreshObs.jktMatched; - const reauthorized = - this.tokenReqObs.reauthorizedInsteadOfRefreshing === true; let status: CheckStatus; let errorMessage: string | undefined; if (refreshed) { @@ -323,14 +312,8 @@ export class DPoPClientScenario implements Scenario { errorMessage = this.refreshObs.error ?? 'Refresh request did not prove possession of the DPoP key bound at the authorization-code exchange'; - } else if (reauthorized) { - status = 'INFO'; - errorMessage = - 'Client recovered from access-token expiry by running a fresh authorization_code flow instead of refreshing; that is permitted and is not a DPoP violation'; } else { - status = 'FAILURE'; - errorMessage = - 'Client did not recover from access-token expiry: no refresh_token grant and no new authorization_code exchange'; + status = 'SKIPPED'; } return this.build('sep-1932-client-refresh-proof', status, { errorMessage, @@ -339,7 +322,7 @@ export class DPoPClientScenario implements Scenario { proofPresent: this.refreshObs.proofPresent, proofValid: this.refreshObs.proofValid, jktMatched: this.refreshObs.jktMatched, - reauthorizedInsteadOfRefreshing: reauthorized + reason: 'Client did not use the optional refresh token' } }); } diff --git a/src/scenarios/client/auth/helpers/createAuthServer.test.ts b/src/scenarios/client/auth/helpers/createAuthServer.test.ts index b995c12c..221af7e4 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.test.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.test.ts @@ -167,6 +167,7 @@ async function startServer(options: AuthServerOptions = {}): Promise<{ dpopSigningAlgValuesSupported: ['ES256'], dpopTokenRequestObs: tokenObs, dpopRefreshObs: refreshObs, + issueRefreshTokens: true, ...options } ); @@ -279,6 +280,36 @@ async function refresh( } describe('createAuthServer — refresh tokens (RFC 9449 §5)', () => { + it('does not issue refresh tokens unless enabled', async () => { + const server = await startServer({ issueRefreshTokens: false }); + try { + const kp = await generateDpopKeyPair(); + const code = await requestAuthorizationCode(server.base, kp); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: `${server.base}/token` + }); + const res = await postToken( + server.base, + { + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT, + code_verifier: 'x', + client_id: 'test' + }, + proof + ); + expect(res.status).toBe(200); + expect( + (await res.json()) as { refresh_token?: string } + ).not.toHaveProperty('refresh_token'); + } finally { + await server.lifecycle.stop(); + } + }); + it('rotates a bound refresh token when the same DPoP key is presented', async () => { const server = await startServer(); try { @@ -353,6 +384,29 @@ describe('createAuthServer — refresh tokens (RFC 9449 §5)', () => { } }); + it('keeps a failed refresh observation after a later valid retry', async () => { + const server = await startServer(); + try { + const kp = await generateDpopKeyPair(); + const issued = await authorizationCode(server.base, kp); + expect(await refresh(server.base, issued.refreshToken)).toHaveProperty( + 'status', + 400 + ); + expect( + await refresh(server.base, issued.refreshToken, kp) + ).toHaveProperty('status', 200); + expect(server.refreshObs).toMatchObject({ + seen: true, + proofPresent: false, + proofValid: false, + jktMatched: false + }); + } finally { + await server.lifecycle.stop(); + } + }); + it('rejects a malformed DPoP proof with invalid_dpop_proof', async () => { const server = await startServer(); try { diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 87281d0e..2d6edc9d 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -163,12 +163,6 @@ export interface DpopTokenRequestObservation { * key. Written only on an authorization_code exchange after a valid proof. */ dpopJktMatched: boolean; - /** - * A later authorization_code exchange completed after an earlier one had - * already issued a token. The client recovered by re-authorizing rather - * than presenting the refresh token. - */ - reauthorizedInsteadOfRefreshing?: boolean; } /** @@ -258,6 +252,8 @@ export interface AuthServerOptions { dpopTokenRequestObs?: DpopTokenRequestObservation; /** Sink for refresh-grant observations; see DpopRefreshObservation. */ dpopRefreshObs?: DpopRefreshObservation; + /** Issue refresh tokens from successful token responses. Default false. */ + issueRefreshTokens?: boolean; /** * `expires_in` (seconds) on token responses, and the DPoP access-token * lifetime. Default 3600. The refresh posture sets this to 30 so a client @@ -322,6 +318,7 @@ export function createAuthServer( dpopMisbehavior, dpopTokenRequestObs, dpopRefreshObs, + issueRefreshTokens = false, dpopRequireNonce = false, accessTokenExpiresIn = 3600, tokenVerifier, @@ -363,7 +360,6 @@ export function createAuthServer( }; const refreshTokens = new Map(); - let issuedAuthorizationCode = false; const issueRefreshToken = (entry: StoredRefreshToken): string => { const refreshToken = randomBytes(32).toString('base64url'); @@ -371,14 +367,6 @@ export function createAuthServer( return refreshToken; }; - const markAuthorizationCodeIssued = (grantType: string): void => { - if (grantType !== 'authorization_code') return; - if (issuedAuthorizationCode && dpopTokenRequestObs) { - dpopTokenRequestObs.reauthorizedInsteadOfRefreshing = true; - } - issuedAuthorizationCode = true; - }; - const recordRefresh = (fields: { proofPresent: boolean; proofValid: boolean; @@ -386,10 +374,17 @@ export function createAuthServer( error?: string; }): void => { if (!dpopRefreshObs) return; + const firstObservation = !dpopRefreshObs.seen; dpopRefreshObs.seen = true; - dpopRefreshObs.proofPresent = fields.proofPresent; - dpopRefreshObs.proofValid = fields.proofValid; - dpopRefreshObs.jktMatched = fields.jktMatched; + dpopRefreshObs.proofPresent = firstObservation + ? fields.proofPresent + : dpopRefreshObs.proofPresent && fields.proofPresent; + dpopRefreshObs.proofValid = firstObservation + ? fields.proofValid + : dpopRefreshObs.proofValid && fields.proofValid; + dpopRefreshObs.jktMatched = firstObservation + ? fields.jktMatched + : dpopRefreshObs.jktMatched && fields.jktMatched; if (fields.error) dpopRefreshObs.error ??= fields.error; }; @@ -418,15 +413,21 @@ export function createAuthServer( omitScope?: boolean; jkt?: string; resource?: string; + authorizationCode?: string; } ): void => { - markAuthorizationCodeIssued(grantType); - const refresh_token = issueRefreshToken({ - ...(body.jkt !== undefined ? { jkt: body.jkt } : {}), - scopes: body.scopes, - ...(body.resource !== undefined ? { resource: body.resource } : {}), - issuedAt: Date.now() - }); + if (body.authorizationCode) { + authorizationCodes.delete(body.authorizationCode); + } + const refreshToken = + issueRefreshTokens || grantType === 'refresh_token' + ? issueRefreshToken({ + ...(body.jkt !== undefined ? { jkt: body.jkt } : {}), + scopes: body.scopes, + ...(body.resource !== undefined ? { resource: body.resource } : {}), + issuedAt: Date.now() + }) + : undefined; const scope = body.omitScope ? undefined : body.scope !== undefined @@ -438,7 +439,7 @@ export function createAuthServer( access_token: body.accessToken, token_type: body.tokenType, expires_in: accessTokenExpiresIn, - refresh_token, + ...(refreshToken !== undefined ? { refresh_token: refreshToken } : {}), ...(scope !== undefined ? { scope } : {}) }); }; @@ -581,7 +582,6 @@ export function createAuthServer( }); return; } - const matched = result.jkt === entry.jkt; recordRefresh({ proofPresent: true, @@ -1014,6 +1014,7 @@ export function createAuthServer( scopes: grantedScopes, omitScope: true, jkt: result.jkt, + ...(authorizationCode ? { authorizationCode } : {}), ...((req.body.resource as string | undefined) ? { resource: req.body.resource as string } : {}) @@ -1031,14 +1032,19 @@ export function createAuthServer( audience: resource || 'urn:conformance-test-resource', jkt: result.jkt, expiresInSeconds: accessTokenExpiresIn, - ...(requestedScope && { scope: requestedScope }) + ...(grantedScopes.length > 0 + ? { scope: grantedScopes.join(' ') } + : {}) }); sendTokenResponse(res, grantType, { accessToken: boundToken, tokenType: 'DPoP', scopes: grantedScopes, - ...(requestedScope ? { scope: requestedScope } : { omitScope: true }), + ...(grantedScopes.length > 0 + ? { scope: grantedScopes.join(' ') } + : { omitScope: true }), jkt: result.jkt, + ...(authorizationCode ? { authorizationCode } : {}), ...(resource ? { resource } : {}) }); return; @@ -1088,6 +1094,7 @@ export function createAuthServer( accessToken: token, tokenType: 'Bearer', scopes, + ...(authorizationCode ? { authorizationCode } : {}), ...(resource ? { resource } : {}) }); }); diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index da9008d4..f81bf8bf 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -38,10 +38,8 @@ import { runClient as dpopNoNonceClient } from '../../../../examples/clients/typ 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 dpopRefreshClient } from '../../../../examples/clients/typescript/auth-test-dpop-refresh'; import { runClient as dpopRefreshNoProofClient } from '../../../../examples/clients/typescript/auth-test-dpop-refresh-no-proof'; import { runClient as dpopRefreshNewKeyClient } from '../../../../examples/clients/typescript/auth-test-dpop-refresh-new-key'; -import { runClient as dpopReauthClient } from '../../../../examples/clients/typescript/auth-test-dpop-reauth'; 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'; @@ -350,20 +348,14 @@ describe('Negative tests', () => { describe('Client Extension Scenarios', () => { for (const scenario of extensionScenariosList) { - test( - `${scenario.name} passes`, - async () => { - const clientFn = getHandler(scenario.name); - if (!clientFn) { - throw new Error( - `No handler registered for scenario: ${scenario.name}` - ); - } - const runner = new InlineClientRunner(clientFn); - await runClientAgainstScenario(runner, scenario.name); - }, - scenario.name === 'auth/dpop-refresh' ? 60_000 : 15_000 - ); + test(`${scenario.name} passes`, async () => { + const clientFn = getHandler(scenario.name); + if (!clientFn) { + throw new Error(`No handler registered for scenario: ${scenario.name}`); + } + const runner = new InlineClientRunner(clientFn); + await runClientAgainstScenario(runner, scenario.name); + }, 15_000); } }); @@ -532,89 +524,58 @@ describe('DPoP client negative tests (SEP-1932)', () => { }); }); -const REFRESH_TEST_TIMEOUT = 60_000; - describe('DPoP client refresh (SEP-1932)', () => { - test( - 'auth/dpop-refresh: client proves the bound key', - async () => { - const runner = new InlineClientRunner(dpopRefreshClient); - const checks = await runClientAgainstScenario( - runner, - 'auth/dpop-refresh', - { expectedSuccessSlugs: ['sep-1932-client-refresh-proof'] } - ); - expect( - checks.find((c) => c.id === 'sep-1932-client-refresh-proof')?.status - ).toBe('SUCCESS'); - }, - REFRESH_TEST_TIMEOUT - ); - - test( - 'auth/dpop-refresh: client omits the DPoP proof on refresh', - async () => { - const runner = new InlineClientRunner(dpopRefreshNoProofClient); - const checks = await runClientAgainstScenario( - runner, - 'auth/dpop-refresh', - { - allowClientError: true, - expectedFailureSlugs: ['sep-1932-client-refresh-proof'], - expectedSuccessSlugs: [ - 'sep-1932-client-token-request-proof', - 'sep-1932-client-dpop-jkt', - 'sep-1932-client-dpop-auth-scheme', - 'sep-1932-client-fresh-proof' - ] - } - ); - expect( - checks.find((c) => c.id === 'sep-1932-client-refresh-proof')?.status - ).toBe('FAILURE'); - }, - REFRESH_TEST_TIMEOUT - ); - - test( - 'auth/dpop-refresh: client refreshes with a different key', - async () => { - const runner = new InlineClientRunner(dpopRefreshNewKeyClient); - const checks = await runClientAgainstScenario( - runner, - 'auth/dpop-refresh', - { - allowClientError: true, - expectedFailureSlugs: ['sep-1932-client-refresh-proof'], - expectedSuccessSlugs: [ - 'sep-1932-client-token-request-proof', - 'sep-1932-client-dpop-jkt', - 'sep-1932-client-dpop-auth-scheme', - 'sep-1932-client-fresh-proof' - ] - } - ); - expect( - checks.find((c) => c.id === 'sep-1932-client-refresh-proof')?.status - ).toBe('FAILURE'); - }, - REFRESH_TEST_TIMEOUT - ); - - test( - 'auth/dpop-refresh: client re-authorizes instead of refreshing', - async () => { - const runner = new InlineClientRunner(dpopReauthClient); - const checks = await runClientAgainstScenario( - runner, - 'auth/dpop-refresh' - ); - expect( - checks.find((c) => c.id === 'sep-1932-client-refresh-proof')?.status - ).toBe('INFO'); - }, - REFRESH_TEST_TIMEOUT - ); + test('auth/dpop: client proves the bound key on refresh', async () => { + const runner = new InlineClientRunner(dpopClient); + const checks = await runClientAgainstScenario(runner, 'auth/dpop', { + expectedSuccessSlugs: ['sep-1932-client-refresh-proof'] + }); + expect( + checks.find((c) => c.id === 'sep-1932-client-refresh-proof')?.status + ).toBe('SUCCESS'); + }); + + test('auth/dpop: client omits the DPoP proof on refresh', async () => { + const runner = new InlineClientRunner(dpopRefreshNoProofClient); + const checks = await runClientAgainstScenario(runner, 'auth/dpop', { + allowClientError: true, + expectedFailureSlugs: ['sep-1932-client-refresh-proof'], + expectedSuccessSlugs: [ + 'sep-1932-client-token-request-proof', + 'sep-1932-client-dpop-jkt', + 'sep-1932-client-dpop-auth-scheme', + 'sep-1932-client-fresh-proof' + ] + }); + expect( + checks.find((c) => c.id === 'sep-1932-client-refresh-proof')?.status + ).toBe('FAILURE'); + }); + + test('auth/dpop: client refreshes with a different key', async () => { + const runner = new InlineClientRunner(dpopRefreshNewKeyClient); + const checks = await runClientAgainstScenario(runner, 'auth/dpop', { + allowClientError: true, + expectedFailureSlugs: ['sep-1932-client-refresh-proof'], + expectedSuccessSlugs: [ + 'sep-1932-client-token-request-proof', + 'sep-1932-client-dpop-jkt', + 'sep-1932-client-dpop-auth-scheme', + 'sep-1932-client-fresh-proof' + ] + }); + expect( + checks.find((c) => c.id === 'sep-1932-client-refresh-proof')?.status + ).toBe('FAILURE'); + }); + + test('auth/dpop: client does not use the optional refresh token', async () => { + const runner = new InlineClientRunner(dpopNoNonceClient); + const checks = await runClientAgainstScenario(runner, 'auth/dpop'); + expect( + checks.find((c) => c.id === 'sep-1932-client-refresh-proof')?.status + ).toBe('SKIPPED'); + }); }); // DPoP nonce-less baseline (SEP-1932): a client that implements NO nonce diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index dcaffabc..c16f5071 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -69,7 +69,6 @@ export const extensionScenariosList: Scenario[] = [ new EnterpriseManagedAuthorizationScenario(), new DPoPClientScenario('baseline'), // auth/dpop — nonce-less baseline (common case) new DPoPClientScenario('nonce'), // auth/dpop-nonce — server-required nonce (§8/§9) - new DPoPClientScenario('refresh'), // auth/dpop-refresh — refresh bound to the DPoP key (§5) new WifJwtBearerScenario() ]; diff --git a/src/scenarios/client/auth/test_helpers/testClient.ts b/src/scenarios/client/auth/test_helpers/testClient.ts index e21459a7..a82de04c 100644 --- a/src/scenarios/client/auth/test_helpers/testClient.ts +++ b/src/scenarios/client/auth/test_helpers/testClient.ts @@ -162,8 +162,10 @@ export async function runClientAgainstScenario( throw new Error('No checks returned from scenario'); } - // Filter out INFO checks - const nonInfoChecks = checks.filter((c) => c.status !== 'INFO'); + // INFO and SKIPPED checks are non-scoring. + const scoredChecks = checks.filter( + (c) => c.status !== 'INFO' && c.status !== 'SKIPPED' + ); // Slugs that must be present and SUCCESS (independent of the failure set). for (const slug of expectedSuccessSlugs) { @@ -185,7 +187,7 @@ export async function runClientAgainstScenario( } // Verify that only the expected checks failed - const failures = nonInfoChecks.filter( + const failures = scoredChecks.filter( (c) => c.status === 'FAILURE' || c.status === 'WARNING' ); const failureSlugs = failures.map((c) => c.id); @@ -195,7 +197,7 @@ export async function runClientAgainstScenario( ); } else { // Default: expect all checks to pass - const failures = nonInfoChecks.filter((c) => c.status === 'FAILURE'); + const failures = scoredChecks.filter((c) => c.status === 'FAILURE'); if (failures.length > 0) { const failureMessages = failures .map((c) => `${c.name}: ${c.errorMessage || c.description}`) @@ -204,10 +206,10 @@ export async function runClientAgainstScenario( } // All non-INFO checks should be SUCCESS - const successes = nonInfoChecks.filter((c) => c.status === 'SUCCESS'); - if (successes.length !== nonInfoChecks.length) { + const successes = scoredChecks.filter((c) => c.status === 'SUCCESS'); + if (successes.length !== scoredChecks.length) { throw new Error( - `Expected all checks to pass but got ${successes.length}/${nonInfoChecks.length}` + `Expected all checks to pass but got ${successes.length}/${scoredChecks.length}` ); } } diff --git a/src/seps/sep-1932.yaml b/src/seps/sep-1932.yaml index 7abea975..a1addab5 100644 --- a/src/seps/sep-1932.yaml +++ b/src/seps/sep-1932.yaml @@ -13,7 +13,7 @@ requirements: url: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1932 # RFC 9449 §5. The SEP-1932 extension doc does not yet contain this sentence. - check: sep-1932-client-refresh-proof - text: 'When an authorization server supporting DPoP issues a refresh token to a public client that presents a valid DPoP proof at the token endpoint, the refresh token MUST be bound to the respective public key.' + text: 'Such a client MUST present a DPoP proof for the same key that was used to obtain the refresh token each time that refresh token is used to obtain a new access token.' url: https://www.rfc-editor.org/rfc/rfc9449.html#section-5 - 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)'