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

View file

@ -0,0 +1,362 @@
import type { ScenarioContext } from '../../../mock-server';
import type {
Scenario,
ConformanceCheck,
CheckStatus,
SpecReference
} from '../../../types';
import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types';
import {
createAuthServer,
type DpopTokenRequestObservation
} from './helpers/createAuthServer';
import { createServer } from './helpers/createServer';
import { ServerLifecycle } from './helpers/serverLifecycle';
import {
createDpopResourceAuth,
newDpopClientObservations,
type DpopClientObservations
} from './helpers/dpopResourceAuth';
import { SpecReferences } from './spec-references';
const PRM_PATH = '/.well-known/oauth-protected-resource/mcp';
/** Static id → (name, description, spec references) for each emitted check. */
const CHECK_DEFS: Record<
string,
{ name: string; description: string; specReferences: SpecReference[] }
> = {
'sep-1932-client-dpop-auth-scheme': {
name: 'DpopAuthScheme',
description:
'Client presents the access token with the DPoP Authorization scheme (not Bearer)',
specReferences: [
SpecReferences.SEP_1932_DPOP,
SpecReferences.DPOP_EXTENSION,
SpecReferences.RFC_9449_AUTH_SCHEME
]
},
'sep-1932-client-fresh-proof': {
name: 'DpopFreshProof',
description:
'Client includes a fresh, well-formed DPoP proof in the DPoP header on each MCP POST request',
specReferences: [
SpecReferences.RFC_9449_CHECKING_PROOFS,
SpecReferences.RFC_9449_PROOF_SYNTAX,
SpecReferences.DPOP_EXTENSION
]
},
'sep-1932-client-token-request-proof': {
name: 'DpopTokenRequestProof',
description:
'Client includes a valid DPoP proof in the DPoP header of its token request — the prerequisite for obtaining a DPoP-bound access token (RFC 9449 §5)',
specReferences: [
SpecReferences.SEP_1932_DPOP,
SpecReferences.DPOP_EXTENSION,
SpecReferences.RFC_9449_TOKEN_REQUEST
]
},
'sep-1932-client-as-nonce': {
name: 'DpopAsNonce',
description:
'On a use_dpop_nonce challenge from the token endpoint, the client retries the token request with the server-supplied nonce (RFC 9449 §8)',
specReferences: [
SpecReferences.SEP_1932_DPOP,
SpecReferences.DPOP_EXTENSION,
SpecReferences.RFC_9449_AS_NONCE
]
},
'sep-1932-client-rs-nonce': {
name: 'DpopRsNonce',
description:
'On a use_dpop_nonce challenge from the MCP server, the client retries the request with the server-supplied nonce (RFC 9449 §9)',
specReferences: [
SpecReferences.SEP_1932_DPOP,
SpecReferences.DPOP_EXTENSION,
SpecReferences.RFC_9449_RS_NONCE
]
}
};
/**
* Scenario: DPoP sender-constrained tokens — MCP client (SEP-1932 / RFC 9449).
*
* The test authorization server (DPoP-capable `createAuthServer`) issues a
* DPoP-bound token; the test MCP server judges how the client presents it.
*
* Registered in two postures, because server-provided nonces are OPTIONAL in
* 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.
* Neither the AS nor the MCP server issues a nonce challenge; the client
* completes the flow with plain proofs. Emits three checks:
* · 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`).
*
* - `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:
* · the client retries the token request with the AS-supplied nonce (§8);
* · the client retries the MCP request with the server-supplied nonce (§9).
*/
function newTokenReqObs(): DpopTokenRequestObservation {
return {
recorded: false,
validProof: false,
asNonceChallengeIssued: false,
asNonceHonored: false
};
}
/**
* Collapse duplicate non-INFO check IDs to a single entry, preferring the
* MOST-SEVERE occurrence (FAILURE > WARNING > SUCCESS > any other status, e.g.
* SKIPPED) so a real failure is never masked. Equal-severity ties keep the LAST
* occurrence (for the nonce round-trip that is the retry's diagnostic, which is
* status-equivalent to the first). Per-request INFO log entries are always kept.
*
* The RFC 9449 §8/§9 nonce round-trip re-POSTs /token (challenge → retry), so
* the shared token-flow conformance checks (`token-request`, `pkce-*`) are
* appended twice; this reports each once without hiding a failure recorded on
* either attempt. Exported so the behaviour is unit-tested directly.
*/
export function collapseDuplicateChecks(
checks: ConformanceCheck[]
): ConformanceCheck[] {
const severity = (s: CheckStatus): number =>
s === 'FAILURE' ? 3 : s === 'WARNING' ? 2 : s === 'SUCCESS' ? 1 : 0;
// Winning index per non-INFO id: highest severity, ties → last occurrence.
const winner = new Map<string, number>();
checks.forEach((c, i) => {
if (c.status === 'INFO') return;
const cur = winner.get(c.id);
if (
cur === undefined ||
severity(c.status) >= severity(checks[cur].status)
) {
winner.set(c.id, i);
}
});
return checks.filter((c, i) => c.status === 'INFO' || winner.get(c.id) === i);
}
export class DPoPClientScenario implements Scenario {
readonly name: string;
readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const;
readonly description: string;
private authServer = new ServerLifecycle();
private server = new ServerLifecycle();
private checks: ConformanceCheck[] = [];
private obs: DpopClientObservations = newDpopClientObservations();
private tokenReqObs: DpopTokenRequestObservation = newTokenReqObs();
/**
* @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.
*/
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).';
}
async start(ctx: ScenarioContext): Promise<ScenarioUrls> {
this.checks = [];
this.obs = newDpopClientObservations();
this.tokenReqObs = newTokenReqObs();
const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, {
dpopSigningAlgValuesSupported: ['ES256'],
dpopTokenRequestObs: this.tokenReqObs,
dpopRequireNonce: this.requireNonce
});
await this.authServer.start(authApp);
const app = createServer(
ctx,
this.checks,
this.server.getUrl,
this.authServer.getUrl,
{
authMiddleware: createDpopResourceAuth(
this.obs,
() => `${this.server.getUrl()}/mcp`,
() => `${this.server.getUrl()}${PRM_PATH}`,
this.requireNonce
)
}
);
await this.server.start(app);
return { serverUrl: `${this.server.getUrl()}/mcp` };
}
async stop(): Promise<void> {
await this.authServer.stop();
await this.server.stop();
}
getChecks(): ConformanceCheck[] {
// Only the nonce posture re-POSTs /token (challenge → retry), which
// 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 checks: ConformanceCheck[] = [
...shared,
this.tokenRequestProofCheck(),
this.authSchemeCheck(),
this.freshProofCheck()
];
// 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) {
checks.push(this.asNonceCheck(), this.rsNonceCheck());
}
return checks;
}
private asNonceCheck(): ConformanceCheck {
const challenged = this.tokenReqObs.asNonceChallengeIssued;
const honored = this.tokenReqObs.asNonceHonored;
// SUCCESS requires that a challenge was actually issued AND then honored —
// grading `honored` alone would let a client that pre-sends the nonce
// (never challenged) pass vacuously.
const pass = challenged && honored;
return this.build(
'sep-1932-client-as-nonce',
pass ? 'SUCCESS' : 'FAILURE',
{
errorMessage: pass
? undefined
: challenged
? 'Client did not complete the token request with the server-supplied nonce after a use_dpop_nonce challenge (it either did not retry or the retried proof was rejected)'
: 'Client never presented a valid proof that was answered with a use_dpop_nonce challenge',
details: {
challengeIssued: challenged,
nonceHonored: honored
}
}
);
}
private rsNonceCheck(): ConformanceCheck {
const challenged = this.obs.rsNonceChallengeIssued;
const honored = this.obs.rsNonceHonored;
// SUCCESS requires that a challenge was actually issued AND then honored —
// see asNonceCheck; grading `honored` alone permits a vacuous pass.
const pass = challenged && honored;
return this.build(
'sep-1932-client-rs-nonce',
pass ? 'SUCCESS' : 'FAILURE',
{
errorMessage: pass
? undefined
: challenged
? 'Client did not complete the MCP request with the server-supplied nonce after a use_dpop_nonce challenge (it either did not retry or the retried proof was rejected)'
: 'Client never made an MCP request with a valid proof that was answered with a use_dpop_nonce challenge',
details: {
challengeIssued: challenged,
nonceHonored: honored
}
}
);
}
private tokenRequestProofCheck(): ConformanceCheck {
let status: CheckStatus;
let errorMessage: string | undefined;
if (!this.tokenReqObs.recorded) {
status = 'FAILURE';
errorMessage =
'Client never completed an authorization_code token request, so it obtained no DPoP-bound token';
} else if (!this.tokenReqObs.validProof) {
status = 'FAILURE';
errorMessage = `Client did not present a valid DPoP proof at the token endpoint: ${this.tokenReqObs.error}`;
} else {
status = 'SUCCESS';
}
return this.build('sep-1932-client-token-request-proof', status, {
errorMessage,
details: {
tokenRequestObserved: this.tokenReqObs.recorded,
validProofPresented: this.tokenReqObs.validProof
}
});
}
private authSchemeCheck(): ConformanceCheck {
let status: CheckStatus;
let errorMessage: string | undefined;
if (this.obs.authenticatedRequests === 0) {
status = 'FAILURE';
errorMessage = 'Client never presented an access token to the MCP server';
} else if (this.obs.nonDpopSchemeSeen) {
status = 'FAILURE';
errorMessage = `Client presented the token with a non-DPoP Authorization scheme (${[...new Set(this.obs.observedSchemes)].join(', ')})`;
} else {
status = 'SUCCESS';
}
return this.build('sep-1932-client-dpop-auth-scheme', status, {
errorMessage,
details: {
authenticatedRequests: this.obs.authenticatedRequests,
observedSchemes: [...new Set(this.obs.observedSchemes)]
}
});
}
private freshProofCheck(): ConformanceCheck {
let status: CheckStatus;
let errorMessage: string | undefined;
if (this.obs.authenticatedRequests === 0) {
status = 'FAILURE';
errorMessage = 'Client never presented an access token to the MCP server';
} else if (!this.obs.allProofsWellFormed) {
status = 'FAILURE';
errorMessage = `DPoP proof was missing or malformed: ${this.obs.proofError}`;
} else if (this.obs.replayDetected) {
status = 'FAILURE';
errorMessage =
'Client reused a DPoP proof (duplicate jti) across requests instead of sending a fresh one';
} else {
status = 'SUCCESS';
}
return this.build('sep-1932-client-fresh-proof', status, {
errorMessage,
details: {
authenticatedRequests: this.obs.authenticatedRequests,
distinctJtis: this.obs.jtisSeen.length,
replayDetected: this.obs.replayDetected
}
});
}
private build(
id: string,
status: CheckStatus,
opts: { errorMessage?: string; details?: Record<string, unknown> } = {}
): ConformanceCheck {
const def = CHECK_DEFS[id];
return {
id,
name: def.name,
description: def.description,
status,
timestamp: new Date().toISOString(),
specReferences: def.specReferences,
...(opts.errorMessage ? { errorMessage: opts.errorMessage } : {}),
...(opts.details ? { details: opts.details } : {})
};
}
}