From ff4d2d05c5e39379dad0156c1ee04bdcb24b5d04 Mon Sep 17 00:00:00 2001 From: Alberto Garcia Illera Date: Mon, 19 Oct 2026 18:37:43 -0400 Subject: [PATCH] feat(dpop): support external signing --- docs/README.md | 5 +- docs/functions/DPoP.md | 18 ++++-- docs/interfaces/DPoPSigner.md | 39 +++++++++++++ src/index.ts | 69 +++++++++++++++++----- test-d/api.ts | 13 +++++ test/dpop.test.ts | 107 ++++++++++++++++++++++++++++++++++ 6 files changed, 232 insertions(+), 19 deletions(-) create mode 100644 docs/interfaces/DPoPSigner.md diff --git a/docs/README.md b/docs/README.md index 1e5a7570..650b29e7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -79,9 +79,10 @@ Support from the community to continue maintaining and improving this module is ## DPoP -| Function | Description | +| Name | Description | | ------ | ------ | -| [DPoP](functions/DPoP.md) | Creates a DPoP handle that signs sender-constraining proofs with a [CryptoKeyPair](interfaces/CryptoKeyPair.md) and tracks server-issued nonces. | +| [DPoPSigner](interfaces/DPoPSigner.md) | A DPoP signing key managed outside Web Crypto, such as a hardware-backed key. | +| [DPoP](functions/DPoP.md) | Creates a DPoP handle that signs sender-constraining proofs with a [CryptoKeyPair](interfaces/CryptoKeyPair.md) or an external [DPoPSigner](interfaces/DPoPSigner.md) and tracks server-issued nonces. | | [isDPoPNonceError](functions/isDPoPNonceError.md) | Returns whether an error requires retrying the request with a fresh DPoP nonce. | ## Device Authorization Grant diff --git a/docs/functions/DPoP.md b/docs/functions/DPoP.md index 88a4ebba..2b0cc018 100644 --- a/docs/functions/DPoP.md +++ b/docs/functions/DPoP.md @@ -8,8 +8,8 @@ Support from the community to continue maintaining and improving this module is ▸ **DPoP**(`client`, `keyPair`, `options?`): [`DPoPHandle`](../interfaces/DPoPHandle.md) -Creates a DPoP handle that signs sender-constraining proofs with a [CryptoKeyPair](../interfaces/CryptoKeyPair.md) and -tracks server-issued nonces. +Creates a DPoP handle that signs sender-constraining proofs with a [CryptoKeyPair](../interfaces/CryptoKeyPair.md) or an +external [DPoPSigner](../interfaces/DPoPSigner.md) and tracks server-issued nonces. This wrapper / handle also keeps track of server-issued nonces, allowing requests to be retried with a fresh nonce when the server indicates the need to use one. [isDPoPNonceError](isDPoPNonceError.md) can be @@ -21,14 +21,14 @@ expired/missing nonce. | Parameter | Type | Description | | ------ | ------ | ------ | | `client` | [`Pick`](https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys)\<[`Client`](../interfaces/Client.md), *typeof* [`clockSkew`](../variables/clockSkew.md)\> | - | -| `keyPair` | [`CryptoKeyPair`](../interfaces/CryptoKeyPair.md) | Public/private key pair to sign the DPoP Proof JWT with | +| `keyPair` | [`CryptoKeyPair`](../interfaces/CryptoKeyPair.md) \| [`DPoPSigner`](../interfaces/DPoPSigner.md) | Public/private key pair or external signer to sign the DPoP Proof JWT with | | `options?` | [`ModifyAssertionOptions`](../interfaces/ModifyAssertionOptions.md) | - | ## Returns [`DPoPHandle`](../interfaces/DPoPHandle.md) -## Example +## Examples ```ts let client!: oauth.Client @@ -37,6 +37,16 @@ let keyPair!: oauth.CryptoKeyPair let DPoP = oauth.DPoP(client, keyPair) ``` +Using a private key managed outside Web Crypto: + +```ts +let client!: oauth.Client +let publicKey!: oauth.CryptoKey +let sign!: oauth.DPoPSigner['sign'] + +let DPoP = oauth.DPoP(client, { publicKey, sign }) +``` + ## See [RFC 9449 - OAuth 2.0 Demonstrating Proof of Possession (DPoP)](https://www.rfc-editor.org/rfc/rfc9449.html) diff --git a/docs/interfaces/DPoPSigner.md b/docs/interfaces/DPoPSigner.md new file mode 100644 index 00000000..b740bb5e --- /dev/null +++ b/docs/interfaces/DPoPSigner.md @@ -0,0 +1,39 @@ +# Interface: DPoPSigner + +[💗 Help the project](https://github.com/sponsors/panva) + +Support from the community to continue maintaining and improving this module is welcome. If you find the module useful, please consider supporting the project by [becoming a sponsor](https://github.com/sponsors/panva). + +*** + +A DPoP signing key managed outside Web Crypto, such as a hardware-backed key. + +## Methods + +### sign() + +▸ **sign**(`input`): [`Promise`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)\<[`ArrayBuffer`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer) \| [`Uint8Array`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array)\<`ArrayBufferLike`\>\> + +Signs the exact JWS Signing Input bytes with the corresponding private key and algorithm. The +input is not pre-hashed. Return the raw JWS signature, not its base64url encoding. ECDSA +signatures must use the IEEE P1363 (r || s) format, not ASN.1 DER. Throwing or rejecting +prevents the request from being sent. + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `input` | [`Uint8Array`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array)\<[`ArrayBuffer`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer)\> | + +#### Returns + +[`Promise`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)\<[`ArrayBuffer`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer) \| [`Uint8Array`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array)\<`ArrayBufferLike`\>\> + +## Properties + +### publicKey + +• **publicKey**: [`CryptoKey`](https://developer.mozilla.org/docs/Web/API/CryptoKey) + +Extractable public key corresponding to the signing key. Its algorithm determines the JWS +algorithm used for the proof. diff --git a/src/index.ts b/src/index.ts index f48f63d3..736fcfdb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2112,9 +2112,9 @@ export function TlsClientAuth(): ClientAuth { async function signJwt( header: CompactJWSHeaderParameters, payload: Record, - key: CryptoKey, + key: CryptoKey | DPoPSigner['sign'], ) { - if (!key.usages.includes('sign')) { + if (typeof key !== 'function' && !key.usages.includes('sign')) { throw CodedTypeError( 'CryptoKey instances used for signing assertions must include "sign" in their "usages"', ERR_INVALID_ARG_VALUE, @@ -2122,7 +2122,9 @@ async function signJwt( } const input = `${b64u(buf(JSON.stringify(header)))}.${b64u(buf(JSON.stringify(payload)))}` const signature = b64u( - await crypto.subtle.sign(keyToSubtle(key), key, buf(input) as Uint8Array), + typeof key === 'function' + ? await key(buf(input)) + : await crypto.subtle.sign(keyToSubtle(key), key, buf(input)), ) return `${input}.${signature}` } @@ -2421,9 +2423,29 @@ export interface DPoPHandle { calculateThumbprint(): Promise } +/** + * A DPoP signing key managed outside Web Crypto, such as a hardware-backed key. + * + * @group DPoP + */ +export interface DPoPSigner { + /** + * Extractable public key corresponding to the signing key. Its algorithm determines the JWS + * algorithm used for the proof. + */ + publicKey: CryptoKey + /** + * Signs the exact JWS Signing Input bytes with the corresponding private key and algorithm. The + * input is not pre-hashed. Return the raw JWS signature, not its base64url encoding. ECDSA + * signatures must use the IEEE P1363 (r || s) format, not ASN.1 DER. Throwing or rejecting + * prevents the request from being sent. + */ + sign(input: Uint8Array): Promise +} + class DPoPHandler implements DPoPHandle { #header?: CompactJWSHeaderParameters - #privateKey: CryptoKey + #signingKey: CryptoKey | DPoPSigner['sign'] #publicKey: CryptoKey #clockSkew: number #modifyAssertion?: ModifyAssertionFunction @@ -2432,10 +2454,18 @@ class DPoPHandler implements DPoPHandle { constructor( client: Pick, - keyPair: CryptoKeyPair, + keyPair: CryptoKeyPair | DPoPSigner, options?: ModifyAssertionOptions, ) { - assertPrivateKey(keyPair?.privateKey, '"DPoP.privateKey"') + if (keyPair && 'sign' in keyPair) { + if (typeof keyPair.sign !== 'function') { + throw CodedTypeError('"DPoP.sign" must be a function', ERR_INVALID_ARG_TYPE) + } + this.#signingKey = keyPair.sign.bind(keyPair) + } else { + assertPrivateKey(keyPair?.privateKey, '"DPoP.privateKey"') + this.#signingKey = keyPair.privateKey + } assertPublicKey(keyPair?.publicKey, '"DPoP.publicKey"') if (!keyPair.publicKey.extractable) { @@ -2444,7 +2474,6 @@ class DPoPHandler implements DPoPHandle { this.#modifyAssertion = options?.[modifyAssertion] this.#clockSkew = getClockSkew(client) - this.#privateKey = keyPair.privateKey this.#publicKey = keyPair.publicKey branded.add(this) } @@ -2478,7 +2507,9 @@ class DPoPHandler implements DPoPHandle { } async addProof(url: URL, headers: Headers, htm: string, accessToken?: string): Promise { - const alg = keyToJws(this.#privateKey) + const alg = keyToJws( + typeof this.#signingKey === 'function' ? this.#publicKey : this.#signingKey, + ) this.#header ||= { alg, typ: 'dpop+jwt', @@ -2501,7 +2532,7 @@ class DPoPHandler implements DPoPHandle { this.#modifyAssertion?.(this.#header, payload) - headers.set('dpop', await signJwt(this.#header, payload, this.#privateKey)) + headers.set('dpop', await signJwt(this.#header, payload, this.#signingKey)) } cacheNonce(response: Response, url: URL): void { @@ -2534,8 +2565,8 @@ export function isDPoPNonceError(err: unknown): boolean { } /** - * Creates a DPoP handle that signs sender-constraining proofs with a {@link CryptoKeyPair} and - * tracks server-issued nonces. + * Creates a DPoP handle that signs sender-constraining proofs with a {@link CryptoKeyPair} or an + * external {@link DPoPSigner} and tracks server-issued nonces. * * This wrapper / handle also keeps track of server-issued nonces, allowing requests to be retried * with a fresh nonce when the server indicates the need to use one. {@link isDPoPNonceError} can be @@ -2551,7 +2582,19 @@ export function isDPoPNonceError(err: unknown): boolean { * let DPoP = oauth.DPoP(client, keyPair) * ``` * - * @param keyPair Public/private key pair to sign the DPoP Proof JWT with + * @example + * + * Using a private key managed outside Web Crypto: + * + * ```ts + * let client!: oauth.Client + * let publicKey!: oauth.CryptoKey + * let sign!: oauth.DPoPSigner['sign'] + * + * let DPoP = oauth.DPoP(client, { publicKey, sign }) + * ``` + * + * @param keyPair Public/private key pair or external signer to sign the DPoP Proof JWT with * * @group DPoP * @@ -2559,7 +2602,7 @@ export function isDPoPNonceError(err: unknown): boolean { */ export function DPoP( client: Pick, - keyPair: CryptoKeyPair, + keyPair: CryptoKeyPair | DPoPSigner, options?: ModifyAssertionOptions, ): DPoPHandle { return new DPoPHandler(client, keyPair, options) diff --git a/test-d/api.ts b/test-d/api.ts index 984467f4..49bce512 100644 --- a/test-d/api.ts +++ b/test-d/api.ts @@ -45,3 +45,16 @@ declare const pair: oauth.CryptoKeyPair const _privateKey: CryptoKey = pair.privateKey const _publicKey: CryptoKey = pair.publicKey + +/* External DPoP signing accepts bytes and never requires a Web Crypto private key. */ +{ + const _signer: oauth.DPoPSigner = { + publicKey: pair.publicKey, + sign: async (input) => input, + } + const _dpopKey: Parameters[1] = _signer + const _webCryptoKey: Parameters[1] = pair + const _input: Equals[0], Uint8Array> = true + // @ts-expect-error a JWS signature must be bytes, not an encoded string + const _invalidSign: oauth.DPoPSigner['sign'] = async () => 'signature' +} diff --git a/test/dpop.test.ts b/test/dpop.test.ts index ea7150b9..711efb76 100644 --- a/test/dpop.test.ts +++ b/test/dpop.test.ts @@ -177,3 +177,110 @@ for (const challenge of [ testContext.false(lib.isDPoPNonceError(error)) }) } + +test('external DPoP signer preserves proof binding and nonce retries', async (t) => { + const keys = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' }) + const publicKey = await jose.importSPKI( + keys.publicKey.export({ format: 'pem', type: 'spki' }).toString(), + 'ES256', + ) + const DPoP = lib.DPoP( + client, + { + publicKey, + sign: async (input) => + crypto.sign('sha256', input, { key: keys.privateKey, dsaEncoding: 'ieee-p1363' }), + }, + { + [lib.modifyAssertion]: (_header, payload) => { + payload.custom = 'value' + }, + }, + ) + const proofs: string[] = [] + const request = () => + lib.protectedResourceRequest( + 'token', + 'GET', + new URL('https://rs.example.com/resource?query#fragment'), + undefined, + undefined, + { + DPoP, + [lib.customFetch]: async (_url, init) => { + proofs.push(new Headers(init.headers).get('dpop')!) + return proofs.length === 1 + ? new Response(null, { + status: 401, + headers: { + 'www-authenticate': 'DPoP error="use_dpop_nonce"', + 'dpop-nonce': 'server-nonce', + }, + }) + : new Response() + }, + }, + ) + + const error = await t.throwsAsync(request(), { instanceOf: lib.WWWAuthenticateChallengeError }) + t.true(lib.isDPoPNonceError(error)) + t.is((await request()).status, 200) + + const first = await jose.jwtVerify(proofs[0], publicKey) + const second = await jose.jwtVerify(proofs[1], publicKey) + t.deepEqual(second.protectedHeader, { + typ: 'dpop+jwt', + alg: 'ES256', + jwk: keys.publicKey.export({ format: 'jwk' }), + }) + const { jti, iat, ...claims } = second.payload + t.deepEqual(claims, { + htu: 'https://rs.example.com/resource', + htm: 'GET', + nonce: 'server-nonce', + custom: 'value', + ath: crypto.createHash('sha256').update('token').digest('base64url'), + }) + t.is(typeof iat, 'number') + t.is(typeof jti, 'string') + t.not(jti, first.payload.jti) + t.is(first.payload.nonce, undefined) + t.is( + await DPoP.calculateThumbprint(), + await jose.calculateJwkThumbprint(second.protectedHeader.jwk!), + ) +}) + +for (const failure of ['throw', 'reject']) { + test(`external DPoP signer ${failure} prevents sending a request`, async (t) => { + const { publicKey } = await lib.generateKeyPair('ES256') + const error = new Error('signing unavailable') + const DPoP = lib.DPoP(client, { + publicKey, + sign: () => { + if (failure === 'throw') throw error + return Promise.reject(error) + }, + }) + let sent = false + + await t.throwsAsync( + lib.protectedResourceRequest( + 'token', + 'GET', + new URL('https://rs.example.com/resource'), + undefined, + undefined, + { + DPoP, + [lib.customFetch]: async () => { + sent = true + return new Response() + }, + }, + ), + { is: error }, + ) + t.false(sent) + }) +}