Files
portal/src/lib/server/api-key.ts
T
NeilClack 46b2cdf53d api: API key authentication with rotation and grace period
- Add ApiKey Prisma model (keyHash, expiresAt, revokedAt)
- Protect /api/v1/* routes with Authorization: Bearer header check in hooks
- create rotateApiKey() utility: normal (48h grace) and emergency (immediate)
- Add Super-only /admin/api-keys page for key rotation with one-time reveal
- Fix roleId check by using parent layout's DB-fetched adminUser
- Add API Keys link to admin sidebar
2026-07-08 13:18:19 -05:00

82 lines
2.1 KiB
TypeScript

import { randomBytes, createHash } from 'node:crypto';
import { prisma } from '$lib/db';
export type ApiKeyRecord = {
id: string;
keyPrefix: string;
createdAt: Date;
expiresAt: Date | null;
revokedAt: Date | null;
};
export type GeneratedKey = {
plaintext: string;
prefix: string;
hash: string;
};
export function generateApiKey(): GeneratedKey {
const raw = randomBytes(32);
const hex = raw.toString('hex');
const plaintext = `sk_${hex}`;
const prefix = plaintext.slice(0, 11);
const hash = createHash('sha256').update(plaintext).digest('hex');
return { plaintext, prefix, hash };
}
export async function validateApiKey(key: string): Promise<ApiKeyRecord | null> {
const hash = createHash('sha256').update(key).digest('hex');
const record = await prisma.apiKey.findUnique({ where: { keyHash: hash } });
if (!record) return null;
if (record.revokedAt && record.revokedAt <= new Date()) return null;
if (record.expiresAt && record.expiresAt <= new Date()) return null;
return record;
}
export async function rotateApiKey(emergency = false): Promise<{
plaintext: string;
prefix: string;
previousExpiresAt: Date | null;
previousRevokedAt: Date | null;
}> {
const now = new Date();
const activeKeys = await prisma.apiKey.findMany({
where: {
revokedAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }]
}
});
let previousExpiresAt: Date | null = null;
let previousRevokedAt: Date | null = null;
if (emergency) {
previousRevokedAt = now;
for (const key of activeKeys) {
await prisma.apiKey.update({
where: { id: key.id },
data: { revokedAt: now }
});
}
previousExpiresAt = null;
} else {
const graceEnd = new Date(now.getTime() + 48 * 60 * 60 * 1000);
previousExpiresAt = graceEnd;
for (const key of activeKeys) {
await prisma.apiKey.update({
where: { id: key.id },
data: { expiresAt: graceEnd }
});
}
previousRevokedAt = null;
}
const { plaintext, prefix, hash } = generateApiKey();
await prisma.apiKey.create({
data: { keyPrefix: prefix, keyHash: hash }
});
return { plaintext, prefix, previousExpiresAt, previousRevokedAt };
}