Skip to content

Commit fad3650

Browse files
authored
Cache api key validations per isolate (#1864)
* Cache successful api key validations per isolate * Add cloud e2e coverage for the api key validation cache * Build the MCP auth layer once per isolate * Key the memoized MCP auth runtime on its WorkOS bindings * Encode the auth-runtime binding fingerprint as JSON
1 parent 9a1fbd5 commit fad3650

7 files changed

Lines changed: 892 additions & 13 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@executor-js/cloud": patch
3+
---
4+
5+
**API key validation is cached per isolate**
6+
7+
Every MCP request and every API-key-authenticated `/api/*` request used to pay a live WorkOS round trip (~100-150ms) to validate the presented key, on every single request. The JWT bearer path beside it already verified locally against a JWKS cached for an hour; API keys had no cache at all.
8+
9+
Successful validations are now cached in a bounded per-isolate map for 60 seconds, keyed by the SHA-256 digest of the key value (never the raw credential). The MCP handler used to rebuild its whole auth layer (and with it the cache) on every request; it now builds the layer once per isolate, so the cache holds on both the `/api/*` and `/mcp` planes. Invalid keys and upstream failures are never cached, so probing bad keys cannot pollute the map and a freshly created key works immediately. The tradeoff: a revoked key remains usable for up to 60 seconds within an isolate that validated it before revocation — far tighter than the one-hour rotation window the JWT path already accepts.
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { Effect, Exit, Layer } from "effect";
3+
4+
import {
5+
ApiKeyService,
6+
ApiKeyValidationError,
7+
makeCachedApiKeyValidate,
8+
type ApiKeyOwner,
9+
} from "./api-keys";
10+
import { WorkOSClient, type WorkOSClientService } from "./workos";
11+
12+
const stubWorkOS = (overrides: Partial<WorkOSClientService>) =>
13+
Layer.succeed(
14+
WorkOSClient,
15+
new Proxy({} as WorkOSClientService, {
16+
get: (_target, prop) => {
17+
if (prop in overrides) return overrides[prop as keyof WorkOSClientService];
18+
return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
19+
},
20+
}),
21+
);
22+
23+
const userOwner = (keyId: string): ApiKeyOwner => ({
24+
scope: "user",
25+
accountId: "user_123",
26+
organizationId: "org_123",
27+
keyId,
28+
});
29+
30+
describe("makeCachedApiKeyValidate", () => {
31+
it.effect("serves a repeat validation from cache without a second upstream call", () =>
32+
Effect.gen(function* () {
33+
let calls = 0;
34+
const cached = makeCachedApiKeyValidate(() =>
35+
Effect.sync(() => {
36+
calls += 1;
37+
return userOwner("api_key_1");
38+
}),
39+
);
40+
41+
const first = yield* cached.validate("sk_test_abc");
42+
const second = yield* cached.validate("sk_test_abc");
43+
44+
expect(calls).toBe(1);
45+
expect(first).toEqual(userOwner("api_key_1"));
46+
expect(second).toEqual(first);
47+
}),
48+
);
49+
50+
it.effect("re-validates once the TTL has expired", () =>
51+
Effect.gen(function* () {
52+
let calls = 0;
53+
let nowMs = 0;
54+
const cached = makeCachedApiKeyValidate(
55+
() =>
56+
Effect.sync(() => {
57+
calls += 1;
58+
return userOwner("api_key_1");
59+
}),
60+
{ ttlMs: 1_000, now: () => nowMs },
61+
);
62+
63+
yield* cached.validate("sk_test_abc");
64+
nowMs = 999;
65+
yield* cached.validate("sk_test_abc");
66+
expect(calls).toBe(1);
67+
68+
nowMs = 1_000;
69+
yield* cached.validate("sk_test_abc");
70+
expect(calls).toBe(2);
71+
}),
72+
);
73+
74+
it.effect("never caches an invalid key", () =>
75+
Effect.gen(function* () {
76+
let calls = 0;
77+
const cached = makeCachedApiKeyValidate(() =>
78+
Effect.sync(() => {
79+
calls += 1;
80+
return null;
81+
}),
82+
);
83+
84+
const first = yield* cached.validate("sk_test_bad");
85+
const second = yield* cached.validate("sk_test_bad");
86+
87+
expect(first).toBeNull();
88+
expect(second).toBeNull();
89+
expect(calls).toBe(2);
90+
expect(cached.cacheKeys()).toHaveLength(0);
91+
}),
92+
);
93+
94+
it.effect("never caches an upstream failure", () =>
95+
Effect.gen(function* () {
96+
let calls = 0;
97+
const cached = makeCachedApiKeyValidate(() =>
98+
Effect.suspend(() => {
99+
calls += 1;
100+
return Effect.fail(new ApiKeyValidationError({ cause: "workos_down" }));
101+
}),
102+
);
103+
104+
const first = yield* Effect.exit(cached.validate("sk_test_abc"));
105+
const second = yield* Effect.exit(cached.validate("sk_test_abc"));
106+
107+
expect(Exit.isFailure(first)).toBe(true);
108+
expect(Exit.isFailure(second)).toBe(true);
109+
expect(calls).toBe(2);
110+
expect(cached.cacheKeys()).toHaveLength(0);
111+
}),
112+
);
113+
114+
it.effect("resets a saturated map of live entries rather than growing past the bound", () =>
115+
Effect.gen(function* () {
116+
const cached = makeCachedApiKeyValidate((value) => Effect.succeed(userOwner(value)), {
117+
maxEntries: 3,
118+
});
119+
120+
for (const value of ["sk_1", "sk_2", "sk_3"]) {
121+
yield* cached.validate(value);
122+
}
123+
expect(cached.cacheKeys()).toHaveLength(3);
124+
125+
// Nothing is expired, so a fourth live entry resets the map instead of
126+
// growing it (mirrors execution-gate's writeCache).
127+
yield* cached.validate("sk_4");
128+
expect(cached.cacheKeys()).toHaveLength(1);
129+
130+
yield* cached.validate("sk_5");
131+
expect(cached.cacheKeys()).toHaveLength(2);
132+
}),
133+
);
134+
135+
it.effect("evicts expired entries before resetting a saturated map", () =>
136+
Effect.gen(function* () {
137+
let nowMs = 0;
138+
const cached = makeCachedApiKeyValidate((value) => Effect.succeed(userOwner(value)), {
139+
ttlMs: 1_000,
140+
maxEntries: 3,
141+
now: () => nowMs,
142+
});
143+
144+
for (const value of ["sk_1", "sk_2", "sk_3"]) {
145+
yield* cached.validate(value);
146+
}
147+
expect(cached.cacheKeys()).toHaveLength(3);
148+
149+
// Everything is expired: the sweep drops the stale entries and the new
150+
// one fits without a full reset.
151+
nowMs = 2_000;
152+
yield* cached.validate("sk_4");
153+
expect(cached.cacheKeys()).toHaveLength(1);
154+
}),
155+
);
156+
157+
it.effect("keys the cache by the SHA-256 digest, never the raw key value", () =>
158+
Effect.gen(function* () {
159+
const rawKey = "sk_live_super_secret_value";
160+
const cached = makeCachedApiKeyValidate(() => Effect.succeed(userOwner("api_key_1")));
161+
162+
yield* cached.validate(rawKey);
163+
164+
const expectedDigest = yield* Effect.promise(async () => {
165+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(rawKey));
166+
return Array.from(new Uint8Array(digest), (byte) =>
167+
byte.toString(16).padStart(2, "0"),
168+
).join("");
169+
});
170+
expect(cached.cacheKeys()).toEqual([expectedDigest]);
171+
expect(cached.cacheKeys()).not.toContain(rawKey);
172+
}),
173+
);
174+
});
175+
176+
describe("ApiKeyService.WorkOS validation cache", () => {
177+
it.effect("validates a repeated key through one upstream call per layer build", () =>
178+
Effect.gen(function* () {
179+
let calls = 0;
180+
const layer = ApiKeyService.WorkOS.pipe(
181+
Layer.provide(
182+
stubWorkOS({
183+
validateApiKey: () =>
184+
Effect.sync(() => {
185+
calls += 1;
186+
return {
187+
apiKey: {
188+
id: "api_key_123",
189+
owner: { type: "user", id: "user_123", organizationId: "org_123" },
190+
},
191+
};
192+
}),
193+
}),
194+
),
195+
);
196+
197+
const { first, second } = yield* Effect.gen(function* () {
198+
const apiKeys = yield* ApiKeyService;
199+
return {
200+
first: yield* apiKeys.validate("sk_test_abc"),
201+
second: yield* apiKeys.validate("sk_test_abc"),
202+
};
203+
}).pipe(Effect.provide(layer));
204+
205+
expect(calls).toBe(1);
206+
expect(first?.keyId).toBe("api_key_123");
207+
expect(second).toEqual(first);
208+
}),
209+
);
210+
});

