From 795d64fda0224d0118b2399727d083755a0c0dec Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 23 Jun 2026 21:18:31 -0500 Subject: [PATCH 1/2] feat(auth): ephemeral, account-scoped api keys (chat#1813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the async chat-generation migration: the headless/scheduled path has no client Privy session to forward into the sandbox and must not put the long-lived service key into model-driven bash. It instead mints a short-lived, account-scoped recoup_sk_ key per run and deletes it on completion. - lib/keys/mintEphemeralAccountKey: generate+hash+insert a recoup_sk_ key with an expires_at (default 15m TTL); returns { rawKey, keyId } for injection + cleanup. - lib/keys/isApiKeyExpired: pure TTL check (NULL/unparseable = never expires). - getApiKeyAccountId: reject a key whose expires_at has passed (401). Backward compatible — existing long-lived keys have NULL expiry. - insertApiKey + database.types: carry the new account_api_keys.expires_at column. Depends on database#36 (adds the column). Security-sensitive (touches the api-key auth path) — please review the expiry-enforcement diff. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/auth/__tests__/getApiKeyAccountId.test.ts | 54 +++++++++++++++++++ lib/auth/getApiKeyAccountId.ts | 7 ++- lib/keys/__tests__/isApiKeyExpired.test.ts | 24 +++++++++ .../__tests__/mintEphemeralAccountKey.test.ts | 42 +++++++++++++++ lib/keys/isApiKeyExpired.ts | 15 ++++++ lib/keys/mintEphemeralAccountKey.ts | 44 +++++++++++++++ lib/supabase/account_api_keys/insertApiKey.ts | 3 ++ types/database.types.ts | 3 ++ 8 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 lib/auth/__tests__/getApiKeyAccountId.test.ts create mode 100644 lib/keys/__tests__/isApiKeyExpired.test.ts create mode 100644 lib/keys/__tests__/mintEphemeralAccountKey.test.ts create mode 100644 lib/keys/isApiKeyExpired.ts create mode 100644 lib/keys/mintEphemeralAccountKey.ts diff --git a/lib/auth/__tests__/getApiKeyAccountId.test.ts b/lib/auth/__tests__/getApiKeyAccountId.test.ts new file mode 100644 index 000000000..e2a9aa7cc --- /dev/null +++ b/lib/auth/__tests__/getApiKeyAccountId.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAccountApiKeys"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) })); +vi.mock("@/lib/keys/hashApiKey", () => ({ hashApiKey: (k: string) => `hashed_${k}` })); +vi.mock("@/lib/const", () => ({ PRIVY_PROJECT_SECRET: "test_secret" })); +vi.mock("@/lib/supabase/account_api_keys/selectAccountApiKeys", () => ({ + selectAccountApiKeys: vi.fn(), +})); + +function req(apiKey?: string) { + const headers = new Headers(); + if (apiKey) headers.set("x-api-key", apiKey); + return new NextRequest("https://x.test/api", { headers }); +} + +const baseRow = { + id: "k", + account: "acc-1", + key_hash: "hashed_recoup_sk_x", + name: "n", + last_used: null, + created_at: "2026-01-01T00:00:00Z", +}; + +describe("getApiKeyAccountId", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns accountId for a non-expiring key (expires_at null)", async () => { + vi.mocked(selectAccountApiKeys).mockResolvedValue([{ ...baseRow, expires_at: null }]); + expect(await getApiKeyAccountId(req("recoup_sk_x"))).toBe("acc-1"); + }); + + it("returns accountId for a future expiry", async () => { + const future = new Date(Date.now() + 60_000).toISOString(); + vi.mocked(selectAccountApiKeys).mockResolvedValue([{ ...baseRow, expires_at: future }]); + expect(await getApiKeyAccountId(req("recoup_sk_x"))).toBe("acc-1"); + }); + + it("rejects an expired key with 401", async () => { + const past = new Date(Date.now() - 60_000).toISOString(); + vi.mocked(selectAccountApiKeys).mockResolvedValue([{ ...baseRow, expires_at: past }]); + const res = await getApiKeyAccountId(req("recoup_sk_x")); + expect(res).toBeInstanceOf(Response); + expect((res as Response).status).toBe(401); + }); + + it("401 when no x-api-key header", async () => { + const res = await getApiKeyAccountId(req()); + expect((res as Response).status).toBe(401); + }); +}); diff --git a/lib/auth/getApiKeyAccountId.ts b/lib/auth/getApiKeyAccountId.ts index 49af4106a..a5dce9ed6 100644 --- a/lib/auth/getApiKeyAccountId.ts +++ b/lib/auth/getApiKeyAccountId.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { hashApiKey } from "@/lib/keys/hashApiKey"; +import { isApiKeyExpired } from "@/lib/keys/isApiKeyExpired"; import { PRIVY_PROJECT_SECRET } from "@/lib/const"; import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAccountApiKeys"; @@ -45,9 +46,11 @@ export async function getApiKeyAccountId(request: NextRequest): Promise { + const now = Date.parse("2026-06-23T00:00:00Z"); + + it("treats null/undefined expiry as never-expiring", () => { + expect(isApiKeyExpired(null, now)).toBe(false); + expect(isApiKeyExpired(undefined, now)).toBe(false); + }); + + it("is false for a future expiry", () => { + expect(isApiKeyExpired("2026-06-23T01:00:00Z", now)).toBe(false); + }); + + it("is true at or past the expiry", () => { + expect(isApiKeyExpired("2026-06-22T23:59:59Z", now)).toBe(true); + expect(isApiKeyExpired("2026-06-23T00:00:00Z", now)).toBe(true); + }); + + it("treats an unparseable expiry as non-expiring (never lock out)", () => { + expect(isApiKeyExpired("not-a-date", now)).toBe(false); + }); +}); diff --git a/lib/keys/__tests__/mintEphemeralAccountKey.test.ts b/lib/keys/__tests__/mintEphemeralAccountKey.test.ts new file mode 100644 index 000000000..57a8777f7 --- /dev/null +++ b/lib/keys/__tests__/mintEphemeralAccountKey.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mintEphemeralAccountKey } from "@/lib/keys/mintEphemeralAccountKey"; +import { insertApiKey } from "@/lib/supabase/account_api_keys/insertApiKey"; + +vi.mock("@/lib/const", () => ({ PRIVY_PROJECT_SECRET: "test_secret" })); +vi.mock("@/lib/keys/hashApiKey", () => ({ + hashApiKey: vi.fn((k: string) => `hashed_${k}`), +})); +vi.mock("@/lib/supabase/account_api_keys/insertApiKey", () => ({ + insertApiKey: vi.fn(), +})); + +describe("mintEphemeralAccountKey", () => { + beforeEach(() => vi.clearAllMocks()); + + it("inserts an account-scoped recoup_sk_ key with a future expiry and returns the raw key + id", async () => { + vi.mocked(insertApiKey).mockResolvedValue({ data: { id: "key-1" } as never, error: null }); + const ttlMs = 15 * 60 * 1000; + const before = Date.now(); + + const result = await mintEphemeralAccountKey("acc-1", { + ttlMs, + name: "ephemeral:report:run-1", + }); + + expect(result.rawKey).toMatch(/^recoup_sk_/); + expect(result.keyId).toBe("key-1"); + + const arg = vi.mocked(insertApiKey).mock.calls[0][0]; + expect(arg.account).toBe("acc-1"); + expect(arg.name).toBe("ephemeral:report:run-1"); + expect(arg.key_hash).toBe(`hashed_${result.rawKey}`); + const exp = Date.parse(arg.expires_at as string); + expect(exp).toBeGreaterThan(before); + expect(exp).toBeLessThanOrEqual(Date.now() + ttlMs + 1000); + }); + + it("throws if the insert fails", async () => { + vi.mocked(insertApiKey).mockResolvedValue({ data: null, error: { message: "boom" } as never }); + await expect(mintEphemeralAccountKey("acc-1")).rejects.toThrow(/mint/i); + }); +}); diff --git a/lib/keys/isApiKeyExpired.ts b/lib/keys/isApiKeyExpired.ts new file mode 100644 index 000000000..57e7632ec --- /dev/null +++ b/lib/keys/isApiKeyExpired.ts @@ -0,0 +1,15 @@ +/** + * Whether an api key's `expires_at` has passed. NULL/undefined = never expires. + * An unparseable value is treated as non-expiring so a bad row can't lock a + * caller out. Used by api auth to reject ephemeral keys past their TTL + * (recoupable/chat#1813). + */ +export function isApiKeyExpired( + expiresAt: string | null | undefined, + now: number = Date.now(), +): boolean { + if (!expiresAt) return false; + const exp = Date.parse(expiresAt); + if (Number.isNaN(exp)) return false; + return exp <= now; +} diff --git a/lib/keys/mintEphemeralAccountKey.ts b/lib/keys/mintEphemeralAccountKey.ts new file mode 100644 index 000000000..ea8187f27 --- /dev/null +++ b/lib/keys/mintEphemeralAccountKey.ts @@ -0,0 +1,44 @@ +import { generateApiKey } from "@/lib/keys/generateApiKey"; +import { hashApiKey } from "@/lib/keys/hashApiKey"; +import { insertApiKey } from "@/lib/supabase/account_api_keys/insertApiKey"; +import { PRIVY_PROJECT_SECRET } from "@/lib/const"; + +/** Default lifetime for an ephemeral key: 15 minutes. */ +export const DEFAULT_EPHEMERAL_KEY_TTL_MS = 15 * 60 * 1000; + +export type EphemeralAccountKey = { rawKey: string; keyId: string }; + +/** + * Mint a short-lived, account-scoped `recoup_sk_` api key for a headless run + * (recoupable/chat#1813). Returns the raw key — to inject as `$RECOUP_API_KEY` + * into the sandbox — and the row id, so the caller can delete it on run end. + * The key also auto-expires via `account_api_keys.expires_at` (defense in depth + * if the delete is missed). The long-lived service key never enters the sandbox. + */ +export async function mintEphemeralAccountKey( + accountId: string, + { + ttlMs = DEFAULT_EPHEMERAL_KEY_TTL_MS, + name = "ephemeral:chat-generate", + }: { + ttlMs?: number; + name?: string; + } = {}, +): Promise { + const rawKey = generateApiKey("recoup_sk"); + const keyHash = hashApiKey(rawKey, PRIVY_PROJECT_SECRET); + const expiresAt = new Date(Date.now() + ttlMs).toISOString(); + + const { data, error } = await insertApiKey({ + name, + account: accountId, + key_hash: keyHash, + expires_at: expiresAt, + }); + + if (error || !data) { + throw new Error(`Failed to mint ephemeral api key: ${error?.message ?? "no row returned"}`); + } + + return { rawKey, keyId: data.id }; +} diff --git a/lib/supabase/account_api_keys/insertApiKey.ts b/lib/supabase/account_api_keys/insertApiKey.ts index 3904b2f46..3a058dede 100644 --- a/lib/supabase/account_api_keys/insertApiKey.ts +++ b/lib/supabase/account_api_keys/insertApiKey.ts @@ -8,12 +8,14 @@ import type { Database } from "@/types/database.types"; * @param input.name - The input object containing the name, account, and key_hash * @param input.account - The account ID * @param input.key_hash - The hash of the API key + * @param input.expires_at - Optional ISO expiry for ephemeral keys (NULL = never) * @returns The inserted API key */ export async function insertApiKey({ name, account, key_hash, + expires_at, }: Database["public"]["Tables"]["account_api_keys"]["Insert"]) { const { data, error } = await supabase .from("account_api_keys") @@ -21,6 +23,7 @@ export async function insertApiKey({ name, account, key_hash, + expires_at, }) .select() .single(); diff --git a/types/database.types.ts b/types/database.types.ts index 1771da89f..872750b72 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -12,6 +12,7 @@ export type Database = { Row: { account: string | null; created_at: string; + expires_at: string | null; id: string; key_hash: string | null; last_used: string | null; @@ -20,6 +21,7 @@ export type Database = { Insert: { account?: string | null; created_at?: string; + expires_at?: string | null; id?: string; key_hash?: string | null; last_used?: string | null; @@ -28,6 +30,7 @@ export type Database = { Update: { account?: string | null; created_at?: string; + expires_at?: string | null; id?: string; key_hash?: string | null; last_used?: string | null; From 9302dde2785c7ca580612b1332055b89d422e0fb Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 24 Jun 2026 08:30:06 -0500 Subject: [PATCH 2/2] refactor(auth): scope PR to expiry enforcement; defer key minting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove mintEphemeralAccountKey + its test and revert the insertApiKey expires_at writer change. Both are orphaned in this PR — mint has no caller anywhere, and insertApiKey's expires_at param is only ever passed by mint. They belong with the re-point PR (handleChatGenerate) that actually mints + injects + deletes the key, so this PR stays a complete, testable slice: enforce expires_at on x-api-key auth (getApiKeyAccountId + isApiKeyExpired). The minting code + its wiring spec are preserved in the tracking issue (recoupable/chat#1813). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/mintEphemeralAccountKey.test.ts | 42 ------------------ lib/keys/mintEphemeralAccountKey.ts | 44 ------------------- lib/supabase/account_api_keys/insertApiKey.ts | 3 -- 3 files changed, 89 deletions(-) delete mode 100644 lib/keys/__tests__/mintEphemeralAccountKey.test.ts delete mode 100644 lib/keys/mintEphemeralAccountKey.ts diff --git a/lib/keys/__tests__/mintEphemeralAccountKey.test.ts b/lib/keys/__tests__/mintEphemeralAccountKey.test.ts deleted file mode 100644 index 57a8777f7..000000000 --- a/lib/keys/__tests__/mintEphemeralAccountKey.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { mintEphemeralAccountKey } from "@/lib/keys/mintEphemeralAccountKey"; -import { insertApiKey } from "@/lib/supabase/account_api_keys/insertApiKey"; - -vi.mock("@/lib/const", () => ({ PRIVY_PROJECT_SECRET: "test_secret" })); -vi.mock("@/lib/keys/hashApiKey", () => ({ - hashApiKey: vi.fn((k: string) => `hashed_${k}`), -})); -vi.mock("@/lib/supabase/account_api_keys/insertApiKey", () => ({ - insertApiKey: vi.fn(), -})); - -describe("mintEphemeralAccountKey", () => { - beforeEach(() => vi.clearAllMocks()); - - it("inserts an account-scoped recoup_sk_ key with a future expiry and returns the raw key + id", async () => { - vi.mocked(insertApiKey).mockResolvedValue({ data: { id: "key-1" } as never, error: null }); - const ttlMs = 15 * 60 * 1000; - const before = Date.now(); - - const result = await mintEphemeralAccountKey("acc-1", { - ttlMs, - name: "ephemeral:report:run-1", - }); - - expect(result.rawKey).toMatch(/^recoup_sk_/); - expect(result.keyId).toBe("key-1"); - - const arg = vi.mocked(insertApiKey).mock.calls[0][0]; - expect(arg.account).toBe("acc-1"); - expect(arg.name).toBe("ephemeral:report:run-1"); - expect(arg.key_hash).toBe(`hashed_${result.rawKey}`); - const exp = Date.parse(arg.expires_at as string); - expect(exp).toBeGreaterThan(before); - expect(exp).toBeLessThanOrEqual(Date.now() + ttlMs + 1000); - }); - - it("throws if the insert fails", async () => { - vi.mocked(insertApiKey).mockResolvedValue({ data: null, error: { message: "boom" } as never }); - await expect(mintEphemeralAccountKey("acc-1")).rejects.toThrow(/mint/i); - }); -}); diff --git a/lib/keys/mintEphemeralAccountKey.ts b/lib/keys/mintEphemeralAccountKey.ts deleted file mode 100644 index ea8187f27..000000000 --- a/lib/keys/mintEphemeralAccountKey.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { generateApiKey } from "@/lib/keys/generateApiKey"; -import { hashApiKey } from "@/lib/keys/hashApiKey"; -import { insertApiKey } from "@/lib/supabase/account_api_keys/insertApiKey"; -import { PRIVY_PROJECT_SECRET } from "@/lib/const"; - -/** Default lifetime for an ephemeral key: 15 minutes. */ -export const DEFAULT_EPHEMERAL_KEY_TTL_MS = 15 * 60 * 1000; - -export type EphemeralAccountKey = { rawKey: string; keyId: string }; - -/** - * Mint a short-lived, account-scoped `recoup_sk_` api key for a headless run - * (recoupable/chat#1813). Returns the raw key — to inject as `$RECOUP_API_KEY` - * into the sandbox — and the row id, so the caller can delete it on run end. - * The key also auto-expires via `account_api_keys.expires_at` (defense in depth - * if the delete is missed). The long-lived service key never enters the sandbox. - */ -export async function mintEphemeralAccountKey( - accountId: string, - { - ttlMs = DEFAULT_EPHEMERAL_KEY_TTL_MS, - name = "ephemeral:chat-generate", - }: { - ttlMs?: number; - name?: string; - } = {}, -): Promise { - const rawKey = generateApiKey("recoup_sk"); - const keyHash = hashApiKey(rawKey, PRIVY_PROJECT_SECRET); - const expiresAt = new Date(Date.now() + ttlMs).toISOString(); - - const { data, error } = await insertApiKey({ - name, - account: accountId, - key_hash: keyHash, - expires_at: expiresAt, - }); - - if (error || !data) { - throw new Error(`Failed to mint ephemeral api key: ${error?.message ?? "no row returned"}`); - } - - return { rawKey, keyId: data.id }; -} diff --git a/lib/supabase/account_api_keys/insertApiKey.ts b/lib/supabase/account_api_keys/insertApiKey.ts index 3a058dede..3904b2f46 100644 --- a/lib/supabase/account_api_keys/insertApiKey.ts +++ b/lib/supabase/account_api_keys/insertApiKey.ts @@ -8,14 +8,12 @@ import type { Database } from "@/types/database.types"; * @param input.name - The input object containing the name, account, and key_hash * @param input.account - The account ID * @param input.key_hash - The hash of the API key - * @param input.expires_at - Optional ISO expiry for ephemeral keys (NULL = never) * @returns The inserted API key */ export async function insertApiKey({ name, account, key_hash, - expires_at, }: Database["public"]["Tables"]["account_api_keys"]["Insert"]) { const { data, error } = await supabase .from("account_api_keys") @@ -23,7 +21,6 @@ export async function insertApiKey({ name, account, key_hash, - expires_at, }) .select() .single();