From 1fca2a30ea8a20e48f3a7f64f6917cb4c7502753 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sat, 28 Sep 2024 14:38:34 +0200 Subject: [PATCH] refactor!: make DPoP implementation tree-shakeable BREAKING CHANGE: DPoP request options are now obtained by calling the `DPoP()` exported function. This returns a handle that also maintains its own LRU nonce caches --- conformance/runner.ts | 24 +- examples/dpop.diff | 16 +- examples/dpop.ts | 3 +- examples/fapi2-message-signing.diff | 14 +- examples/fapi2-message-signing.ts | 3 +- examples/fapi2.diff | 11 +- examples/fapi2.ts | 3 +- src/index.ts | 326 +++++++++++++++------------- tap/end2end-client-credentials.ts | 4 +- tap/end2end-device-code.ts | 23 +- tap/end2end.ts | 4 +- tap/modulus_length.ts | 2 +- test/authorization_code.test.ts | 2 +- test/client_credentials.test.ts | 2 +- test/device_flow.test.ts | 2 +- test/dpop.test.ts | 10 +- test/par.test.ts | 2 +- test/refresh_token.test.ts | 2 +- 18 files changed, 252 insertions(+), 201 deletions(-) diff --git a/conformance/runner.ts b/conformance/runner.ts index 72263f56..6efed17d 100644 --- a/conformance/runner.ts +++ b/conformance/runner.ts @@ -303,12 +303,14 @@ export const flow = (options?: MacroOptions) => { authorizationUrl.searchParams.set('response_type', response_type) } - let DPoP!: oauth.CryptoKeyPair + let DPoPKeyPair!: oauth.CryptoKeyPair + let DPoP!: oauth.DPoPRequestOptions['DPoP'] if (usesDpop(variant)) { - DPoP = await oauth.generateKeyPair(JWS_ALGORITHM as oauth.JWSAlgorithm) + DPoPKeyPair = await oauth.generateKeyPair(JWS_ALGORITHM as oauth.JWSAlgorithm) + DPoP = oauth.DPoP(client, DPoPKeyPair) authorizationUrl.searchParams.set( 'dpop_jkt', - await calculateJwkThumbprint(await exportJWK(DPoP.publicKey)), + await calculateJwkThumbprint(await exportJWK(DPoPKeyPair.publicKey)), ) } @@ -325,7 +327,11 @@ export const flow = (options?: MacroOptions) => { try { result = await oauth.processPushedAuthorizationResponse(as, client, par) } catch (err) { - if (DPoP && err instanceof oauth.ResponseBodyError && err.error === 'use_dpop_nonce') { + if ( + DPoPKeyPair && + err instanceof oauth.ResponseBodyError && + err.error === 'use_dpop_nonce' + ) { t.log('error', inspect(err, { depth: Infinity })) t.log('retrying with a newly obtained dpop nonce') par = await request() @@ -399,7 +405,11 @@ export const flow = (options?: MacroOptions) => { result = await oauth.processAuthorizationCodeResponse(as, client, response) } } catch (err) { - if (DPoP && err instanceof oauth.ResponseBodyError && err.error === 'use_dpop_nonce') { + if ( + DPoPKeyPair && + err instanceof oauth.ResponseBodyError && + err.error === 'use_dpop_nonce' + ) { t.log('error', inspect(err, { depth: Infinity })) t.log('retrying with a newly obtained dpop nonce') response = await request() @@ -443,7 +453,7 @@ export const flow = (options?: MacroOptions) => { t.log('userinfo endpoint response', { ...result }) } catch (err) { t.log('error', inspect(err, { depth: Infinity })) - if (DPoP && err instanceof oauth.WWWAuthenticateChallengeError) { + if (DPoPKeyPair && err instanceof oauth.WWWAuthenticateChallengeError) { const { 0: challenge, length } = err.cause if ( length === 1 && @@ -483,7 +493,7 @@ export const flow = (options?: MacroOptions) => { accounts = await request() } catch (err) { t.log('error', inspect(err, { depth: Infinity })) - if (DPoP && err instanceof oauth.WWWAuthenticateChallengeError) { + if (DPoPKeyPair && err instanceof oauth.WWWAuthenticateChallengeError) { const { 0: challenge, length } = err.cause if ( length === 1 && diff --git a/examples/dpop.diff b/examples/dpop.diff index 842c8d4c..6334071f 100644 --- a/examples/dpop.diff +++ b/examples/dpop.diff @@ -1,5 +1,5 @@ diff --git a/examples/oauth.ts b/examples/dpop.ts -index d55e62d..0cb9bdd 100644 +index d55e62d..29b3c7f 100644 --- a/examples/oauth.ts +++ b/examples/dpop.ts @@ -15,6 +15,12 @@ let client_secret!: string @@ -11,11 +11,19 @@ index d55e62d..0cb9bdd 100644 + * session. In the browser environment you shall use IndexedDB to persist the generated + * CryptoKeyPair. + */ -+let DPoP!: oauth.CryptoKeyPair ++let DPoPKeys!: oauth.CryptoKeyPair // End of prerequisites -@@ -64,16 +70,32 @@ let access_token: string +@@ -24,6 +30,7 @@ const as = await oauth + + const client: oauth.Client = { client_id } + const clientAuth = oauth.ClientSecretPost(client_secret) ++const DPoP = oauth.DPoP(client, DPoPKeys) + + const code_challenge_method = 'S256' + /** +@@ -64,16 +71,32 @@ let access_token: string const currentUrl: URL = getCurrentUrl() const params = oauth.validateAuthResponse(as, client, currentUrl, state) @@ -57,7 +65,7 @@ index d55e62d..0cb9bdd 100644 console.log('Access Token Response', result) ;({ access_token } = result) -@@ -81,11 +103,29 @@ let access_token: string +@@ -81,11 +104,29 @@ let access_token: string // Protected Resource Request { diff --git a/examples/dpop.ts b/examples/dpop.ts index 0cb9bddd..29b3c7f9 100644 --- a/examples/dpop.ts +++ b/examples/dpop.ts @@ -20,7 +20,7 @@ let redirect_uri!: string * session. In the browser environment you shall use IndexedDB to persist the generated * CryptoKeyPair. */ -let DPoP!: oauth.CryptoKeyPair +let DPoPKeys!: oauth.CryptoKeyPair // End of prerequisites @@ -30,6 +30,7 @@ const as = await oauth const client: oauth.Client = { client_id } const clientAuth = oauth.ClientSecretPost(client_secret) +const DPoP = oauth.DPoP(client, DPoPKeys) const code_challenge_method = 'S256' /** diff --git a/examples/fapi2-message-signing.diff b/examples/fapi2-message-signing.diff index e379e3e6..3432250d 100644 --- a/examples/fapi2-message-signing.diff +++ b/examples/fapi2-message-signing.diff @@ -1,8 +1,8 @@ diff --git a/examples/fapi2.ts b/examples/fapi2-message-signing.ts -index b8ec053..3fe9af1 100644 +index e49247d..76ce800 100644 --- a/examples/fapi2.ts +++ b/examples/fapi2-message-signing.ts -@@ -25,6 +25,11 @@ let DPoP!: oauth.CryptoKeyPair +@@ -25,6 +25,11 @@ let DPoPKeys!: oauth.CryptoKeyPair * client authentication method. */ let clientPrivateKey!: oauth.CryptoKey @@ -14,7 +14,7 @@ index b8ec053..3fe9af1 100644 // End of prerequisites -@@ -44,8 +49,8 @@ const code_challenge_method = 'S256' +@@ -45,8 +50,8 @@ const code_challenge_method = 'S256' const code_verifier = oauth.generateRandomCodeVerifier() const code_challenge = await oauth.calculatePKCECodeChallenge(code_verifier) @@ -25,7 +25,7 @@ index b8ec053..3fe9af1 100644 { const params = new URLSearchParams() params.set('client_id', client.client_id) -@@ -53,7 +58,18 @@ let request_uri: string +@@ -54,7 +59,18 @@ let request_uri: string params.set('code_challenge_method', code_challenge_method) params.set('redirect_uri', redirect_uri) params.set('response_type', 'code') @@ -45,7 +45,7 @@ index b8ec053..3fe9af1 100644 const pushedAuthorizationRequest = () => oauth.pushedAuthorizationRequest(as, client, clientAuth, params, { -@@ -91,7 +107,7 @@ let request_uri: string +@@ -92,7 +108,7 @@ let request_uri: string let access_token: string { const currentUrl: URL = getCurrentUrl() @@ -54,7 +54,7 @@ index b8ec053..3fe9af1 100644 const authorizationCodeGrantRequest = () => oauth.authorizationCodeGrantRequest( -@@ -107,7 +123,7 @@ let access_token: string +@@ -108,7 +124,7 @@ let access_token: string let response = await authorizationCodeGrantRequest() const processAuthorizationCodeResponse = () => @@ -63,7 +63,7 @@ index b8ec053..3fe9af1 100644 let result = await processAuthorizationCodeResponse().catch(async (err) => { if (err instanceof oauth.ResponseBodyError) { -@@ -120,6 +136,9 @@ let access_token: string +@@ -121,6 +137,9 @@ let access_token: string throw err }) diff --git a/examples/fapi2-message-signing.ts b/examples/fapi2-message-signing.ts index 3fe9af15..76ce800f 100644 --- a/examples/fapi2-message-signing.ts +++ b/examples/fapi2-message-signing.ts @@ -19,7 +19,7 @@ let redirect_uri!: string * session. In the browser environment you shall use IndexedDB to persist the generated * CryptoKeyPair. */ -let DPoP!: oauth.CryptoKeyPair +let DPoPKeys!: oauth.CryptoKeyPair /** * A key that the client has pre-registered at the Authorization Server for use with Private Key JWT * client authentication method. @@ -39,6 +39,7 @@ const as = await oauth const client: oauth.Client = { client_id } const clientAuth = oauth.PrivateKeyJwt(clientPrivateKey) +const DPoP = oauth.DPoP(client, DPoPKeys) const code_challenge_method = 'S256' /** diff --git a/examples/fapi2.diff b/examples/fapi2.diff index 828cb614..acedf7c2 100644 --- a/examples/fapi2.diff +++ b/examples/fapi2.diff @@ -1,5 +1,5 @@ diff --git a/examples/oauth.ts b/examples/fapi2.ts -index d55e62d..b8ec053 100644 +index d55e62d..e49247d 100644 --- a/examples/oauth.ts +++ b/examples/fapi2.ts @@ -9,12 +9,22 @@ let algorithm!: @@ -17,7 +17,7 @@ index d55e62d..b8ec053 100644 + * session. In the browser environment you shall use IndexedDB to persist the generated + * CryptoKeyPair. + */ -+let DPoP!: oauth.CryptoKeyPair ++let DPoPKeys!: oauth.CryptoKeyPair +/** + * A key that the client has pre-registered at the Authorization Server for use with Private Key JWT + * client authentication method. @@ -26,12 +26,13 @@ index d55e62d..b8ec053 100644 // End of prerequisites -@@ -23,36 +33,55 @@ const as = await oauth +@@ -23,36 +33,56 @@ const as = await oauth .then((response) => oauth.processDiscoveryResponse(issuer, response)) const client: oauth.Client = { client_id } -const clientAuth = oauth.ClientSecretPost(client_secret) +const clientAuth = oauth.PrivateKeyJwt(clientPrivateKey) ++const DPoP = oauth.DPoP(client, DPoPKeys) const code_challenge_method = 'S256' /** @@ -100,7 +101,7 @@ index d55e62d..b8ec053 100644 // now redirect the user to authorizationUrl.href } -@@ -62,18 +91,34 @@ let state: string | undefined +@@ -62,18 +92,34 @@ let state: string | undefined let access_token: string { const currentUrl: URL = getCurrentUrl() @@ -145,7 +146,7 @@ index d55e62d..b8ec053 100644 console.log('Access Token Response', result) ;({ access_token } = result) -@@ -81,11 +126,29 @@ let access_token: string +@@ -81,11 +127,29 @@ let access_token: string // Protected Resource Request { diff --git a/examples/fapi2.ts b/examples/fapi2.ts index b8ec0538..e49247d2 100644 --- a/examples/fapi2.ts +++ b/examples/fapi2.ts @@ -19,7 +19,7 @@ let redirect_uri!: string * session. In the browser environment you shall use IndexedDB to persist the generated * CryptoKeyPair. */ -let DPoP!: oauth.CryptoKeyPair +let DPoPKeys!: oauth.CryptoKeyPair /** * A key that the client has pre-registered at the Authorization Server for use with Private Key JWT * client authentication method. @@ -34,6 +34,7 @@ const as = await oauth const client: oauth.Client = { client_id } const clientAuth = oauth.PrivateKeyJwt(clientPrivateKey) +const DPoP = oauth.DPoP(client, DPoPKeys) const code_challenge_method = 'S256' /** diff --git a/src/index.ts b/src/index.ts index 6c95a5db..33d2fa06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1026,64 +1026,6 @@ function b64u(input: string | Uint8Array | ArrayBuffer) { return encodeBase64Url(input) } -/** - * Simple LRU - */ -class LRU { - cache = new Map() - _cache = new Map() - maxSize: number - - constructor(maxSize: number) { - this.maxSize = maxSize - } - - get(key: T1) { - let v = this.cache.get(key) - if (v) { - return v - } - - if ((v = this._cache.get(key))) { - this.update(key, v) - return v - } - - return undefined - } - - has(key: T1) { - return this.cache.has(key) || this._cache.has(key) - } - - set(key: T1, value: T2) { - if (this.cache.has(key)) { - this.cache.set(key, value) - } else { - this.update(key, value) - } - return this - } - - delete(key: T1) { - if (this.cache.has(key)) { - return this.cache.delete(key) - } - if (this._cache.has(key)) { - return this._cache.delete(key) - } - return false - } - - update(key: T1, value: T2) { - this.cache.set(key, value) - if (this.cache.size >= this.maxSize) { - this._cache = this.cache - this.cache = new Map() - } - } -} - /** * @group Errors */ @@ -1125,8 +1067,6 @@ function OPE(message: string, code?: string, cause?: unknown) { return new OperationProcessingError(message, { code, cause }) } -const dpopNonces: LRU = new LRU(100) - function assertCryptoKey(key: unknown, it: string): asserts key is CryptoKey { if (!(key instanceof CryptoKey)) { throw CodedTypeError(`${it} must be a private CryptoKey`, ERR_INVALID_ARG_TYPE) @@ -1243,16 +1183,6 @@ export interface DiscoveryRequestOptions extends HttpRequestOptions<'GET'> { algorithm?: 'oidc' | 'oauth2' } -function processDpopNonce(response: Response) { - try { - const nonce = response.headers.get('dpop-nonce') - if (nonce) { - dpopNonces.set(new URL(response.url).origin, nonce) - } - } catch {} - return response -} - function normalizeTyp(value: string) { return value.toLowerCase().replace(/^application\//, '') } @@ -1362,7 +1292,7 @@ export async function discoveryRequest( method: 'GET', redirect: 'manual', signal: options?.signal ? signal(options.signal) : null, - }).then(processDpopNonce) + }) } function assertNumber( @@ -1600,7 +1530,7 @@ function getKeyAndKid(input: CryptoKey | PrivateKey | undefined): NormalizedKeyI } } -export interface DPoPOptions extends CryptoKeyPair { +export interface DPoPKeyPair extends CryptoKeyPair { /** * Private CryptoKey instance to sign the DPoP Proof JWT with. * @@ -1609,17 +1539,10 @@ export interface DPoPOptions extends CryptoKeyPair { privateKey: CryptoKey /** - * The public key corresponding to {@link DPoPOptions.privateKey}. + * The public key corresponding to {@link DPoPKeyPair.privateKey}. */ publicKey: CryptoKey - /** - * Server-Provided Nonce to use in the request. This option serves as an override in case the - * self-correcting mechanism does not work with a particular server. Previously received nonces - * will be used automatically. - */ - nonce?: string - /** * Use to modify the DPoP Proof JWT right before it is signed. * @@ -1630,9 +1553,9 @@ export interface DPoPOptions extends CryptoKeyPair { export interface DPoPRequestOptions { /** - * DPoP-related options. + * DPoP handle, obtained from {@link DPoP} */ - DPoP?: DPoPOptions + DPoP?: DPoPHandle } export interface PushedAuthorizationRequestOptions @@ -2042,50 +1965,6 @@ export async function issueRequestObject( return signJwt(header, claims, key) } -/** - * Generates a unique DPoP Proof JWT - */ -async function dpopProofJwt( - headers: Headers, - options: DPoPOptions, - url: URL, - htm: string, - clockSkew: number, - accessToken?: string, -) { - const { privateKey, publicKey, nonce = dpopNonces.get(url.origin) } = options - - assertPrivateKey(privateKey, '"DPoP.privateKey"') - assertPublicKey(publicKey, '"DPoP.publicKey"') - - if (nonce !== undefined) { - assertString(nonce, '"DPoP.nonce"') - } - - if (!publicKey.extractable) { - throw CodedTypeError('"DPoP.publicKey.extractable" must be true', ERR_INVALID_ARG_VALUE) - } - - const now = epochTime() + clockSkew - const header = { - alg: keyToJws(privateKey), - typ: 'dpop+jwt', - jwk: await publicJwk(publicKey), - } - const payload = { - iat: now, - jti: randomBytes(), - htm, - nonce, - htu: `${url.origin}${url.pathname}`, - ath: accessToken ? b64u(await crypto.subtle.digest('SHA-256', buf(accessToken))) : undefined, - } - - options[modifyAssertion]?.(header, payload) - - headers.set('dpop', await signJwt(header, payload, privateKey)) -} - let jwkCache: WeakMap async function getSetPublicJwkCache(key: CryptoKey) { @@ -2206,10 +2085,141 @@ export async function pushedAuthorizationRequest( headers.set('accept', 'application/json') if (options?.DPoP !== undefined) { - await dpopProofJwt(headers, options.DPoP, url, 'POST', getClockSkew(client)) + assertDPoP(options.DPoP) + await options.DPoP.addProof(url, headers, 'POST') } - return authenticatedRequest(as, client, clientAuthentication, url, body, headers, options) + const response = await authenticatedRequest( + as, + client, + clientAuthentication, + url, + body, + headers, + options, + ) + options?.DPoP?.cacheNonce(response) + return response +} + +/** + * DPoP handle, obtained from {@link DPoP} + */ +export interface DPoPHandle { + /** + * This is not part of the public API. + * + * @private + * + * @ignore + * + * @internal + */ + addProof(url: URL, headers: Headers, htm: string, accessToken?: string): Promise + /** + * This is not part of the public API. + * + * @private + * + * @ignore + * + * @internal + */ + cacheNonce(response: Response): void +} + +class DPoPHandler implements DPoPHandle { + #header?: CompactJWSHeaderParameters + #privateKey: CryptoKey + #publicKey: CryptoKey + #clockSkew: number + #modifyAssertion?: ModifyAssertionFunction + #map?: Map + + constructor(client: Client, keyPair: DPoPKeyPair) { + assertPrivateKey(keyPair?.privateKey, '"DPoP.privateKey"') + assertPublicKey(keyPair?.publicKey, '"DPoP.publicKey"') + + if (!keyPair.publicKey.extractable) { + throw CodedTypeError('"DPoP.publicKey.extractable" must be true', ERR_INVALID_ARG_VALUE) + } + + this.#modifyAssertion = keyPair[modifyAssertion] + this.#clockSkew = getClockSkew(client) + this.#privateKey = keyPair.privateKey + this.#publicKey = keyPair.publicKey + branded.add(this) + } + + #get(key: string) { + this.#map ||= new Map() + let item = this.#map.get(key) + if (item) { + this.#map.delete(key) + this.#map.set(key, item) + } + return item + } + + #set(key: string, val: string) { + this.#map ||= new Map() + this.#map.delete(key) + if (this.#map.size === 100) { + this.#map.delete(this.#map.keys().next().value!) + } + this.#map.set(key, val) + } + + async addProof(url: URL, headers: Headers, htm: string, accessToken?: string): Promise { + this.#header ||= { + alg: keyToJws(this.#privateKey), + typ: 'dpop+jwt', + jwk: await publicJwk(this.#publicKey), + } + + const nonce = this.#get(url.origin) + + const now = epochTime() + this.#clockSkew + const payload = { + iat: now, + jti: randomBytes(), + htm, + nonce, + htu: `${url.origin}${url.pathname}`, + ath: accessToken ? b64u(await crypto.subtle.digest('SHA-256', buf(accessToken))) : undefined, + } + + this.#modifyAssertion?.(this.#header, payload) + + headers.set('dpop', await signJwt(this.#header, payload, this.#privateKey)) + } + + cacheNonce(response: Response): void { + try { + const nonce = response.headers.get('dpop-nonce') + if (nonce) { + this.#set(new URL(response.url).origin, nonce) + } + } catch {} + } +} + +/** + * Returns a wrapper / handle around a {@link !CryptoKeyPair} that is used for negotiating and + * proving proof-of-possession to sender-constrain OAuth 2.0 tokens via DPoP at the Authorization + * Server and Resource Server. + * + * This wrapper / handle also keeps track of server-issued nonces, allowing this module to + * automatically retry requests with a fresh nonce when the server indicates the need to use one. + * + * @param keyPair Public/private key pair to sign the DPoP Proof JWT with + * + * @group DPoP + * + * @see {@link !DPoP RFC 9449 - OAuth 2.0 Demonstrating Proof of Possession (DPoP)} + */ +export function DPoP(client: Client, keyPair: DPoPKeyPair): DPoPHandle { + return new DPoPHandler(client, keyPair) } export interface PushedAuthorizationResponse { @@ -2533,6 +2543,7 @@ export async function processPushedAuthorizationResponse( if (response.status !== 201) { let err: OAuth2Error | undefined if ((err = await handleOAuthBodyError(response))) { + await response.body?.cancel() throw new ResponseBodyError('server responded with an error in the response body', { cause: err, response, @@ -2580,11 +2591,12 @@ export type ProtectedResourceRequestBody = export interface ProtectedResourceRequestOptions extends Omit, 'headers'>, - DPoPRequestOptions { - /** - * See {@link clockSkew}. - */ - [clockSkew]?: number + DPoPRequestOptions {} + +function assertDPoP(option: DPoPHandle): asserts option is DPoPHandler { + if (!branded.has(option)) { + throw CodedTypeError('"options.DPoP" is not a valid DPoPHandle', ERR_INVALID_ARG_VALUE) + } } async function resourceRequest( @@ -2605,27 +2617,23 @@ async function resourceRequest( headers = prepareHeaders(headers) - if (options?.DPoP === undefined) { - headers.set('authorization', `Bearer ${accessToken}`) - } else { - await dpopProofJwt( - headers, - options.DPoP, - url, - method.toUpperCase(), - getClockSkew({ [clockSkew]: options?.[clockSkew] }), - accessToken, - ) + if (options?.DPoP) { + assertDPoP(options.DPoP) + await options.DPoP.addProof(url, headers, method.toUpperCase(), accessToken) headers.set('authorization', `DPoP ${accessToken}`) + } else { + headers.set('authorization', `Bearer ${accessToken}`) } - return (options?.[customFetch] || fetch)(url.href, { + const response = await (options?.[customFetch] || fetch)(url.href, { body, headers: Object.fromEntries(headers.entries()), method, redirect: 'manual', signal: options?.signal ? signal(options.signal) : null, - }).then(processDpopNonce) + }) + options?.DPoP?.cacheNonce(response) + return response } /** @@ -3053,7 +3061,7 @@ async function authenticatedRequest( method: 'POST', redirect: 'manual', signal: options?.signal ? signal(options.signal) : null, - }).then(processDpopNonce) + }) } export interface TokenEndpointRequestOptions @@ -3085,10 +3093,21 @@ async function tokenEndpointRequest( headers.set('accept', 'application/json') if (options?.DPoP !== undefined) { - await dpopProofJwt(headers, options.DPoP, url, 'POST', getClockSkew(client)) + assertDPoP(options.DPoP) + await options.DPoP.addProof(url, headers, 'POST') } - return authenticatedRequest(as, client, clientAuthentication, url, parameters, headers, options) + const response = await authenticatedRequest( + as, + client, + clientAuthentication, + url, + parameters, + headers, + options, + ) + options?.DPoP?.cacheNonce(response) + return response } /** @@ -3544,7 +3563,7 @@ function validateIssuer(as: AuthorizationServer, result: Awaited() +const branded = new WeakSet() function brand(searchParams: URLSearchParams) { branded.add(searchParams) return searchParams @@ -3648,6 +3667,8 @@ interface CompactJWSHeaderParameters { typ?: string crit?: string[] jwk?: JWK + + [parameter: string]: JsonValue | undefined } interface ParsedJWT { @@ -4284,6 +4305,7 @@ export async function processRevocationResponse(response: Response): Promise { ['client_credentials'], encryption, ) - const DPoP = dpop ? await lib.generateKeyPair(alg as lib.JWSAlgorithm) : undefined + const DPoP = dpop + ? lib.DPoP(client, await lib.generateKeyPair(alg as lib.JWSAlgorithm)) + : undefined let clientAuth: lib.ClientAuthenticationImplementation switch (authMethod) { diff --git a/tap/end2end-device-code.ts b/tap/end2end-device-code.ts index 4c336735..a9c343d1 100644 --- a/tap/end2end-device-code.ts +++ b/tap/end2end-device-code.ts @@ -24,7 +24,16 @@ export default (QUnit: QUnit) => { ['refresh_token', 'urn:ietf:params:oauth:grant-type:device_code'], false, ) - const DPoP = dpop ? await lib.generateKeyPair(alg as lib.JWSAlgorithm) : undefined + const DPoPKeyPair = await lib.generateKeyPair(alg as lib.JWSAlgorithm) + const DPoP = dpop + ? lib.DPoP(client, { + ...DPoPKeyPair, + [lib.modifyAssertion](h, p) { + t.equal(h.alg, 'ES256') + p.foo = 'bar' + }, + }) + : undefined const clientAuth = lib.ClientSecretBasic(client.client_secret as string) @@ -83,15 +92,7 @@ export default (QUnit: QUnit) => { undefined, undefined, { - DPoP: DPoP - ? { - ...DPoP, - [lib.modifyAssertion](h, p) { - t.equal(h.alg, 'ES256') - p.foo = 'bar' - }, - } - : undefined, + DPoP, [lib.allowInsecureRequests]: true, async [lib.customFetch](...params: Parameters) { const url = new URL(params[0] as string) @@ -111,7 +112,7 @@ export default (QUnit: QUnit) => { if (DPoP) { t.equal( jwtAccessToken.cnf!.jkt, - await jose.calculateJwkThumbprint(await jose.exportJWK(DPoP.publicKey)), + await jose.calculateJwkThumbprint(await jose.exportJWK(DPoPKeyPair.publicKey)), ) t.propContains(await jose.decodeJwt(request.headers.get('dpop')!), { foo: 'bar' }) diff --git a/tap/end2end.ts b/tap/end2end.ts index 61695c03..99fb3037 100644 --- a/tap/end2end.ts +++ b/tap/end2end.ts @@ -69,7 +69,9 @@ export default (QUnit: QUnit) => { : ['authorization_code', 'refresh_token'], encryption, ) - const DPoP = dpop ? await lib.generateKeyPair(alg as lib.JWSAlgorithm) : undefined + const DPoP = dpop + ? lib.DPoP(client, await lib.generateKeyPair(alg as lib.JWSAlgorithm)) + : undefined const as = await lib .discoveryRequest(issuerIdentifier) diff --git a/tap/modulus_length.ts b/tap/modulus_length.ts index b4e0b73f..2aef8e64 100644 --- a/tap/modulus_length.ts +++ b/tap/modulus_length.ts @@ -25,7 +25,7 @@ export default async (QUnit: QUnit) => { new URL('https://rs.example.com/api'), undefined, undefined, - { DPoP: { privateKey, publicKey } }, + { DPoP: lib.DPoP(client, { privateKey, publicKey }) }, ), (err: Error) => { t.propContains(err, { diff --git a/test/authorization_code.test.ts b/test/authorization_code.test.ts index 5ff1a7ef..fb729674 100644 --- a/test/authorization_code.test.ts +++ b/test/authorization_code.test.ts @@ -247,7 +247,7 @@ test('authorizationCodeGrantRequest() w/ DPoP', async (t) => { }) .reply(200, { access_token: 'token', token_type: 'DPoP' }) - const DPoP = t.context.ES256 + const DPoP = lib.DPoP(tClient, await lib.generateKeyPair('ES256')) await t.notThrowsAsync( lib.authorizationCodeGrantRequest( tIssuer, diff --git a/test/client_credentials.test.ts b/test/client_credentials.test.ts index 2e652a38..59463db9 100644 --- a/test/client_credentials.test.ts +++ b/test/client_credentials.test.ts @@ -163,7 +163,7 @@ test('clientCredentialsGrantRequest() w/ DPoP', async (t) => { }) .reply(200, { access_token: 'token', token_type: 'DPoP' }) - const DPoP = await lib.generateKeyPair('ES256') + const DPoP = lib.DPoP(tClient, await lib.generateKeyPair('ES256')) await t.notThrowsAsync( lib.clientCredentialsGrantRequest(tIssuer, tClient, lib.None(), new URLSearchParams(), { DPoP, diff --git a/test/device_flow.test.ts b/test/device_flow.test.ts index c2a55d95..50f9b625 100644 --- a/test/device_flow.test.ts +++ b/test/device_flow.test.ts @@ -371,7 +371,7 @@ test('deviceCodeGrantRequest() w/ DPoP', async (t) => { }) .reply(200, { access_token: 'token', token_type: 'DPoP' }) - const DPoP = await lib.generateKeyPair('ES256') + const DPoP = lib.DPoP(tClient, await lib.generateKeyPair('ES256')) await t.notThrowsAsync( lib.deviceCodeGrantRequest(tIssuer, tClient, lib.None(), 'device_code', { DPoP }), ) diff --git a/test/dpop.test.ts b/test/dpop.test.ts index b8c533ea..6036c017 100644 --- a/test/dpop.test.ts +++ b/test/dpop.test.ts @@ -1,6 +1,6 @@ import anyTest, { type TestFn } from 'ava' import * as crypto from 'crypto' -import setup, { type Context, teardown } from './_setup.js' +import setup, { type Context, teardown, client } from './_setup.js' import * as jose from 'jose' import * as lib from '../src/index.js' @@ -10,8 +10,8 @@ test.before(setup) test.after(teardown) test('dpop()', async (t) => { - const sign = await lib.generateKeyPair('ES256') - const publicJwk = await jose.exportJWK(sign.publicKey) + const kp = await lib.generateKeyPair('ES256') + const publicJwk = await jose.exportJWK(kp.publicKey) t.context.mock .get('https://rs.example.com') @@ -35,6 +35,7 @@ test('dpop()', async (t) => { }) .reply(200, '') + const sign = lib.DPoP(client, kp) const url = new URL('https://rs.example.com/resource?foo#bar') const response = await lib.protectedResourceRequest('token', 'GET', url, undefined, undefined, { DPoP: sign, @@ -43,8 +44,6 @@ test('dpop()', async (t) => { }) test('dpop() w/ a nonce', async (t) => { - const sign = await lib.generateKeyPair('ES256') - t.context.mock .get('https://rs2.example.com') .intercept({ @@ -61,6 +60,7 @@ test('dpop() w/ a nonce', async (t) => { .reply(401, '', { headers: { 'DPoP-Nonce': 'foo' } }) const url = new URL('https://rs2.example.com/resource?foo#bar') + const sign = lib.DPoP(client, await lib.generateKeyPair('ES256')) await lib.protectedResourceRequest('token', 'GET', url, undefined, undefined, { DPoP: sign, }) diff --git a/test/par.test.ts b/test/par.test.ts index da8d23df..c4b71ceb 100644 --- a/test/par.test.ts +++ b/test/par.test.ts @@ -118,7 +118,7 @@ test('pushedAuthorizationRequest() w/ DPoP', async (t) => { }) .reply(200, { request_uri: 'urn:example:uri', expires_in: 60 }) - const DPoP = await lib.generateKeyPair('ES256') + const DPoP = lib.DPoP(tClient, await lib.generateKeyPair('ES256')) await t.notThrowsAsync( lib.pushedAuthorizationRequest(tIssuer, tClient, lib.None(), new URLSearchParams(), { DPoP }), ) diff --git a/test/refresh_token.test.ts b/test/refresh_token.test.ts index 1d694def..4a570c6f 100644 --- a/test/refresh_token.test.ts +++ b/test/refresh_token.test.ts @@ -149,7 +149,7 @@ test('refreshTokenGrantRequest() w/ DPoP', async (t) => { }) .reply(200, { access_token: 'token', token_type: 'DPoP' }) - const DPoP = await lib.generateKeyPair('ES256') + const DPoP = lib.DPoP(tClient, await lib.generateKeyPair('ES256')) await t.notThrowsAsync( lib.refreshTokenGrantRequest(tIssuer, tClient, lib.None(), 'refresh_token', { DPoP }), )