Enhance secret rotation tests and optimize JWT caching
Some checks failed
DB migration compat / Check if migrations changed (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled

- 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.
This commit is contained in:
mantrakp04 2026-05-23 13:19:20 -07:00
parent 87ecaf6370
commit ba00893be3
2 changed files with 19 additions and 2 deletions

View File

@ -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({

View File

@ -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<string, Promise<PrivateJwk[]>>();
/**
@ -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<PrivateJwk[]> => {
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;
}