Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 14 additions & 4 deletions docs/functions/DPoP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
39 changes: 39 additions & 0 deletions docs/interfaces/DPoPSigner.md
Original file line number Diff line number Diff line change
@@ -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.
69 changes: 56 additions & 13 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2112,17 +2112,19 @@ export function TlsClientAuth(): ClientAuth {
async function signJwt(
header: CompactJWSHeaderParameters,
payload: Record<string, unknown>,
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,
)
}
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<ArrayBuffer>),
typeof key === 'function'
? await key(buf(input))
: await crypto.subtle.sign(keyToSubtle(key), key, buf(input)),
)
return `${input}.${signature}`
}
Expand Down Expand Up @@ -2421,9 +2423,29 @@ export interface DPoPHandle {
calculateThumbprint(): Promise<string>
}

/**
* 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<ArrayBuffer>): Promise<Uint8Array | ArrayBuffer>
}

class DPoPHandler implements DPoPHandle {
#header?: CompactJWSHeaderParameters
#privateKey: CryptoKey
#signingKey: CryptoKey | DPoPSigner['sign']
#publicKey: CryptoKey
#clockSkew: number
#modifyAssertion?: ModifyAssertionFunction
Expand All @@ -2432,10 +2454,18 @@ class DPoPHandler implements DPoPHandle {

constructor(
client: Pick<Client, typeof clockSkew>,
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) {
Expand All @@ -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)
}
Expand Down Expand Up @@ -2478,7 +2507,9 @@ class DPoPHandler implements DPoPHandle {
}

async addProof(url: URL, headers: Headers, htm: string, accessToken?: string): Promise<void> {
const alg = keyToJws(this.#privateKey)
const alg = keyToJws(
typeof this.#signingKey === 'function' ? this.#publicKey : this.#signingKey,
)
this.#header ||= {
alg,
typ: 'dpop+jwt',
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -2551,15 +2582,27 @@ 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
*
* @see {@link https://www.rfc-editor.org/rfc/rfc9449.html RFC 9449 - OAuth 2.0 Demonstrating Proof of Possession (DPoP)}
*/
export function DPoP(
client: Pick<Client, typeof clockSkew>,
keyPair: CryptoKeyPair,
keyPair: CryptoKeyPair | DPoPSigner,
options?: ModifyAssertionOptions,
): DPoPHandle {
return new DPoPHandler(client, keyPair, options)
Expand Down
13 changes: 13 additions & 0 deletions test-d/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof oauth.DPoP>[1] = _signer
const _webCryptoKey: Parameters<typeof oauth.DPoP>[1] = pair
const _input: Equals<Parameters<oauth.DPoPSigner['sign']>[0], Uint8Array<ArrayBuffer>> = true
// @ts-expect-error a JWS signature must be bytes, not an encoded string
const _invalidSign: oauth.DPoPSigner['sign'] = async () => 'signature'
}
107 changes: 107 additions & 0 deletions test/dpop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}