‎apps/cloud/src/auth/api-keys.ts‎

Lines changed: 102 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Context, Data, Effect, Layer, Option, Schema } from "effect";
22

3+
import { sha256Hex } from "@executor-js/sdk";
4+
35
import { ApiKeyManagementError } from "./errors";
46
import { WorkOSClient } from "./workos";
57

@@ -277,6 +279,98 @@ const createdFromResponse = (value: unknown): CreatedApiKey | null =>
277279
},
278280
});
279281

282+
// ---------------------------------------------------------------------------
283+
// Per-isolate validation cache.
284+
//
285+
// Every /mcp request and every api-key-authenticated /api/* request funnels
286+
// through `validate`, and each call is a live WorkOS round trip (~100-150ms).
287+
// The JWT bearer path beside it verifies locally against a JWKS cached for an
288+
// hour; api keys had no cache at all. This is the same bounded TTL map the
289+
// engine already uses for billing outcomes (engine/execution-gate.ts).
290+
//
291+
// Security shape, stated explicitly because this is auth code:
292+
// - The map key is the SHA-256 digest of the presented key value, never the
293+
// raw credential, so the map's keys cannot be reversed into a usable key.
294+
// The digest reaches no log, span, or error message.
295+
// - Only SUCCESSFUL validations are cached. An invalid key and an upstream
296+
// failure always miss: an attacker probing bad keys cannot poison the map
297+
// (the size bound alone handles memory), and a just-created key works
298+
// immediately instead of waiting out a stale negative entry.
299+
// - REVOCATION WINDOW: a revoked key keeps working for up to the TTL (60s)
300+
// in any isolate that validated it before revocation. That is the price
301+
// of the cache, and it is far tighter than the 1h JWKS rotation window
302+
// the JWT path already accepts.
303+
// - Concurrent misses for the same key each call WorkOS; there is no
304+
// in-flight dedupe. The duplicate window is one round trip per key per
305+
// TTL per isolate — exactly the cost EVERY request paid before this
306+
// cache — and dedupe would add interruption-safety machinery (a
307+
// cancelled leader must not strand its waiters) to auth-critical code.
308+
// ---------------------------------------------------------------------------
309+
310+
const API_KEY_VALIDATION_CACHE_TTL_MS = 60_000;
311+
// Sweep guard so a long-lived isolate serving many keys can't grow the cache
312+
// map unbounded (mirrors BALANCE_CACHE_MAX_ENTRIES in execution-gate.ts).
313+
const API_KEY_VALIDATION_CACHE_MAX_ENTRIES = 10_000;
314+
315+
export type ApiKeyValidate = (
316+
value: string,
317+
) => Effect.Effect<ApiKeyOwner | null, ApiKeyValidationError>;
318+
319+
/**
320+
* Wrap a `validate` function with the per-isolate success cache described
321+
* above. Exported for tests only — production use is the single instance the
322+
* `ApiKeyService.WorkOS` layer builds, which lives in `boot` and is therefore
323+
* constructed once per isolate.
324+
*
325+
* The `ttlMs` / `maxEntries` / `now` knobs exist so tests can exercise expiry
326+
* and the size bound without waiting on wall time; production passes nothing.
327+
*/
328+
export const makeCachedApiKeyValidate = (
329+
validate: ApiKeyValidate,
330+
options?: {
331+
readonly ttlMs?: number;
332+
readonly maxEntries?: number;
333+
readonly now?: () => number;
334+
},
335+
): {
336+
readonly validate: ApiKeyValidate;
337+
/** Test seam: the digests currently cached, so tests can assert the raw
338+
* credential never appears as a map key. */
339+
readonly cacheKeys: () => ReadonlyArray<string>;
340+
} => {
341+
const ttlMs = options?.ttlMs ?? API_KEY_VALIDATION_CACHE_TTL_MS;
342+
const maxEntries = options?.maxEntries ?? API_KEY_VALIDATION_CACHE_MAX_ENTRIES;
343+
const now = options?.now ?? Date.now;
344+
const cache = new Map<string, { readonly owner: ApiKeyOwner; readonly expiresAtMs: number }>();
345+
346+
const writeCache = (digest: string, owner: ApiKeyOwner, nowMs: number): void => {
347+
if (cache.size >= maxEntries) {
348+
for (const [key, entry] of cache) {
349+
if (entry.expiresAtMs <= nowMs) cache.delete(key);
350+
}
351+
// Still saturated after dropping expired entries: reset rather than grow.
352+
if (cache.size >= maxEntries) cache.clear();
353+
}
354+
cache.set(digest, { owner, expiresAtMs: nowMs + ttlMs });
355+
};
356+
357+
return {
358+
cacheKeys: () => [...cache.keys()],
359+
validate: (value) =>
360+
Effect.gen(function* () {
361+
const digest = yield* sha256Hex(value);
362+
const nowMs = now();
363+
const cached = cache.get(digest);
364+
if (cached && cached.expiresAtMs > nowMs) return cached.owner;
365+
const owner = yield* validate(value);
366+
// Cache only a resolved owner — see the block comment above for why
367+
// null results and failures always stay misses.
368+
if (owner) writeCache(digest, owner, nowMs);
369+
return owner;
370+
}),
371+
};
372+
};
373+
280374
export class ApiKeyService extends Context.Service<
281375
ApiKeyService,
282376
{
@@ -320,12 +414,15 @@ export class ApiKeyService extends Context.Service<
320414
static WorkOS = Layer.effect(this)(
321415
Effect.gen(function* () {
322416
const workos = yield* WorkOSClient;
417+
// Built once with the boot layer, so the success cache is per-isolate.
418+
const cachedValidate = makeCachedApiKeyValidate((value) =>
419+
workos.validateApiKey(value).pipe(
420+
Effect.map(ownerFromResponse),
421+
Effect.mapError((cause) => new ApiKeyValidationError({ cause })),
422+
),
423+
);
323424
return {
324-
validate: (value: string) =>
325-
workos.validateApiKey(value).pipe(
326-
Effect.map(ownerFromResponse),
327-
Effect.mapError((cause) => new ApiKeyValidationError({ cause })),
328-
),
425+
validate: cachedValidate.validate,
329426
listUserKeys: ({ accountId, organizationId }) =>
330427
workos.listUserApiKeys(accountId, organizationId).pipe(
331428
Effect.map(listFromResponse),

0 commit comments

Comments
 (0)