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/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/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;