From ba00893be37d64c78454b74512e1994cd5a93a3d Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Sat, 23 May 2026 13:19:20 -0700 Subject: [PATCH] Enhance secret rotation tests and optimize JWT caching - Updated test expectations in `secret-rotation.test.ts` to use `toContain` for content-type checks, improving clarity. - Introduced a bounded LRU cache mechanism in `getPrivateJwks` to manage cached entries effectively, ensuring optimal performance during secret rotations. These changes aim to improve test reliability and enhance the efficiency of JWT handling. --- .../endpoints/api/v1/secret-rotation.test.ts | 2 +- packages/stack-shared/src/utils/jwt.tsx | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts index 6965f509a..c833f853c 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts @@ -29,7 +29,7 @@ const INTERNAL_JWKS_PATH = "/api/v1/projects/internal/.well-known/jwks.json"; it("JWKS publishes 2 entries in steady state or 4 during rotation, all ES256 P-256, no duplicates, no private scalars", async ({ expect }) => { const response = await niceBackendFetch(INTERNAL_JWKS_PATH); expect(response.status).toBe(200); - expect(response.headers.get("content-type")).includes("application/json"); + expect(response.headers.get("content-type")).toContain("application/json"); expect(response.headers.get("cache-control")).toBe("public, max-age=3600"); for (const key of response.body.keys) { expect(key).toEqual({ diff --git a/packages/stack-shared/src/utils/jwt.tsx b/packages/stack-shared/src/utils/jwt.tsx index f876dbb03..b28abb0a4 100644 --- a/packages/stack-shared/src/utils/jwt.tsx +++ b/packages/stack-shared/src/utils/jwt.tsx @@ -118,6 +118,12 @@ async function getPrivateJwkFromDerivedSecret(derivedSecret: string, kid: string // Derivation is purely a function of those three inputs, so the cache is safe across calls // as long as the env vars are re-read on every call (which they are, below). Cached entries // stay valid even if env vars later change because the key includes the secret values. +// +// Bounded LRU: audience is typically a per-project identifier, so the keyspace grows with +// the number of projects (and again on each secret rotation). Cap the cache and evict the +// oldest entry on insert. Map iteration order is insertion order, so the first key returned +// by keys() is the oldest. On a hit, we re-insert to bump recency. +const PRIVATE_JWKS_CACHE_MAX = 1000; const privateJwksCache = new Map>(); /** @@ -131,7 +137,12 @@ export async function getPrivateJwks(options: { const oldSecret = getOldStackServerSecret(); const cacheKey = JSON.stringify([primarySecret, oldSecret, options.audience]); const cached = privateJwksCache.get(cacheKey); - if (cached) return await cached; + if (cached) { + // Bump recency: re-insert so this key becomes most-recently-used. + privateJwksCache.delete(cacheKey); + privateJwksCache.set(cacheKey, cached); + return await cached; + } const derivePairForSecret = async (secret: string): Promise => { const getHashOfJwkInfo = (type: string) => jose.base64url.encode( @@ -165,6 +176,12 @@ export async function getPrivateJwks(options: { // Evict on rejection so a transient error doesn't poison the cache. computePromise.catch(() => privateJwksCache.delete(cacheKey)); privateJwksCache.set(cacheKey, computePromise); + // Evict oldest entries while over capacity (Map iterates in insertion order). + while (privateJwksCache.size > PRIVATE_JWKS_CACHE_MAX) { + const oldestKey = privateJwksCache.keys().next().value; + if (oldestKey === undefined) break; + privateJwksCache.delete(oldestKey); + } return await computePromise; }