Repository navigation
Conversation
When the protected resource metadata advertises dpop_signing_alg_values_supported with ES256, attach DPoP proof JWTs to the authorization_code exchange and refresh_token grant, with automatic single retry on server nonce challenges. Accept token_type DPoP in token responses. Zero new dependencies. Closes modelcontextprotocol#3671
|
This PR has been closed automatically. This repo only keeps pull requests open when they come from a maintainer, or from a contributor a maintainer has assigned to the linked issue, and you aren't currently assigned to #3671. If a maintainer assigns you to #3671, this PR reopens on its own and there's nothing more you need to do here. Assignment is a maintainer call based on capacity; comments that only ask to be assigned don't factor in. What does help is engaging on the issue itself by confirming the repro, explaining why it matters for your use case, or describing the approach you'd take. You're welcome to keep pushing commits here (just avoid force-pushing, since GitHub can't reopen a rewritten branch), but that on its own won't get the PR reviewed or the issue assigned, and realistically most auto-closed PRs stay closed. There's no need to open a new PR either way. CONTRIBUTING.md has the full reasoning, but in short:
Maintainers: reopen, remove |
Closes #3671.
What
OAuthClientProvidernow speaks DPoP on token endpoint requests when theserver advertises it: if the protected resource metadata contains
dpop_signing_alg_values_supportedwithES256, theauthorization_codeexchange and every
refresh_tokengrant carry aDPoPproof header (aproof JWT signed by a per-client P-256 key). Servers that don't advertise
DPoP get byte-identical requests to before.
This is the missing client half of the story in #3671: a stolen
authorization code or refresh token (cf. CVE-2026-104850) can no longer be
redeemed by anyone who doesn't hold the client's private key.
Scope (deliberate)
the
DPoPauthorization scheme) are follow-up work; API calls still sendAuthorization: Bearer ....it; the next refresh then fails once and the flow re-authorizes with a
fresh key. Persisting the key via
TokenStorageis a natural follow-up.pyjwt[crypto], already required.Changes
src/mcp/client/auth/dpop.py(new): proof minting (create_dpop_proof),P-256 key generation, RFC 7638 thumbprint,
is_dpop_supportedcheck.src/mcp/client/auth/oauth2.py:_dpop_active/_dpop_headers/_dpop_nonce_retryhelpers;DPoPheader on both token-request builders;single automatic retry on a
400+DPoP-Noncechallenge.src/mcp/shared/auth.py:OAuthToken.token_typeaccepts"DPoP"(RFC 9449 §5 mandates it in token responses); the normalizer still
title-cases
Bearerand passesDPoPthrough exactly.docs/client/oauth-clients.md: new "DPoP" section.Tests
tests/client/auth/test_dpop.py: 13 unit tests — proof structure(RFC 9449 §4.2), nonce handling (§9.1), signature round-trip, and the
RFC 9449 Figures 8/9 published
jktvector.tests/client/test_auth.py: 9 integration tests — header presence/absence,key reuse, nonce-challenge retry (direct + full
_auth_flowagainst anin-memory mock AS for both exchange and refresh), and the no-challenge
no-retry path.
ruff format,ruff check, andpyrightclean.AI disclosure
Per CONTRIBUTING.md: this PR was drafted with AI assistance (Muse) and
reviewed by the reporter before submission. I hit this gap while hardening a
Python DPoP implementation against the token-theft class in CVE-2026-104850
and verified the SDK's client sends no DPoP proof or key material today.
Follow-ups (not in this PR)
TokenStorageso restarts don't re-authorize.DPoPscheme on resource-server requests (RFC 9449 §7).dpop_signing_alg_values_supportedfrom authorization servermetadata as well (currently read from protected resource metadata, where
the SDK models it).