Skip to content
Merged
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
54 changes: 54 additions & 0 deletions lib/auth/__tests__/getApiKeyAccountId.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
7 changes: 5 additions & 2 deletions lib/auth/getApiKeyAccountId.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -45,9 +46,11 @@ export async function getApiKeyAccountId(request: NextRequest): Promise<string |
);
}

const accountId = apiKeys[0]?.account ?? null;
const matched = apiKeys[0];
const accountId = matched?.account ?? null;

if (!accountId) {
// Reject an unknown key, or an ephemeral key past its TTL (chat#1813).
if (!accountId || isApiKeyExpired(matched?.expires_at)) {
return NextResponse.json(
{
status: "error",
Expand Down
24 changes: 24 additions & 0 deletions lib/keys/__tests__/isApiKeyExpired.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { isApiKeyExpired } from "@/lib/keys/isApiKeyExpired";

describe("isApiKeyExpired", () => {
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);
});
});
15 changes: 15 additions & 0 deletions lib/keys/isApiKeyExpired.ts
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Fail-open on unparseable expires_at allows an ephemeral key to become permanent if its timestamp is corrupted, undermining the short-lived key security model.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/keys/isApiKeyExpired.ts, line 13:

<comment>Fail-open on unparseable expires_at allows an ephemeral key to become permanent if its timestamp is corrupted, undermining the short-lived key security model.</comment>

<file context>
@@ -0,0 +1,15 @@
+): boolean {
+  if (!expiresAt) return false;
+  const exp = Date.parse(expiresAt);
+  if (Number.isNaN(exp)) return false;
+  return exp <= now;
+}
</file context>

return exp <= now;
}
3 changes: 3 additions & 0 deletions types/database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
Loading