From a278acdb0f458e555abdc1d048920e7da4fb7981 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 14 Feb 2023 19:08:31 +0100 Subject: [PATCH] feat: enable key iteration over JWKSMultipleMatchingKeys --- src/jwks/local.ts | 83 ++++++++++++++---- src/jwks/remote.ts | 55 +++++++++--- src/util/errors.ts | 5 ++ tap/jwks.ts | 179 ++++++++++++++++++++++++++++++++++++-- test/jwks/local.test.mjs | 168 +---------------------------------- test/jwks/remote.test.mjs | 21 ++++- tsconfig/base.json | 2 +- 7 files changed, 312 insertions(+), 201 deletions(-) diff --git a/src/jwks/local.ts b/src/jwks/local.ts index cd917bfd0a..adbef6849f 100644 --- a/src/jwks/local.ts +++ b/src/jwks/local.ts @@ -4,7 +4,6 @@ import type { JWK, JSONWebKeySet, FlattenedJWSInput, - GetKeyFunction, } from '../types.d' import { importJWK } from '../key/import.js' import { @@ -73,8 +72,8 @@ export class LocalJWKSet { this._jwks = clone(jwks) } - async getKey(protectedHeader: JWSHeaderParameters, token: FlattenedJWSInput): Promise { - const { alg, kid } = { ...protectedHeader, ...token.header } + async getKey(protectedHeader?: JWSHeaderParameters, token?: FlattenedJWSInput): Promise { + const { alg, kid } = { ...protectedHeader, ...token?.header } const kty = getKtyFromAlg(alg) const candidates = this._jwks!.keys.filter((jwk) => { @@ -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: JWK, alg: string) { + const cached = cache.get(jwk) || cache.set(jwk, {}).get(jwk)! + if (cached[alg] === undefined) { + const keyObject = 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 * @@ -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 { +export function createLocalJWKSet(jwks: JSONWebKeySet) { return LocalJWKSet.prototype.getKey.bind(new LocalJWKSet(jwks)) } diff --git a/src/jwks/remote.ts b/src/jwks/remote.ts index c6eeec7ba8..0ed34fa708 100644 --- a/src/jwks/remote.ts +++ b/src/jwks/remote.ts @@ -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' @@ -84,7 +84,7 @@ class RemoteJWKSet extends LocalJWKSet { : false } - async getKey(protectedHeader: JWSHeaderParameters, token: FlattenedJWSInput): Promise { + async getKey(protectedHeader?: JWSHeaderParameters, token?: FlattenedJWSInput): Promise { if (!this._jwks || !this.fresh()) { await this.reload() } @@ -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 * @@ -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 { +export function createRemoteJWKSet(url: URL, options?: RemoteJWKSetOptions) { return RemoteJWKSet.prototype.getKey.bind(new RemoteJWKSet(url, options)) } diff --git a/src/util/errors.ts b/src/util/errors.ts index ab215512ed..84b9a43cb6 100644 --- a/src/util/errors.ts +++ b/src/util/errors.ts @@ -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. */ @@ -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 + static get code(): 'ERR_JWKS_MULTIPLE_MATCHING_KEYS' { return 'ERR_JWKS_MULTIPLE_MATCHING_KEYS' } diff --git a/tap/jwks.ts b/tap/jwks.ts index fc9844fdc8..64e8b28384 100644 --- a/tap/jwks.ts +++ b/tap/jwks.ts @@ -7,18 +7,183 @@ export default (QUnit: QUnit, lib: typeof jose) => { const jwksUri = 'https://www.googleapis.com/oauth2/v3/certs' - test('fetches the JWKSet', async (t: typeof QUnit.assert) => { + test('[createRemoteJWKSet] fetches the JWKSet', async (t: typeof QUnit.assert) => { const response = await fetch(jwksUri).then((r) => r.json()) const { alg, kid } = response.keys[0] const jwks = lib.createRemoteJWKSet(new URL(jwksUri)) + await t.rejects(jwks({ alg: 'RS256' }), 'multiple matching keys found in the JSON Web Key Set') await t.rejects( - (async () => jwks({ alg: 'RS256' }, {}))(), - 'multiple matching keys found in the JSON Web Key Set', - ) - await t.rejects( - (async () => jwks({ kid: 'foo', alg: 'RS256' }, {}))(), + jwks({ kid: 'foo', alg: 'RS256' }), 'no applicable key found in the JSON Web Key Set', ) - t.ok(await jwks({ alg, kid }, {})) + t.ok(await jwks({ alg, kid })) + }) + + test('[createLocalJWKSet] establishes local JWKSet', async (t: typeof QUnit.assert) => { + const keys = [ + { + e: 'AQAB', + n: 'wAR7gpvDJx2cUR15R1gyBYxEXanhOIDzk7evzadBpNCEpf6HA6utqMrf8dZ3EXSslKPSPBD5Qrz63kc2u8y7NqzwJQIi_i5xR6AxAyWLG3_kOHBwxnct6talLCZqgr8pDwnyP1BPnIaNf2hZxgS-UZbHCAVycd1n2qCdyb4FzFhcaNtiOLg5VSfgvtOdhHQlDXW-DBvwatpd9HzzTP6l5MZRyQ-N_AoGbIfhNCZRUfnb-A8IBPSqXBWN4TEpt-0yHAOIhWnSpu66AYE4f1efZdHVFCTQZ13e5bS-5RQra4pfmGqU9hog1j1SpHnDTia-s__qGi43rev2MqzY-qeUlw', + d: 'buWn14TSLtMhJo_ZLWU4bo_WJCoq0xFWm-eodyOz-9YZ5iycGXibcTLKJ8fvOHuj-KysjNhYvTybvqhuagQR08AJabZUM2zrK6zO4bxbHOS-EAKQf27xbAHPnzIIrb5tnivmZr6hXAsxyXWg84ZlzIVCKdXLhQuUIWZF-u_uNVeJSUTDMRVTL2J0mzAGTXqi-yHejapEeLS7lFXDe6cpDnBVXauJfB4GmSUOjxtdAEVW7uGNQJGarGwRz6l3Tpy_xQiYl8e_IrU1N6qAN_HJEBrdgfK7js93RcsxHGbtdnj1ylevZqGFpB1UXrWE4JSz3sJgyXrmKNFFWOCjalMccQ', + p: '98OCXxur1omXdjfWkDubqkI3xRehVQryIhqt0go-1yLS4Nwa7KyrdAbzTo81bCHN0A-NlmIvHA4YZc8QUHftq65s4nCbb3g_CwTfGCJEVCvoaTO2EE6Pd8VrGu2PVsN4SM52Gc0TNJGS54yUhyCWDTi1onUBEg8gnqpMSoWuaVM', + q: 'xmaRdSJf4wN5Vse5jiIjZy5jy9veHHhzXxTmW25epr9KTMERUDDCzX0bNbnF8jCvDFN5ebzcEe-9nkWyzJ17wVcJTouEfw8A5pBPcx6Gr8Kd8WIrUjuom4xu-4619kMItoV4j62_nq3p0QUGot_6CgUdq63PCp9Fh-sHv8wViy0', + dp: '0OCXwbzfYu_-rCCpGFHYi3Jl-BhS4BJpTc02K3SNw-vM4ttNK6jqptfRObLMNAxPqg_iqxy9YKaVdQdbVqu0yF811rVepVw3sf96YatJ9bhKqJ566EaC91ONV1dd16TVfHPq5xeYEGKF-gXvlfgn6J-dqYeAzovIUVt7E_ydrJc', + dq: 'sYDOnqe0dhyDkNp77ugoGIZujtMVcw9o2SaPujmSwUjfprANV1tozgQiNf0RVk-sLTD5u6r2ka2WTmY5Q8uaDy5Zi0ZTsoGv4pg2HN6wzcsnF_EmpRnvDcuk97eEoOD0iKf9Zz6h88vRJ0qB13Lf99r_4rtMQ0qgIKxscHKcy7k', + qi: '7uvpgL15VFjd_zjhU0fPVeTzAa6Vg3P3Q2v5DLwLkAIlQDqF50maTYztxtxssVNJtEMIxKefwrmGkyVCXNhrGHZDoj7wj-2o0k878bQqtltCO2TPm9TSYZgW7dR3ji0t4Msc5DcrQL002M_Vxqr9MAunQcAsnulRTepQM2n-aOc', + kty: 'RSA', + kid: 'ZuLUAgyr6RQV3ERjDukHzOO_90rVbrPiE1vD_HtPFuM', + }, + { + e: 'AQAB', + n: '0PjQVV2ZAT27Y0h7hfAWWcnPetORCvR1_gHvEUxtlrlnhZia7utHl7BCJH9HP17YHMMBeeEkmUDflYoUL6MDl4DYHgVDq8jZfu1pxH1XqrpeswqOVoReknEe0F5kRt_mPtIoShI2Qv-pxGAw392akAXTirVRLL4Fn_0Oiifxp182P7eTPy41rlKDevLHuKHBZzzaes_33YE2epY2YCLp9k3mZ-tJEei2qiq0T1fERQicGUL8kppOnz0cDNuKRBHyYtXWhjhuDQ8OZQHNLfte9cqzTJMJ4Leu4MGjikSZMsk-_aRFnXtYHH0orwY-giSenRnwNaReAXaR1Px9ReljAQ', + d: 'LXGufKH6IBb4pUKh-iKX-ba1dBSGOkenUTHCd5STUG_JX3gsWUC5NPeTqrQzHkjV3otZytN3TgyZkr-QXDurEEtotD6Y1Ma85aljkuNfKTWWWoE1KwNmPZp0BQRB8lfGjmrNcC49tpw6owX4GvbqId_ifQupN32rY3t4qfq9xpO9SAqZF0oUMoS7xE0zChsCJmNYpD9jx87p5Vud1naeaZPlvwWW0ITV4kp2zjYSbBh5DkI52rSrGjkuzlsJ_lKJk5YB557OHhN9XTRBnjqlwwWevh6QAoUivqpcelcplgmfxTHoII1opovYXn8AVt-DbGSO_7LLJ0Sw9sJR5GAqcQ', + p: '9RdDqZ3O73lH6nWUGi0abQRRfgvj-HM0zP7GSDQ185l-ZByletl1VuJ86qYJTUY8Q3Gagv6_eXmQMo_14-0wT_FPUMiTMYsjw5QNgFgjlJTM1AayS_U5ddix_Ut7Kti7EXgM0gsavsIazv2-xwCrFzD4sa-t2FWELzzWxgt8wbs', + q: '2kX8MN8ItGnn7NnPx-0iqe8kkhy5s9gJRiD3mxN9E6xzRCnf488yhc3aBwI9kZzQtV0XVjX5VhCws5vnJv9b7KA8NATDhpGNrqy2h9ncmsjTTjafUg3jb6QG08kIKDR-A97Mc-MJbIUNzYs10BAG4z9wk7t1bdo4gZJEvjiXVHM', + dp: 'Ahggy-I9Um6G3soCafbYsvXGfH09hXH2kYnbx-IqU9qL6e8UuugAyK1Gw_qHOdHP0gO2fkgO-sq_IK96OmhccVJuixIrr9CwjYtGUkJui2Z6GZW1EFEYHJmta6ypcMRJVOzhrynJILgn4nzolGq9C4WvmlUV9zND3eN3MloGxuE', + dq: 'uXKWlusX2TjVvM0-FO2r8tdkqeNP_7XAA15FIPOI5Cszb6loOIQ0t6wy3puPteSXClBCYJPQ-MeLab4-wUpaTovBOq0FdpK53ruNBZUbMkMIDL6p1CxKnPKufkeh747RtfYYnSk7O4E8PfNV0CWdxHuE6W9ukNvEAIpGb5tjL3M', + qi: '3BLQ03cHEmO8nUT7U8M_H_JciEWAH8XWh_9nihIhXzLKYbNmWM16Ah0F9DUg0GPeiG7e_08ZJ4X3oK1bHnnXdns6NSOEoULWfHl5LUY5PoFPYaBDy3f6td2SCTE83p1YzegXKysWEk1snA2ROq4UEfz1vL8v64RtwR3SvNrAyOI', + kty: 'RSA', + alg: 'RS256', + kid: 'hJU8GvYjtifxLVuBDSNmhLBF19wQHaZvQhfpT3wKzpE', + }, + { + crv: 'P-256', + x: 'fqCXPnWs3sSfwztvwYU9SthmRdoT4WCXxS8eD8icF6U', + y: 'nP6GIc42c61hoKqPcZqkvzhzIJkBV3Jw3g8sGG7UeP8', + d: 'XikZvoy8ayRpOnuz7ont2DkgMxp_kmmg1EKcuIJWX_E', + kty: 'EC', + kid: 'a-5xuiQoRqlLBtec9jRpXoGTVOP10SGnj2Und0CHHxw', + }, + ] + const jwks = { + keys: [ + { + e: 'AQAB', + n: 'wAR7gpvDJx2cUR15R1gyBYxEXanhOIDzk7evzadBpNCEpf6HA6utqMrf8dZ3EXSslKPSPBD5Qrz63kc2u8y7NqzwJQIi_i5xR6AxAyWLG3_kOHBwxnct6talLCZqgr8pDwnyP1BPnIaNf2hZxgS-UZbHCAVycd1n2qCdyb4FzFhcaNtiOLg5VSfgvtOdhHQlDXW-DBvwatpd9HzzTP6l5MZRyQ-N_AoGbIfhNCZRUfnb-A8IBPSqXBWN4TEpt-0yHAOIhWnSpu66AYE4f1efZdHVFCTQZ13e5bS-5RQra4pfmGqU9hog1j1SpHnDTia-s__qGi43rev2MqzY-qeUlw', + kty: 'RSA', + kid: 'ZuLUAgyr6RQV3ERjDukHzOO_90rVbrPiE1vD_HtPFuM', + }, + { + e: 'AQAB', + n: '0PjQVV2ZAT27Y0h7hfAWWcnPetORCvR1_gHvEUxtlrlnhZia7utHl7BCJH9HP17YHMMBeeEkmUDflYoUL6MDl4DYHgVDq8jZfu1pxH1XqrpeswqOVoReknEe0F5kRt_mPtIoShI2Qv-pxGAw392akAXTirVRLL4Fn_0Oiifxp182P7eTPy41rlKDevLHuKHBZzzaes_33YE2epY2YCLp9k3mZ-tJEei2qiq0T1fERQicGUL8kppOnz0cDNuKRBHyYtXWhjhuDQ8OZQHNLfte9cqzTJMJ4Leu4MGjikSZMsk-_aRFnXtYHH0orwY-giSenRnwNaReAXaR1Px9ReljAQ', + alg: 'RS256', + kty: 'RSA', + kid: 'hJU8GvYjtifxLVuBDSNmhLBF19wQHaZvQhfpT3wKzpE', + }, + { + crv: 'P-256', + x: 'fqCXPnWs3sSfwztvwYU9SthmRdoT4WCXxS8eD8icF6U', + y: 'nP6GIc42c61hoKqPcZqkvzhzIJkBV3Jw3g8sGG7UeP8', + kty: 'EC', + kid: 'a-5xuiQoRqlLBtec9jRpXoGTVOP10SGnj2Und0CHHxw', + }, + { + e: 'AQAB', + n: '0PjQVV2ZAT27Y0h7hfAWWcnPetORCvR1_gHvEUxtlrlnhZia7utHl7BCJH9HP17YHMMBeeEkmUDflYoUL6MDl4DYHgVDq8jZfu1pxH1XqrpeswqOVoReknEe0F5kRt_mPtIoShI2Qv-pxGAw392akAXTirVRLL4Fn_0Oiifxp182P7eTPy41rlKDevLHuKHBZzzaes_33YE2epY2YCLp9k3mZ-tJEei2qiq0T1fERQicGUL8kppOnz0cDNuKRBHyYtXWhjhuDQ8OZQHNLfte9cqzTJMJ4Leu4MGjikSZMsk-_aRFnXtYHH0orwY-giSenRnwNaReAXaR1Px9ReljAQ', + alg: 'RS256', + kty: 'RSA', + use: 'enc', + }, + { + e: 'AQAB', + n: '0PjQVV2ZAT27Y0h7hfAWWcnPetORCvR1_gHvEUxtlrlnhZia7utHl7BCJH9HP17YHMMBeeEkmUDflYoUL6MDl4DYHgVDq8jZfu1pxH1XqrpeswqOVoReknEe0F5kRt_mPtIoShI2Qv-pxGAw392akAXTirVRLL4Fn_0Oiifxp182P7eTPy41rlKDevLHuKHBZzzaes_33YE2epY2YCLp9k3mZ-tJEei2qiq0T1fERQicGUL8kppOnz0cDNuKRBHyYtXWhjhuDQ8OZQHNLfte9cqzTJMJ4Leu4MGjikSZMsk-_aRFnXtYHH0orwY-giSenRnwNaReAXaR1Px9ReljAQ', + alg: 'RS256', + kty: 'RSA', + use: 'sig', + key_ops: [], + }, + { + // this is not valid + e: 'AQAB', + kty: 'RSA', + }, + ], + } + + const JWKS = lib.createLocalJWKSet(jwks) + // Signed JWT + { + const [jwk] = keys + const key = await lib.importJWK({ ...jwk, alg: 'PS256' }) + const jwt = await new lib.SignJWT({}) + .setProtectedHeader({ alg: 'PS256', kid: jwk.kid }) + .sign(key) + const { key: resolvedKey } = await lib.jwtVerify(jwt, JWKS) + t.ok(resolvedKey) + t.equal((resolvedKey).type, 'public') + } + // Compact JWS + { + const [jwk] = keys + const key = await lib.importJWK({ ...jwk, alg: 'PS256' }) + const jws = await new lib.CompactSign(new Uint8Array(1)) + .setProtectedHeader({ alg: 'PS256', kid: jwk.kid }) + .sign(key) + const { key: resolvedKey } = await lib.compactVerify(jws, JWKS) + t.ok(resolvedKey) + t.equal((resolvedKey).type, 'public') + } + // Flattened JWS + { + const [jwk] = keys + const key = await lib.importJWK({ ...jwk, alg: 'PS256' }) + const jws = await new lib.FlattenedSign(new Uint8Array(1)) + .setProtectedHeader({ alg: 'PS256' }) + .setUnprotectedHeader({ kid: jwk.kid }) + .sign(key) + const { key: resolvedKey } = await lib.flattenedVerify(jws, JWKS) + t.ok(resolvedKey) + t.equal((resolvedKey).type, 'public') + } + // General JWS + { + const [jwk] = keys + const key = await lib.importJWK({ ...jwk, alg: 'PS256' }) + const jws = await new lib.GeneralSign(new Uint8Array(1)) + .addSignature(key) + .setProtectedHeader({ alg: 'PS256' }) + .setUnprotectedHeader({ kid: jwk.kid }) + .sign() + const { key: resolvedKey } = await lib.generalVerify(jws, JWKS) + t.ok(resolvedKey) + t.equal((resolvedKey).type, 'public') + } + { + await t.rejects( + JWKS({ alg: 'RS256' }), + 'multiple matching keys found in the JSON Web Key Set', + ) + + // async iterator (KeyLike) + let error = ( + await JWKS({ alg: 'RS256' }).catch((err) => err) + ) + { + const cache = new WeakSet() + for await (const ko of error) { + t.equal(ko.type, 'public') + cache.add(ko) + } + error = ( + await JWKS({ alg: 'RS256' }).catch((err) => err) + ) + let i = 0 + for await (const ko of error) { + i++ + t.true(cache.has(ko)) + } + t.equal(i, 2) + } + } + { + const [, { kid }] = keys + await t.rejects( + JWKS({ alg: 'PS256', kid }), + 'no applicable key found in the JSON Web Key Set', + ) + } + { + await JWKS({ alg: 'ES256' }) + } }) } diff --git a/test/jwks/local.test.mjs b/test/jwks/local.test.mjs index cf0897d040..bca6b3dda7 100644 --- a/test/jwks/local.test.mjs +++ b/test/jwks/local.test.mjs @@ -1,17 +1,7 @@ import test from 'ava' -import { root, keyRoot } from '../dist.mjs' +import { keyRoot } from '../dist.mjs' -const { - jwtVerify, - SignJWT, - FlattenedSign, - flattenedVerify, - GeneralSign, - generalVerify, - CompactSign, - compactVerify, -} = await import(root) -const { importJWK, createLocalJWKSet } = await import(keyRoot) +const { createLocalJWKSet } = await import(keyRoot) test('LocalJWKSet', async (t) => { for (const f of [ @@ -28,158 +18,4 @@ test('LocalJWKSet', async (t) => { ]) { t.throws(() => createLocalJWKSet(f), { code: 'ERR_JWKS_INVALID' }) } - - const keys = [ - { - e: 'AQAB', - n: 'wAR7gpvDJx2cUR15R1gyBYxEXanhOIDzk7evzadBpNCEpf6HA6utqMrf8dZ3EXSslKPSPBD5Qrz63kc2u8y7NqzwJQIi_i5xR6AxAyWLG3_kOHBwxnct6talLCZqgr8pDwnyP1BPnIaNf2hZxgS-UZbHCAVycd1n2qCdyb4FzFhcaNtiOLg5VSfgvtOdhHQlDXW-DBvwatpd9HzzTP6l5MZRyQ-N_AoGbIfhNCZRUfnb-A8IBPSqXBWN4TEpt-0yHAOIhWnSpu66AYE4f1efZdHVFCTQZ13e5bS-5RQra4pfmGqU9hog1j1SpHnDTia-s__qGi43rev2MqzY-qeUlw', - d: 'buWn14TSLtMhJo_ZLWU4bo_WJCoq0xFWm-eodyOz-9YZ5iycGXibcTLKJ8fvOHuj-KysjNhYvTybvqhuagQR08AJabZUM2zrK6zO4bxbHOS-EAKQf27xbAHPnzIIrb5tnivmZr6hXAsxyXWg84ZlzIVCKdXLhQuUIWZF-u_uNVeJSUTDMRVTL2J0mzAGTXqi-yHejapEeLS7lFXDe6cpDnBVXauJfB4GmSUOjxtdAEVW7uGNQJGarGwRz6l3Tpy_xQiYl8e_IrU1N6qAN_HJEBrdgfK7js93RcsxHGbtdnj1ylevZqGFpB1UXrWE4JSz3sJgyXrmKNFFWOCjalMccQ', - p: '98OCXxur1omXdjfWkDubqkI3xRehVQryIhqt0go-1yLS4Nwa7KyrdAbzTo81bCHN0A-NlmIvHA4YZc8QUHftq65s4nCbb3g_CwTfGCJEVCvoaTO2EE6Pd8VrGu2PVsN4SM52Gc0TNJGS54yUhyCWDTi1onUBEg8gnqpMSoWuaVM', - q: 'xmaRdSJf4wN5Vse5jiIjZy5jy9veHHhzXxTmW25epr9KTMERUDDCzX0bNbnF8jCvDFN5ebzcEe-9nkWyzJ17wVcJTouEfw8A5pBPcx6Gr8Kd8WIrUjuom4xu-4619kMItoV4j62_nq3p0QUGot_6CgUdq63PCp9Fh-sHv8wViy0', - dp: '0OCXwbzfYu_-rCCpGFHYi3Jl-BhS4BJpTc02K3SNw-vM4ttNK6jqptfRObLMNAxPqg_iqxy9YKaVdQdbVqu0yF811rVepVw3sf96YatJ9bhKqJ566EaC91ONV1dd16TVfHPq5xeYEGKF-gXvlfgn6J-dqYeAzovIUVt7E_ydrJc', - dq: 'sYDOnqe0dhyDkNp77ugoGIZujtMVcw9o2SaPujmSwUjfprANV1tozgQiNf0RVk-sLTD5u6r2ka2WTmY5Q8uaDy5Zi0ZTsoGv4pg2HN6wzcsnF_EmpRnvDcuk97eEoOD0iKf9Zz6h88vRJ0qB13Lf99r_4rtMQ0qgIKxscHKcy7k', - qi: '7uvpgL15VFjd_zjhU0fPVeTzAa6Vg3P3Q2v5DLwLkAIlQDqF50maTYztxtxssVNJtEMIxKefwrmGkyVCXNhrGHZDoj7wj-2o0k878bQqtltCO2TPm9TSYZgW7dR3ji0t4Msc5DcrQL002M_Vxqr9MAunQcAsnulRTepQM2n-aOc', - kty: 'RSA', - kid: 'ZuLUAgyr6RQV3ERjDukHzOO_90rVbrPiE1vD_HtPFuM', - }, - { - e: 'AQAB', - n: '0PjQVV2ZAT27Y0h7hfAWWcnPetORCvR1_gHvEUxtlrlnhZia7utHl7BCJH9HP17YHMMBeeEkmUDflYoUL6MDl4DYHgVDq8jZfu1pxH1XqrpeswqOVoReknEe0F5kRt_mPtIoShI2Qv-pxGAw392akAXTirVRLL4Fn_0Oiifxp182P7eTPy41rlKDevLHuKHBZzzaes_33YE2epY2YCLp9k3mZ-tJEei2qiq0T1fERQicGUL8kppOnz0cDNuKRBHyYtXWhjhuDQ8OZQHNLfte9cqzTJMJ4Leu4MGjikSZMsk-_aRFnXtYHH0orwY-giSenRnwNaReAXaR1Px9ReljAQ', - d: 'LXGufKH6IBb4pUKh-iKX-ba1dBSGOkenUTHCd5STUG_JX3gsWUC5NPeTqrQzHkjV3otZytN3TgyZkr-QXDurEEtotD6Y1Ma85aljkuNfKTWWWoE1KwNmPZp0BQRB8lfGjmrNcC49tpw6owX4GvbqId_ifQupN32rY3t4qfq9xpO9SAqZF0oUMoS7xE0zChsCJmNYpD9jx87p5Vud1naeaZPlvwWW0ITV4kp2zjYSbBh5DkI52rSrGjkuzlsJ_lKJk5YB557OHhN9XTRBnjqlwwWevh6QAoUivqpcelcplgmfxTHoII1opovYXn8AVt-DbGSO_7LLJ0Sw9sJR5GAqcQ', - p: '9RdDqZ3O73lH6nWUGi0abQRRfgvj-HM0zP7GSDQ185l-ZByletl1VuJ86qYJTUY8Q3Gagv6_eXmQMo_14-0wT_FPUMiTMYsjw5QNgFgjlJTM1AayS_U5ddix_Ut7Kti7EXgM0gsavsIazv2-xwCrFzD4sa-t2FWELzzWxgt8wbs', - q: '2kX8MN8ItGnn7NnPx-0iqe8kkhy5s9gJRiD3mxN9E6xzRCnf488yhc3aBwI9kZzQtV0XVjX5VhCws5vnJv9b7KA8NATDhpGNrqy2h9ncmsjTTjafUg3jb6QG08kIKDR-A97Mc-MJbIUNzYs10BAG4z9wk7t1bdo4gZJEvjiXVHM', - dp: 'Ahggy-I9Um6G3soCafbYsvXGfH09hXH2kYnbx-IqU9qL6e8UuugAyK1Gw_qHOdHP0gO2fkgO-sq_IK96OmhccVJuixIrr9CwjYtGUkJui2Z6GZW1EFEYHJmta6ypcMRJVOzhrynJILgn4nzolGq9C4WvmlUV9zND3eN3MloGxuE', - dq: 'uXKWlusX2TjVvM0-FO2r8tdkqeNP_7XAA15FIPOI5Cszb6loOIQ0t6wy3puPteSXClBCYJPQ-MeLab4-wUpaTovBOq0FdpK53ruNBZUbMkMIDL6p1CxKnPKufkeh747RtfYYnSk7O4E8PfNV0CWdxHuE6W9ukNvEAIpGb5tjL3M', - qi: '3BLQ03cHEmO8nUT7U8M_H_JciEWAH8XWh_9nihIhXzLKYbNmWM16Ah0F9DUg0GPeiG7e_08ZJ4X3oK1bHnnXdns6NSOEoULWfHl5LUY5PoFPYaBDy3f6td2SCTE83p1YzegXKysWEk1snA2ROq4UEfz1vL8v64RtwR3SvNrAyOI', - kty: 'RSA', - alg: 'RS256', - kid: 'hJU8GvYjtifxLVuBDSNmhLBF19wQHaZvQhfpT3wKzpE', - }, - { - crv: 'P-256', - x: 'fqCXPnWs3sSfwztvwYU9SthmRdoT4WCXxS8eD8icF6U', - y: 'nP6GIc42c61hoKqPcZqkvzhzIJkBV3Jw3g8sGG7UeP8', - d: 'XikZvoy8ayRpOnuz7ont2DkgMxp_kmmg1EKcuIJWX_E', - kty: 'EC', - kid: 'a-5xuiQoRqlLBtec9jRpXoGTVOP10SGnj2Und0CHHxw', - }, - ] - const jwks = { - keys: [ - { - e: 'AQAB', - n: 'wAR7gpvDJx2cUR15R1gyBYxEXanhOIDzk7evzadBpNCEpf6HA6utqMrf8dZ3EXSslKPSPBD5Qrz63kc2u8y7NqzwJQIi_i5xR6AxAyWLG3_kOHBwxnct6talLCZqgr8pDwnyP1BPnIaNf2hZxgS-UZbHCAVycd1n2qCdyb4FzFhcaNtiOLg5VSfgvtOdhHQlDXW-DBvwatpd9HzzTP6l5MZRyQ-N_AoGbIfhNCZRUfnb-A8IBPSqXBWN4TEpt-0yHAOIhWnSpu66AYE4f1efZdHVFCTQZ13e5bS-5RQra4pfmGqU9hog1j1SpHnDTia-s__qGi43rev2MqzY-qeUlw', - kty: 'RSA', - kid: 'ZuLUAgyr6RQV3ERjDukHzOO_90rVbrPiE1vD_HtPFuM', - }, - { - e: 'AQAB', - n: '0PjQVV2ZAT27Y0h7hfAWWcnPetORCvR1_gHvEUxtlrlnhZia7utHl7BCJH9HP17YHMMBeeEkmUDflYoUL6MDl4DYHgVDq8jZfu1pxH1XqrpeswqOVoReknEe0F5kRt_mPtIoShI2Qv-pxGAw392akAXTirVRLL4Fn_0Oiifxp182P7eTPy41rlKDevLHuKHBZzzaes_33YE2epY2YCLp9k3mZ-tJEei2qiq0T1fERQicGUL8kppOnz0cDNuKRBHyYtXWhjhuDQ8OZQHNLfte9cqzTJMJ4Leu4MGjikSZMsk-_aRFnXtYHH0orwY-giSenRnwNaReAXaR1Px9ReljAQ', - alg: 'RS256', - kty: 'RSA', - kid: 'hJU8GvYjtifxLVuBDSNmhLBF19wQHaZvQhfpT3wKzpE', - }, - { - crv: 'P-256', - x: 'fqCXPnWs3sSfwztvwYU9SthmRdoT4WCXxS8eD8icF6U', - y: 'nP6GIc42c61hoKqPcZqkvzhzIJkBV3Jw3g8sGG7UeP8', - kty: 'EC', - kid: 'a-5xuiQoRqlLBtec9jRpXoGTVOP10SGnj2Und0CHHxw', - }, - { - e: 'AQAB', - n: '0PjQVV2ZAT27Y0h7hfAWWcnPetORCvR1_gHvEUxtlrlnhZia7utHl7BCJH9HP17YHMMBeeEkmUDflYoUL6MDl4DYHgVDq8jZfu1pxH1XqrpeswqOVoReknEe0F5kRt_mPtIoShI2Qv-pxGAw392akAXTirVRLL4Fn_0Oiifxp182P7eTPy41rlKDevLHuKHBZzzaes_33YE2epY2YCLp9k3mZ-tJEei2qiq0T1fERQicGUL8kppOnz0cDNuKRBHyYtXWhjhuDQ8OZQHNLfte9cqzTJMJ4Leu4MGjikSZMsk-_aRFnXtYHH0orwY-giSenRnwNaReAXaR1Px9ReljAQ', - alg: 'RS256', - kty: 'RSA', - use: 'enc', - }, - { - e: 'AQAB', - n: '0PjQVV2ZAT27Y0h7hfAWWcnPetORCvR1_gHvEUxtlrlnhZia7utHl7BCJH9HP17YHMMBeeEkmUDflYoUL6MDl4DYHgVDq8jZfu1pxH1XqrpeswqOVoReknEe0F5kRt_mPtIoShI2Qv-pxGAw392akAXTirVRLL4Fn_0Oiifxp182P7eTPy41rlKDevLHuKHBZzzaes_33YE2epY2YCLp9k3mZ-tJEei2qiq0T1fERQicGUL8kppOnz0cDNuKRBHyYtXWhjhuDQ8OZQHNLfte9cqzTJMJ4Leu4MGjikSZMsk-_aRFnXtYHH0orwY-giSenRnwNaReAXaR1Px9ReljAQ', - alg: 'RS256', - kty: 'RSA', - use: 'sig', - key_ops: [], - }, - ], - } - - const JWKS = createLocalJWKSet(jwks) - // Signed JWT - { - const [jwk] = keys - const key = await importJWK({ ...jwk, alg: 'PS256' }) - const jwt = await new SignJWT({}).setProtectedHeader({ alg: 'PS256', kid: jwk.kid }).sign(key) - await t.notThrowsAsync(async () => { - const { key: resolvedKey } = await jwtVerify(jwt, JWKS) - t.truthy(resolvedKey) - t.is(resolvedKey.type, 'public') - }) - } - // Compact JWS - { - const [jwk] = keys - const key = await importJWK({ ...jwk, alg: 'PS256' }) - const jws = await new CompactSign(new Uint8Array(1)) - .setProtectedHeader({ alg: 'PS256', kid: jwk.kid }) - .sign(key) - await t.notThrowsAsync(async () => { - const { key: resolvedKey } = await compactVerify(jws, JWKS) - t.truthy(resolvedKey) - t.is(resolvedKey.type, 'public') - }) - } - // Flattened JWS - { - const [jwk] = keys - const key = await importJWK({ ...jwk, alg: 'PS256' }) - const jws = await new FlattenedSign(new Uint8Array(1)) - .setProtectedHeader({ alg: 'PS256' }) - .setUnprotectedHeader({ kid: jwk.kid }) - .sign(key) - await t.notThrowsAsync(async () => { - const { key: resolvedKey } = await flattenedVerify(jws, JWKS) - t.truthy(resolvedKey) - t.is(resolvedKey.type, 'public') - }) - } - // General JWS - { - const [jwk] = keys - const key = await importJWK({ ...jwk, alg: 'PS256' }) - const jws = await new GeneralSign(new Uint8Array(1)) - .addSignature(key) - .setProtectedHeader({ alg: 'PS256' }) - .setUnprotectedHeader({ kid: jwk.kid }) - .sign() - await t.notThrowsAsync(async () => { - const { key: resolvedKey } = await generalVerify(jws, JWKS) - t.truthy(resolvedKey) - t.is(resolvedKey.type, 'public') - }) - } - { - const [jwk] = keys - const key = await importJWK({ ...jwk, alg: 'RS256' }) - const jwt = await new SignJWT({}).setProtectedHeader({ alg: 'RS256' }).sign(key) - await t.throwsAsync(jwtVerify(jwt, JWKS), { - code: 'ERR_JWKS_MULTIPLE_MATCHING_KEYS', - message: 'multiple matching keys found in the JSON Web Key Set', - }) - } - { - const [, jwk] = keys - const key = await importJWK({ ...jwk, alg: 'PS256' }) - const jwt = await new SignJWT({}).setProtectedHeader({ alg: 'PS256', kid: jwk.kid }).sign(key) - await t.throwsAsync(jwtVerify(jwt, JWKS), { - code: 'ERR_JWKS_NO_MATCHING_KEY', - message: 'no applicable key found in the JSON Web Key Set', - }) - } - { - const [, , jwk] = keys - const key = await importJWK({ ...jwk, alg: 'ES256' }) - const jwt = await new SignJWT({}).setProtectedHeader({ alg: 'ES256' }).sign(key) - await t.notThrowsAsync(jwtVerify(jwt, JWKS)) - } }) diff --git a/test/jwks/remote.test.mjs b/test/jwks/remote.test.mjs index 2113920496..e3fdf3a1e9 100644 --- a/test/jwks/remote.test.mjs +++ b/test/jwks/remote.test.mjs @@ -184,10 +184,29 @@ skipOnUndiciTestSerial('RemoteJWKSet', async (t) => { const [jwk] = keys const key = await importJWK({ ...jwk, alg: 'RS256' }) const jwt = await new SignJWT({}).setProtectedHeader({ alg: 'RS256' }).sign(key) - await t.throwsAsync(jwtVerify(jwt, JWKS), { + let error = await t.throwsAsync(jwtVerify(jwt, JWKS), { code: 'ERR_JWKS_MULTIPLE_MATCHING_KEYS', message: 'multiple matching keys found in the JSON Web Key Set', }) + + // async iterator (KeyLike) + { + const cache = new WeakSet() + for await (const ko of error) { + t.like(ko, { type: 'public' }) + cache.add(ko) + } + error = await t.throwsAsync(jwtVerify(jwt, JWKS), { + code: 'ERR_JWKS_MULTIPLE_MATCHING_KEYS', + message: 'multiple matching keys found in the JSON Web Key Set', + }) + let i = 0 + for await (const ko of error) { + i++ + t.true(cache.has(ko)) + } + t.is(i, 2) + } } { const [, jwk] = keys diff --git a/tsconfig/base.json b/tsconfig/base.json index 2550e0f966..44a24d45d8 100644 --- a/tsconfig/base.json +++ b/tsconfig/base.json @@ -3,7 +3,7 @@ "../src/index.ts" ], "compilerOptions": { - "lib": ["ES2017", "DOM"], + "lib": ["ES2017", "DOM", "ES2018.asynciterable"], "types": [], "strict": true, "forceConsistentCasingInFileNames": true,