Skip to content

Add DPoP (RFC 9449) support for token endpoint requests - #3672

Closed
eaakun wants to merge 1 commit into
modelcontextprotocol:mainfrom
eaakun:dpop-token-endpoint
Closed

eaakun wants to merge 1 commit into
modelcontextprotocol:mainfrom
eaakun:dpop-token-endpoint

Conversation

@eaakun

@eaakun eaakun commented Oct 10, 2026

Copy link
Copy Markdown

Closes #3671.

What

OAuthClientProvider now speaks DPoP on token endpoint requests when the
server advertises it: if the protected resource metadata contains
dpop_signing_alg_values_supported with ES256, the authorization_code
exchange and every refresh_token grant carry a DPoP proof header (a
proof 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)

  • Token endpoint only. DPoP proofs on resource-server requests (RFC 9449 §7,
    the DPoP authorization scheme) are follow-up work; API calls still send
    Authorization: Bearer ....
  • The DPoP key is generated in memory per provider instance. A restart drops
    it; the next refresh then fails once and the flow re-authorizes with a
    fresh key. Persisting the key via TokenStorage is a natural follow-up.
  • Zero new dependencies — implemented on 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_supported check.
  • src/mcp/client/auth/oauth2.py: _dpop_active / _dpop_headers /
    _dpop_nonce_retry helpers; DPoP header on both token-request builders;
    single automatic retry on a 400 + DPoP-Nonce challenge.
  • src/mcp/shared/auth.py: OAuthToken.token_type accepts "DPoP"
    (RFC 9449 §5 mandates it in token responses); the normalizer still
    title-cases Bearer and passes DPoP through 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 jkt vector.
  • tests/client/test_auth.py: 9 integration tests — header presence/absence,
    key reuse, nonce-challenge retry (direct + full _auth_flow against an
    in-memory mock AS for both exchange and refresh), and the no-challenge
    no-retry path.
  • Full suite green; ruff format, ruff check, and pyright clean.

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)

  • Persist the DPoP key in TokenStorage so restarts don't re-authorize.
  • DPoP proofs + DPoP scheme on resource-server requests (RFC 9449 §7).
  • Surface dpop_signing_alg_values_supported from authorization server
    metadata as well (currently read from protected resource metadata, where
    the SDK models it).

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
@github-actions github-actions Bot added the missing-issue-link Auto-closed: PR needs a linked issue assigned to its author (see CONTRIBUTING.md) label Oct 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

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:

  • We're a small team with very little capacity to review community PRs right now.
  • Many recent PRs are AI-generated with little human review, and reviewing one carefully still costs a maintainer as much time as it ever did. A well-described issue is usually more useful to us than the code.

Maintainers: reopen, remove missing-issue-link, or add bypass-issue-check to override.

@github-actions github-actions Bot closed this Oct 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

missing-issue-link Auto-closed: PR needs a linked issue assigned to its author (see CONTRIBUTING.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DPoP sender-constrained tokens not implemented in OAuth client (spec mandates it)

1 participant