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,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 };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user