Client Auth: DPoP Sender-Constrained Tokens (SEP-1932) (#394)
* 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>
This commit is contained in:
parent
5ad96dbfbc
commit
6e726734f5
1 changed files with 2713 additions and 4 deletions
48
src/seps/sep-1932.yaml
Normal file
48
src/seps/sep-1932.yaml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
sep: 1932
|
||||
spec_url: https://github.com/modelcontextprotocol/ext-auth/blob/pieterkas-dpop-extension/specification/draft/dpop-extension.mdx
|
||||
requirements:
|
||||
- check: sep-1932-client-dpop-auth-scheme
|
||||
text: 'When making requests to protected MCP server resources, clients MUST include the access token using the `Authorization` header with the `DPoP` authentication scheme as specified in RFC 9449 Section 7.1'
|
||||
- check: sep-1932-client-fresh-proof
|
||||
text: 'When making requests to protected MCP server resources, clients MUST include a fresh DPoP proof in the `DPoP` header'
|
||||
- check: sep-1932-client-token-request-proof
|
||||
text: 'To obtain a DPoP-bound access token, the client MUST include a DPoP proof in the `DPoP` header of its token request (RFC 9449 Section 5)'
|
||||
- check: sep-1932-client-as-nonce
|
||||
text: 'When the authorization server responds with `use_dpop_nonce`, the client MUST retry the token request with a DPoP proof that includes the supplied `nonce` (RFC 9449 Section 8)'
|
||||
- check: sep-1932-client-rs-nonce
|
||||
text: 'When the MCP server responds with `use_dpop_nonce`, the client MUST retry the request with a DPoP proof that includes the supplied `nonce` (RFC 9449 Section 9)'
|
||||
- check: sep-1932-server-validate-proof
|
||||
text: 'MCP servers MUST validate DPoP proofs according to RFC 9449 Section 4.3'
|
||||
- check: sep-1932-server-iat-window
|
||||
text: "Implementations conforming to this specification MUST verify that the `iat` claim is within ±5 minutes of the server's current time / MCP servers that are not capable of keeping state or performing global `jti` tracking MUST provide DPoP proof replay protection by enforcing short `iat` acceptance windows of ±5 minutes and standard RFC 9449 claim validation"
|
||||
- check: sep-1932-server-reject-401
|
||||
text: 'If any validation step fails, the MCP server MUST reject the request with an HTTP 401 response and include appropriate error information in the `WWW-Authenticate` header as specified in RFC 9449 Section 7.1'
|
||||
- check: sep-1932-as-metadata-alg-values
|
||||
text: 'Authorization servers supporting DPoP MUST include the `dpop_signing_alg_values_supported` field in their Authorization Server Metadata as defined in RFC 9449 Section 5.1. This field MUST contain a JSON array listing the JWS algorithm values supported for DPoP proof JWTs'
|
||||
- check: sep-1932-as-no-none-alg
|
||||
text: 'Only asymmetric signature algorithms are permitted; the `none` algorithm MUST NOT be included'
|
||||
- check: sep-1932-as-dpop-bound-enforcement
|
||||
text: 'When `dpop_bound_access_tokens` is set to `true`, the authorization server MUST reject token requests from the client that do not include a valid DPoP proof'
|
||||
- check: sep-1932-as-token-binding
|
||||
text: "When issuing a DPoP-bound access token, the authorization server MUST bind it to the client's DPoP public key by including a `cnf` claim carrying the JWK SHA-256 thumbprint (`jkt`) of that key (RFC 9449 Section 6) and MUST set the token response `token_type` to `DPoP` (RFC 9449 Section 5)"
|
||||
- check: sep-1932-asymmetric-alg-only
|
||||
text: 'Only asymmetric signature algorithms MUST be used for DPoP proofs. Symmetric algorithms and the `none` algorithm MUST NOT be permitted as specified in RFC 9449 Section 11.6'
|
||||
- check: sep-1932-server-nonce
|
||||
text: 'For high-security environments, MCP servers SHOULD implement server-provided nonces as described in RFC 9449 Section 9 to further limit proof lifetime, even if it is stateless'
|
||||
- check: sep-1932-server-audience-validation
|
||||
text: 'MCP servers MUST continue to validate that access tokens were specifically issued for them, even when DPoP is used'
|
||||
|
||||
- text: 'Implementations MUST conform to all requirements specified in this extension'
|
||||
excluded: 'Umbrella requirement satisfied by the specific checks above; not separately observable on the wire.'
|
||||
- text: 'Implementations MUST also conform to the baseline authorization requirements'
|
||||
excluded: 'Covered by the existing baseline MCP authorization conformance suite; not DPoP-specific.'
|
||||
- text: 'Authorization servers and clients SHOULD support modern, secure algorithms such as ES256 (ECDSA using P-256 and SHA-256)'
|
||||
excluded: 'No crisp wire-observable behaviour; the spec explicitly does not mandate specific algorithms.'
|
||||
- text: 'Implementations adopting this extension MUST follow the security considerations outlined in RFC 9449 Section 11, the baseline MCP Authorization specification security requirements, and OAuth 2.1 security best practices'
|
||||
excluded: 'Umbrella reference to the security considerations of other specifications; not a discrete observable behaviour.'
|
||||
- text: 'Clients MUST protect DPoP private keys using appropriate security measures. If hardware security modules or secure enclaves are available, they SHOULD be used to protect private keys to prevent key extraction'
|
||||
excluded: 'Key storage is an implementation/internal concern, not observable on the wire.'
|
||||
- text: 'Browser-based clients SHOULD use the Web Crypto API with non-extractable keys to prevent key exfiltration via XSS attacks'
|
||||
excluded: 'Client implementation detail, not observable on the wire.'
|
||||
- text: 'MCP servers requiring maximum replay protection SHOULD implement `jti` tracking despite the operational complexity'
|
||||
excluded: 'Explicitly optional (servers are NOT required to track jti) and stateful/internal; only indirectly observable via replay.'
|
||||
Loading…
Reference in a new issue