feat(client): add DPoP (RFC 9449) sender-constrained token support - #2629
Conversation
🦋 Changeset detectedLatest commit: 398e426 The changes in this PR will be included in the next version bump. This PR includes changesets to release 10 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: |
pcarleton
left a comment
There was a problem hiding this comment.
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
9991200 to
6e28838
Compare
|
@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 ( One intentional deviation: Could you take another look? |
pcarleton
left a comment
There was a problem hiding this comment.
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
Done! |
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>
b0bd88a to
ea8c08e
Compare
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/dpopandauth/dpop-nonceclient scenarios.Review follow-up: DPoP is applied at the fetch layer (Paul’s suggestion — gbshankar#1), not via extra
AuthProviderhooks. One integration path instead of two.DpopSession(client/dpop.ts): non-extractable keypair generation, RFC 9449 §4 proof building (freshjtiper proof, query/fragment-strippedhtu,athwhen presenting a token), and per-origin nonce tracking for AS and RS (§8/§9).OAuthClientProvider.dpop()returning aDpopSession. Hosts that don’t implement it keep Bearer-only behavior.fetch:StreamableHTTPClientTransport,SSEClientTransport, andwithOAuthwrap resource-serverfetch(including a caller-suppliedfetch/eventSourceInit.fetch) withwithDpopFromProvider. Proofs bind to the request actually sent. The fetch handed toauth()stays unwrapped; token-endpoint DPoP remainsexecuteTokenRequest’s job.AuthProvideris unchanged (token()/onUnauthorized?()).adaptOAuthProviderstays the existing v2 Bearer adapter (not un-exported).withDpop(session, getToken)is for hosts that manage tokens themselves.withOAuthis 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)
Authorization: DPoP <token>+ proof only whentoken_typeisDPoP; a Bearer token stays Bearer even ifdpop()resolves.use_dpop_noncewhen the challenge carries a freshDPoP-Nonce; remember a nonce from any response, including 2xx (§8.2).DELETEgets the same nonce retry as POST/GET.400 use_dpop_nonce,addClientAuthenticationre-applied per attempt (freshprivate_key_jwtjti).auth()treatsinvalid_dpop_proofon refresh likeinvalid_grant.OAuthErrorCodegainsInvalidDpopProof/UseDpopNonce;extractWWWAuthenticateParamsrecognizes theDPoPscheme;OAuthMetadataSchemagainsdpop_signing_alg_values_supported.Verification
DpopSessionbehind anOAuthClientProvider, mocked fetch or a local HTTP server).Activation
Fully opt-in: DPoP only activates when a host supplies a
DpopSessionviaOAuthClientProvider.dpop().Stack
This is part of a 3-PR DPoP rollout (mirroring the client/server split in the conformance repo’s
#394/#395):Not order-dependent with #2 — either can merge first.