Skip to content

feat(server): DPoP follow-ups: replay hook, requestUrl, async nonce verify, DPoP-Nonce on 401 - #2

Open
figitaki wants to merge 5 commits into
gbshankar:feat/dpop-server-validationfrom
tkhq:dpop-server-followups
Open

figitaki wants to merge 5 commits into
gbshankar:feat/dpop-server-validationfrom
tkhq:dpop-server-followups

Conversation

@figitaki

@figitaki figitaki commented Oct 16, 2026 •

Copy link
Copy Markdown

Motivation and Context

These are the four follow-ups from the modelcontextprotocol#2630 thread, as separate commits on top of ccd9f583, per your comment asking for them against this branch instead of a rebase PR. They come from running a resource server on the preview build with a 2.3.1 client. The design discussion is in modelcontextprotocol#2630.

Each commit has its own changeset, so any one of them can be dropped without touching the others. Your .changeset/dpop-server-validation.md is unchanged. A fifth, separate commit fixes a doc contract verifyDpopToken inherits from VerifyBearerTokenOptions; drop it if you would rather keep that out of modelcontextprotocol#2630.

What changed

  • replay on DpopAuthOptions: one optional method, consume(key, ttlSeconds), keyed <jkt>.<jti>, called after every other check. The TTL is the iat window (twice iatSkewSeconds) rounded up to whole seconds, at least 1, so a fractional or zero skew never reaches a store that takes integers. A seen key answers 401 invalid_dpop_proof. This closes the jti TODO without changing the return type of verifyDpopToken or the gate. No store ships. The fetch gate and the Express middleware pass it through.
  • requestUrl on the fetch requireDpopAuth: a function of the Request that derives the URL the proof's htu must match, for a server behind a reverse proxy or TLS termination. It is a function only: a fixed string would pin every route behind one gate to one htu. Defaults to request.url. The Express adapter keeps its trust proxy reconstruction. A throwing function answers 500, like a throwing verifier.
  • DpopNonceState.verify may return Promise<boolean>, for a nonce store shared between instances. verifyDpopProof always awaits it, so both forms behave the same. issue() stays synchronous because dpopAuthChallengeResponse returns a Response.
  • With a nonce option set, every 401 carries DPoP-Nonce, not only use_dpop_nonce, so a client's first token-less request already learns the nonce. issue() therefore runs for unauthenticated requests; its JSDoc now says to return the current value and keep no per-call state. The Express middleware also sets the header on accepted requests. The fetch gate does not: a fetch host sets DPoP-Nonce: nonce.issue() on its own response from the nonce object it configured. Rotation policy stays with the host.
  • (separate commit) verifyDpopToken stamps the gate's resourceMetadataUrl onto the returned AuthInfo when the verifier left it unset. VerifyBearerTokenOptions.resourceMetadataUrl, which DpopAuthOptions extends, documents that hand-off, and verifyBearerToken does it on main; verifyDpopToken did not, so a later scope challenge on a DPoP request advertised no document. A verifier-set value still wins.

How Has This Been Tested?

  • pnpm lint:all is clean on the branch head. pnpm test:all passes in every package except two cases in test/e2e, protocol:timeout:max-total [sse …], which fail the same way on a clean main checkout on my machine and pass in CI on the base commit; they do not touch auth.
  • The DPoP test files pass at every one of the five commits, not only at the head.
  • DPoP tests: 85 in @modelcontextprotocol/server (dpop.test.ts, dpopAuth.test.ts) and 14 in @modelcontextprotocol/express. Each commit adds the tests for its own change: replayed jti refused on second use through verifyDpopToken, the fetch gate and Express; TTL rounding for skew 2.25 (→ 5) and 0 (→ 1); requestUrl function and its 500 path; async nonce verify; DPoP-Nonce on invalid_token and invalid_dpop_proof 401s and on an accepted Express request; resourceMetadataUrl stamped and not overwritten.
  • An external resource server built on this branch passes its own suite, including a 2.3.1 client with DpopSession end to end: discovery through the challenge, nonce challenge and retry, nonce rotation, bound tool calls, and the negative vectors from tkhq/mcp-dpop-fixtures.

