diff --git a/apps/cloud/src/account/account-api.ts b/apps/cloud/src/account/account-api.ts index 94f434278..a84864171 100644 --- a/apps/cloud/src/account/account-api.ts +++ b/apps/cloud/src/account/account-api.ts @@ -46,14 +46,15 @@ import { AccountCaller, workosAccountProvider } from "./workos-account-service"; // Long-lived `WorkOSClient | AutumnService` come from the surrounding context // (Autumn provided by `makeAccountApiLive` for the seat-gate); the per-request // `UserStoreService` is supplied by the combined `rsLive` layer. -// `ApiKeyService.WorkOS` is built here on top of the boot `WorkOSClient`. +// The production app supplies the same boot-scoped `ApiKeyService` used by +// bearer authentication, so revoking an org key evicts its cached validation. const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvider }>()( Effect.gen(function* () { // Long-lived services only (built once at boot). `UserStoreService` and // `DbService` are NOT grabbed here — they come per request from the combined // `requestScopedMiddleware(rsLive)` layer, which folds them into this // middleware's body context (so they drop out of `requires`). - const longLived = yield* Effect.context(); + const longLived = yield* Effect.context(); const workos = yield* WorkOSClient; return (httpEffect) => Effect.gen(function* () { @@ -75,10 +76,7 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvi // the combined request-scoped layer. const accountProvider = yield* Effect.provide( AccountProvider.asEffect(), - workosAccountProvider.pipe( - Layer.provide(ApiKeyService.WorkOS), - Layer.provide(Layer.succeed(AccountCaller)({ session })), - ), + workosAccountProvider.pipe(Layer.provide(Layer.succeed(AccountCaller)({ session }))), ); return yield* Effect.provideService(httpEffect, AccountProvider, accountProvider); }).pipe(Effect.provideContext(longLived)); @@ -109,5 +107,12 @@ export const makeAccountApiLive = (rsLive: Layer.Layer) => + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => { + if (prop in overrides) return overrides[prop as keyof WorkOSClientService]; + return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); + }, + }); + const stubWorkOS = (overrides: Partial) => - Layer.succeed( - WorkOSClient, - new Proxy({} as WorkOSClientService, { - get: (_target, prop) => { - if (prop in overrides) return overrides[prop as keyof WorkOSClientService]; - return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); - }, - }), - ); + Layer.succeed(WorkOSClient, stubWorkOSService(overrides)); const validate = (response: unknown) => Effect.gen(function* () { @@ -28,6 +29,181 @@ const validate = (response: unknown) => ); describe("ApiKeyService.WorkOS", () => { + it.effect("reuses a recent positive validation", () => + Effect.gen(function* () { + let calls = 0; + const { validate } = makeApiKeyValidator( + stubWorkOSService({ + validateApiKey: () => { + calls += 1; + return Effect.succeed({ + apiKey: { + id: "api_key_shared", + owner: { type: "organization", id: "org_123" }, + }, + }); + }, + }), + ); + + const first = yield* validate("shared_secret"); + const second = yield* validate("shared_secret"); + + expect(first).toEqual(second); + expect(calls).toBe(1); + }), + ); + + it.effect("coalesces concurrent validation of the same key", () => + Effect.gen(function* () { + let calls = 0; + const { validate } = makeApiKeyValidator( + stubWorkOSService({ + validateApiKey: () => { + calls += 1; + return Effect.yieldNow.pipe( + Effect.as({ + apiKey: { + id: "api_key_shared", + owner: { type: "organization", id: "org_123" }, + }, + }), + ); + }, + }), + ); + + const results = yield* Effect.all([validate("shared_secret"), validate("shared_secret")], { + concurrency: "unbounded", + }); + + expect(results[0]).toEqual(results[1]); + expect(calls).toBe(1); + }), + ); + + it.effect("does not cache invalid keys", () => + Effect.gen(function* () { + let calls = 0; + const { validate } = makeApiKeyValidator( + stubWorkOSService({ + validateApiKey: () => { + calls += 1; + return Effect.succeed({ apiKey: null }); + }, + }), + ); + + expect(yield* validate("invalid_secret")).toBeNull(); + expect(yield* validate("invalid_secret")).toBeNull(); + expect(calls).toBe(2); + }), + ); + + it.effect("does not cache user-owned keys", () => + Effect.gen(function* () { + let calls = 0; + const { validate } = makeApiKeyValidator( + stubWorkOSService({ + validateApiKey: () => { + calls += 1; + return Effect.succeed({ + apiKey: { + id: "api_key_user", + owner: { + type: "user", + id: "user_123", + organizationId: "org_123", + }, + }, + }); + }, + }), + ); + + yield* validate("user_secret"); + yield* validate("user_secret"); + expect(calls).toBe(2); + }), + ); + + it.effect("does not cache WorkOS failures", () => + Effect.gen(function* () { + let calls = 0; + const { validate } = makeApiKeyValidator( + stubWorkOSService({ + validateApiKey: () => { + calls += 1; + return Effect.fail(new WorkOSError({ status: 503 })); + }, + }), + ); + + const first = yield* Effect.flip(validate("unavailable_secret")); + const second = yield* Effect.flip(validate("unavailable_secret")); + + expect(first).toBeInstanceOf(ApiKeyValidationError); + expect(second).toBeInstanceOf(ApiKeyValidationError); + expect(calls).toBe(2); + }), + ); + + it.effect("revalidates a positive key after the short TTL", () => + Effect.gen(function* () { + let calls = 0; + let nowMs = 1_000; + const { validate } = makeApiKeyValidator( + stubWorkOSService({ + validateApiKey: () => { + calls += 1; + return Effect.succeed({ + apiKey: { + id: "api_key_shared", + owner: { type: "organization", id: "org_123" }, + }, + }); + }, + }), + { now: () => nowMs, ttlMs: 10 }, + ); + + yield* validate("shared_secret"); + nowMs += 9; + yield* validate("shared_secret"); + expect(calls).toBe(1); + + nowMs += 1; + yield* validate("shared_secret"); + expect(calls).toBe(2); + }), + ); + + it.effect("invalidates a cached key when it is revoked locally", () => + Effect.gen(function* () { + let calls = 0; + const validator = makeApiKeyValidator( + stubWorkOSService({ + validateApiKey: () => { + calls += 1; + return Effect.succeed({ + apiKey: { + id: "api_key_shared", + owner: { type: "organization", id: "org_123" }, + }, + }); + }, + }), + ); + + yield* validator.validate("shared_secret"); + yield* validator.validate("shared_secret"); + validator.invalidate("api_key_shared"); + yield* validator.validate("shared_secret"); + + expect(calls).toBe(2); + }), + ); + it.effect("accepts user-owned keys with camel-case organization id", () => Effect.gen(function* () { const principal = yield* validate({ diff --git a/apps/cloud/src/auth/api-keys.ts b/apps/cloud/src/auth/api-keys.ts index 0effa2609..2142e563b 100644 --- a/apps/cloud/src/auth/api-keys.ts +++ b/apps/cloud/src/auth/api-keys.ts @@ -1,7 +1,18 @@ -import { Context, Data, Effect, Layer, Option, Schema } from "effect"; +import { Context, Data, Deferred, Effect, Layer, Option, Schema } from "effect"; + +import { sha256Hex } from "@executor-js/sdk"; import { ApiKeyManagementError } from "./errors"; -import { WorkOSClient } from "./workos"; +import { WorkOSClient, type WorkOSClientService } from "./workos"; + +// WorkOS validation is a remote call on every bearer-authenticated request. +// Keep the revocation window for organization-owned machine credentials +// deliberately short while allowing an active Worker isolate to reuse the +// same positive result across a burst of platform reads. A revoked org key can +// remain accepted by one isolate for at most this TTL. User keys, invalid keys, +// and upstream failures are never cached. +const API_KEY_VALIDATION_CACHE_TTL_MS = 10_000; +const API_KEY_VALIDATION_CACHE_MAX_ENTRIES = 1_000; /** * Which view a validated key resolves to. @@ -196,6 +207,78 @@ const ownerFromResponse = (value: unknown): ApiKeyOwner | null => onSome: ({ apiKey }) => (apiKey ? ownerFromApiKey(apiKey) : null), }); +type ApiKeyValidationCacheOptions = { + readonly now?: () => number; + readonly ttlMs?: number; + readonly maxEntries?: number; +}; + +/** + * Positive, process-local WorkOS validation cache for organization-owned keys. + * Cache keys are SHA-256 digests so bearer credentials are never retained as + * map keys. Concurrent validation of any one key joins the same in-flight + * request; a user-owned/null result or failure is shared only with those + * waiters and is not retained. + */ +export const makeApiKeyValidator = ( + workos: WorkOSClientService, + options: ApiKeyValidationCacheOptions = {}, +) => { + const now = options.now ?? Date.now; + const ttlMs = options.ttlMs ?? API_KEY_VALIDATION_CACHE_TTL_MS; + const maxEntries = options.maxEntries ?? API_KEY_VALIDATION_CACHE_MAX_ENTRIES; + const cache = new Map(); + const inFlight = new Map>(); + + const writeCache = (key: string, owner: ApiKeyOwner): void => { + const nowMs = now(); + if (cache.size >= maxEntries) { + for (const [cachedKey, entry] of cache) { + if (entry.expiresAtMs <= nowMs) cache.delete(cachedKey); + } + if (cache.size >= maxEntries) cache.clear(); + } + cache.set(key, { owner, expiresAtMs: nowMs + ttlMs }); + }; + + return { + validate: (value: string): Effect.Effect => + sha256Hex(value).pipe( + Effect.flatMap((key) => + Effect.suspend(() => { + const cached = cache.get(key); + if (cached && cached.expiresAtMs > now()) return Effect.succeed(cached.owner); + if (cached) cache.delete(key); + + const pending = inFlight.get(key); + if (pending) return Deferred.await(pending); + + const latch = Deferred.makeUnsafe(); + inFlight.set(key, latch); + return workos.validateApiKey(value).pipe( + Effect.map(ownerFromResponse), + Effect.mapError((cause) => new ApiKeyValidationError({ cause })), + Effect.tap((owner) => + owner?.scope === "org" ? Effect.sync(() => writeCache(key, owner)) : Effect.void, + ), + Effect.onExit((exit) => + Effect.gen(function* () { + inFlight.delete(key); + yield* Deferred.done(latch, exit); + }), + ), + ); + }), + ), + ), + invalidate: (keyId: string): void => { + for (const [key, entry] of cache) { + if (entry.owner.keyId === keyId) cache.delete(key); + } + }, + }; +}; + const summaryFromApiKey = (apiKey: typeof ApiKey.Type): ApiKeySummary | null => { const organizationId = apiKey.owner.organizationId ?? apiKey.owner.organization_id; if (!organizationId) return null; @@ -320,12 +403,9 @@ export class ApiKeyService extends Context.Service< static WorkOS = Layer.effect(this)( Effect.gen(function* () { const workos = yield* WorkOSClient; + const validator = makeApiKeyValidator(workos); return { - validate: (value: string) => - workos.validateApiKey(value).pipe( - Effect.map(ownerFromResponse), - Effect.mapError((cause) => new ApiKeyValidationError({ cause })), - ), + validate: validator.validate, listUserKeys: ({ accountId, organizationId }) => workos.listUserApiKeys(accountId, organizationId).pipe( Effect.map(listFromResponse), @@ -378,6 +458,7 @@ export class ApiKeyService extends Context.Service< yield* workos .deleteApiKey(keyId) .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))); + validator.invalidate(keyId); }), }; }),