Visitar URL original
Client Auth: DPoP Sender-Constrained Tokens (SEP-1932) (#394) · modelcontextprotocol/conformance@6e72673 · GitHub
Skip to content

Commit 6e72673

Browse files
PieterKasclaude
andauthored
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>
1 parent 5ad96db commit 6e72673

24 files changed

Lines changed: 2713 additions & 4 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env node
2+
3+
import { runDpopClient } from './helpers/dpopClientFlow';
4+
import { runAsCli } from './helpers/cliRunner';
5+
6+
/**
7+
* Broken DPoP client: builds correct per-request proofs but presents the
8+
* DPoP-bound token with the `Bearer` scheme instead of `DPoP`. Isolates a
9+
* failure of sep-1932-client-dpop-auth-scheme (RFC 9449 §7.1).
10+
*/
11+
export async function runClient(serverUrl: string): Promise<void> {
12+
await runDpopClient(serverUrl, {
13+
scheme: 'Bearer',
14+
freshProofPerRequest: true,
15+
sendTokenRequestProof: true,
16+
handleAsNonce: true,
17+
handleRsNonce: true
18+
});
19+
}
20+
21+
runAsCli(runClient, import.meta.url, 'auth-test-dpop-bearer <server-url>');
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#!/usr/bin/env node
2+
3+
import { runDpopClient } from './helpers/dpopClientFlow';
4+
import { runAsCli } from './helpers/cliRunner';
5+
6+
/**
7+
* Broken DPoP client: ignores the authorization server's `use_dpop_nonce`
8+
* challenge at the token endpoint (RFC 9449 §8) — it does not retry the token
9+
* request with the supplied nonce. Isolates a failure of
10+
* sep-1932-client-as-nonce.
11+
*/
12+
export async function runClient(serverUrl: string): Promise<void> {
13+
await runDpopClient(serverUrl, {
14+
scheme: 'DPoP',
15+
freshProofPerRequest: true,
16+
sendTokenRequestProof: true,
17+
handleAsNonce: false,
18+
handleRsNonce: true
19+
});
20+
}
21+
22+
runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-as-nonce <server-url>');
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#!/usr/bin/env node
2+
3+
import { runDpopClient } from './helpers/dpopClientFlow';
4+
import { runAsCli } from './helpers/cliRunner';
5+
6+
/**
7+
* Nonce-incapable but otherwise compliant DPoP client (SEP-1932 / RFC 9449):
8+
* presents the DPoP-bound token with the `DPoP` Authorization scheme and a fresh
9+
* proof on every request, but implements NO `use_dpop_nonce` handling. Against
10+
* the nonce-less `auth/dpop` scenario (where neither server issues a challenge)
11+
* it completes the flow successfully — proving that a client which does not
12+
* support nonces still passes when the server does not require one (the common
13+
* case, since server nonces are OPTIONAL in RFC 9449 §8/§9).
14+
*/
15+
export async function runClient(serverUrl: string): Promise<void> {
16+
await runDpopClient(serverUrl, {
17+
scheme: 'DPoP',
18+
freshProofPerRequest: true,
19+
sendTokenRequestProof: true,
20+
handleAsNonce: false,
21+
handleRsNonce: false
22+
});
23+
}
24+
25+
runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-nonce <server-url>');
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#!/usr/bin/env node
2+
3+
import { runDpopClient } from './helpers/dpopClientFlow';
4+
import { runAsCli } from './helpers/cliRunner';
5+
6+
/**
7+
* Broken DPoP client: obtains the token correctly (handles the AS nonce) but
8+
* ignores the MCP server's `use_dpop_nonce` challenge at the resource
9+
* (RFC 9449 §9) — it does not retry the request with the supplied nonce.
10+
* Isolates a failure of sep-1932-client-rs-nonce.
11+
*/
12+
export async function runClient(serverUrl: string): Promise<void> {
13+
await runDpopClient(serverUrl, {
14+
scheme: 'DPoP',
15+
freshProofPerRequest: true,
16+
sendTokenRequestProof: true,
17+
handleAsNonce: true,
18+
handleRsNonce: false
19+
});
20+
}
21+
22+
runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-rs-nonce <server-url>');
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/usr/bin/env node
2+
3+
import { runDpopClient } from './helpers/dpopClientFlow';
4+
import { runAsCli } from './helpers/cliRunner';
5+
6+
/**
7+
* Broken DPoP client: never sends a DPoP proof at the token endpoint, so it
8+
* never asks for a sender-constrained token — the AS falls back to an unbound
9+
* Bearer token. Isolates a failure of sep-1932-client-token-request-proof
10+
* (RFC 9449 §5). Because the resulting token is unbound, the resource-side
11+
* binding check (sep-1932-client-fresh-proof) also fails as a consequence.
12+
*/
13+
export async function runClient(serverUrl: string): Promise<void> {
14+
await runDpopClient(serverUrl, {
15+
scheme: 'DPoP',
16+
freshProofPerRequest: true,
17+
sendTokenRequestProof: false,
18+
handleAsNonce: true,
19+
handleRsNonce: true
20+
});
21+
}
22+
23+
runAsCli(
24+
runClient,
25+
import.meta.url,
26+
'auth-test-dpop-no-token-proof <server-url>'
27+
);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env node
2+
3+
import { runDpopClient } from './helpers/dpopClientFlow';
4+
import { runAsCli } from './helpers/cliRunner';
5+
6+
/**
7+
* Broken DPoP client: uses the DPoP scheme correctly but reuses a single proof
8+
* (same `jti`) across requests instead of a fresh one each time. Isolates a
9+
* failure of sep-1932-client-fresh-proof.
10+
*/
11+
export async function runClient(serverUrl: string): Promise<void> {
12+
await runDpopClient(serverUrl, {
13+
scheme: 'DPoP',
14+
freshProofPerRequest: false,
15+
sendTokenRequestProof: true,
16+
handleAsNonce: true,
17+
handleRsNonce: true
18+
});
19+
}
20+
21+
runAsCli(runClient, import.meta.url, 'auth-test-dpop-replay <server-url>');
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env node
2+
3+
import { runDpopClient } from './helpers/dpopClientFlow';
4+
import { runAsCli } from './helpers/cliRunner';
5+
6+
/**
7+
* Well-behaved DPoP client (SEP-1932 / RFC 9449): presents the DPoP-bound token
8+
* with the `DPoP` Authorization scheme and a fresh proof on every MCP request.
9+
*/
10+
export async function runClient(serverUrl: string): Promise<void> {
11+
await runDpopClient(serverUrl, {
12+
scheme: 'DPoP',
13+
freshProofPerRequest: true,
14+
sendTokenRequestProof: true,
15+
handleAsNonce: true,
16+
handleRsNonce: true
17+
});
18+
}
19+
20+
runAsCli(runClient, import.meta.url, 'auth-test-dpop <server-url>');

‎examples/clients/typescript/everything-client.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
} from './helpers/withOAuthRetry.js';
4343
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
4444
import { runClient as issValidationClient } from './auth-test-iss-validation.js';
45+
import { runClient as dpopClient } from './auth-test-dpop.js';
4546
import { logger } from './helpers/logger.js';
4647

4748
/**
@@ -854,6 +855,13 @@ registerScenario(
854855
runEnterpriseManagedAuthorization
855856
);
856857

858+
// ============================================================================
859+
// DPoP client conformance (SEP-1932)
860+
// ============================================================================
861+
862+
registerScenario('auth/dpop', dpopClient);
863+
registerScenario('auth/dpop-nonce', dpopClient);
864+
857865
// ============================================================================
858866
// MRTR client conformance (SEP-2322)
859867
// ============================================================================

0 commit comments

Comments
 (0)