diff --git a/package-lock.json b/package-lock.json index aaadd2a5..378ceeaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@aws-sdk/client-s3": "^3.456.0", "@aws-sdk/s3-request-presigner": "^3.468.0", "@ssttevee/cfw-formdata-polyfill": "^0.2.1", - "jose": "^5.1.3" + "jose": "^5.9.6" }, "devDependencies": { "@adobe/eslint-config-helix": "2.0.6", @@ -31,6 +31,7 @@ "husky": "^9.1.7", "lint-staged": "^15.4.3", "mocha": "^10.2.0", + "nock": "^14.0.1", "semantic-release": "^24.2.2", "wrangler": "^3.107.3" } @@ -4793,6 +4794,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.37.6", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", + "integrity": "sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4984,6 +5003,31 @@ "@octokit/openapi-types": "^23.0.1" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -11604,6 +11648,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -11854,9 +11905,10 @@ } }, "node_modules/jose": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.1.3.tgz", - "integrity": "sha512-GPExOkcMsCLBTi1YetY2LmkoY559fss0+0KVa6kOfb2YFe84nAM7Nm/XzuZozah4iHgmBGrCOHL5/cy670SBRw==", + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } @@ -11929,6 +11981,13 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, "node_modules/json5": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", @@ -12952,6 +13011,21 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/nock": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.1.tgz", + "integrity": "sha512-IJN4O9pturuRdn60NjQ7YkFt6Rwei7ZKaOwb1tvUIIqTgeD0SDDAX3vrqZD4wcXczeEy/AsUXxpGpP/yHqV7xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mswjs/interceptors": "^0.37.3", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">=18.20.0 <20 || >=20.12.1" + } + }, "node_modules/node-emoji": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", @@ -16091,6 +16165,13 @@ "node": ">= 0.8.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/p-each-series": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz", @@ -16584,6 +16665,16 @@ "react-is": "^16.13.1" } }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -18083,6 +18174,13 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", diff --git a/package.json b/package.json index 4a624158..a65047cc 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,16 @@ "url": "https://github.com/adobe/da-admin" }, "license": "Apache-2.0", + "mocha": { + "require": [ + "nock" + ] + }, "dependencies": { "@aws-sdk/client-s3": "^3.456.0", "@aws-sdk/s3-request-presigner": "^3.468.0", "@ssttevee/cfw-formdata-polyfill": "^0.2.1", - "jose": "^5.1.3" + "jose": "^5.9.6" }, "devDependencies": { "@adobe/eslint-config-helix": "2.0.6", @@ -52,6 +57,7 @@ "husky": "^9.1.7", "lint-staged": "^15.4.3", "mocha": "^10.2.0", + "nock": "^14.0.1", "semantic-release": "^24.2.2", "wrangler": "^3.107.3" }, diff --git a/src/utils/auth.js b/src/utils/auth.js index 8121528b..1699a3cc 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -9,7 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { decodeJwt } from 'jose'; +import { createRemoteJWKSet, jwtVerify, jwksCache } from 'jose'; export async function logout({ daCtx, env }) { await Promise.all(daCtx.users.map((u) => env.DA_AUTH.delete(u.ident))); @@ -53,6 +53,41 @@ export async function setUser(userId, expiration, headers, env) { return value; } +/** + * Retrieve cached IMS keys from KV Store + * @param {*} env + * @param {string} keysUrl + * @returns {Promise} + */ +async function getPreviouslyCachedJWKS(env, keysUrl) { + const cachedJwks = await env.DA_AUTH.get(keysUrl); + if (!cachedJwks) return {}; + return JSON.parse(cachedJwks); +} + +/** + * Store new set of IMS keys in the KV Store + * @param {*} env + * @param {string} keysUrl + * @param {import('jose').ExportedJWKSCache} keysCache + * @returns {Promise} + */ +async function storeJWSInCache(env, keysUrl, keysCache) { + try { + await env.DA_AUTH.put( + keysUrl, + JSON.stringify(keysCache), + { + expirationTtl: 24 * 60 * 60, // 24 hours in seconds + }, + ); + } catch (err) { + // An error may be thrown if a write to the same key is made within 1 second + // eslint-disable-next-line no-console + console.error('Failed to store keys in cache', err); + } +} + export async function getUsers(req, env) { const authHeader = req.headers?.get('authorization'); if (!authHeader) return [{ email: 'anonymous' }]; @@ -60,7 +95,35 @@ export async function getUsers(req, env) { async function parseUser(token) { if (!token || token.trim().length === 0) return { email: 'anonymous' }; - const { user_id: userId, created_at: createdAt, expires_in: expiresIn } = decodeJwt(token); + let payload; + try { + const keysURL = `${env.IMS_ORIGIN}/ims/keys`; + + const keysCache = await getPreviouslyCachedJWKS(env, keysURL); + const { uat } = keysCache; + + const jwks = createRemoteJWKSet( + new URL(keysURL), + { + [jwksCache]: keysCache, + cacheMaxAge: 24 * 60 * 60 * 1000, // 24 hours in milliseconds + }, + ); + + ({ payload } = await jwtVerify(token, jwks)); + + if (uat !== keysCache.uat) { + await storeJWSInCache(env, keysURL, keysCache); + } + } catch (e) { + // eslint-disable-next-line no-console + console.log('IMS token offline verification failed', e); + return { email: 'anonymous' }; + } + + if (!payload) return { email: 'anonymous' }; + + const { user_id: userId, created_at: createdAt, expires_in: expiresIn } = payload; const expires = Number(createdAt) + Number(expiresIn); const now = Math.floor(new Date().getTime() / 1000); diff --git a/test/utils/mocks/env.js b/test/utils/mocks/env.js index ad026e2d..eff14ddc 100644 --- a/test/utils/mocks/env.js +++ b/test/utils/mocks/env.js @@ -25,6 +25,7 @@ const env = { S3_DEF_URL: 'https://s3.com', S3_ACCESS_KEY_ID: 'an-id', S3_SECRET_ACCESS_KEY: 'too-many-secrets', + IMS_ORIGIN: 'https://ims-na1.adobelogin.com', DA_AUTH: { get: (kvNamespace) => { return NAMESPACES[kvNamespace]; diff --git a/test/utils/mocks/jose.js b/test/utils/mocks/jose.js index 08ad60a7..f06e85c1 100644 --- a/test/utils/mocks/jose.js +++ b/test/utils/mocks/jose.js @@ -9,15 +9,17 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -const decodeJwt = (token) => { +const jwtVerify = (token) => { let [email, created_at = 0, expires_in = 0] = token.split(':'); created_at += Math.floor(new Date().getTime() / 1000); expires_in += created_at; return { - user_id: email, - created_at, - expires_in: expires_in || created_at + 1000, + payload: { + user_id: email, + created_at, + expires_in: expires_in || created_at + 1000, + }, }; }; -export default { decodeJwt }; +export default { jwtVerify }; diff --git a/test/utils/offlineValidation.test.js b/test/utils/offlineValidation.test.js new file mode 100644 index 00000000..1d2837bb --- /dev/null +++ b/test/utils/offlineValidation.test.js @@ -0,0 +1,362 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import nock from 'nock'; +import assert from 'assert'; +import esmock from 'esmock'; +import { generateKeyPair, exportJWK, SignJWT } from 'jose'; +import env from './mocks/env.js'; + +const fetch = async (url) => { + if (url === `${env.IMS_ORIGIN}/ims/profile/v1`) { + return { + ok: true, + status: 200, + json: async () => { + return { + email: 'mocked@example.com', + }; + }, + }; + } else if (url === `${env.IMS_ORIGIN}/ims/organizations/v5`) { + return { + ok: true, + status: 200, + json: async () => { + return []; + }, + }; + } + return { + ok: false, + status: 404, + }; +}; + +const { getUsers } = await esmock('../../src/utils/auth.js', { import: { fetch } }); + +async function generateMockKeyPair(kid) { + const { publicKey, privateKey } = await generateKeyPair('RS256'); + const publicKeyJwk = await exportJWK(publicKey); + + publicKeyJwk.use = 'sig'; + publicKeyJwk.kid = kid; + publicKeyJwk.alg = 'RS256'; + return { + privateKey, + publicKeyJwk, + } +} + +async function generateToken(kid, privateKey) { + return new SignJWT({ + user_id: 'mocked_example_com', + created_at: Date.now() / 1000, + expires_in: 60, + }) + .setProtectedHeader({ + alg: 'RS256', + kid, + }) + .sign(privateKey); +} + +function mockRequest(accessToken) { + return new Request( + 'https://da.live/api/source/cq/', + { + headers: new Headers({ + Authorization: `Bearer ${accessToken}`, + }), + }, + ) +} + +describe('Offline Token Validation', async () => { + afterEach(() => { + nock.cleanAll(); + }); + + it('should fetch keys from upstream if not in cache and store them', async () => { + const kid = 'id1'; + const { privateKey, publicKeyJwk } = await generateMockKeyPair(kid); + + const scope = nock('https://ims-na1.adobelogin.com') + .get('/ims/keys') + .reply(200, { keys: [publicKeyJwk] }); + + const accessToken = await generateToken(kid, privateKey); + + const before = Date.now(); + + let cacheLookup = false; + let cached; + const localEnv = { + ...env, + DA_AUTH: { + get: async (key) => { + if (key === 'https://ims-na1.adobelogin.com/ims/keys') { + cacheLookup = true; + } + }, + put: async (key, value) => { + if (key === 'https://ims-na1.adobelogin.com/ims/keys') { + cached = JSON.parse(value); + } + }, + } + } + + const users = await getUsers(mockRequest(accessToken), localEnv); + assert.deepStrictEqual(users, [{ email: 'mocked@example.com', groups: [], orgs: [] } ]); + assert.ok(cacheLookup, 'Should have looked up the keys in the cache'); + assert.ok(cached, 'Should have cached the keys'); + assert.ok(cached.uat >= before, 'Timestamp of keys in cache should be after the test started'); + assert.ok(cached.uat <= Date.now(), 'Timestamp of keys in cache should be before the test ended'); + assert.deepStrictEqual(cached.jwks, { keys: [publicKeyJwk]}, 'Cached keys should match the fetched keys'); + scope.done(); + }); + + it('should only use keys from cache if present and not stale', async () => { + const kid = 'id1'; + const { privateKey, publicKeyJwk } = await generateMockKeyPair(kid); + + const scope = nock('https://ims-na1.adobelogin.com') + .get('/ims/keys') + .reply(200, { keys: [publicKeyJwk] }); + + const accessToken = await generateToken(kid, privateKey); + + let cacheLookup = false; + let cached = false; + const localEnv = { + ...env, + DA_AUTH: { + get: async (key) => { + if (key === 'https://ims-na1.adobelogin.com/ims/keys') { + cacheLookup = true; + return JSON.stringify({ + uat: Date.now(), + jwks: { + keys: [publicKeyJwk] + } + }); + } + }, + put: async (key) => { + if (key === 'https://ims-na1.adobelogin.com/ims/keys') { + cached = true; + } + }, + } + } + + const users = await getUsers(mockRequest(accessToken), localEnv); + assert.deepStrictEqual(users, [{ email: 'mocked@example.com', groups: [], orgs: [] } ]); + assert.ok(cacheLookup, 'Should have looked up the keys in the cache'); + assert.ok(!cached, 'There should be no cache update'); + assert.ok(!scope.isDone()); + }); + + it('should fetch key missing from cache if not in cooldown period', async () => { + const kid1 = 'id1'; + const { + publicKeyJwk: publicKeyJwk1, + } = await generateMockKeyPair(kid1); + + const kid2 = 'id2'; + const { + privateKey: privateKey2, + publicKeyJwk: publicKeyJwk2, + } = await generateMockKeyPair(kid2); + + const scope = nock('https://ims-na1.adobelogin.com') + .get('/ims/keys') + .reply(200, { keys: [publicKeyJwk1, publicKeyJwk2] }); + + const accessToken = await generateToken(kid2, privateKey2); + + let cacheLookup = false; + let cached; + const localEnv = { + ...env, + DA_AUTH: { + get: async (key) => { + if (key === 'https://ims-na1.adobelogin.com/ims/keys') { + cacheLookup = true; + return JSON.stringify({ + uat: Date.now() - 60 * 1000, + jwks: { + keys: [publicKeyJwk1], + } + }); + } + }, + put: async (key, value) => { + if (key === 'https://ims-na1.adobelogin.com/ims/keys') { + cached = JSON.parse(value); + } + }, + } + } + + const before = Date.now(); + + const users = await getUsers(mockRequest(accessToken), localEnv); + assert.deepStrictEqual(users, [{ email: 'mocked@example.com', groups: [], orgs: [] } ]); + assert.ok(cacheLookup, 'Should have looked up the keys in the cache'); + assert.ok(cached, 'Should have cached the keys'); + assert.ok(cached.uat >= before, 'Timestamp of keys in cache should be after the test started'); + assert.ok(cached.uat <= Date.now(), 'Timestamp of keys in cache should be before the test ended'); + assert.deepStrictEqual( + cached.jwks, + { keys: [publicKeyJwk1, publicKeyJwk2] }, + 'Cached keys should match the fetched keys', + ); + scope.done(); + }); + + it('should not fetch key missing if in the cooldown period and fail validation', async () => { + const kid1 = 'id1'; + const { + publicKeyJwk: publicKeyJwk1, + } = await generateMockKeyPair(kid1); + + const kid2 = 'id2'; + const { + privateKey: privateKey2, + publicKeyJwk: publicKeyJwk2, + } = await generateMockKeyPair(kid2); + + const scope = nock('https://ims-na1.adobelogin.com') + .get('/ims/keys') + .reply(200, { keys: [publicKeyJwk1, publicKeyJwk2] }); + + const accessToken = await generateToken(kid2, privateKey2); + + let cacheLookup = false; + let cached; + const localEnv = { + ...env, + DA_AUTH: { + get: async (key) => { + if (key === 'https://ims-na1.adobelogin.com/ims/keys') { + cacheLookup = true; + return JSON.stringify({ + uat: Date.now() - 20 * 1000, + jwks: { + keys: [publicKeyJwk1], + } + }); + } + }, + put: async (key) => { + if (key === 'https://ims-na1.adobelogin.com/ims/keys') { + cached = true; + } + }, + } + } + + const users = await getUsers(mockRequest(accessToken), localEnv); + assert.deepStrictEqual(users, [{ email: 'anonymous' } ]); + assert.ok(cacheLookup, 'Should have looked up the keys in the cache'); + assert.ok(!cached, 'Should not try to store keys in cache.'); + assert.ok(!scope.isDone()); + }); + + it('should refresh cache after 24h', async () => { + const kid = 'id1'; + const { privateKey, publicKeyJwk } = await generateMockKeyPair(kid); + + const scope = nock('https://ims-na1.adobelogin.com') + .get('/ims/keys') + .reply(200, { keys: [publicKeyJwk] }); + + const accessToken = await generateToken(kid, privateKey); + + const before = Date.now(); + + let cacheLookup = false; + let cached; + const localEnv = { + ...env, + DA_AUTH: { + get: async (key) => { + if (key === 'https://ims-na1.adobelogin.com/ims/keys') { + cacheLookup = true; + return JSON.stringify({ + uat: Date.now() - 25 * 60 * 60 * 1000, // more than 24h ago + jwks: { + keys: [publicKeyJwk] + } + }); + } + }, + put: async (key, value) => { + if (key === 'https://ims-na1.adobelogin.com/ims/keys') { + cached = JSON.parse(value); + } + }, + } + } + + const users = await getUsers(mockRequest(accessToken), localEnv); + assert.deepStrictEqual(users, [{ email: 'mocked@example.com', groups: [], orgs: [] } ]); + assert.ok(cacheLookup, 'Should have looked up the keys in the cache'); + assert.ok(cached, 'Should have cached the keys'); + assert.ok(cached.uat >= before, 'Timestamp of keys in cache should be after the test started'); + assert.ok(cached.uat <= Date.now(), 'Timestamp of keys in cache should be before the test ended'); + assert.deepStrictEqual(cached.jwks, { keys: [publicKeyJwk]}, 'Cached keys should match the fetched keys'); + scope.done(); + }); + + it('should not fail if storing in cache fails', async () => { + const kid = 'id1'; + const { privateKey, publicKeyJwk } = await generateMockKeyPair(kid); + + const scope = nock('https://ims-na1.adobelogin.com') + .get('/ims/keys') + .reply(200, { keys: [publicKeyJwk] }); + + const accessToken = await generateToken(kid, privateKey); + + const before = Date.now(); + + let cacheLookup = false; + let cacheAttempt; + const localEnv = { + ...env, + DA_AUTH: { + get: async (key) => { + if (key === 'https://ims-na1.adobelogin.com/ims/keys') { + cacheLookup = true; + } + }, + put: async (key, value) => { + if (key === 'https://ims-na1.adobelogin.com/ims/keys') { + cacheAttempt = JSON.parse(value); + throw new Error('429: Too many requests'); + } + }, + } + } + + const users = await getUsers(mockRequest(accessToken), localEnv); + assert.deepStrictEqual(users, [{ email: 'mocked@example.com', groups: [], orgs: [] } ]); + assert.ok(cacheLookup, 'Should have looked up the keys in the cache'); + assert.ok(cacheAttempt, 'Should have try to cache the keys'); + assert.ok(cacheAttempt.uat >= before, 'Timestamp of keys in cache should be after the test started'); + assert.ok(cacheAttempt.uat <= Date.now(), 'Timestamp of keys in cache should be before the test ended'); + assert.deepStrictEqual(cacheAttempt.jwks, { keys: [publicKeyJwk]}, 'Cached attempt keys should match the fetched keys'); + scope.done(); + }); +});