Skip to content

Server Auth: DPoP Proof Validation (SEP-1932)#2

Closed
PieterKas wants to merge 3 commits into
dpop-clientfrom
dpop-server
Closed

Server Auth: DPoP Proof Validation (SEP-1932)#2
PieterKas wants to merge 3 commits into
dpop-clientfrom
dpop-server

Conversation

@PieterKas

@PieterKas PieterKas commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Overview

Conformance tests for DPoP proof validation (SEP-1932 / RFC 9449) on the MCP 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#369.

What it tests

That an MCP server correctly validates DPoP proofs presented with an access token (RFC 9449 §4.3). The scenario acts as the client, presenting one valid proof and then a battery of deliberately-broken ones, and checks the server:

  • Accepts a valid DPoP-bound request.
  • Rejects each malformed or invalid proof — wrong type, non-asymmetric algorithm, bad signature, missing claims, htm/htu mismatch, iat outside the acceptance window, wrong token binding, replays — with an appropriate 401 and WWW-Authenticate challenge.

Server-provided nonces are optional (a SHOULD): if the server issues one, the full nonce flow is exercised; if it doesn't, that check is skipped and the mandatory validation checks still run.

Each check is proven to pass (compliant example server) and to fail (misbehaving example servers), with an automated acceptance suite.

Scope

MCP-server behaviour only. The framework plays the client. The grant type used to obtain the token is irrelevant to how the server validates the proof.

Notes

  • Registered as draft / not-scored (SEP-1932 is a draft extension).
  • Validated against the bundled example fixtures. No reference SDK implements DPoP yet, so there is no real-SDK run to report.

@PieterKas
PieterKas force-pushed the dpop-client branch 2 times, most recently from 10f09cb to d178c85 Compare July 3, 2026 07:04
@PieterKas
PieterKas force-pushed the dpop-server branch 2 times, most recently from 860ecc0 to b3257e1 Compare July 4, 2026 12:23
PieterKas added a commit that referenced this pull request Jul 6, 2026
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. (modelcontextprotocol#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. (modelcontextprotocol#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). (modelcontextprotocol#6)
- Fix the DpopTokenRequestObservation docstring ("last write wins" →
  sticky-failure). (modelcontextprotocol#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 <[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]>
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]>
PieterKas and others added 3 commits July 6, 2026 18:31
…ocol#369)

Follow-up on the DPoP client PR (shared foundation). Adds the server-role
conformance for SEP-1932 / RFC 9449: the framework acts as a DPoP client
against the MCP server under test and emits the sep-1932-server-* checks
across the RFC 9449 §4.3 validation surface — proof validation, the ±5-minute
iat window, asymmetric-only algorithms, the 401 + WWW-Authenticate challenge,
token audience validation under DPoP, and the optional server-provided nonce.

- src/scenarios/server/auth/dpop.ts (+ test, spec-references): the scenario.
- examples/servers/typescript/sep-1932-{compliant,broken}-server.ts: passing
  and failing fixtures proving every check passes and fails.
- Registered in the pending + all-client scenario lists.

Depends only on the shared DPoP foundation (dpopProof/dpopToken); independent
of the authorization-server PR. No DPoP-capable MCP SDK exists yet, so
correctness rests on RFC vectors + an independent verifier + the fixtures.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
- 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]>
- Soften the heldNonce comment: newest-wins refresh is correct for ROTATING
  servers; a strict single-use server that re-arms only via challenges can still
  push alternate negatives to untestable (correctly reported, not mis-scored) —
  the previous "rotate or single-use" over-claimed. (R5)
- Fix the clock-skew test comment: the stale probe is now -303s (~273s old),
  not -301s/~271s. (R5)

Comment-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@PieterKas

Copy link
Copy Markdown
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.

@PieterKas PieterKas closed this Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant