|
1 | 1 | import { Context, Data, Effect, Layer, Option, Schema } from "effect"; |
2 | 2 |
|
| 3 | +import { sha256Hex } from "@executor-js/sdk"; |
| 4 | + |
3 | 5 | import { ApiKeyManagementError } from "./errors"; |
4 | 6 | import { WorkOSClient } from "./workos"; |
5 | 7 |
|
@@ -277,6 +279,98 @@ const createdFromResponse = (value: unknown): CreatedApiKey | null => |
277 | 279 | }, |
278 | 280 | }); |
279 | 281 |
|
| 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 | + |
280 | 374 | export class ApiKeyService extends Context.Service< |
281 | 375 | ApiKeyService, |
282 | 376 | { |
@@ -320,12 +414,15 @@ export class ApiKeyService extends Context.Service< |
320 | 414 | static WorkOS = Layer.effect(this)( |
321 | 415 | Effect.gen(function* () { |
322 | 416 | 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 | + ); |
323 | 424 | 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, |
329 | 426 | listUserKeys: ({ accountId, organizationId }) => |
330 | 427 | workos.listUserApiKeys(accountId, organizationId).pipe( |
331 | 428 | Effect.map(listFromResponse), |
|
0 commit comments