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
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user