Skip to content

Commit a278acd

Browse files
committed
feat: enable key iteration over JWKSMultipleMatchingKeys
1 parent 8e3ca5e commit a278acd

File tree

7 files changed

+312
-201
lines changed

7 files changed

+312
-201
lines changed

src/jwks/local.ts

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import type {
44
JWK,
55
JSONWebKeySet,
66
FlattenedJWSInput,
7-
GetKeyFunction,
87
} from '../types.d'
98
import { importJWK } from '../key/import.js'
109
import {
@@ -73,8 +72,8 @@ export class LocalJWKSet {
7372
this._jwks = clone<JSONWebKeySet>(jwks)
7473
}
7574

76-
async getKey(protectedHeader: JWSHeaderParameters, token: FlattenedJWSInput): Promise<KeyLike> {
77-
const { alg, kid } = { ...protectedHeader, ...token.header }
75+
async getKey(protectedHeader?: JWSHeaderParameters, token?: FlattenedJWSInput): Promise<KeyLike> {
76+
const { alg, kid } = { ...protectedHeader, ...token?.header }
7877
const kty = getKtyFromAlg(alg)
7978

8079
const candidates = this._jwks!.keys.filter((jwk) => {
@@ -132,29 +131,53 @@ export class LocalJWKSet {
132131
if (length === 0) {
133132
throw new JWKSNoMatchingKey()
134133
} else if (length !== 1) {
135-
throw new JWKSMultipleMatchingKeys()
134+
const error = new JWKSMultipleMatchingKeys()
135+
136+
const { _cached } = this
137+
error[Symbol.asyncIterator] = async function* () {
138+
for (const jwk of candidates) {
139+
try {
140+
yield await importWithAlgCache(_cached, jwk, alg!)
141+
} catch {
142+
continue
143+
}
144+
}
145+
}
146+
147+
throw error
136148
}
137149

138-
const cached = this._cached.get(jwk) || this._cached.set(jwk, {}).get(jwk)!
139-
if (cached[alg!] === undefined) {
140-
const keyObject = await importJWK({ ...jwk, ext: true }, alg)
150+
return importWithAlgCache(this._cached, jwk, alg!)
151+
}
152+
}
141153

142-
if (keyObject instanceof Uint8Array || keyObject.type !== 'public') {
143-
throw new JWKSInvalid('JSON Web Key Set members must be public keys')
144-
}
154+
async function importWithAlgCache(cache: WeakMap<JWK, Cache>, jwk: JWK, alg: string) {
155+
const cached = cache.get(jwk) || cache.set(jwk, {}).get(jwk)!
156+
if (cached[alg] === undefined) {
157+
const keyObject = <KeyLike>await importJWK({ ...jwk, ext: true }, alg)
145158

146-
cached[alg!] = keyObject
159+
if (keyObject.type !== 'public') {
160+
throw new JWKSInvalid('JSON Web Key Set members must be public keys')
147161
}
148162

149-
return cached[alg!]
163+
cached[alg] = keyObject
150164
}
165+
166+
return cached[alg]
151167
}
152168

153169
/**
154170
* Returns a function that resolves to a key object from a locally stored, or otherwise available,
155171
* JSON Web Key Set.
156172
*
157-
* Only a single public key must match the selection process.
173+
* It uses the "alg" (JWS Algorithm) Header Parameter to determine the right JWK "kty" (Key Type),
174+
* then proceeds to match the JWK "kid" (Key ID) with one found in the JWS Header Parameters (if
175+
* there is one) while also respecting the JWK "use" (Public Key Use) and JWK "key_ops" (Key
176+
* Operations) Parameters (if they are present on the JWK).
177+
*
178+
* Only a single public key must match the selection process. As shown in the example below when
179+
* multiple keys get matched it is possible to opt-in to iterate over the matched keys and attempt
180+
* verification in an iterative manner.
158181
*
159182
* @example Usage
160183
*
@@ -185,10 +208,38 @@ export class LocalJWKSet {
185208
* console.log(payload)
186209
* ```
187210
*
211+
* @example Opting-in to multiple JWKS matches using `createLocalJWKSet`
212+
*
213+
* ```js
214+
* const options = {
215+
* issuer: 'urn:example:issuer',
216+
* audience: 'urn:example:audience',
217+
* }
218+
* const { payload, protectedHeader } = await jose
219+
* .jwtVerify(jwt, JWKS, options)
220+
* .catch(async (error) => {
221+
* if (error?.code === 'ERR_JWKS_MULTIPLE_MATCHING_KEYS') {
222+
* for await (const publicKey of error) {
223+
* try {
224+
* return await jose.jwtVerify(jwt, publicKey, options)
225+
* } catch (innerError) {
226+
* if (innerError?.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
227+
* continue
228+
* }
229+
* throw innerError
230+
* }
231+
* }
232+
* throw new jose.errors.JWSSignatureVerificationFailed()
233+
* }
234+
*
235+
* throw error
236+
* })
237+
* console.log(protectedHeader)
238+
* console.log(payload)
239+
* ```
240+
*
188241
* @param jwks JSON Web Key Set formatted object.
189242
*/
190-
export function createLocalJWKSet(
191-
jwks: JSONWebKeySet,
192-
): GetKeyFunction<JWSHeaderParameters, FlattenedJWSInput> {
243+
export function createLocalJWKSet(jwks: JSONWebKeySet) {
193244
return LocalJWKSet.prototype.getKey.bind(new LocalJWKSet(jwks))
194245
}

src/jwks/remote.ts

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fetchJwks from '../runtime/fetch_jwks.js'
22
import { isCloudflareWorkers } from '../runtime/env.js'
33

4-
import type { KeyLike, JWSHeaderParameters, FlattenedJWSInput, GetKeyFunction } from '../types.d'
4+
import type { KeyLike, JWSHeaderParameters, FlattenedJWSInput } from '../types.d'
55
import { JWKSInvalid, JWKSNoMatchingKey } from '../util/errors.js'
66

77
import { isJWKSLike, LocalJWKSet } from './local.js'
@@ -84,7 +84,7 @@ class RemoteJWKSet extends LocalJWKSet {
8484
: false
8585
}
8686

87-
async getKey(protectedHeader: JWSHeaderParameters, token: FlattenedJWSInput): Promise<KeyLike> {
87+
async getKey(protectedHeader?: JWSHeaderParameters, token?: FlattenedJWSInput): Promise<KeyLike> {
8888
if (!this._jwks || !this.fresh()) {
8989
await this.reload()
9090
}
@@ -140,10 +140,18 @@ class RemoteJWKSet extends LocalJWKSet {
140140

141141
/**
142142
* Returns a function that resolves to a key object downloaded from a remote endpoint returning a
143-
* JSON Web Key Set, that is, for example, an OAuth 2.0 or OIDC jwks_uri. Only a single public key
144-
* must match the selection process. The JSON Web Key Set is fetched when no key matches the
145-
* selection process but only as frequently as the `cooldownDuration` option allows, to prevent
146-
* abuse.
143+
* JSON Web Key Set, that is, for example, an OAuth 2.0 or OIDC jwks_uri. The JSON Web Key Set is
144+
* fetched when no key matches the selection process but only as frequently as the
145+
* `cooldownDuration` option allows to prevent abuse.
146+
*
147+
* It uses the "alg" (JWS Algorithm) Header Parameter to determine the right JWK "kty" (Key Type),
148+
* then proceeds to match the JWK "kid" (Key ID) with one found in the JWS Header Parameters (if
149+
* there is one) while also respecting the JWK "use" (Public Key Use) and JWK "key_ops" (Key
150+
* Operations) Parameters (if they are present on the JWK).
151+
*
152+
* Only a single public key must match the selection process. As shown in the example below when
153+
* multiple keys get matched it is possible to opt-in to iterate over the matched keys and attempt
154+
* verification in an iterative manner.
147155
*
148156
* @example Usage
149157
*
@@ -158,12 +166,39 @@ class RemoteJWKSet extends LocalJWKSet {
158166
* console.log(payload)
159167
* ```
160168
*
169+
* @example Opting-in to multiple JWKS matches using `createRemoteJWKSet`
170+
*
171+
* ```js
172+
* const options = {
173+
* issuer: 'urn:example:issuer',
174+
* audience: 'urn:example:audience',
175+
* }
176+
* const { payload, protectedHeader } = await jose
177+
* .jwtVerify(jwt, JWKS, options)
178+
* .catch(async (error) => {
179+
* if (error?.code === 'ERR_JWKS_MULTIPLE_MATCHING_KEYS') {
180+
* for await (const publicKey of error) {
181+
* try {
182+
* return await jose.jwtVerify(jwt, publicKey, options)
183+
* } catch (innerError) {
184+
* if (innerError?.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
185+
* continue
186+
* }
187+
* throw innerError
188+
* }
189+
* }
190+
* throw new jose.errors.JWSSignatureVerificationFailed()
191+
* }
192+
*
193+
* throw error
194+
* })
195+
* console.log(protectedHeader)
196+
* console.log(payload)
197+
* ```
198+
*
161199
* @param url URL to fetch the JSON Web Key Set from.
162200
* @param options Options for the remote JSON Web Key Set.
163201
*/
164-
export function createRemoteJWKSet(
165-
url: URL,
166-
options?: RemoteJWKSetOptions,
167-
): GetKeyFunction<JWSHeaderParameters, FlattenedJWSInput> {
202+
export function createRemoteJWKSet(url: URL, options?: RemoteJWKSetOptions) {
168203
return RemoteJWKSet.prototype.getKey.bind(new RemoteJWKSet(url, options))
169204
}

src/util/errors.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { KeyLike } from '../types.d'
2+
13
/** A generic Error subclass that all other specific JOSE Error subclasses inherit from. */
24
export class JOSEError extends Error {
35
/** A unique error code for the particular error subclass. */
@@ -148,6 +150,9 @@ export class JWKSNoMatchingKey extends JOSEError {
148150

149151
/** An error subclass thrown when multiple keys match from a JWKS. */
150152
export class JWKSMultipleMatchingKeys extends JOSEError {
153+
/** @ignore */
154+
[Symbol.asyncIterator]!: () => AsyncIterableIterator<KeyLike>
155+
151156
static get code(): 'ERR_JWKS_MULTIPLE_MATCHING_KEYS' {
152157
return 'ERR_JWKS_MULTIPLE_MATCHING_KEYS'
153158
}

0 commit comments

Comments
 (0)