Skip to content

Authorization Server: DPoP Support (SEP-1932) - #396

Open
PieterKas wants to merge 7 commits into
modelcontextprotocol:mainfrom
PieterKas:dpop-as
Open

Authorization Server: DPoP Support (SEP-1932)#396
PieterKas wants to merge 7 commits into
modelcontextprotocol:mainfrom
PieterKas:dpop-as

Conversation

@PieterKas

@PieterKas PieterKas commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

ℹ️ Update: the client foundation PR (#394) is now merged and this branch has been rebased onto main, so the diff below contains only the authorization-server-side changes.

Overview

Conformance tests for DPoP support (SEP-1932 / RFC 9449) on the authorization server side.

Builds on the shared DPoP test helpers introduced in the client PR (#394, merged).

Closes #370.

What it tests

That an authorization server correctly advertises and implements DPoP. The scenario drives a real authorization-code + PKCE flow and checks the server:

  • Advertises dpop_signing_alg_values_supported in its metadata (RFC 9449 §5.1).
  • Lists only asymmetric algorithms — no none, no symmetric algorithms.
  • Binds the issued token to the client's DPoP key (cnf.jkt matches the presented proof, token_type: DPoP).

The scenario is support-gated: an authorization server that doesn't advertise DPoP is not a DPoP server, so it's skipped rather than failed.

Each check is proven to pass (compliant example authorization server) and to fail (misbehaving configurations), with an automated acceptance suite.

Scope

Authorization-server behaviour only — metadata and token issuance. Proof validation at the resource is covered by the MCP-server PR (#369); client presentation by the client PR (#394).

Notes

  • SEP-1932 is a draft extension.
  • Validated against the bundled example fixtures. No reference SDK implements DPoP yet, so there is no real-SDK run to report.

@Michito-Okai

Copy link
Copy Markdown
Contributor

@PieterKas
Could you please resolve the conflicts? Also, would it be possible to share the test execution procedure, test scenarios, and the check items?

PieterKas and others added 4 commits September 9, 2026 18:53
…l#370)

Follow-up on the DPoP client PR (shared foundation: createAuthServer DPoP core
+ dpopProof/dpopToken helpers). Adds an authorization-server scenario testing
DPoP (SEP-1932 / RFC 9449): metadata (dpop_signing_alg_values_supported
present, asymmetric-only), token binding (cnf.jkt + token_type=DPoP), and
no-proof enforcement when dpop_bound_access_tokens is advertised. Probes a live
AS via authorization_code + PKCE (auto-follows a direct redirect, falls back to
an interactive callback) and returns four sep-1932-as-* checks (compliant run +
four one-defect-isolation misbehaving configs).

- authorization-server/dpop.ts (+ acceptance test, spec-references).
- dpopToken: adds readTokenBinding() (reads token_type + cnf.jkt back out of a
  token response) — introduced here because this scenario is its only consumer.

Depends only on the shared DPoP foundation; independent of the server PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
negotiateProofAlg fell back to ES256 for a present-but-non-array
dpop_signing_alg_values_supported (e.g. the string "RS256"), contradicting
its docstring and risking a token-binding mis-score for that malformed shape.
Treat a present-but-non-array value as null (SKIP), like a non-empty list with
no supported alg; only an absent/empty list still falls back to ES256.

Defensive against malformed metadata; not independently exercised by a fixture
(would need a malformed-metadata AS option), consistent with the htu-strip
defensive fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The round-4 non-array guard carved out `null` (advertised !== null), so
metadata with "dpop_signing_alg_values_supported": null passed the support gate
(which only tests === undefined), skipped the guard, and fell through to the
ES256 fallback — the exact binding mis-score the fix targeted.

Extract the negotiation to an exported pure function negotiateProofAlg(advertised)
and treat ANY present-but-non-array shape (string, null, number, object) as
malformed → null (SKIP). Only an empty array still falls back to ES256. Correct
the docstring (an absent field never reaches here — the support gate SKIPs
upstream). Add unit tests for every shape (array / empty / no-overlap / string /
null / number / object), pinning the fix against a silent refactor regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Broaden the negotiateProofAlg fallback test to also assert undefined → ES256
and correct its title ("empty array or absent field") — the contract covers
both, though absent is gated upstream in the scenario.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pkg-pr-new

pkg-pr-new Bot commented Sep 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

npx https://pkg.pr.new/@modelcontextprotocol/conformance@396

commit: e1c4fb9

@PieterKas

Copy link
Copy Markdown
Contributor Author

@Michito-Okai - I believe the conflicts are resolved.

@tnorimat tnorimat left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@PieterKas Hello, I reviewed the PR and added some comment. I would appreciate it if you could check them.

Comment thread src/seps/sep-1932.yaml
text: 'Only asymmetric signature algorithms are permitted; the `none` algorithm MUST NOT be included'
- check: sep-1932-as-dpop-bound-enforcement
text: 'When `dpop_bound_access_tokens` is set to `true`, the authorization server MUST reject token requests from the client that do not include a valid DPoP proof'
- check: sep-1932-as-token-binding

@tnorimat tnorimat Sep 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This part (sep-1932-as-token-binding) was not added/modified by the PR, but its following text is not described in the "spec_url" referring the spec https://github.com/modelcontextprotocol/ext-auth/blob/pieterkas-dpop-extension/specification/draft/dpop-extension.mdx
It might be good to show where the text is coming.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. I will add that.

// Only inconclusive when the AS CLAIMS a DPoP binding (token_type=DPoP)
// but the token is opaque: cnf.jkt can't be read off the wire (it may
// still hold, verifiable only via introspection) → documented harness gap
// → SKIP. A non-DPoP token_type is a plain binding failure below, opaque

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Theoretically, an access token is opaque so that some real authorization server issues an reference type access token.
Therefore, in this case, it might be good to do token introspection and verify its response by following https://www.rfc-editor.org/rfc/rfc9449.html#section-6.2 .

Regarding that, I think there are the following options. Either one is fine.:

  1. the PR does not handle it. write the comment in the issue showing that non-JWT access token is out of scope.
  2. The PR does handle it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Going with out-of-scope for now.

A DPoP binding on an opaque (reference) token isn't observable on the wire (the cnf.jkt lives inside the token), so verifying it requires token introspection (RFC 9449 §6.2), which is optional in OAuth. In addition we couldn't validate such a check in the suite since the shared test AS (createAuthServer) has no introspection endpoint, so there's no way to build the conformant-vs-misbehaving pair the "prove it passes and fails" rule needs.

No other AS scenario introspects or inspects token structure, so the scenario reports the binding check as SKIPPED for opaque tokens (documented harness gap) rather than mis-scoring it. This is consistent with the rest of the suite. I'll note this out-of-scope on #370. This can be revisited if the suite gains introspection support.

// but the token is opaque: cnf.jkt can't be read off the wire (it may
// still hold, verifiable only via introspection) → documented harness gap
// → SKIP. A non-DPoP token_type is a plain binding failure below, opaque
// or not, so it does not reach here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It appears that this pull request does not cover some part of the corresponding issue #370 .

According to the "In Scope" in the issue, it states that "Binding refresh tokens to the same key for public clients".
And according to the "Checks to Cover - Positive" in the issue, it states that "For a public client, an issued refresh token is bound to the same key."

However, the PR does not test this point.

Regarding that, I think there are the following options. Either one is fine.:

  1. the PR does not handle it. write the comment in the issue stating that a public client is out of scope so that it does not check if a refresh token is bound to the same key.
  2. The PR does handle it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right it's listed In Scope on #370 and this PR doesn't cover it.

Based on my understanding the shared test AS (createAuthServer) doesn't support refresh tokens today. It advertises refresh_token in grant_types_supported, but never issues a refresh_token and has no grant_type=refresh_token handler.

Without that we can't build the conformant-vs-misbehaving pair needed to validate a refresh-binding check (an AS that re-binds vs one that doesn't), so the check can't be implemented and validated under the suite's 'prove it passes and fails' rule.

I'll leave refresh-token binding out of scope for this PR, record it as an excluded: row in sep-1932.yaml with this rationale, and note the descope on #370. Once the test AS gains refresh support, it can be added in the future.

Comment thread src/seps/sep-1932.yaml
- check: sep-1932-server-audience-validation
text: 'MCP servers MUST continue to validate that access tokens were specifically issued for them, even when DPoP is used'

- text: 'When `dpop_bound_access_tokens` is set to `true`, the authorization server MUST reject token requests from the client that do not include a valid DPoP proof'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It appears that this pull request does not cover some part of the corresponding issue #370 .

According to the "Check to Cover - Client registration enforcement", it states that "With dpop_bound_access_tokens=true, the authorization server rejects a
token request that does not include a DPoP proof.".

However, the PR does not test it (excluded).

I think it might be better to update the issue showing that (intentionally excluded).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. I will update the issue to show it is intentionally excluded.

redirect_uri: redirectUri,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Recently, by the merged PR #466 , resource parameter is supported in authorization-code-grant.ts:171-172 .

I think it is good for dpop.ts to support it to be align with authorization-code-grant.ts.

@PieterKas PieterKas Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. I will add this.

PieterKas and others added 2 commits September 10, 2026 19:21
…yaml

Add a one-line comment above the sep-1932-as-token-binding requirement noting
that its binding mechanics are defined in RFC 9449 (§6 cnf/jkt thumbprint,
§5 token_type: DPoP) — which the SEP builds on rather than restating — so a
reader can see where the requirement text derives from. Addresses review
feedback on modelcontextprotocol#396; the check id and text are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The DPoP AS scenario drives its own authorization_code + PKCE flow but did not
forward the `resource` parameter, unlike authorization-code-grant.ts after modelcontextprotocol#466.
Send it on both the authorization request and the token request when supplied
(guarded by options.resource, so it's a no-op otherwise). Keeps the two AS
scenarios consistent and lets the DPoP binding checks be evaluated cleanly
against a resource-enforcing AS. Addresses review feedback on modelcontextprotocol#396.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Public-client refresh-token binding (issue modelcontextprotocol#370) is not exercisable today: the
shared conformance test AS (createAuthServer) doesn't issue refresh tokens or
handle the refresh_token grant, so a conformant-vs-misbehaving pair can't be
built to validate the check under the suite's "prove it passes and fails" rule.
Record it as an excluded: row in sep-1932.yaml with this rationale; deferred as
a follow-up until the test AS gains refresh support. Addresses review feedback
on modelcontextprotocol#396.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Authorization Server Auth: DPoP Token Binding (SEP-1932)

3 participants