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:
NeilClack
2026-07-08 13:18:19 -05:00
parent 6266be0edf
commit 46b2cdf53d
14 changed files with 1658 additions and 7 deletions
@@ -0,0 +1,72 @@
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import { rotateApiKey } from '$lib/server/api-key';
import { ROLES } from '$lib/svelte-admin/roles';
export const load: PageServerLoad = async ({ locals, parent }) => {
const { adminUser } = await parent();
if (adminUser.roleId > ROLES.SUPER) {
throw error(403, 'Only Super admins can manage API keys');
}
const now = new Date();
const activeKey = await prisma.apiKey.findFirst({
where: {
revokedAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }]
},
select: { keyPrefix: true, createdAt: true, expiresAt: true },
orderBy: { createdAt: 'desc' }
});
const expiredCount = await prisma.apiKey.count({
where: {
OR: [{ revokedAt: { not: null, lte: now } }, { expiresAt: { not: null, lte: now } }]
}
});
return {
activeKey: activeKey
? {
prefix: activeKey.keyPrefix,
createdAt: activeKey.createdAt.toISOString(),
expiresAt: activeKey.expiresAt?.toISOString() ?? null
}
: null,
totalRotated: expiredCount
};
};
async function viewerRoleId(locals: App.Locals): Promise<number> {
if (!locals.user?.id) return 4;
const user = await prisma.user.findUnique({
where: { id: locals.user.id },
select: { roleId: true }
});
return user?.roleId ?? 4;
}
export const actions = {
rotate: async ({ locals }) => {
if ((await viewerRoleId(locals)) > ROLES.SUPER) {
return { success: false, error: 'Not authorized' };
}
const result = await rotateApiKey(false);
return {
success: true,
newKey: result.plaintext,
mode: 'normal' as const,
expiresAt: result.previousExpiresAt?.toISOString() ?? null
};
},
rotateEmergency: async ({ locals }) => {
if ((await viewerRoleId(locals)) > ROLES.SUPER) {
return { success: false, error: 'Not authorized' };
}
const result = await rotateApiKey(true);
return { success: true, newKey: result.plaintext, mode: 'emergency' as const };
}
};