feat(server): add DPoP (RFC 9449) proof validation - #2630
Open
gbshankar wants to merge 9 commits into
Open
Conversation
Implements the server half of SEP-1932: a hand-rolled WebCrypto DPoP proof validator (packages/server/src/server/middleware/dpop.ts) covering RFC 9449 Section 4.3's validation order and RFC 7638 JWK thumbprints across all 10 asymmetric algs (ES256/384/512, RS256/384/512, PS256/384/512, EdDSA) - no new dependency, keeps @modelcontextprotocol/ server runtime-neutral. dpopAuth.ts adds verifyDpopToken/dpopAuthChallengeResponse/ requireDpopAuth mirroring the existing bearerAuth.ts trio, sharing the WWW-Authenticate builder (extracted to authChallenge.ts) so Bearer and DPoP challenges stay consistent. AuthInfo.cnf.jkt (core-internal) lets a verifier surface RFC 9449 Section 6 token binding as a typed field. verifyBearerToken now rejects a DPoP-bound token presented as Bearer (RFC 9449 Section 7.1). packages/middleware/express/src/auth/dpopAuth.ts adapts requireDpopAuth for Express, reconstructing the request URI for htu from req.protocol/host/originalUrl. 77 new unit + integration tests (real generated keys per algorithm, real listening-server HTTP round trips for the Express adapter) - no unit test synthesizes a fake CryptoKey. Independent of the client-side DPoP PR (modelcontextprotocol#2629) - no shared files.
🦋 Changeset detectedLatest commit: 4393c9b The changes in this PR will be included in the next version bump. This PR includes changesets to release 7 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
Export DpopJwk so calculateJwkThumbprint's parameter type is documented, and qualify cross-module @linkcode references that TypeDoc could not resolve once the DPoP API surface was fully included. Co-authored-by: Cursor <cursoragent@cursor.com>
gbshankar
marked this pull request as ready for review
August 31, 2026 17:01
…n-sync # Conflicts: # packages/core-internal/src/auth/errors.ts
…heme rule
The rule that a DPoP-bound token MUST be rejected when presented under the
Bearer scheme is defined in RFC 9449 §7.2 ("Compatibility with the Bearer
Authentication Scheme"), not §7.1 ("The DPoP Authentication Scheme"). Fixes
the citation in the changeset, the AuthInfo.cnf JSDoc, and the bearerAuth.ts
implementation comment.
verifyDpopProof checks the proof's jti claim for presence only; it never tracks replay/uniqueness, and DpopAuthOptions has a first-class hook for a server-provided nonce but no equivalent hook for jti-replay tracking. RFC 9449 §11.1 treats this as a SHOULD, defense-in-depth measure rather than a strict requirement, so this doesn't change behavior — it just makes the known gap visible in the code instead of only in review notes.
verifyDpopToken's ath/cnf.jkt handling is format-agnostic by design, but
every existing fixture used an opaque string ('tok-1', 'bound-token', etc.)
- nothing exercised an actual JWT-shaped access token, and bearerAuth.ts's
new "reject a DPoP-bound token presented as Bearer" branch had zero direct
tests. Add:
- verifyDpopToken cases using a real signed-JWT-shaped access token whose
own payload carries cnf.jkt, decoded (not looked up) by the verifier -
one accepting the matching-key case, one rejecting a mismatched key.
- a verifyBearerToken case confirming a token with cnf.jkt set is rejected
under the Bearer scheme.
test/helpers/src/helpers/http.ts and oauth.ts (this package's own source, not its tests) import vitest directly, since @modelcontextprotocol/test-helpers is itself a shared testing-helper library consumed by other packages' test suites. Pre-existing on main, surfaced by import/no-extraneous-dependencies once the lint run got this far - unrelated to the DPoP changes above.
Contributor
Author
|
@pcarleton this is ready for review |
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.
Motivation and Context
Adds server-side DPoP (RFC 9449 / SEP-1932) proof validation for MCP resource servers, hand-rolled on WebCrypto so it introduces no new dependency. It adds a requireDpopAuth gate mirroring the existing Bearer-auth gate (plus an Express adapter), and teaches verifyBearerToken to reject a DPoP-bound token sent under the plain Bearer scheme, as RFC 9449 requires.
MCP servers acting as OAuth 2.0 resource servers currently only support plain Bearer tokens. DPoP (RFC 9449, adopted by MCP as SEP-1932) lets a resource server cryptographically verify that whoever is presenting an access token also holds the private key it's bound to, closing the token-theft/replay gap plain Bearer tokens have. This completes the resource-server half, alongside the client-side support already merged in #2629.
How Has This Been Tested?
81 unit + integration tests, all passing locally (typecheck and lint clean for the touched packages):
dpop.ts: proof validation against RFC 9449 §4.3's checking order, with real generated WebCrypto keys across all 10 supported algorithms and RFC 7638 thumbprint calculation.dpopAuth.ts:verifyDpopToken/requireDpopAuth, covering both a JWT-shaped and an opaque-string access token, the Bearer-scheme rejection path, scope/expiry enforcement, and the server-nonce retry flow.bearerAuth.ts: the new DPoP-bound-token-rejected-under-Bearer branch.@modelcontextprotocol/express'srequireDpopAuthadapter: real listening-server HTTP round trips, not mocked requests.Breaking Changes
None for existing callers. The one behavior change -
verifyBearerTokenrejecting a token under Bearer when itsAuthInfo.cnf.jktis set - only affects verifiers that opt into populatingcnf.jkt, which didn't exist before this PR.Types of changes
Checklist
Additional context
Part of a 3-PR DPoP rollout: client-side (#2629, merged), server-side proof validation (this PR), and a server-side conformance fixture stacked on top (#2631).