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,14 @@
-- CreateTable
CREATE TABLE "ApiKey" (
"id" TEXT NOT NULL,
"keyPrefix" TEXT NOT NULL,
"keyHash" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
+9
View File
@@ -178,6 +178,15 @@ model LicenseKey {
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
} }
model ApiKey {
id String @id @default(cuid())
keyPrefix String
keyHash String @unique
createdAt DateTime @default(now())
expiresAt DateTime?
revokedAt DateTime?
}
model SubscriptionLevel { model SubscriptionLevel {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
+22 -2
View File
@@ -1,14 +1,34 @@
import { auth } from '$lib/auth'; import { auth } from '$lib/auth';
import { svelteKitHandler } from 'better-auth/svelte-kit'; import { svelteKitHandler } from 'better-auth/svelte-kit';
import { building } from '$app/environment'; import { building } from '$app/environment';
import { validateApiKey } from '$lib/server/api-key';
export async function handle({ event, resolve }) { export async function handle({ event, resolve }) {
// Fetch current session from Better Auth if (event.url.pathname.startsWith('/api/v1/')) {
const authHeader = event.request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new Response(JSON.stringify({ error: 'API key required' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
}
const key = authHeader.slice(7);
const record = await validateApiKey(key);
if (!record) {
return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
}
return resolve(event);
}
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: event.request.headers headers: event.request.headers
}); });
// Make session and user available on server
if (session) { if (session) {
event.locals.session = session.session; event.locals.session = session.session;
event.locals.user = session.user; event.locals.user = session.user;
+5
View File
@@ -82,6 +82,11 @@ export type ProclamationCategorySubscriptionLevel = Prisma.ProclamationCategoryS
* *
*/ */
export type LicenseKey = Prisma.LicenseKeyModel export type LicenseKey = Prisma.LicenseKeyModel
/**
* Model ApiKey
*
*/
export type ApiKey = Prisma.ApiKeyModel
/** /**
* Model SubscriptionLevel * Model SubscriptionLevel
* *
+5
View File
@@ -106,6 +106,11 @@ export type ProclamationCategorySubscriptionLevel = Prisma.ProclamationCategoryS
* *
*/ */
export type LicenseKey = Prisma.LicenseKeyModel export type LicenseKey = Prisma.LicenseKeyModel
/**
* Model ApiKey
*
*/
export type ApiKey = Prisma.ApiKeyModel
/** /**
* Model SubscriptionLevel * Model SubscriptionLevel
* *
File diff suppressed because one or more lines are too long
@@ -397,6 +397,7 @@ export const ModelName = {
ProclamationCategory: 'ProclamationCategory', ProclamationCategory: 'ProclamationCategory',
ProclamationCategorySubscriptionLevel: 'ProclamationCategorySubscriptionLevel', ProclamationCategorySubscriptionLevel: 'ProclamationCategorySubscriptionLevel',
LicenseKey: 'LicenseKey', LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey',
SubscriptionLevel: 'SubscriptionLevel' SubscriptionLevel: 'SubscriptionLevel'
} as const } as const
@@ -413,7 +414,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions omit: GlobalOmitOptions
} }
meta: { meta: {
modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "userProclamations" | "solfeggioFrequency" | "proclamationCategory" | "proclamationCategorySubscriptionLevel" | "licenseKey" | "subscriptionLevel" modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "userProclamations" | "solfeggioFrequency" | "proclamationCategory" | "proclamationCategorySubscriptionLevel" | "licenseKey" | "apiKey" | "subscriptionLevel"
txIsolationLevel: TransactionIsolationLevel txIsolationLevel: TransactionIsolationLevel
} }
model: { model: {
@@ -1379,6 +1380,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
} }
} }
} }
ApiKey: {
payload: Prisma.$ApiKeyPayload<ExtArgs>
fields: Prisma.ApiKeyFieldRefs
operations: {
findUnique: {
args: Prisma.ApiKeyFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload> | null
}
findUniqueOrThrow: {
args: Prisma.ApiKeyFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>
}
findFirst: {
args: Prisma.ApiKeyFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload> | null
}
findFirstOrThrow: {
args: Prisma.ApiKeyFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>
}
findMany: {
args: Prisma.ApiKeyFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>[]
}
create: {
args: Prisma.ApiKeyCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>
}
createMany: {
args: Prisma.ApiKeyCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.ApiKeyCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>[]
}
delete: {
args: Prisma.ApiKeyDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>
}
update: {
args: Prisma.ApiKeyUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>
}
deleteMany: {
args: Prisma.ApiKeyDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ApiKeyUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.ApiKeyUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>[]
}
upsert: {
args: Prisma.ApiKeyUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ApiKeyPayload>
}
aggregate: {
args: Prisma.ApiKeyAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateApiKey>
}
groupBy: {
args: Prisma.ApiKeyGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ApiKeyGroupByOutputType>[]
}
count: {
args: Prisma.ApiKeyCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ApiKeyCountAggregateOutputType> | number
}
}
}
SubscriptionLevel: { SubscriptionLevel: {
payload: Prisma.$SubscriptionLevelPayload<ExtArgs> payload: Prisma.$SubscriptionLevelPayload<ExtArgs>
fields: Prisma.SubscriptionLevelFieldRefs fields: Prisma.SubscriptionLevelFieldRefs
@@ -1648,6 +1723,18 @@ export const LicenseKeyScalarFieldEnum = {
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum] export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
export const ApiKeyScalarFieldEnum = {
id: 'id',
keyPrefix: 'keyPrefix',
keyHash: 'keyHash',
createdAt: 'createdAt',
expiresAt: 'expiresAt',
revokedAt: 'revokedAt'
} as const
export type ApiKeyScalarFieldEnum = (typeof ApiKeyScalarFieldEnum)[keyof typeof ApiKeyScalarFieldEnum]
export const SubscriptionLevelScalarFieldEnum = { export const SubscriptionLevelScalarFieldEnum = {
id: 'id', id: 'id',
name: 'name', name: 'name',
@@ -1872,6 +1959,7 @@ export type GlobalOmitConfig = {
proclamationCategory?: Prisma.ProclamationCategoryOmit proclamationCategory?: Prisma.ProclamationCategoryOmit
proclamationCategorySubscriptionLevel?: Prisma.ProclamationCategorySubscriptionLevelOmit proclamationCategorySubscriptionLevel?: Prisma.ProclamationCategorySubscriptionLevelOmit
licenseKey?: Prisma.LicenseKeyOmit licenseKey?: Prisma.LicenseKeyOmit
apiKey?: Prisma.ApiKeyOmit
subscriptionLevel?: Prisma.SubscriptionLevelOmit subscriptionLevel?: Prisma.SubscriptionLevelOmit
} }
@@ -64,6 +64,7 @@ export const ModelName = {
ProclamationCategory: 'ProclamationCategory', ProclamationCategory: 'ProclamationCategory',
ProclamationCategorySubscriptionLevel: 'ProclamationCategorySubscriptionLevel', ProclamationCategorySubscriptionLevel: 'ProclamationCategorySubscriptionLevel',
LicenseKey: 'LicenseKey', LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey',
SubscriptionLevel: 'SubscriptionLevel' SubscriptionLevel: 'SubscriptionLevel'
} as const } as const
@@ -239,6 +240,18 @@ export const LicenseKeyScalarFieldEnum = {
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum] export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
export const ApiKeyScalarFieldEnum = {
id: 'id',
keyPrefix: 'keyPrefix',
keyHash: 'keyHash',
createdAt: 'createdAt',
expiresAt: 'expiresAt',
revokedAt: 'revokedAt'
} as const
export type ApiKeyScalarFieldEnum = (typeof ApiKeyScalarFieldEnum)[keyof typeof ApiKeyScalarFieldEnum]
export const SubscriptionLevelScalarFieldEnum = { export const SubscriptionLevelScalarFieldEnum = {
id: 'id', id: 'id',
name: 'name', name: 'name',
+1
View File
@@ -21,5 +21,6 @@ export type * from './models/SolfeggioFrequency.ts'
export type * from './models/ProclamationCategory.ts' export type * from './models/ProclamationCategory.ts'
export type * from './models/ProclamationCategorySubscriptionLevel.ts' export type * from './models/ProclamationCategorySubscriptionLevel.ts'
export type * from './models/LicenseKey.ts' export type * from './models/LicenseKey.ts'
export type * from './models/ApiKey.ts'
export type * from './models/SubscriptionLevel.ts' export type * from './models/SubscriptionLevel.ts'
export type * from './commonInputTypes.ts' export type * from './commonInputTypes.ts'
File diff suppressed because it is too large Load Diff
+81
View File
@@ -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 };
}
+8
View File
@@ -55,6 +55,14 @@
{model.displayName} {model.displayName}
</a> </a>
{/each} {/each}
<a
href="/admin/api-keys"
class="rounded px-3 py-1 text-sm transition-colors {isActive('/admin/api-keys')
? 'bg-blue-50 text-blue-700 dark:bg-gray-800 dark:text-gray-100'
: 'text-blue-600 hover:bg-gray-50 hover:text-blue-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'}"
>
API Keys
</a>
</div> </div>
</div> </div>
{/if} {/if}
@@ -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 };
}
};
@@ -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>