Skip to content

Authorization Server: DPoP Support (SEP-1932)#396

Open
PieterKas wants to merge 9 commits into
modelcontextprotocol:mainfrom
PieterKas:dpop-as
Open

Authorization Server: DPoP Support (SEP-1932)#396
PieterKas wants to merge 9 commits into
modelcontextprotocol:mainfrom
PieterKas:dpop-as

Conversation

@PieterKas

Copy link
Copy Markdown

⚠️ Depends on #394 (the client PR) — please review that first. This is stacked on top of #394, so until it merges the diff below also includes its commits.

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 (#394). Review that one first — this PR sits on top of it.

Closes #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:

  • Advertises dpop_signing_alg_values_supported in its metadata (RFC 9449 §5.1).
  • Lists only asymmetric algorithms — no none, no symmetric algorithms.
  • Binds the issued token to the client's DPoP key (cnf.jkt matches 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 (#369); client presentation by the client PR (#394).

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 and others added 9 commits July 3, 2026 22:01
…odelcontextprotocol#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 (modelcontextprotocol#369) and
authorization-server (modelcontextprotocol#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>
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>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- 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>
…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>
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.

Authorization Server Auth: DPoP Token Binding (SEP-1932)

1 participant