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,148 @@
<script lang="ts">
import type { PageProps } from './$types';
import { enhance } from '$app/forms';
let { data }: PageProps = $props();
let newKey = $state('');
let mode = $state<'normal' | 'emergency' | null>(null);
let expiryDate = $state<string | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
function handleRotate(emergency: boolean) {
return ({ cancel }: any) => {
const msg = emergency
? 'EMERGENCY ROTATION: The current key will be revoked IMMEDIATELY. Continue?'
: 'Rotate the API key? The current key will remain valid for 48 hours.';
if (!confirm(msg)) {
cancel();
return;
}
return async ({ result }: any) => {
if (result.type === 'success' && result.status === 200) {
if (result.data?.newKey) {
newKey = result.data.newKey;
mode = result.data.mode ?? null;
expiryDate = result.data.expiresAt ?? null;
}
} else {
toastMessage = 'Rotation failed';
toastType = 'error';
}
};
};
}
function copyKey() {
navigator.clipboard.writeText(newKey);
toastMessage = 'Copied to clipboard';
toastType = 'success';
}
function dismissKey() {
newKey = '';
mode = null;
expiryDate = null;
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">API Key Management</h1>
{#if newKey}
<div
class="mb-6 border border-amber-300 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-900/30"
>
<p class="mb-2 text-sm font-semibold text-amber-800 dark:text-amber-300">
{mode === 'emergency' ? 'Emergency Rotation Complete' : 'New API Key Generated'}
</p>
<p class="mb-1 text-xs text-amber-700 dark:text-amber-400">
This key will not be shown again. Copy it now.
</p>
<div class="mb-3">
<code
class="rounded border border-amber-300 bg-white px-3 py-2 font-mono text-sm break-all dark:border-amber-600 dark:bg-gray-900 dark:text-gray-100"
>
{newKey}
</code>
</div>
<div class="flex gap-2">
<button
type="button"
onclick={copyKey}
class="border border-amber-300 bg-white px-3 py-1.5 text-sm font-medium hover:bg-amber-100 dark:border-amber-600 dark:bg-gray-800 dark:text-amber-300 dark:hover:bg-amber-900/50"
>
Copy to Clipboard
</button>
<button
type="button"
onclick={dismissKey}
class="border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:text-gray-300"
>
Dismiss
</button>
</div>
{#if mode === 'normal' && expiryDate}
<p class="mt-3 text-xs text-amber-700 dark:text-amber-400">
The previous key will remain valid until {new Date(expiryDate).toLocaleString()}
to give in-field devices time to update.
</p>
{/if}
</div>
{/if}
{#if toastMessage}
<div
class="mb-4 border px-4 py-2 text-sm {toastType === 'success'
? 'border-green-300 bg-green-50 text-green-700'
: 'border-red-300 bg-red-50 text-red-700'}"
>
{toastMessage}
</div>
{/if}
<div class="mb-6 rounded border border-gray-200 p-4 dark:border-gray-700">
<h2 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">Current Key Status</h2>
{#if data.activeKey}
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<div>
Prefix: <code class="font-mono font-medium text-gray-900 dark:text-gray-100"
>{data.activeKey.prefix}</code
>
</div>
<div>Created: {new Date(data.activeKey.createdAt).toLocaleString()}</div>
{#if data.activeKey.expiresAt}
<div>Expires: {new Date(data.activeKey.expiresAt).toLocaleString()}</div>
{/if}
</div>
{:else}
<p class="text-sm text-gray-500 dark:text-gray-400">No active API key. Generate one below.</p>
{/if}
{#if data.totalRotated > 0}
<p class="mt-2 text-xs text-gray-400">
{data.totalRotated} previous key(s) have been rotated.
</p>
{/if}
</div>
<div class="flex flex-wrap gap-3">
<form method="POST" action="?/rotate" use:enhance={handleRotate(false)}>
<button
type="submit"
class="border border-gray-300 bg-gray-100 px-4 py-2 text-sm font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Generate New API Key
</button>
</form>
<form method="POST" action="?/rotateEmergency" use:enhance={handleRotate(true)}>
<button
type="submit"
class="border border-red-300 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
>
Emergency Rotation
</button>
</form>
</div>
</div>