feat(sep-1932): AS negative probes and nonce checks (stacked on #396) - #525
nbarbettini wants to merge 8 commits into
Conversation
…l#370) Follow-up on the DPoP client PR (shared foundation: createAuthServer DPoP core + dpopProof/dpopToken helpers). Adds an authorization-server scenario testing DPoP (SEP-1932 / RFC 9449): metadata (dpop_signing_alg_values_supported present, asymmetric-only), token binding (cnf.jkt + token_type=DPoP), and no-proof enforcement when dpop_bound_access_tokens is advertised. Probes a live AS via authorization_code + PKCE (auto-follows a direct redirect, falls back to an interactive callback) and returns four sep-1932-as-* checks (compliant run + four one-defect-isolation misbehaving configs). - authorization-server/dpop.ts (+ acceptance test, spec-references). - dpopToken: adds readTokenBinding() (reads token_type + cnf.jkt back out of a token response) — introduced here because this scenario is its only consumer. Depends only on the shared DPoP foundation; independent of the server PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
negotiateProofAlg fell back to ES256 for a present-but-non-array dpop_signing_alg_values_supported (e.g. the string "RS256"), contradicting its docstring and risking a token-binding mis-score for that malformed shape. Treat a present-but-non-array value as null (SKIP), like a non-empty list with no supported alg; only an absent/empty list still falls back to ES256. Defensive against malformed metadata; not independently exercised by a fixture (would need a malformed-metadata AS option), consistent with the htu-strip defensive fixes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The round-4 non-array guard carved out `null` (advertised !== null), so metadata with "dpop_signing_alg_values_supported": null passed the support gate (which only tests === undefined), skipped the guard, and fell through to the ES256 fallback — the exact binding mis-score the fix targeted. Extract the negotiation to an exported pure function negotiateProofAlg(advertised) and treat ANY present-but-non-array shape (string, null, number, object) as malformed → null (SKIP). Only an empty array still falls back to ES256. Correct the docstring (an absent field never reaches here — the support gate SKIPs upstream). Add unit tests for every shape (array / empty / no-overlap / string / null / number / object), pinning the fix against a silent refactor regression. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Broaden the negotiateProofAlg fallback test to also assert undefined → ES256
and correct its title ("empty array or absent field") — the contract covers
both, though absent is gated upstream in the scenario.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…yaml Add a one-line comment above the sep-1932-as-token-binding requirement noting that its binding mechanics are defined in RFC 9449 (§6 cnf/jkt thumbprint, §5 token_type: DPoP) — which the SEP builds on rather than restating — so a reader can see where the requirement text derives from. Addresses review feedback on modelcontextprotocol#396; the check id and text are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The DPoP AS scenario drives its own authorization_code + PKCE flow but did not forward the `resource` parameter, unlike authorization-code-grant.ts after modelcontextprotocol#466. Send it on both the authorization request and the token request when supplied (guarded by options.resource, so it's a no-op otherwise). Keeps the two AS scenarios consistent and lets the DPoP binding checks be evaluated cleanly against a resource-enforcing AS. Addresses review feedback on modelcontextprotocol#396. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Public-client refresh-token binding (issue modelcontextprotocol#370) is not exercisable today: the shared conformance test AS (createAuthServer) doesn't issue refresh tokens or handle the refresh_token grant, so a conformant-vs-misbehaving pair can't be built to validate the check under the suite's "prove it passes and fails" rule. Record it as an excluded: row in sep-1932.yaml with this rationale; deferred as a follow-up until the test AS gains refresh support. Addresses review feedback on modelcontextprotocol#396. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An authorization server that binds a token without checking the proof, or that mishandles a DPoP nonce, now fails. Negative probes run on headless redirects and stay not-testable on a login-gated server unless opted in. Co-authored-by: Cursor <cursoragent@cursor.com>
commit: |
|
@nbarbettini Hello, I will review the PR. Please wait for a moment. |
| for (const probe of INVALID_PROOF_CASES) { | ||
| try { | ||
| const fresh = await this.obtainAuthorizationCode(metadata, options); | ||
| const proof = await buildDpopProof( |
There was a problem hiding this comment.
A fully conformant nonce-requiring AS gets two WARNING marks here, and the thing these probes exist to test never actually gets tested against it.
The scenario may already hold the AS's nonce by the time the negative probes run (suppliedNonce is in scope at the call site, line 699), but the tampered-signature and wrong-htu proofs are built without it. RFC 9449 §4.3 lets a server run its checks "in any order" — an AS that checks the nonce first answers both probes 400 use_dpop_nonce, which grades as WARNING ("wrong error code") against a compliant server. Worse: because the probes never get past the nonce gate, the signature/htu validation they target is never exercised — a nonce-first AS that accepts tampered signatures looks identical to a strict one. (Reproduced empirically against a nonce-first in-process AS: binding and nonce checks all SUCCESS, both invalid-proof probes WARNING. The bundled fixture hides this only because it happens to validate proofs before nonces.)
Fix: pass the held nonce into the negative proofs — the injected defect is still present, so a validating AS then rejects for the right reason. Same shape as the heldNonce fix the server scenario got in conformance#395's review rounds.
Generated with the aid of Claude (Fable 5).
| errorMessage: `Authorization server issued an access token for a DPoP proof with the wrong nonce (HTTP ${result.statusCode})` | ||
| }; | ||
| } | ||
| if (result.statusCode >= 400 && result.statusCode < 500) { |
There was a problem hiding this comment.
An AS that never compares nonces at all can pass this check.
This accepts ANY token-less 4xx as proof of nonce-mismatch rejection — but the probe's exchange can fail 4xx for reasons that have nothing to do with the nonce: invalid_grant from a code policy, invalid_client, rate limiting. Each gets credited as "the AS validated the nonce" when no comparison ever ran. The sibling judge for invalid proofs already gets this right (SUCCESS only for the prescribed error code, other 400s → WARNING); suggest the same here: SUCCESS for error in {use_dpop_nonce, invalid_dpop_proof}, inconclusive otherwise. (The unit test currently pins the any-4xx behavior, so it would need updating too.)
Generated with the aid of Claude (Fable 5).
| } | ||
|
|
||
| private nonceRetryCheck(final: TokenExchangeResult): ConformanceCheck { | ||
| const accepted = final.statusCode === 200 && issuedAccessToken(final.body); |
There was a problem hiding this comment.
One non-DPoP hiccup on the retry turns into a hard FAILURE (and empirically, four of them).
RFC 9449 §8 places no MUST on the AS to accept the retry — and the retry can fail for reasons the AS is allowed: client auth we didn't send, a second rotated use_dpop_nonce challenge (§8 leaves re-challenge timing to the server), or the AS having burned the single-use code on the challenge itself. Reproduced against a code-burning AS: the binding check correctly grades the same response "inconclusive (non-DPoP reason)", while this check reports FAILURE "retry not accepted" — and the gated probes stack three more untestable-FAILUREs on top. Suggest mirroring bindingCheckFor's inconclusive rule for non-DPoP error codes.
Generated with the aid of Claude (Fable 5).
| } | ||
|
|
||
| await this.finishNegativeProbes({ | ||
| bindingSucceeded: binding.status === 'SUCCESS', |
There was a problem hiding this comment.
An AS that accepted the harness's valid proof and issued a token can be reported as "rejects everything" — three FAILUREs for probes that were never sent.
The flow here: the harness (playing the client) first presents a valid DPoP proof at the token endpoint; if that baseline goes well, it then presents deliberately broken proofs on fresh codes and checks they're refused. This line is the gate between those two stages, and it asks the wrong question. The bad-proof probes need exactly one thing from the baseline: "the AS accepts proofs and issues tokens" — they never look inside a token. But the gate asks "did the binding check return SUCCESS?", and the binding check has an extra sensitivity the probes don't share: it must decode cnf.jkt out of the issued token.
Those two questions give different answers whenever the token was issued but can't be inspected. The concrete case (reproduced empirically): an AS issuing opaque access tokens. Baseline: valid proof in, 200 + token_type: DPoP out — accepted. Binding: SKIPPED, correctly, since an opaque token can't be decoded. The gate then withholds all the probes and reports them as untestable-FAILUREs with the reason "did not issue a DPoP-bound token for a valid proof … rejects everything" — describing a rejection that never happened. (The unbound-token path hits the same false text: binding FAILURE is deserved there, but the proof was accepted, so the probes are attributable and would be informative.)
Fix: gate on "valid-proof exchange succeeded and a token was issued" instead of the binding verdict. Opaque-token ASs then keep their honest binding SKIP and get real probe coverage; the genuinely blocked case (baseline exchange failed, no token) still hits the gate, with a reason that's true.
Generated with the aid of Claude (Fable 5).
| errorMessage: `Authorization server issued an access token for a ${caseLabel} DPoP proof (HTTP 200)` | ||
| }; | ||
| } | ||
| if (result.statusCode === 400 && error === 'invalid_dpop_proof') { |
There was a problem hiding this comment.
This judge decides what a response proves by matching exact status codes, and the edges are wrong in both directions: clear evidence that the AS validates proofs gets thrown away, while clear evidence that it doesn't can escape ungraded.
Three cases, each with a concrete victim:
-
An AS rejects the tampered proof with 401 + error=invalid_dpop_proof. That's unambiguous evidence it validated the proof — the status just deviates from RFC 6749 §5.2's prescribed 400. Because only exactly-400 is matched, this falls through to SKIPPED and vanishes from pass/fail entirely. Meanwhile an AS answering 400 with the wrong error code gets a WARNING. So the smaller deviation (right behavior, odd status) is treated more harshly than silence, and the stronger evidence counts for nothing — the asymmetry is backwards.
-
An AS issues a token for the tampered proof — the worst possible outcome, the thing this whole check exists to catch — but answers 201 instead of 200. The FAILURE arm matches only statusCode === 200, so this non-conformant AS lands in SKIPPED "inconclusive" and the defect disappears from the counts.
-
An AS answers 200 with no access_token in the body. The binding check one screen up explicitly calls that "a plainly broken AS" and fails it; this judge lets the same response fall to SKIPPED. Same PR, same response, two verdicts.
Suggested boundaries: a token issued on any 2xx → FAILURE; a token-less 2xx → FAILURE (matching the binding check's rule); 4xx with error=invalid_dpop_proof → SUCCESS whether 400 or 401 (optionally noting a status deviation in details); other 4xx codes keep the current WARNING.
Generated with the aid of Claude (Fable 5).
| # RFC 9449 §5. The SEP does not restate this sentence; it requires conformance to RFC 9449. | ||
| # Graded MUST from "MUST contain a valid DPoP proof JWT". A 400 that names some other | ||
| # error is WARNING: the prescribed `invalid_dpop_proof` code is stated without its own keyword. | ||
| - check: sep-1932-as-rejects-invalid-proof | ||
| text: 'The `DPoP` HTTP header field MUST contain a valid DPoP proof JWT. If the DPoP proof is invalid, the authorization server issues an error response per Section 5.2 of [RFC6749] with `invalid_dpop_proof` as the value of the `error` parameter.' | ||
| url: https://www.rfc-editor.org/rfc/rfc9449.html#section-5 |
There was a problem hiding this comment.
The MUST this row derives the FAILURE from binds the client, not the authorization server — "The DPoP HTTP header field MUST contain a valid DPoP proof JWT" is about what the request must contain, and the AS-side sentence in the quote ("the authorization server issues an error response…") carries no 2119 keyword. An AS vendor disputing their FAILURE would follow this citation, find a client requirement, and conclude the verdict is ungrounded — when a solid ground exists one section over: RFC 9449 §4.3 binds every proof-receiving server directly ("To validate a DPoP proof, the receiving server MUST ensure…"), and an AS minting a token for a tampered proof has violated it. Suggested re-grounding:
| # RFC 9449 §5. The SEP does not restate this sentence; it requires conformance to RFC 9449. | |
| # Graded MUST from "MUST contain a valid DPoP proof JWT". A 400 that names some other | |
| # error is WARNING: the prescribed `invalid_dpop_proof` code is stated without its own keyword. | |
| - check: sep-1932-as-rejects-invalid-proof | |
| text: 'The `DPoP` HTTP header field MUST contain a valid DPoP proof JWT. If the DPoP proof is invalid, the authorization server issues an error response per Section 5.2 of [RFC6749] with `invalid_dpop_proof` as the value of the `error` parameter.' | |
| url: https://www.rfc-editor.org/rfc/rfc9449.html#section-5 | |
| # RFC 9449 §4.3 ("MUST ensure") binds the proof-receiving server; the §5 sentence | |
| # describes the AS's error response. The SEP does not restate these sentences; it | |
| # requires conformance to RFC 9449. A 400 that names some other error is WARNING: | |
| # the prescribed `invalid_dpop_proof` code is stated without its own keyword. | |
| - check: sep-1932-as-rejects-invalid-proof | |
| text: 'To validate a DPoP proof, the receiving server MUST ensure the following: … The JWT signature verifies with the public key contained in the `jwk` JOSE Header Parameter. … The `htu` claim matches the HTTP URI value for the HTTP request in which the JWT was received, ignoring any query and fragment parts. / If the DPoP proof is invalid, the authorization server issues an error response per Section 5.2 of [RFC6749] with `invalid_dpop_proof` as the value of the `error` parameter.' | |
| url: https://www.rfc-editor.org/rfc/rfc9449.html#section-4.3 |
(The ellipses mark the elided items of §4.3's numbered checklist — the two quoted items are the ones these probes exercise, signature and htu; the / join follows this file's existing multi-sentence convention.)
Generated with the aid of Claude (Fable 5).
|
Default behavior question: after this merges, a compliant login-gated AS that passes today's dpop scenario will start reporting FAILUREs simply because the operator upgraded the conformance suite — with no change on the AS side. The new probes are off by default and the off state is reported via untestableCheck (FAILURE, "Not testable:"). Both halves are firsts for the suite: no other check is gated behind a flag (the merged AS scenario's own interactive flow — the closest analogue of an expensive probe — runs by default and reports non-completion as SKIPPED), and all existing untestableCheck users are server-side-prerequisite cases per #248, not harness-side choices. If the cost argues for opt-in here, precedent argues the not-opted-in state should render as SKIPPED (or the probes should run by default like the interactive flow they extend); keeping untestable-FAILURE for a harness choice would be new policy, worth a maintainer ruling rather than a side effect of this PR. Whichever way it lands, the migration should be visible: the flag currently appears only in --help and the scenario description — README and the example settings file don't mention it. Related messaging nit: the opt-in text says "an additional authorization code" (singular) — a full nonce run spends three extra codes, which on a login-gated AS means up to three more interactive 300s waits (~15 min); worth stating the real cost where operators decide. Generated with the aid of Claude (Fable 5). |
| ] | ||
| }, | ||
| 'sep-1932-as-nonce': { | ||
| name: 'DpopNonce', |
There was a problem hiding this comment.
Nit: CHECK_DEFS registers name 'DpopNonce' but no emission path uses it — the id is emitted under three path-dependent names (NonceChallengeHeader/NonceRetryAccepted/NonceRejectsWrongValue), which also drop the file's Dpop- name prefix; report consumers keying on (id, name) see the check set mutate between runs. Consider per-case ids or fixed names.
Generated with the aid of Claude (Fable 5).
| 'sep-1932-as-rejects-invalid-proof', | ||
| probe.name, | ||
| probe.description, | ||
| `could not obtain an authorization code for the ${probe.caseId} probe: ${this.message(error)}`, |
There was a problem hiding this comment.
Nit: this catch wraps the token exchange too, but the reason always says "could not obtain an authorization code" — a token-endpoint failure (e.g. ECONNRESET during the tampered-signature exchange) sends the operator to debug the wrong endpoint. Same pattern at the wrong-nonce probe's catch.
Generated with the aid of Claude (Fable 5).
| }); | ||
| }); | ||
|
|
||
| // Isolation matrix: each defect fails EXACTLY its target check, the rest stay |
There was a problem hiding this comment.
Nit: this header comment still claims "each defect fails EXACTLY its target check" but the unbound-token case now produces three FAILUREs, and the loop no longer asserts the absence of unexpected failures — comment and assertions should both catch up.
Generated with the aid of Claude (Fable 5).
| ); | ||
| }); | ||
|
|
||
| afterEach(() => { |
There was a problem hiding this comment.
Nit: this describe deletes MCP_CONFORMANCE_DPOP_NEGATIVE_PROBES in afterEach instead of save/restore like the sibling login-gated tests — a developer running the suite with the var exported in their shell has it silently destroyed for later tests in the same worker. Same save/restore (or vi.stubEnv) pattern as the siblings would do.
Generated with the aid of Claude (Fable 5).
PieterKas
left a comment
There was a problem hiding this comment.
Thorough review done (adversarial pass with the aid of Claude, Fable 5 .
Much here is good: fresh authorization code per probe with no reuse, post-signing tamper so the proof is structurally valid, honest SKIP reasons on the prerequisite paths, faithful MAY-gating of the nonce check, and the shared test-AS changes are cleanly opt-in — both the AS suite (51/51) and the client auth suite (135/135) pass unchanged.
Requesting changes for two themes, detailed in the inline comments:
-
Attribution discipline — several arms grade responses without establishing the response was about the injected defect: the negative probes omit the nonce the scenario already holds, so a conformant nonce-first AS gets WARNINGs while the signature/htu MUSTs go unprobed (line 772); the wrong-nonce check credits any token-less 4xx (line 146); the nonce retry hard-fails on non-DPoP errors the binding check treats as inconclusive (line 639); the probe gate keys on the binding verdict rather than "valid proof accepted", mislabeling opaque-token ASs (line 532); and the response judge discards strong evidence (401 + invalid_dpop_proof) while letting a 201-issued token or a token-less 200 escape (line 113).
-
Grounding and policy: the yaml derives the FAILURE from a client-side MUST — RFC 9449 §4.3's "MUST ensure" is the right citation (yaml comment, with suggestion); and the default-off probes reporting as untestable-FAILURE is a suite-first that flips passing setups red on upgrade — raised as a maintainer policy question on the Conversation tab.
Plus four minor nits inline (check naming, a stale isolation-matrix comment, a catch mislabel, env-var hygiene in tests). Happy to re-review quickly once these land.
Stacked on #396 (
PieterKas/conformance:dpop-as, heade1c4fb9). This branch contains that PR's commits plusff169cc. Rebase ontomainonce #396 merges.The new commit (ff169cc) is the only one to review.
What each check asserts
#396 covers the happy path: metadata, and that a valid proof yields a token bound to the proof key.
These additional checks fail an authorization server that binds a token to any proof without verifying it, or that mishandles nonces. They run only after
sep-1932-as-token-bindingis SUCCESS, otherwise are skipped.sep-1932-as-rejects-invalid-proofRFC 9449 §5, graded MUST from "The DPoP HTTP header field MUST contain a valid DPoP proof JWT [...] If the DPoP proof is invalid, the authorization server issues an error response per Section 5.2 of [RFC6749] with
invalid_dpop_proofas the value of theerrorparameter."Two checks for this:
htuset to a URL other than the token endpointHTTP 400 with
error=invalid_dpop_proofis SUCCESS. HTTP 200 with an access token is FAILURE. HTTP 400 with a different error is WARNING.sep-1932-as-nonceRelevant only when the first token exchange is
use_dpop_nonce. Supplying a nonce is MAY (RFC 9449 §8: "An authorization server MAY supply a nonce value..."), so an AS that never challenges does not emit the check.When relevant, we now check for:
DPoP-Nonceheader. §8 states this as the content of the response ("The authorization server includes aDPoP-NonceHTTP header in the response supplying a nonce value..."). A missing header used to fall throughexchangeWithProofand leavesep-1932-as-token-bindingSKIPPED. It is now a FAILURE on this check.nonceclaim in the DPoP proof does not exactly match a nonce recently supplied by the authorization server to the client, the authorization server MUST reject the request." Issuing a token is FAILURE. Any token-less 4xx counts as a rejection. Other statuses are SKIPPED.Testing
npm test— 46 files / 616 tests passnpm run check(tsgo + eslint + prettier) — cleanMade with Cursor