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..46639c43 --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-refresh-new-key.ts @@ -0,0 +1,27 @@ +#!/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, + 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..e7d6dbab --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-refresh-no-proof.ts @@ -0,0 +1,26 @@ +#!/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, + exerciseRefresh: true, + sendRefreshProof: false + }); +} + +runAsCli( + runClient, + import.meta.url, + 'auth-test-dpop-refresh-no-proof ' +); diff --git a/examples/clients/typescript/auth-test-dpop.ts b/examples/clients/typescript/auth-test-dpop.ts index feabfa74..d25bc3ad 100644 --- a/examples/clients/typescript/auth-test-dpop.ts +++ b/examples/clients/typescript/auth-test-dpop.ts @@ -6,6 +6,7 @@ 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. + * It also uses the optional refresh token with a proof for the same key (§5). */ export async function runClient(serverUrl: string): Promise { await runDpopClient(serverUrl, { @@ -13,7 +14,8 @@ export async function runClient(serverUrl: string): Promise { freshProofPerRequest: true, sendTokenRequestProof: true, handleAsNonce: true, - handleRsNonce: true + handleRsNonce: true, + exerciseRefresh: true }); } diff --git a/examples/clients/typescript/helpers/dpopClientFlow.ts b/examples/clients/typescript/helpers/dpopClientFlow.ts index f4cb81ff..7e2fd456 100644 --- a/examples/clients/typescript/helpers/dpopClientFlow.ts +++ b/examples/clients/typescript/helpers/dpopClientFlow.ts @@ -29,6 +29,12 @@ 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 + * - `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 */ export interface DpopClientOptions { scheme: 'DPoP' | 'Bearer'; @@ -38,6 +44,12 @@ 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; + /** 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; } const REDIRECT_URI = 'http://127.0.0.1:9876/callback'; @@ -83,89 +95,133 @@ 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 authorizeUrl = `${authorizationEndpoint}?${new URLSearchParams({ - response_type: 'code', - client_id: clientId, - state, - redirect_uri: REDIRECT_URI, - code_challenge: codeChallenge, - code_challenge_method: 'S256' - }).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; + + 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; + 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' }); + 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()); + }; + + await exchangeAuthorizationCode(); - // 6. MCP session — present the token to the resource with a per-request proof. + // 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. const mcpUrl = `${serverUrl}`; let reusableProof: string | undefined; let rsNonce: string | undefined; @@ -197,12 +253,13 @@ 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 @@ -225,6 +282,12 @@ export async function runDpopClient( logger.debug('Listed tools'); await client.callTool({ name: 'test-tool', arguments: {} }); logger.debug('Called tool'); + if (options.exerciseRefresh) { + await refreshAccessToken(); + reusableProof = undefined; + 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 8b16ba5a..2d7d9c53 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'; @@ -76,6 +77,16 @@ const CHECK_DEFS: Record< SpecReferences.DPOP_EXTENSION, SpecReferences.RFC_9449_RS_NONCE ] + }, + 'sep-1932-client-refresh-proof': { + name: 'DpopRefreshProof', + description: + '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, + SpecReferences.RFC_9449_TOKEN_REQUEST + ] } }; @@ -89,17 +100,19 @@ const CHECK_DEFS: Record< * 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 three 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 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 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). */ @@ -112,6 +125,17 @@ function newTokenReqObs(): DpopTokenRequestObservation { }; } +function newRefreshObs(): DpopRefreshObservation { + return { + seen: false, + proofPresent: false, + proofValid: false, + jktMatched: false + }; +} + +export type DpopClientPosture = 'baseline' | 'nonce'; + export class DPoPClientScenario implements Scenario { readonly name: string; readonly source = { @@ -124,29 +148,37 @@ 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. */ - 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' : '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).' + : '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 { 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, + issueRefreshTokens: true, + dpopRequireNonce: this.posture === 'nonce', + onRegistrationRequest: () => ({ + clientId: 'conformance-dpop-public-client', + clientSecret: undefined, + tokenEndpointAuthMethod: 'none' + }) }); await this.authServer.start(authApp); @@ -160,7 +192,7 @@ export class DPoPClientScenario implements Scenario { this.obs, () => `${this.server.getUrl()}/mcp`, () => `${this.server.getUrl()}${PRM_PATH}`, - this.requireNonce + this.posture === 'nonce' ) } ); @@ -179,19 +211,21 @@ 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(), 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` // challenge, so there is no nonce behaviour to assert. - if (this.requireNonce) { + if (this.posture === 'nonce') { checks.push(this.asNonceCheck(), this.rsNonceCheck()); } return checks; @@ -244,6 +278,35 @@ export class DPoPClientScenario implements Scenario { ); } + private refreshProofCheck(): ConformanceCheck { + const refreshed = + this.refreshObs.seen && + this.refreshObs.proofValid && + this.refreshObs.jktMatched; + 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 { + status = 'SKIPPED'; + } + 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, + reason: 'Client did not use the optional refresh token' + } + }); + } + 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..edac17c2 --- /dev/null +++ b/src/scenarios/client/auth/helpers/createAuthServer.test.ts @@ -0,0 +1,401 @@ +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, + type DpopKeyPair +} from './dpopProof'; +import { + createAuthServer, + type AuthServerOptions, + type DpopRefreshObservation, + type DpopTokenRequestObservation +} from './createAuthServer'; +import { ServerLifecycle } from './serverLifecycle'; + +function newObs(): DpopTokenRequestObservation { + return { + recorded: false, + validProof: false, + asNonceChallengeIssued: false, + asNonceHonored: false + }; +} + +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, + issueRefreshTokens: true, + ...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 requestAuthorizationCode(base: string): Promise { + const params = new URLSearchParams({ + response_type: 'code', + client_id: 'test', + redirect_uri: REDIRECT, + code_challenge: 'x', + code_challenge_method: 'S256' + }); + const response = await fetch(`${base}/authorize?${params}`, { + redirect: 'manual' + }); + return new URL(response.headers.get('location')!).searchParams.get('code')!; +} + +async function authorizationCode( + base: string, + keyPair: DpopKeyPair | undefined, + nonce?: string +): Promise<{ refreshToken: string; accessToken: string; tokenType: string }> { + const code = await requestAuthorizationCode(base); + const proof = keyPair + ? await buildDpopProof({ + keyPair, + htm: 'POST', + htu: `${base}/token`, + ...(nonce ? { nonce } : {}) + }) + : undefined; + const res = await postToken( + base, + { + grant_type: 'authorization_code', + 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('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); + 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 { + 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('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 { + 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 162f93aa..e565a16d 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'; @@ -154,6 +154,26 @@ export interface DpopTokenRequestObservation { asNonceHonored: 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 { metadataPath?: string; isOpenIdConfiguration?: boolean; @@ -199,14 +219,30 @@ 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; + /** 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 + * 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 @@ -264,7 +300,10 @@ export function createAuthServer( dpopSigningAlgValuesSupported, dpopMisbehavior, dpopTokenRequestObs, + dpopRefreshObs, + issueRefreshTokens = false, dpopRequireNonce = false, + accessTokenExpiresIn = 3600, tokenVerifier, onTokenRequest, onAuthorizationRequest, @@ -304,6 +343,270 @@ export function createAuthServer( if (!valid && detail) dpopTokenRequestObs.error ??= detail; }; + const refreshTokens = new Map(); + + const issueRefreshToken = (entry: StoredRefreshToken): string => { + const refreshToken = randomBytes(32).toString('base64url'); + refreshTokens.set(refreshToken, entry); + return refreshToken; + }; + + const recordRefresh = (fields: { + proofPresent: boolean; + proofValid: boolean; + jktMatched: boolean; + error?: string; + }): void => { + if (!dpopRefreshObs) return; + const firstObservation = !dpopRefreshObs.seen; + dpopRefreshObs.seen = true; + 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; + }; + + 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 => { + 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 + ? body.scope + : body.scopes.length > 0 + ? body.scopes.join(' ') + : undefined; + res.json({ + access_token: body.accessToken, + token_type: body.tokenType, + expires_in: accessTokenExpiresIn, + ...(refreshToken !== undefined ? { refresh_token: refreshToken } : {}), + ...(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`, @@ -512,6 +815,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') { @@ -646,12 +954,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; } @@ -659,19 +973,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; } @@ -715,11 +1032,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 41c84ae8..cce8bf0f 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 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 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'; @@ -351,7 +353,7 @@ describe('Client Extension Scenarios', () => { } const runner = new InlineClientRunner(clientFn); await runClientAgainstScenario(runner, scenario.name); - }); + }, 15_000); } }); @@ -476,6 +478,58 @@ describe('DPoP client negative tests (SEP-1932)', () => { }); }); +describe('DPoP client refresh (SEP-1932)', () => { + 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-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-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 // 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 @@ -485,7 +539,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'); }); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 7daec39b..c16f5071 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -67,8 +67,8 @@ 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 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 c3c650f3..90430a16 100644 --- a/src/seps/sep-1932.yaml +++ b/src/seps/sep-1932.yaml @@ -7,6 +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)' + # RFC 9449 §5. The SEP-1932 extension doc does not yet contain this sentence. + - check: sep-1932-client-refresh-proof + 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)' - check: sep-1932-client-rs-nonce