History

Invalidate cached api key validations on revoke

Rhys Sullivan committed 2 months ago

Commit 50ee2fd1 parent 8e2515c4 files · +184 −41Browse files

Unified · Split

4 files
  1. .changeset/apikey-revoke-cache-invalidation.md +7 −0
  2. apps/cloud/src/auth/api-key-validation-cache.node.test.ts +101 −8
  3. apps/cloud/src/auth/api-keys.ts +62 −20
  4. apps/cloud/src/mcp/agent-handler.ts +14 −13
.changeset/apikey-revoke-cache-invalidation.md+7−0
@@ -0,0 +1,7 @@
1---
2"@executor-js/cloud": patch
3---
4
5**Revoking an API key now invalidates its cached validation**
6
7The per-isolate validation cache made a revoked key keep authenticating for up to 60 seconds. Two fixes close that: the cache map now lives at module scope, shared by every build of the key service (the account middleware rebuilds it per request, so a per-build map left the revoke invalidating a fresh empty cache), and both revoke paths drop the revoked key's entries from it. A revoked key is refused on the next request served by the isolate that processed the revoke; other isolates still age the entry out within the TTL.
apps/cloud/src/auth/api-key-validation-cache.node.test.ts+101−8
@@ -171,10 +171,46 @@ describe("makeCachedApiKeyValidate", () => { · 170 unmodified lines
171 expect(cached.cacheKeys()).not.toContain(rawKey);171 expect(cached.cacheKeys()).not.toContain(rawKey);
172 }),172 }),
173 );173 );
174
175 it.effect("invalidating a key id drops its entries and forces revalidation", () =>
176 Effect.gen(function* () {
177 let calls = 0;
178 const cached = makeCachedApiKeyValidate(() =>
179 Effect.sync(() => {
180 calls += 1;
181 return userOwner("api_key_1");
182 }),
183 );
184
185 yield* cached.validate("sk_test_abc");
186 cached.invalidateKeyId("api_key_1");
187 expect(cached.cacheKeys()).toHaveLength(0);
188
189 yield* cached.validate("sk_test_abc");
190 expect(calls).toBe(2);
191 }),
192 );
193
194 it.effect("invalidating one key id leaves other keys' entries cached", () =>
195 Effect.gen(function* () {
196 const cached = makeCachedApiKeyValidate((value) =>
197 Effect.succeed(userOwner(`key_for_${value}`)),
198 );
199
200 yield* cached.validate("sk_1");
201 yield* cached.validate("sk_2");
202 cached.invalidateKeyId("key_for_sk_1");
203
204 expect(cached.cacheKeys()).toHaveLength(1);
205 }),
206 );
174});207});
175 208
209// These tests build the REAL WorkOS layer, whose cache map is module-scope —
210// entries written by one test are visible to the next within this file. Each
211// test therefore uses key values of its own; none may reuse another's.
176describe("ApiKeyService.WorkOS validation cache", () => {212describe("ApiKeyService.WorkOS validation cache", () => {
177 it.effect("validates a repeated key through one upstream call per layer build", () =>213 it.effect("serves a key cached by one layer build from a second build", () =>
178 Effect.gen(function* () {214 Effect.gen(function* () {
179 let calls = 0;215 let calls = 0;
180 const layer = ApiKeyService.WorkOS.pipe(216 const layer = ApiKeyService.WorkOS.pipe(
@@ -185,7 +221,7 @@ describe("ApiKeyService.WorkOS validation cache", () => { · 4 unmodified lines
185 calls += 1;221 calls += 1;
186 return {222 return {
187 apiKey: {223 apiKey: {
188 id: "api_key_123",224 id: "api_key_shared",
189 owner: { type: "user", id: "user_123", organizationId: "org_123" },225 owner: { type: "user", id: "user_123", organizationId: "org_123" },
190 },226 },
191 };227 };
@@ -194,17 +230,74 @@ describe("ApiKeyService.WorkOS validation cache", () => { · 2 unmodified lines
194 ),230 ),
195 );231 );
196 232
197 const { first, second } = yield* Effect.gen(function* () {233 // Two separate `Effect.provide(layer)` runs are two separate layer
234 // builds — the account middleware rebuilds this layer per request, so
235 // cross-build reuse is exactly what production needs the cache to do.
236 const first = yield* Effect.gen(function* () {
198 const apiKeys = yield* ApiKeyService;237 const apiKeys = yield* ApiKeyService;
199 return {238 return yield* apiKeys.validate("sk_test_cross_build");
200 first: yield* apiKeys.validate("sk_test_abc"),239 }).pipe(Effect.provide(layer));
201 second: yield* apiKeys.validate("sk_test_abc"),240 const second = yield* Effect.gen(function* () {
202 };241 const apiKeys = yield* ApiKeyService;
242 return yield* apiKeys.validate("sk_test_cross_build");
203 }).pipe(Effect.provide(layer));243 }).pipe(Effect.provide(layer));
204 244
205 expect(calls).toBe(1);245 expect(calls).toBe(1);
206 expect(first?.keyId).toBe("api_key_123");246 expect(first?.keyId).toBe("api_key_shared");
207 expect(second).toEqual(first);247 expect(second).toEqual(first);
208 }),248 }),
209 );249 );
250
251 it.effect("a revoke in one layer build refuses the cached value in every build", () =>
252 Effect.gen(function* () {
253 // Live until revoked: the stub answers with the key while `revoked` is
254 // false and with a null apiKey afterwards, the same flip the WorkOS
255 // backend performs. The revoke runs in its OWN layer build — the shape
256 // of the real console flow, where the account middleware rebuilds the
257 // layer per request — and must still drop the entry the validating
258 // plane cached.
259 let revoked = false;
260 let calls = 0;
261 const layer = ApiKeyService.WorkOS.pipe(
262 Layer.provide(
263 stubWorkOS({
264 validateApiKey: () =>
265 Effect.sync(() => {
266 calls += 1;
267 return {
268 apiKey: revoked
269 ? null
270 : {
271 id: "api_key_revoked",
272 owner: { type: "user", id: "user_123", organizationId: "org_123" },
273 },
274 };
275 }),
276 deleteApiKey: () =>
277 Effect.sync(() => {
278 revoked = true;
279 return {};
280 }),
281 }),
282 ),
283 );
284
285 const before = yield* Effect.gen(function* () {
286 const apiKeys = yield* ApiKeyService;
287 return yield* apiKeys.validate("sk_test_revocable");
288 }).pipe(Effect.provide(layer));
289 yield* Effect.gen(function* () {
290 const apiKeys = yield* ApiKeyService;
291 return yield* apiKeys.revokeUserKey({ keyId: "api_key_revoked" });
292 }).pipe(Effect.provide(layer));
293 const after = yield* Effect.gen(function* () {
294 const apiKeys = yield* ApiKeyService;
295 return yield* apiKeys.validate("sk_test_revocable");
296 }).pipe(Effect.provide(layer));
297
298 expect(before?.keyId).toBe("api_key_revoked");
299 expect(after, "the revoke dropped the cached entry, not just the upstream key").toBeNull();
300 expect(calls).toBe(2);
301 }),
302 );
210});303});
apps/cloud/src/auth/api-keys.ts+62−20
@@ -296,10 +296,14 @@ const createdFromResponse = (value: unknown): CreatedApiKey | null => · 295 unmodified lines
296// failure always miss: an attacker probing bad keys cannot poison the map296// failure always miss: an attacker probing bad keys cannot poison the map
297// (the size bound alone handles memory), and a just-created key works297// (the size bound alone handles memory), and a just-created key works
298// immediately instead of waiting out a stale negative entry.298// immediately instead of waiting out a stale negative entry.
299// - REVOCATION WINDOW: a revoked key keeps working for up to the TTL (60s)299// - REVOCATION WINDOW: the isolate that serves the revoke drops its own
300// in any isolate that validated it before revocation. That is the price300// entries for that key id immediately (`invalidateKeyId`, called by both
301// of the cache, and it is far tighter than the 1h JWKS rotation window301// revoke paths below), so revoking a key through the console refuses it
302// the JWT path already accepts.302// on the next request wherever the same isolate validates. OTHER isolates
303// that validated the key before revocation keep serving it for up to the
304// TTL (60s). That residual window is the price of the cache, and it is
305// far tighter than the 1h JWKS rotation window the JWT path already
306// accepts.
303// - Concurrent misses for the same key each call WorkOS; there is no307// - 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 per308// in-flight dedupe. The duplicate window is one round trip per key per
305// TTL per isolate — exactly the cost EVERY request paid before this309// TTL per isolate — exactly the cost EVERY request paid before this
@@ -312,18 +316,34 @@ const API_KEY_VALIDATION_CACHE_TTL_MS = 60_000; · 6 unmodified lines
312// map unbounded (mirrors BALANCE_CACHE_MAX_ENTRIES in execution-gate.ts).316// map unbounded (mirrors BALANCE_CACHE_MAX_ENTRIES in execution-gate.ts).
313const API_KEY_VALIDATION_CACHE_MAX_ENTRIES = 10_000;317const API_KEY_VALIDATION_CACHE_MAX_ENTRIES = 10_000;
314 318
319type ApiKeyValidationCacheEntry = {
320 readonly owner: ApiKeyOwner;
321 readonly expiresAtMs: number;
322};
323
324// The map lives at MODULE scope — one per isolate — not inside
325// `makeCachedApiKeyValidate`, because the `ApiKeyService.WorkOS` layer is NOT
326// built once per isolate on every plane: the account middleware rebuilds it
327// per request. A map owned by each build would give the revoke paths a fresh
328// empty cache to invalidate while the long-lived identity plane kept serving
329// the revoked key from its own. Every build shares this map instead (entries
330// are pure validation RESULTS, so which build's WorkOS client wrote them does
331// not matter), which is also what makes the entries survive between requests
332// on the per-request plane at all.
333const isolateApiKeyValidationCache = new Map<string, ApiKeyValidationCacheEntry>();
334
315export type ApiKeyValidate = (335export type ApiKeyValidate = (
316 value: string,336 value: string,
317) => Effect.Effect<ApiKeyOwner | null, ApiKeyValidationError>;337) => Effect.Effect<ApiKeyOwner | null, ApiKeyValidationError>;
318 338
319/**339/**
320 * Wrap a `validate` function with the per-isolate success cache described340 * Wrap a `validate` function with the success cache described above. Exported
321 * above. Exported for tests only — production use is the single instance the341 * for tests only — production use is the `ApiKeyService.WorkOS` layer below,
322 * `ApiKeyService.WorkOS` layer builds, which lives in `boot` and is therefore342 * every build of which shares the module-scope map.
323 * constructed once per isolate.
324 *343 *
325 * The `ttlMs` / `maxEntries` / `now` knobs exist so tests can exercise expiry344 * The `ttlMs` / `maxEntries` / `now` / `cache` knobs exist so tests can
326 * and the size bound without waiting on wall time; production passes nothing.345 * exercise expiry, the size bound, and map sharing without wall time or
346 * module state; production passes only `cache`.
327 */347 */
328export const makeCachedApiKeyValidate = (348export const makeCachedApiKeyValidate = (
329 validate: ApiKeyValidate,349 validate: ApiKeyValidate,
@@ -331,9 +351,19 @@ export const makeCachedApiKeyValidate = ( · 1 unmodified line
331 readonly ttlMs?: number;351 readonly ttlMs?: number;
332 readonly maxEntries?: number;352 readonly maxEntries?: number;
333 readonly now?: () => number;353 readonly now?: () => number;
354 /** The map to cache into. Production passes the module-scope
355 * `isolateApiKeyValidationCache`; tests default to a private map. */
356 readonly cache?: Map<string, ApiKeyValidationCacheEntry>;
334 },357 },
335): {358): {
336 readonly validate: ApiKeyValidate;359 readonly validate: ApiKeyValidate;
360 /**
361 * Drop every cached entry that resolved to this key id. The cache is keyed
362 * by the digest of the presented VALUE and a revoke only knows the key ID,
363 * so this walks the map — bounded by `maxEntries`, and revocation is a rare
364 * human action, not a hot path.
365 */
366 readonly invalidateKeyId: (keyId: string) => void;
337 /** Test seam: the digests currently cached, so tests can assert the raw367 /** Test seam: the digests currently cached, so tests can assert the raw
338 * credential never appears as a map key. */368 * credential never appears as a map key. */
339 readonly cacheKeys: () => ReadonlyArray<string>;369 readonly cacheKeys: () => ReadonlyArray<string>;
@@ -341,7 +371,7 @@ export const makeCachedApiKeyValidate = ( · 1 unmodified line
341 const ttlMs = options?.ttlMs ?? API_KEY_VALIDATION_CACHE_TTL_MS;371 const ttlMs = options?.ttlMs ?? API_KEY_VALIDATION_CACHE_TTL_MS;
342 const maxEntries = options?.maxEntries ?? API_KEY_VALIDATION_CACHE_MAX_ENTRIES;372 const maxEntries = options?.maxEntries ?? API_KEY_VALIDATION_CACHE_MAX_ENTRIES;
343 const now = options?.now ?? Date.now;373 const now = options?.now ?? Date.now;
344 const cache = new Map<string, { readonly owner: ApiKeyOwner; readonly expiresAtMs: number }>();374 const cache = options?.cache ?? new Map<string, ApiKeyValidationCacheEntry>();
345 375
346 const writeCache = (digest: string, owner: ApiKeyOwner, nowMs: number): void => {376 const writeCache = (digest: string, owner: ApiKeyOwner, nowMs: number): void => {
347 if (cache.size >= maxEntries) {377 if (cache.size >= maxEntries) {
@@ -356,6 +386,11 @@ export const makeCachedApiKeyValidate = ( · 8 unmodified lines
356 386
357 return {387 return {
358 cacheKeys: () => [...cache.keys()],388 cacheKeys: () => [...cache.keys()],
389 invalidateKeyId: (keyId) => {
390 for (const [digest, entry] of cache) {
391 if (entry.owner.keyId === keyId) cache.delete(digest);
392 }
393 },
359 validate: (value) =>394 validate: (value) =>
360 Effect.gen(function* () {395 Effect.gen(function* () {
361 const digest = yield* sha256Hex(value);396 const digest = yield* sha256Hex(value);
@@ -414,12 +449,17 @@ export class ApiKeyService extends Context.Service< · 52 unmodified lines
414 static WorkOS = Layer.effect(this)(449 static WorkOS = Layer.effect(this)(
415 Effect.gen(function* () {450 Effect.gen(function* () {
416 const workos = yield* WorkOSClient;451 const workos = yield* WorkOSClient;
417 // Built once with the boot layer, so the success cache is per-isolate.452 // The cache MAP is the module-scope per-isolate one (see its comment);
418 const cachedValidate = makeCachedApiKeyValidate((value) =>453 // this build only contributes the validate function that fills it, so
419 workos.validateApiKey(value).pipe(454 // however many times a plane rebuilds this layer, hits, misses, and
420 Effect.map(ownerFromResponse),455 // invalidations all land in the same map.
421 Effect.mapError((cause) => new ApiKeyValidationError({ cause })),456 const cachedValidate = makeCachedApiKeyValidate(
422 ),457 (value) =>
458 workos.validateApiKey(value).pipe(
459 Effect.map(ownerFromResponse),
460 Effect.mapError((cause) => new ApiKeyValidationError({ cause })),
461 ),
462 { cache: isolateApiKeyValidationCache },
423 );463 );
424 return {464 return {
425 validate: cachedValidate.validate,465 validate: cachedValidate.validate,
@@ -439,9 +479,10 @@ export class ApiKeyService extends Context.Service< · 13 unmodified lines
439 }),479 }),
440 ),480 ),
441 revokeUserKey: ({ keyId }) =>481 revokeUserKey: ({ keyId }) =>
442 workos482 workos.deleteApiKey(keyId).pipe(
443 .deleteApiKey(keyId)483 Effect.mapError((cause) => new ApiKeyManagementError({ cause })),
444 .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))),484 Effect.tap(() => Effect.sync(() => cachedValidate.invalidateKeyId(keyId))),
485 ),
445 listOrgKeys: ({ organizationId }) =>486 listOrgKeys: ({ organizationId }) =>
446 workos.listOrgApiKeys(organizationId).pipe(487 workos.listOrgApiKeys(organizationId).pipe(
447 Effect.map((response) => orgListFromResponse(response, organizationId)),488 Effect.map((response) => orgListFromResponse(response, organizationId)),
@@ -475,6 +516,7 @@ export class ApiKeyService extends Context.Service< · 27 unmodified lines
475 yield* workos516 yield* workos
476 .deleteApiKey(keyId)517 .deleteApiKey(keyId)
477 .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause })));518 .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause })));
519 cachedValidate.invalidateKeyId(keyId);
478 }),520 }),
479 };521 };
480 }),522 }),
apps/cloud/src/mcp/agent-handler.ts+14−13
@@ -133,25 +133,26 @@ const authenticate = (request: Request) => · 132 unmodified lines
133 133
134// ONE auth layer per isolate. `authenticate` used to end in134// ONE auth layer per isolate. `authenticate` used to end in
135// `Effect.provide(cloudMcpAuth)`, which rebuilt the entire auth stack — the135// `Effect.provide(cloudMcpAuth)`, which rebuilt the entire auth stack — the
136// WorkOS client, `ApiKeyService` and with it the api-key validation success136// WorkOS client and `ApiKeyService` — on every MCP request. A lazily created
137// cache — on every MCP request, so that cache never survived a request on this137// ManagedRuntime builds `cloudMcpAuth` on the first request and memoizes it;
138// plane (the /api/* plane resolves the same service from the app's `boot`138// `runTraced` runs every program on it. Nothing inside the layer is
139// layer, built once per isolate). A lazily created ManagedRuntime builds139// request-scoped: the WorkOS client holds no sockets (just config), the JWKS
140// `cloudMcpAuth` on the first request and memoizes it; `runTraced` runs every140// cache is already module-scope, and `McpOrganizationAuthLive` builds its
141// program on it. Nothing inside the layer is request-scoped: the WorkOS client141// postgres socket FRESH inside every `authorize` call precisely so the
142// holds no sockets (just config), the JWKS cache is already module-scope, and142// service itself can outlive a request (Cloudflare Workers' I/O isolation —
143// `McpOrganizationAuthLive` builds its postgres socket FRESH inside every143// see makeMcpOrganizationAuthServices).
144// `authorize` call precisely so the service itself can outlive a request
145// (Cloudflare Workers' I/O isolation — see makeMcpOrganizationAuthServices).
146//144//
147// The memo is keyed on the WorkOS binding values the layer captures at build,145// The memo is keyed on the WorkOS binding values the layer captures at build,
148// NOT held forever: Cloudflare reuses warm isolates across binding-only146// NOT held forever: Cloudflare reuses warm isolates across binding-only
149// deployments, so a bare `??=` would keep authenticating with a rotated-out147// deployments, so a bare `??=` would keep authenticating with a rotated-out
150// WORKOS_API_KEY until isolate eviction. `runTraced` fingerprints the148// WORKOS_API_KEY until isolate eviction. `runTraced` fingerprints the
151// request's own `env` (a string compare per request) and the cache swaps in a149// request's own `env` (a string compare per request) and the cache swaps in a
152// fresh runtime — fresh ApiKeyService validation cache included, as a rotated150// fresh runtime when the bindings changed. The api-key validation success
153// key must not serve cached validations — when the bindings changed. See151// cache is NOT swapped with it — it is module-scope in api-keys.ts (shared
154// auth-runtime.ts for the drop-vs-dispose reasoning.152// with the /api/* plane so revocation can invalidate it everywhere), so after
153// a WORKOS_API_KEY rotation its remaining entries age out within the 60s TTL
154// rather than dropping instantly. See auth-runtime.ts for the drop-vs-dispose
155// reasoning.
155const mcpAuthRuntimeFor: (156const mcpAuthRuntimeFor: (
156 fingerprint: string,157 fingerprint: string,
157) => ManagedRuntime.ManagedRuntime<McpAuthProvider, never> = makeBindingKeyedRuntime(() =>158) => ManagedRuntime.ManagedRuntime<McpAuthProvider, never> = makeBindingKeyedRuntime(() =>