Authorization Server: DPoP Support (SEP-1932)#3
Closed
PieterKas wants to merge 4 commits into
Closed
Conversation
PieterKas
force-pushed
the
dpop-as
branch
2 times, most recently
from
July 3, 2026 07:04
9c9a626 to
bd48a0a
Compare
PieterKas
force-pushed
the
dpop-client
branch
2 times, most recently
from
July 3, 2026 09:58
d178c85 to
8cc75d4
Compare
PieterKas
force-pushed
the
dpop-as
branch
2 times, most recently
from
July 4, 2026 12:24
48e5d57 to
2759aae
Compare
PieterKas
added a commit
that referenced
this pull request
Jul 6, 2026
- Refresh the held DPoP nonce from every response's DPoP-Nonce header (RFC 9449 §8.2 newest-wins) instead of capturing it once, so a server that rotates or single-uses its nonce no longer turns the negative probes into false "not testable" failures (or vacuous passes). (#1) - Widen the stale iat probe to -303 s (matching the future side) so the ±1 s quantization of the whole-second Date header can't pull it onto the ±300 s boundary and be false-accepted. (#2) - Update the isNonceChallenge comment and the untestable message: probes now carry the held nonce, so a use_dpop_nonce challenge is about the nonce lifetime (rotated/stale/single-use), not a missing nonce. (#3) - Fixture: parse integer env vars NaN-safely (intEnv helper) so a malformed DPOP_CLOCK_OFFSET_SECONDS / DPOP_IAT_SKEW_SECONDS falls back to its default instead of silently disabling the iat window. (modelcontextprotocol#4) Co-Authored-By: Claude Opus 4.8 <[email protected]>
PieterKas
added a commit
that referenced
this pull request
Jul 6, 2026
- 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 modelcontextprotocol#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 <[email protected]>
PieterKas
added a commit
that referenced
this pull request
Jul 6, 2026
- Refresh the held DPoP nonce from every response's DPoP-Nonce header (RFC 9449 §8.2 newest-wins) instead of capturing it once, so a server that rotates or single-uses its nonce no longer turns the negative probes into false "not testable" failures (or vacuous passes). (#1) - Widen the stale iat probe to -303 s (matching the future side) so the ±1 s quantization of the whole-second Date header can't pull it onto the ±300 s boundary and be false-accepted. (#2) - Update the isNonceChallenge comment and the untestable message: probes now carry the held nonce, so a use_dpop_nonce challenge is about the nonce lifetime (rotated/stale/single-use), not a missing nonce. (#3) - Fixture: parse integer env vars NaN-safely (intEnv helper) so a malformed DPOP_CLOCK_OFFSET_SECONDS / DPOP_IAT_SKEW_SECONDS falls back to its default instead of silently disabling the iat window. (modelcontextprotocol#4) Co-Authored-By: Claude Opus 4.8 <[email protected]>
…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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
PieterKas
added a commit
that referenced
this pull request
Jul 6, 2026
- Refresh the held DPoP nonce from every response's DPoP-Nonce header (RFC 9449 §8.2 newest-wins) instead of capturing it once, so a server that rotates or single-uses its nonce no longer turns the negative probes into false "not testable" failures (or vacuous passes). (#1) - Widen the stale iat probe to -303 s (matching the future side) so the ±1 s quantization of the whole-second Date header can't pull it onto the ±300 s boundary and be false-accepted. (#2) - Update the isNonceChallenge comment and the untestable message: probes now carry the held nonce, so a use_dpop_nonce challenge is about the nonce lifetime (rotated/stale/single-use), not a missing nonce. (#3) - Fixture: parse integer env vars NaN-safely (intEnv helper) so a malformed DPOP_CLOCK_OFFSET_SECONDS / DPOP_IAT_SKEW_SECONDS falls back to its default instead of silently disabling the iat window. (modelcontextprotocol#4) Co-Authored-By: Claude Opus 4.8 <[email protected]>
Owner
Author
|
Superseded by the upstream PRs on modelcontextprotocol/conformance (modelcontextprotocol#394 client, modelcontextprotocol#395 server, modelcontextprotocol#396 authorization server). Closing this fork-internal staging PR; the branch is unchanged and now backs the upstream PR. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
Conformance tests for DPoP support (SEP-1932 / RFC 9449) on the authorization server side.
Builds on the shared DPoP test helpers introduced in the client PR (modelcontextprotocol#368). Review that one first — this PR sits on top of it.
Closes modelcontextprotocol#370.
What it tests
That an authorization server correctly advertises and implements DPoP. The scenario drives a real authorization-code + PKCE flow and checks the server:
dpop_signing_alg_values_supportedin its metadata (RFC 9449 §5.1).none, no symmetric algorithms.cnf.jktmatches the presented proof,token_type: DPoP).The scenario is support-gated: an authorization server that doesn't advertise DPoP is not a DPoP server, so it's skipped rather than failed.
Each check is proven to pass (compliant example authorization server) and to fail (misbehaving configurations), with an automated acceptance suite.
Scope
Authorization-server behaviour only — metadata and token issuance. Proof validation at the resource is covered by the MCP-server PR (modelcontextprotocol#369); client presentation by the client PR (modelcontextprotocol#368).
Notes