Skip to content

Client DPoP: support external signers (non-CryptoKey keys) in DpopSession #3026

Open

Description

@figitaki

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

Activity

  1. added
    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes
    on Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions