Skip to content

Commit

Permalink
feat: enable key iteration over JWKSMultipleMatchingKeys
Browse files Browse the repository at this point in the history
  • Loading branch information
panva committed Feb 15, 2023
1 parent 8e3ca5e commit a278acd
Show file tree
Hide file tree
Showing 7 changed files with 312 additions and 201 deletions.
83 changes: 67 additions & 16 deletions src/jwks/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type {
JWK,
JSONWebKeySet,
FlattenedJWSInput,
GetKeyFunction,
} from '../types.d'
import { importJWK } from '../key/import.js'
import {
Expand Down Expand Up @@ -73,8 +72,8 @@ export class LocalJWKSet {
this._jwks = clone<JSONWebKeySet>(jwks)
}

async getKey(protectedHeader: JWSHeaderParameters, token: FlattenedJWSInput): Promise<KeyLike> {
const { alg, kid } = { ...protectedHeader, ...token.header }
async getKey(protectedHeader?: JWSHeaderParameters, token?: FlattenedJWSInput): Promise<KeyLike> {
const { alg, kid } = { ...protectedHeader, ...token?.header }
const kty = getKtyFromAlg(alg)

const candidates = this._jwks!.keys.filter((jwk) => {
Expand Down Expand Up @@ -132,29 +131,53 @@ export class LocalJWKSet {
if (length === 0) {
throw new JWKSNoMatchingKey()
} else if (length !== 1) {
throw new JWKSMultipleMatchingKeys()
const error = new JWKSMultipleMatchingKeys()

const { _cached } = this
error[Symbol.asyncIterator] = async function* () {
for (const jwk of candidates) {
try {
yield await importWithAlgCache(_cached, jwk, alg!)
} catch {
continue
}
}
}

throw error
}

const cached = this._cached.get(jwk) || this._cached.set(jwk, {}).get(jwk)!
if (cached[alg!] === undefined) {
const keyObject = await importJWK({ ...jwk, ext: true }, alg)
return importWithAlgCache(this._cached, jwk, alg!)
}
}

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

cached[alg!] = keyObject
if (keyObject.type !== 'public') {
throw new JWKSInvalid('JSON Web Key Set members must be public keys')
}

return cached[alg!]
cached[alg] = keyObject
}

return cached[alg]
}

/**
* Returns a function that resolves to a key object from a locally stored, or otherwise available,
* JSON Web Key Set.
*
* Only a single public key must match the selection process.
* It uses the "alg" (JWS Algorithm) Header Parameter to determine the right JWK "kty" (Key Type),
* then proceeds to match the JWK "kid" (Key ID) with one found in the JWS Header Parameters (if
* there is one) while also respecting the JWK "use" (Public Key Use) and JWK "key_ops" (Key
* Operations) Parameters (if they are present on the JWK).
*
* Only a single public key must match the selection process. As shown in the example below when
* multiple keys get matched it is possible to opt-in to iterate over the matched keys and attempt
* verification in an iterative manner.
*
* @example Usage
*
Expand Down Expand Up @@ -185,10 +208,38 @@ export class LocalJWKSet {
* console.log(payload)
* ```
*
* @example Opting-in to multiple JWKS matches using `createLocalJWKSet`
*
* ```js
* const options = {
* issuer: 'urn:example:issuer',
* audience: 'urn:example:audience',
* }
* const { payload, protectedHeader } = await jose
* .jwtVerify(jwt, JWKS, options)
* .catch(async (error) => {
* if (error?.code === 'ERR_JWKS_MULTIPLE_MATCHING_KEYS') {
* for await (const publicKey of error) {
* try {
* return await jose.jwtVerify(jwt, publicKey, options)
* } catch (innerError) {
* if (innerError?.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
* continue
* }
* throw innerError
* }
* }
* throw new jose.errors.JWSSignatureVerificationFailed()
* }
*
* throw error
* })
* console.log(protectedHeader)
* console.log(payload)
* ```
*
* @param jwks JSON Web Key Set formatted object.
*/
export function createLocalJWKSet(
jwks: JSONWebKeySet,
): GetKeyFunction<JWSHeaderParameters, FlattenedJWSInput> {
export function createLocalJWKSet(jwks: JSONWebKeySet) {
return LocalJWKSet.prototype.getKey.bind(new LocalJWKSet(jwks))
}
55 changes: 45 additions & 10 deletions src/jwks/remote.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fetchJwks from '../runtime/fetch_jwks.js'
import { isCloudflareWorkers } from '../runtime/env.js'

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

import { isJWKSLike, LocalJWKSet } from './local.js'
Expand Down Expand Up @@ -84,7 +84,7 @@ class RemoteJWKSet extends LocalJWKSet {
: false
}

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

/**
* Returns a function that resolves to a key object downloaded from a remote endpoint returning a
* JSON Web Key Set, that is, for example, an OAuth 2.0 or OIDC jwks_uri. Only a single public key
* must match the selection process. The JSON Web Key Set is fetched when no key matches the
* selection process but only as frequently as the `cooldownDuration` option allows, to prevent
* abuse.
* JSON Web Key Set, that is, for example, an OAuth 2.0 or OIDC jwks_uri. The JSON Web Key Set is
* fetched when no key matches the selection process but only as frequently as the
* `cooldownDuration` option allows to prevent abuse.
*
* It uses the "alg" (JWS Algorithm) Header Parameter to determine the right JWK "kty" (Key Type),
* then proceeds to match the JWK "kid" (Key ID) with one found in the JWS Header Parameters (if
* there is one) while also respecting the JWK "use" (Public Key Use) and JWK "key_ops" (Key
* Operations) Parameters (if they are present on the JWK).
*
* Only a single public key must match the selection process. As shown in the example below when
* multiple keys get matched it is possible to opt-in to iterate over the matched keys and attempt
* verification in an iterative manner.
*
* @example Usage
*
Expand All @@ -158,12 +166,39 @@ class RemoteJWKSet extends LocalJWKSet {
* console.log(payload)
* ```
*
* @example Opting-in to multiple JWKS matches using `createRemoteJWKSet`
*
* ```js
* const options = {
* issuer: 'urn:example:issuer',
* audience: 'urn:example:audience',
* }
* const { payload, protectedHeader } = await jose
* .jwtVerify(jwt, JWKS, options)
* .catch(async (error) => {
* if (error?.code === 'ERR_JWKS_MULTIPLE_MATCHING_KEYS') {
* for await (const publicKey of error) {
* try {
* return await jose.jwtVerify(jwt, publicKey, options)
* } catch (innerError) {
* if (innerError?.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
* continue
* }
* throw innerError
* }
* }
* throw new jose.errors.JWSSignatureVerificationFailed()
* }
*
* throw error
* })
* console.log(protectedHeader)
* console.log(payload)
* ```
*
* @param url URL to fetch the JSON Web Key Set from.
* @param options Options for the remote JSON Web Key Set.
*/
export function createRemoteJWKSet(
url: URL,
options?: RemoteJWKSetOptions,
): GetKeyFunction<JWSHeaderParameters, FlattenedJWSInput> {
export function createRemoteJWKSet(url: URL, options?: RemoteJWKSetOptions) {
return RemoteJWKSet.prototype.getKey.bind(new RemoteJWKSet(url, options))
}
5 changes: 5 additions & 0 deletions src/util/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { KeyLike } from '../types.d'

/** A generic Error subclass that all other specific JOSE Error subclasses inherit from. */
export class JOSEError extends Error {
/** A unique error code for the particular error subclass. */
Expand Down Expand Up @@ -148,6 +150,9 @@ export class JWKSNoMatchingKey extends JOSEError {

/** An error subclass thrown when multiple keys match from a JWKS. */
export class JWKSMultipleMatchingKeys extends JOSEError {
/** @ignore */
[Symbol.asyncIterator]!: () => AsyncIterableIterator<KeyLike>

static get code(): 'ERR_JWKS_MULTIPLE_MATCHING_KEYS' {
return 'ERR_JWKS_MULTIPLE_MATCHING_KEYS'
}
Expand Down
Loading

0 comments on commit a278acd

Please sign in to comment.