- 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
82 lines
2.1 KiB
TypeScript
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 };
|
|
}
|