Client DPoP: support external signers (non-CryptoKey keys) in DpopSession #3026
Description
Problem
OAuthClientProvider.dpop() must return a DpopSession. Today the only way to get one is DpopSession.create({ alg?, keyPair? }), and keyPair.privateKey must be a WebCrypto CryptoKey, because buildProof() signs with jose's SignJWT(...).sign(privateKey). The constructor is private.
So the DPoP key must be a key that WebCrypto or jose can use in the same process. These keys do not work:
- A key in a cloud KMS or an HSM (for example PKCS#11).
- A platform authenticator or secure enclave key that is used through a native bridge.
- A remote signing service or a policy-controlled key custody service.
RFC 9449 §11 says that clients should protect the DPoP private key and that hardware-backed keys are preferred. generateDpopKeyPair already defaults to extractable: false for this reason. A key that is outside the process is the strongest form of that protection, but the SDK does not support it.
What we tried (SDK 2.3.1)
We wrote a class that builds the RFC 9449 §4.2 header and claims and sends the JWS signing input to an external sign(). It has the same public surface as DpopSession (buildProof, observeNonce, nonceFor, rememberNonce, thumbprint, alg, publicJwk).
TypeScript rejects every typed approach, because DpopSession has private members, so it is compared nominally:
| Attempt | Error |
|---|---|
const s: DpopSession = external |
TS2741: Property 'keyPair' is missing ... |
external as DpopSession |
TS2352: neither type sufficiently overlaps |
dpop: () => external |
TS2322 (same cause) |
new DpopSession(...) |
TS2673: constructor is private |
class X extends DpopSession |
TS2675: cannot extend, constructor is private |
DpopSession.create({ keyPair: { ...kp, privateKey: remote } }) |
TS2739: not a CryptoKey. If you force it with a cast, the first buildProof throws TypeError: Key for the ES256 algorithm must be one of type CryptoKey, KeyObject, or JSON Web Key. |
external as unknown as DpopSession compiles and works at runtime. In 2.3.1, the transports (withDpop) and the token request (executeTokenRequest) call only buildProof() and observeNonce() on the session, and they do no instanceof check. A bare object with only those methods completes auth() (client_credentials), AS and RS use_dpop_nonce retries, and tool calls against a DPoP-validating server (we used the #2630 preview). This works, but it depends on internals that can change in any release without a type error.
Proposal
Add a supported way to supply the signature. Either of these would work.
Option A: a signer in create.
interface DpopSigner {
alg: DpopAlg;
publicJwk: JWK; // public members only
sign(signingInput: Uint8Array): Promise<Uint8Array>; // raw JWS signature (for ES*, r || s)
}
const session = await DpopSession.create({ signer });DpopSession keeps doing the RFC 9449 work, such as jti, htu normalization, ath, nonces, and the thumbprint. It calls signer.sign() instead of SignJWT.sign(). keyPair stays as it is, and signer is an alternative to it.
Option B: a structural type for the provider.
Type OAuthClientProvider.dpop() (and DpopSessionSource) as an interface:
interface DpopProofSource {
buildProof(request: DpopProofRequest): Promise<string>;
observeNonce(response: Response, url: string | URL): void;
}DpopSession implements this interface, so current code does not change. Callers with special needs can bring their own implementation.
Option A keeps the RFC logic in one place. Option B makes the actual contract explicit. They can be combined.
Why the hook must be async and called once for each request
A proof binds htm, htu, ath, iat, and nonce, so it cannot be signed in advance or reused. With an external signer, each MCP HTTP request waits for one signature. In our measurement (N = 25 callTool calls, nearest-rank):
| Proof key | p50 | p95 |
|---|---|---|
DpopSession with a WebCrypto key |
1.1 ms | 1.5 ms |
| Same flow, external-signer object with a local key | 0.8 ms | 1.1 ms |
| Same flow, remote signing service (measured; the fixtures repo reproduces it with a mock signer delay) | 235 ms | 580 ms |
The adapter itself adds no time. The time comes from the signer, so sign() must be async and must not be cached. connect() signs 3 proofs (initialize, the initialized notification, and the GET stream), and the token request signs 1 more. If the hook received the DpopProofRequest along with the signing input, a signer could log or apply policy for each request. This is optional.
Related
- feat(client): add DPoP (RFC 9449) sender-constrained token support #2629 (client DPoP), feat(server): add DPoP (RFC 9449) proof validation #2630 (server DPoP validation), Feat/dpop server conformance fixture #2631 (conformance fixture).
- Reproduction (the duck-typing probe, its TypeScript errors, and the benchmark with a mock remote signer): https://github.com/tkhq/mcp-dpop-fixtures
- Small related point: the client does not read
dpop_signing_alg_values_supportedor the RSalgschallenge parameter. A signer with a fixed algorithm would benefit if the SDK checked these and failed early.
Activity