A baseline entry names a scenario, but a scenario is many checks:
server-stateless is 29. Excusing the one or two a spec change breaks
means baselining the whole scenario, which stops enforcing the rest.
An entry may now be '<scenario>:<check-id>', in which case every failing
check in that scenario is judged on its own. Bare scenario entries are
unchanged, and YAML parses '- a:b' as a plain string, so every existing
baseline keeps working untouched.
loadExpectedFailures now owns the entry grammar and returns parsed
BaselineEntry values, so evaluateBaseline only matches and cannot throw.
Rejecting a bad entry and interpreting a good one no longer live in
separate layers.
A check id matches all of its occurrences, since ids repeat within a run;
they collapse to one verdict, most-severe first. Absent and SKIPPED are
tolerated, matching how the runner already treats them as green.
Move collapseDuplicateChecks to src/checks/collapse.ts with its tests:
expected-failures.ts needs the reducer and cannot import dpop.ts's auth
server stack. No behavior change.
Reject two configs that previously coerced to an entry matching nothing:
a mapping ('- scenario: check-id', with a space) and an empty list item.
Move the DPoP scenarios (auth/dpop, auth/dpop-nonce) and the WIF
JWT-bearer scenario (auth/wif-jwt-bearer) off the draft spec timeline
and register them as the io.modelcontextprotocol/auth/dpop and
io.modelcontextprotocol/auth/wif protocol extensions (SEP-2133
reverse-DNS ids), so they run under --suite extensions rather than at
--spec-version draft.
Claude-Session: https://claude.ai/code/session_012E7BriGaLimJxQr7B7ChiM
Co-authored-by: Claude <noreply@anthropic.com>
* 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>