Breaking Changes

None. DpopNonceState, DpopAuthOptions and requireDpopAuth are new in modelcontextprotocol#2630 and not in any release, so widening verify and adding optional members changes no released API.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed (one changeset per commit, JSDoc on each option)

AI assistance: Claude Code was used for the implementation, tests and this description, with human review. Commits carry a Co-Authored-By trailer.

🤖 Generated with Claude Code

Comment thread .changeset/dpop-server-validation.md Outdated
Comment thread packages/server/src/server/middleware/dpop.ts
Comment thread packages/server/src/server/middleware/dpopAuth.ts Outdated
Comment thread packages/server/test/server/dpopAuth.test.ts
figitaki and others added 5 commits October 16, 2026 10:32
RFC 9449 section 11.1 says a server SHOULD refuse a proof whose jti it
has seen before. Without that, a captured proof and token work again
for the whole iat window. The gate had a TODO for it.

DpopAuthOptions gets an optional `replay` with one method,
`consume(key, ttlSeconds)`, which returns false when the key was seen.
verifyDpopToken calls it last, after every other check, keyed by
"<jkt>.<jti>" with a TTL of twice the iat skew rounded up to whole seconds (at least 1), the longest a
proof can still pass the iat check. A seen key answers 401
invalid_dpop_proof. requireDpopAuth and the Express middleware pass it
through.

Why a hook and not the proof claims on the result: returning the
claims would change the return type of verifyDpopToken and of the
gate (AuthInfo | Response), and every host would still have to write
the same check. One optional method keeps the public shape as it is,
mirrors the existing `nonce` option, and leaves storage and atomicity
to the host. No store ships with the SDK.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The fetch gate compared the proof's htu with request.url. Behind a
reverse proxy or TLS termination that is the internal URL, not the one
the client signed, so every such host had to skip the gate and call
verifyDpopToken itself. The Express middleware does not have this
problem: it rebuilds the URL through Express's `trust proxy` setting.

requireDpopAuth now takes `requestUrl`, a function of the Request for
hosts that read Forwarded headers they trust. It is a function only: a
fixed string would pin every route behind one gate to one htu. It
defaults to request.url. The option
lives on the gate only; verifyDpopToken already takes the URL in its
request argument, and the Express adapter keeps its own reconstruction.
A requestUrl function that throws answers 500, like a throwing verifier.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A nonce store shared between server instances (Redis, a database)
cannot answer synchronously. `verify` may now return
`boolean | Promise<boolean>`; verifyDpopProof awaits it. A synchronous
`verify` keeps working unchanged.

`issue()` stays synchronous: dpopAuthChallengeResponse returns a
Response, not a Promise, and a host can mint the nonce value ahead of
time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The gate issued a nonce only inside a use_dpop_nonce challenge. A
client's first request has no proof, so it got a plain 401, retried
with a proof, got use_dpop_nonce, and retried again. RFC 9449 section
8.2 lets a nonce ride on any response, and the SDK client already
remembers a DPoP-Nonce from any response.

With a nonce option set, dpopAuthChallengeResponse now puts
DPoP-Nonce on every 401 (invalid_token, invalid_dpop_proof,
use_dpop_nonce). 403, 400 and 500 answers do not carry it. The Express
middleware also sets it on accepted requests before calling next().
A fetch host already holds the nonce object it configured, so it sets
`DPoP-Nonce: nonce.issue()` on its own response; no new export for that.
When to rotate stays the host's choice, expressed by what issue()
returns.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
verifyBearerToken on main copies the gate's resourceMetadataUrl onto
the AuthInfo it returns when the verifier left it unset, so scope
challenges built later advertise the same document. verifyDpopToken
now does the same, so a DPoP request gets the same scope challenge as
a Bearer one. A verifier-set value still wins.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@figitaki
figitaki force-pushed the dpop-server-followups branch from 5c57ba1 to 49dccb0 Compare October 16, 2026 17:40
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.

1 participant