Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions apps/cloud/src/account/account-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkOSClient | AutumnService>();
const longLived = yield* Effect.context<WorkOSClient | ApiKeyService | AutumnService>();
const workos = yield* WorkOSClient;
return (httpEffect) =>
Effect.gen(function* () {
Expand All @@ -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));
Expand Down Expand Up @@ -109,5 +107,12 @@ export const makeAccountApiLive = (rsLive: Layer.Layer<DbService | UserStoreServ
const accountMiddleware = AccountProviderMiddleware.combine(
requestScopedMiddleware(rsLive),
).layer;
return makeAccountApiLayer(accountMiddleware).pipe(Layer.provideMerge(AutumnService.Default));
return makeAccountApiLayer(accountMiddleware).pipe(
// The legacy standalone router has no unified boot control plane. Its
// self-contained layer still supplies the API-key service here; production
// uses `workosAccountMiddleware` above and therefore shares the boot-scoped
// service with bearer authentication.
Layer.provide(ApiKeyService.WorkOS),
Layer.provideMerge(AutumnService.Default),
);
};
196 changes: 186 additions & 10 deletions apps/cloud/src/auth/api-keys.node.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect, Layer } from "effect";

import { ApiKeyService } from "./api-keys";
import { ApiKeyService, ApiKeyValidationError, makeApiKeyValidator } from "./api-keys";
import { WorkOSError } from "./errors";
import { WorkOSClient, type WorkOSClientService } from "./workos";

const stubWorkOSService = (overrides: Partial<WorkOSClientService>) =>
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<WorkOSClientService>) =>
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* () {
Expand All @@ -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({
Expand Down
95 changes: 88 additions & 7 deletions apps/cloud/src/auth/api-keys.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<string, { readonly owner: ApiKeyOwner; readonly expiresAtMs: number }>();
const inFlight = new Map<string, Deferred.Deferred<ApiKeyOwner | null, ApiKeyValidationError>>();

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<ApiKeyOwner | null, ApiKeyValidationError> =>
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<ApiKeyOwner | null, ApiKeyValidationError>();
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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -378,6 +458,7 @@ export class ApiKeyService extends Context.Service<
yield* workos
.deleteApiKey(keyId)
.pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause })));
validator.invalidate(keyId);
}),
};
}),
Expand Down
Loading