Skip to content

feat(client): add DPoP (RFC 9449) sender-constrained token support - #2629

Merged
pcarleton merged 5 commits into
modelcontextprotocol:mainfrom
gbshankar:feat/dpop-client-tokens
Aug 31, 2026
Merged

feat(client): add DPoP (RFC 9449) sender-constrained token support#2629
pcarleton merged 5 commits into
modelcontextprotocol:mainfrom
gbshankar:feat/dpop-client-tokens

Conversation

@gbshankar

@gbshankar gbshankar commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the client half of DPoP (RFC 9449) sender-constrained tokens per SEP-1932, a draft OAuth extension for MCP. This is the SDK-side work backing conformance#394 (merged), which added the auth/dpop and auth/dpop-nonce client scenarios.

Review follow-up: DPoP is applied at the fetch layer (Paul’s suggestion — gbshankar#1), not via extra AuthProvider hooks. One integration path instead of two.

  • DpopSession (client/dpop.ts): non-extractable keypair generation, RFC 9449 §4 proof building (fresh jti per proof, query/fragment-stripped htu, ath when presenting a token), and per-origin nonce tracking for AS and RS (§8/§9).
  • Opt-in: implement OAuthClientProvider.dpop() returning a DpopSession. Hosts that don’t implement it keep Bearer-only behavior.
  • Resource-server DPoP lives on fetch: StreamableHTTPClientTransport, SSEClientTransport, and withOAuth wrap resource-server fetch (including a caller-supplied fetch / eventSourceInit.fetch) with withDpopFromProvider. Proofs bind to the request actually sent. The fetch handed to auth() stays unwrapped; token-endpoint DPoP remains executeTokenRequest’s job.
  • AuthProvider is unchanged (token() / onUnauthorized?()). adaptOAuthProvider stays the existing v2 Bearer adapter (not un-exported). withDpop(session, getToken) is for hosts that manage tokens themselves.
  • withOAuth is Bearer + a single re-auth, with DPoP nonce retry composed underneath — so nonce retry and credential retry work in either order without a two-budget loop.

Behaviour (RFC 9449)

  • Present Authorization: DPoP <token> + proof only when token_type is DPoP; a Bearer token stays Bearer even if dpop() resolves.
  • Retry once on RS use_dpop_nonce when the challenge carries a fresh DPoP-Nonce; remember a nonce from any response, including 2xx (§8.2).
  • SSE POST proofs bind to the announced message endpoint. Session DELETE gets the same nonce retry as POST/GET.
  • Token requests: proof on the token endpoint, retry-once on AS 400 use_dpop_nonce, addClientAuthentication re-applied per attempt (fresh private_key_jwt jti). auth() treats invalid_dpop_proof on refresh like invalid_grant.
  • OAuthErrorCode gains InvalidDpopProof / UseDpopNonce; extractWWWAuthenticateParams recognizes the DPoP scheme; OAuthMetadataSchema gains dpop_signing_alg_values_supported.

Verification

  • Client unit suite: 873/873 passing; typecheck/lint clean.
  • Tests assert on the wire (real DpopSession behind an OAuthClientProvider, mocked fetch or a local HTTP server).

Activation

Fully opt-in: DPoP only activates when a host supplies a DpopSession via OAuthClientProvider.dpop().

Stack

This is part of a 3-PR DPoP rollout (mirroring the client/server split in the conformance repo’s #394/#395):

  1. → this PR ← — client-side DPoP (SEP-1932, conformance#394)
  2. Server-side DPoP proof validation (SEP-1932, conformance#395) — independent of this PR, no shared files
  3. Server-side conformance fixture for Race condition between transport start and server connection #2, stacked on it

Not order-dependent with #2 — either can merge first.

@gbshankar
gbshankar requested a review from a team as a code owner August 9, 2026 17:53
@changeset-bot

changeset-bot Bot commented Aug 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 398e426

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@modelcontextprotocol/client Minor
@modelcontextprotocol/core Minor
@modelcontextprotocol/core-internal Patch
@modelcontextprotocol/server-legacy Minor
@modelcontextprotocol/server Minor
@modelcontextprotocol/codemod Minor
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/node Major

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

@pkg-pr-new

pkg-pr-new Bot commented Aug 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2629

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2629

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2629

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2629

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2629

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2629

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2629

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2629

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2629

commit: 398e426

@claude claude Bot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Aug 18, 2026

@pcarleton pcarleton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hi @gbshankar thank you for the work on this.

could you have a look at the PR I opened here: gbshankar#1

I believe we can simplify this by always using the middleware approach instead of having 2 approaches. lmkwyt

@gbshankar
gbshankar force-pushed the feat/dpop-client-tokens branch from 9991200 to 6e28838 Compare August 23, 2026 15:11
@gbshankar

Copy link
Copy Markdown
Contributor Author

@pcarleton thanks for the review and for gbshankar#1 — adopted the middleware-only approach from that commit.

DPoP is now applied at the fetch layer (withDpopFromProvider wrapping resource-server fetch in the transports and withOAuth). AuthProvider is back to { token(); onUnauthorized?() }.

One intentional deviation: adaptOAuthProvider stays exported. It is already documented v2 public API (Bearer adapter); dropping it would be an unrelated breaking change. It does not sign DPoP proofs.

Could you take another look?

@gbshankar
gbshankar requested a review from pcarleton August 24, 2026 23:35

@pcarleton pcarleton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One intentional deviation: adaptOAuthProvider stays exported. It is already documented v2 public API (Bearer adapter); dropping it would be an unrelated breaking change. It does not sign DPoP proofs.

Hmm this is not pre-existing and I don't think necessary for this PR. In the interests of keeping the public API scope small, could you remove this line:
https://github.com/gbshankar/typescript-sdk/blob/6e2883823067cd08980ffeef6a896a573c3fd185/packages/client/src/index.ts#L21

@gbshankar

Copy link
Copy Markdown
Contributor Author

Hmm this is not pre-existing and I don't think necessary for this PR. In the interests of keeping the public API scope small, could you remove this line: https://github.com/gbshankar/typescript-sdk/blob/6e2883823067cd08980ffeef6a896a573c3fd185/packages/client/src/index.ts#L21

Done!

gbshankar and others added 4 commits August 26, 2026 21:00
Implements the client half of SEP-1932: DpopSession (key generation,
proof building, nonce tracking), optional AuthProvider.authorizeRequest/
consumeChallenge and OAuthClientProvider.dpop() hooks, DPoP wiring in the
token endpoint (auth.ts) and both transports (streamableHttp.ts, sse.ts),
and a standalone withDpop() middleware. Opt-in via provider.dpop() -
existing Bearer-only hosts are unaffected.

Also wires the conformance harness client side: registers auth/dpop and
auth/dpop-nonce against the pinned referee and removes them from
expected-failures.yaml.

Fixes a retry-ordering bug in withOAuth/withOAuthRetry found while
verifying against the real referee: the DPoP nonce challenge must be
re-checked *after* a credential re-authorization retry too, not only
before it (auth/dpop-nonce's actual sequence), so both are now tried in
whichever order the server presents them, bounded to one retry each.

Verified against the pinned @modelcontextprotocol/conformance referee:
auth/dpop 12/12, auth/dpop-nonce 14/14, full --suite all regression-clean.
Follow-up to the DPoP (RFC 9449 / SEP-1932) client support, addressing review
findings and reworking how the transports integrate it.

Integration shape
-----------------
The initial version grew AuthProvider from {token, onUnauthorized} to five
members (authorizeRequest / consumeChallenge / observeResponse +
AuthRequestContext) and threaded a nonce-retry counter through every fetch site
in both transports, each of which had to hand the request's method/URL to the
provider and report the response back. The sites that got that wrong or were
missed are exactly where the SSE htu, DELETE-retry and 2xx-nonce bugs below
came from. A fetch wrapper sees the real method/URL/response of every request
by construction, and withDpop already existed, so:

- AuthProvider is back to {token(); onUnauthorized?()}; adaptOAuthProvider is
  Bearer-only again; AuthRequestContext and the adaptOAuthProvider export are
  dropped.
- withDpop(session, getToken) also accepts a lazy/provider-backed session
  source and passes the request through untouched when there is no session or
  token, so it can sit under a layer that already set Bearer. It now requires a
  fresh DPoP-Nonce before spending its retry and remembers a DPoP-Nonce from
  any response (RFC 9449 §8.2).
- New withDpopFromProvider(provider): withDpop driven by provider.dpop() and
  provider.tokens(), upgrading to the DPoP scheme only for token_type=DPoP
  (RFC 9449 §7.1) and stamping provider failures as auth-seam errors.
- StreamableHTTPClientTransport / SSEClientTransport wrap their resource-server
  fetch (including a caller-supplied fetch and SSE's eventSourceInit.fetch) with
  withDpopFromProvider when the OAuthClientProvider implements dpop(). The fetch
  handed to auth() stays unwrapped; token-endpoint DPoP remains
  executeTokenRequest's job. All hook plumbing and retry-counter threading is
  removed from the transports.
- withOAuth and the conformance withOAuthRetry helper revert to Bearer + single
  re-auth and compose withDpopFromProvider underneath, which yields "nonce retry
  on every attempt, in either order" without a two-budget loop.

Behaviour fixes (each covered by a test that failed before)
----------------------------------------------------------
- A token the AS issued as Bearer is presented as Bearer even when dpop()
  resolves (was always DPoP → hard 401 at a Bearer-only RS).
- SSE POST proofs are bound to the announced message endpoint, not the /sse URL.
- Session termination (DELETE) gets the same use_dpop_nonce retry as POST/GET.
- A use_dpop_nonce challenge without a fresh DPoP-Nonce is not retried with the
  stale nonce.
- A DPoP-Nonce delivered on a 2xx is used in the next proof.
- addClientAuthentication runs per token-request attempt, so the AS nonce retry
  does not replay a private_key_jwt client_assertion (same jti).
- auth() treats invalid_dpop_proof on refresh like invalid_grant (drop tokens,
  re-authorize) — e.g. a refresh token bound to a key no longer held.
  OAuthErrorCode gains InvalidDpopProof / UseDpopNonce.

Tests assert on the wire (real DpopSession behind an OAuthClientProvider,
mocked fetch or a local HTTP server) rather than on hook invocations. Changeset
updated to describe the final surface. Conformance auth/dpop 12/12,
auth/dpop-nonce 14/14, auth suite 216/216 unchanged.
…write

Dropping it would break existing v2 callers; DPoP stays on withDpopFromProvider.

Co-authored-by: Cursor <cursoragent@cursor.com>
Address PR review — it is not on main and is not needed for DPoP.
Transports still adapt OAuth providers internally.

Co-authored-by: Cursor <cursoragent@cursor.com>
@gbshankar
gbshankar force-pushed the feat/dpop-client-tokens branch from b0bd88a to ea8c08e Compare August 27, 2026 04:00
@gbshankar
gbshankar requested a review from pcarleton August 27, 2026 04:01

@pcarleton pcarleton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants