Client Auth: DPoP Sender-Constrained Tokens (SEP-1932) (#394)

* Add DPoP client conformance scenario + shared DPoP helpers (SEP-1932, #368)

Client-anchored packaging: carries the shared DPoP foundation (dpopProof /
dpopToken helpers, the createAuthServer DPoP core, and sep-1932.yaml) together
with the client scenario, so it can merge first; the server (#369) and
authorization-server (#370) scenarios are follow-ups that depend on it.

DPoP is treated as a draft/not-yet-official feature (mirroring the WIF SEP-1933
scenario): the scenario's source is `introducedIn: DRAFT_PROTOCOL_VERSION` and
it is registered in draftScenariosList (informational, not scored). It is NOT
registered in EXTENSION_IDS, since the ext-auth DPoP extension is still on a
branch rather than merged.

Client checks (the two extension MUSTs, RFC 9449 §7.1 / §4.2-4.3):
- sep-1932-client-dpop-auth-scheme — token presented with the DPoP scheme.
- sep-1932-client-fresh-proof — a fresh, well-formed DPoP proof per request.

The test MCP server judges the client via an opt-in DPoP middleware
(dpopResourceAuth) passed through createServer's authMiddleware hook; a compliant
example client (covered by the Client Draft Scenarios loop) plus deliberately-
broken bearer/replay variants drive the pass/fail acceptance tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* client/dpop: split into nonce-less baseline + nonce-required scenario

Server-provided nonces are optional in RFC 9449 (AS §8 "MAY", RS §9 "can
also choose"), and the nonce-less flow is the common case. The single
auth/dpop scenario hard-wired nonce enforcement on both the test AS and
MCP server, so a plain nonce-less proof was never accepted end-to-end and
a client that only does plain proofs was never exercised on its happy path.

Parameterize DPoPClientScenario with `requireNonce` and register it twice:
- auth/dpop        (requireNonce=false) — nonce-less baseline; emits the
  three baseline checks (token-request-proof, dpop-auth-scheme, fresh-proof).
- auth/dpop-nonce  (requireNonce=true)  — prior behavior; adds as-nonce and
  rs-nonce (five checks).

Check IDs are reused (no new IDs, no sep-1932.yaml change); the two nonce
checks are emitted only by auth/dpop-nonce, so traceability stays intact.

Add auth-test-dpop-no-nonce.ts (nonce-incapable but otherwise compliant)
asserting SUCCESS against auth/dpop — proof that a client with no nonce
handling still completes DPoP when the server does not require a nonce.
Baseline negatives stay on auth/dpop (nonce-independent); the two nonce
negatives are re-pointed to auth/dpop-nonce.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* client/dpop: round-4 nonce-surface correctness fixes

Address review findings on the auth/dpop-nonce posture:

- Record the token-request proof observation BEFORE the §8 nonce challenge,
  so a client that is challenged but does not retry is no longer mis-reported
  by token-request-proof as "never completed a token request" (its proof was
  just validated). (#2)
- Record the resource-request jti/replay on ANY valid proof (before the §9
  nonce gate), so fresh-proof is never asserted on zero evidence and a client
  that never honours the RS nonce fails ONLY rs-nonce. (#5)
- Require challengeIssued && honored for as-nonce/rs-nonce SUCCESS, closing a
  vacuous-pass path (a client that pre-sends the nonce is never challenged). (#3a)
- Gate the AS nonce observation on the authorization_code exchange, matching
  recordTokenRequestProof (a refresh exchange must not satisfy §8). (#3c)
- Collapse duplicate shared token-flow check IDs (token-request, pkce-*) that
  the §8 round-trip re-POST would otherwise emit twice. (#4)
- Example client: retry the token request only on a use_dpop_nonce error code,
  not any 400 carrying a DPoP-Nonce header (consistent with the RS path). (#6)
- Fix the DpopTokenRequestObservation docstring ("last write wins" →
  sticky-failure). (#7)

Adds an expectedSuccessSlugs option to the client test helper and tightens the
two nonce negative tests to pin the now-clean behavior (token-request-proof
SUCCESS for no-as-nonce; only rs-nonce fails for no-rs-nonce).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* client/dpop: round-5 fixes — dedupe masking, wording, replay decision

- dedupeSharedChecks was masking failures: it kept the last occurrence of a
  duplicate check ID regardless of status and ran in BOTH postures, so a
  FAILURE on a challenged POST (or an earlier attempt in a restarted flow) was
  silently dropped. Replace with an exported, unit-tested collapseDuplicateChecks
  that prefers the MOST-SEVERE occurrence, and only apply it in the nonce
  posture (the baseline keeps genuinely distinct repeated attempts). (R5 #1)
- Reword the token-request-proof description: it can be SUCCESS for a client
  that presented a valid proof but never obtained a token (challenged, no
  retry), so it no longer claims "obtaining a DPoP-bound access token". (R5 #2)
- Reword the as-nonce/rs-nonce failure messages to cover a client that DID
  retry but whose retry proof was rejected (not only "did not retry"). (R5 #3)
- Document the deliberate replay decision: recording jti on challenged requests
  means re-sending the identical proof across the challenge/retry boundary trips
  replay detection — a genuine RFC 9449 §4.2 jti-uniqueness violation. (R5 #5)

Adds src/scenarios/client/auth/dpop.test.ts unit-testing collapseDuplicateChecks
(prefers failures, keeps INFO, preserves order) — the dedupe fix is now pinned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* client/dpop: round-6 polish — pin collapse wiring; docstring

- Add an integration assertion that the §8/§9 double-POST is collapsed to one
  token-request/pkce-* entry each in auth/dpop-nonce, so the requireNonce gating
  of collapseDuplicateChecks can't be silently deleted or inverted (previously
  mutation-survivable). runClientAgainstScenario now returns the emitted checks
  so callers can make occurrence-level assertions (backward-compatible).
- Docstring: note the severity ladder ranks SKIPPED below SUCCESS, and that
  equal-severity ties keep the last occurrence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
PieterKas 2026-07-10 17:13:02 +01:00 • committed by GitHub
commit 6e726734f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 2713 additions and 4 deletions

View file

@ -0,0 +1,21 @@
#!/usr/bin/env node
import { runDpopClient } from './helpers/dpopClientFlow';
import { runAsCli } from './helpers/cliRunner';
/**
* Broken DPoP client: builds correct per-request proofs but presents the
* DPoP-bound token with the `Bearer` scheme instead of `DPoP`. Isolates a
* failure of sep-1932-client-dpop-auth-scheme (RFC 9449 §7.1).
*/
export async function runClient(serverUrl: string): Promise<void> {
await runDpopClient(serverUrl, {
scheme: 'Bearer',
freshProofPerRequest: true,
sendTokenRequestProof: true,
handleAsNonce: true,
handleRsNonce: true
});
}
runAsCli(runClient, import.meta.url, 'auth-test-dpop-bearer <server-url>');

View file

@ -0,0 +1,22 @@
#!/usr/bin/env node
import { runDpopClient } from './helpers/dpopClientFlow';
import { runAsCli } from './helpers/cliRunner';
/**
* Broken DPoP client: ignores the authorization server's `use_dpop_nonce`
* challenge at the token endpoint (RFC 9449 §8) — it does not retry the token
* request with the supplied nonce. Isolates a failure of
* sep-1932-client-as-nonce.
*/
export async function runClient(serverUrl: string): Promise<void> {
await runDpopClient(serverUrl, {
scheme: 'DPoP',
freshProofPerRequest: true,
sendTokenRequestProof: true,
handleAsNonce: false,
handleRsNonce: true
});
}
runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-as-nonce <server-url>');

View file

@ -0,0 +1,25 @@
#!/usr/bin/env node
import { runDpopClient } from './helpers/dpopClientFlow';
import { runAsCli } from './helpers/cliRunner';
/**
* Nonce-incapable but otherwise compliant DPoP client (SEP-1932 / RFC 9449):
* presents the DPoP-bound token with the `DPoP` Authorization scheme and a fresh
* proof on every request, but implements NO `use_dpop_nonce` handling. Against
* the nonce-less `auth/dpop` scenario (where neither server issues a challenge)
* it completes the flow successfully — proving that a client which does not
* support nonces still passes when the server does not require one (the common
* case, since server nonces are OPTIONAL in RFC 9449 §8/§9).
*/
export async function runClient(serverUrl: string): Promise<void> {
await runDpopClient(serverUrl, {
scheme: 'DPoP',
freshProofPerRequest: true,
sendTokenRequestProof: true,
handleAsNonce: false,
handleRsNonce: false
});
}
runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-nonce <server-url>');

View file

@ -0,0 +1,22 @@
#!/usr/bin/env node
import { runDpopClient } from './helpers/dpopClientFlow';
import { runAsCli } from './helpers/cliRunner';
/**
* Broken DPoP client: obtains the token correctly (handles the AS nonce) but
* ignores the MCP server's `use_dpop_nonce` challenge at the resource
* (RFC 9449 §9) — it does not retry the request with the supplied nonce.
* Isolates a failure of sep-1932-client-rs-nonce.
*/
export async function runClient(serverUrl: string): Promise<void> {
await runDpopClient(serverUrl, {
scheme: 'DPoP',
freshProofPerRequest: true,
sendTokenRequestProof: true,
handleAsNonce: true,
handleRsNonce: false
});
}
runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-rs-nonce <server-url>');

View file

@ -0,0 +1,27 @@
#!/usr/bin/env node
import { runDpopClient } from './helpers/dpopClientFlow';
import { runAsCli } from './helpers/cliRunner';
/**
* Broken DPoP client: never sends a DPoP proof at the token endpoint, so it
* never asks for a sender-constrained token — the AS falls back to an unbound
* Bearer token. Isolates a failure of sep-1932-client-token-request-proof
* (RFC 9449 §5). Because the resulting token is unbound, the resource-side
* binding check (sep-1932-client-fresh-proof) also fails as a consequence.
*/
export async function runClient(serverUrl: string): Promise<void> {
await runDpopClient(serverUrl, {
scheme: 'DPoP',
freshProofPerRequest: true,
sendTokenRequestProof: false,
handleAsNonce: true,
handleRsNonce: true
});
}
runAsCli(
runClient,
import.meta.url,
'auth-test-dpop-no-token-proof <server-url>'
);

View file

@ -0,0 +1,21 @@
#!/usr/bin/env node
import { runDpopClient } from './helpers/dpopClientFlow';
import { runAsCli } from './helpers/cliRunner';
/**
* Broken DPoP client: uses the DPoP scheme correctly but reuses a single proof
* (same `jti`) across requests instead of a fresh one each time. Isolates a
* failure of sep-1932-client-fresh-proof.
*/
export async function runClient(serverUrl: string): Promise<void> {
await runDpopClient(serverUrl, {
scheme: 'DPoP',
freshProofPerRequest: false,
sendTokenRequestProof: true,
handleAsNonce: true,
handleRsNonce: true
});
}
runAsCli(runClient, import.meta.url, 'auth-test-dpop-replay <server-url>');

View file

@ -0,0 +1,20 @@
#!/usr/bin/env node
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.
*/
export async function runClient(serverUrl: string): Promise<void> {
await runDpopClient(serverUrl, {
scheme: 'DPoP',
freshProofPerRequest: true,
sendTokenRequestProof: true,
handleAsNonce: true,
handleRsNonce: true
});
}
runAsCli(runClient, import.meta.url, 'auth-test-dpop <server-url>');

View file

@ -42,6 +42,7 @@ import {
} from './helpers/withOAuthRetry.js';
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 { logger } from './helpers/logger.js';
/**
@ -854,6 +855,13 @@ registerScenario(
runEnterpriseManagedAuthorization
);
// ============================================================================
// DPoP client conformance (SEP-1932)
// ============================================================================
registerScenario('auth/dpop', dpopClient);
registerScenario('auth/dpop-nonce', dpopClient);
// ============================================================================
// MRTR client conformance (SEP-2322)
// ============================================================================

View file

@ -0,0 +1,234 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { request } from 'undici';
import { createHash, randomBytes } from 'crypto';
import {
generateDpopKeyPair,
buildDpopProof
} from '../../../../src/scenarios/client/auth/helpers/dpopProof';
import { logger } from './logger';
/**
* Shared DPoP client flow (SEP-1932 / RFC 9449). Acquires a DPoP-bound access
* token via the authorization_code + PKCE grant (with a DPoP proof at the token
* endpoint), then opens an MCP session presenting the token to the resource.
*
* The OAuth flow is hand-rolled rather than reusing ConformanceOAuthProvider /
* withOAuthRetry because the SDK's OAuth provider offers no hook to attach a
* DPoP proof to the token-endpoint request, which is required to obtain a bound
* token. The MCP session itself does use the SDK Client (via a fetch wrapper).
*
* Parameterized so the compliant client and the deliberately-broken variants
* differ by exactly one behaviour:
* - `scheme: 'Bearer'` → fails sep-1932-client-dpop-auth-scheme
* - `freshProofPerRequest:false` → reuses one proof, fails sep-1932-client-fresh-proof
* - `sendTokenRequestProof:false`→ omits the token-endpoint proof, so the AS
* issues an unbound Bearer token; fails sep-1932-client-token-request-proof
* (and, downstream, the resource binding check, since the token isn't bound)
* - `handleAsNonce:false` → ignores the token endpoint's `use_dpop_nonce`
* 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
*/
export interface DpopClientOptions {
scheme: 'DPoP' | 'Bearer';
freshProofPerRequest: boolean;
sendTokenRequestProof: boolean;
/** Retry the token request with the AS-supplied nonce on a use_dpop_nonce challenge (RFC 9449 §8). */
handleAsNonce: boolean;
/** Retry an MCP request with the server-supplied nonce on a use_dpop_nonce challenge (RFC 9449 §9). */
handleRsNonce: boolean;
}
const REDIRECT_URI = 'http://127.0.0.1:9876/callback';
export async function runDpopClient(
serverUrl: string,
options: DpopClientOptions
): Promise<void> {
const keyPair = await generateDpopKeyPair();
// 1. Discover the authorization server via Protected Resource Metadata.
const prmUrl = new URL(
'/.well-known/oauth-protected-resource/mcp',
serverUrl
);
const prm = await (await fetch(prmUrl.toString())).json();
const authServerUrl: string = prm.authorization_servers[0];
// 2. Authorization server metadata.
const asMeta = await (
await fetch(
new URL(
'/.well-known/oauth-authorization-server',
authServerUrl
).toString()
)
).json();
const authorizationEndpoint: string = asMeta.authorization_endpoint;
const tokenEndpoint: string = asMeta.token_endpoint;
const registrationEndpoint: string = asMeta.registration_endpoint;
// 3. Dynamic client registration.
const reg = await (
await fetch(registrationEndpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
client_name: 'conformance-dpop-client',
redirect_uris: [REDIRECT_URI],
application_type: 'native'
})
})
).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<Response> => {
const headers: Record<string, string> = {
'content-type': 'application/x-www-form-urlencoded'
};
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 } : {})
});
}
return fetch(tokenEndpoint, {
method: 'POST',
headers,
body: tokenReqBody
});
};
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);
}
}
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`);
// 6. 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.
const mcpUrl = `${serverUrl}`;
let reusableProof: string | undefined;
let rsNonce: string | undefined;
const dpopFetch = async (
input: string | URL,
init?: RequestInit
): Promise<Response> => {
const method = (init?.method ?? 'POST').toUpperCase();
const htu = stripQuery(
typeof input === 'string' ? input : input.toString()
);
const attempt = async (): Promise<Response> => {
const headers = new Headers(init?.headers);
headers.set('Authorization', `${options.scheme} ${accessToken}`);
let proof: string;
if (options.freshProofPerRequest || !reusableProof) {
proof = await buildDpopProof({
keyPair,
htm: method,
htu,
accessToken,
...(rsNonce ? { nonce: rsNonce } : {})
});
if (!options.freshProofPerRequest) reusableProof = proof;
} else {
proof = reusableProof;
}
headers.set('DPoP', proof);
return fetch(input, { ...init, headers });
};
let res = await attempt();
const nonce = res.headers.get('DPoP-Nonce');
if (
res.status === 401 &&
nonce &&
options.handleRsNonce &&
(res.headers.get('WWW-Authenticate') ?? '').includes('use_dpop_nonce')
) {
rsNonce = nonce;
reusableProof = undefined; // rebuild the proof carrying the nonce
res = await attempt();
}
return res;
};
const client = new Client(
{ name: 'conformance-dpop-client', version: '1.0.0' },
{ capabilities: {} }
);
const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
fetch: dpopFetch as typeof fetch
});
await client.connect(transport);
logger.debug('Connected to MCP server');
await client.listTools();
logger.debug('Listed tools');
await client.callTool({ name: 'test-tool', arguments: {} });
logger.debug('Called tool');
await transport.close();
}
function stripQuery(url: string): string {
const u = new URL(url);
return `${u.origin}${u.pathname}`;
}