From d8b3935fc5329773860b3e4fdc8e6afae366ae82 Mon Sep 17 00:00:00 2001 From: Catherine Renelle <32781029+catrenelle@users.noreply.github.com> Date: Sun, 8 Mar 2026 11:34:56 -0400 Subject: [PATCH] Add API key authentication for programmatic access - New ApiKey model in Prisma schema (tenant + user scoped) - Keys are SHA-256 hashed, only shown once on creation - Bearer token auth in hooks.server.ts (alongside session cookies) - CRUD endpoint at /api/account/api-keys (max 10 per user) - Keys prefixed with pnc_ for easy identification Co-Authored-By: Claude Opus 4.6 --- prisma/schema.prisma | 20 ++++++ src/hooks.server.ts | 40 +++++++++-- src/lib/server/auth.ts | 44 ++++++++++++ src/routes/api/account/api-keys/+server.ts | 80 ++++++++++++++++++++++ 4 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 src/routes/api/account/api-keys/+server.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e6b1a9c..119e71d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,6 +27,7 @@ model Tenant { theme TenantTheme? invites TenantInvite[] webhooks Webhook[] + apiKeys ApiKey[] @@map("tenants") } @@ -75,6 +76,7 @@ model User { activities Activity[] notifications Notification[] cardWatchers CardWatcher[] + apiKeys ApiKey[] @@unique([tenantId, email]) @@map("users") @@ -476,6 +478,24 @@ model CardDependency { @@map("card_dependencies") } +// ─── API Keys ──────────────────────────────────────────────── + +model ApiKey { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + userId String @map("user_id") @db.Uuid + name String + keyHash String @unique @map("key_hash") + keyPrefix String @map("key_prefix") // first 8 chars for display + lastUsedAt DateTime? @map("last_used_at") + createdAt DateTime @default(now()) @map("created_at") + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("api_keys") +} + // ─── Tenant Branding & Theming ─────────────────────────────── model TenantTheme { diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 5d7bf7c..45aaf28 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,6 +1,6 @@ import type { Handle } from '@sveltejs/kit'; import { resolveTenant, seedSystemTemplates } from '$lib/server/tenant.js'; -import { validateSession, sessionCookieName } from '$lib/server/auth.js'; +import { validateSession, validateApiKey, sessionCookieName } from '$lib/server/auth.js'; import { logger } from '$lib/server/logger.js'; import { generateTenantCSS } from '$lib/server/theme-css.js'; @@ -20,9 +20,39 @@ export const handle: Handle = async ({ event, resolve }) => { const tenant = await resolveTenant(hostname); event.locals.tenant = tenant; - // 2. Resolve user session - const sessionId = event.cookies.get(sessionCookieName()); - if (sessionId) { + // 2. Resolve user session (API key or session cookie) + const authHeader = event.request.headers.get('authorization'); + const apiKeyValue = authHeader?.startsWith('Bearer pnc_') ? authHeader.slice(7) : null; + + if (apiKeyValue) { + // API key auth (for programmatic access) + const result = await validateApiKey(apiKeyValue); + if (result) { + event.locals.session = { id: result.id }; + event.locals.user = { + id: result.user.id, + email: result.user.email, + name: result.user.name, + globalRole: result.user.globalRole, + tenantRole: result.user.tenantRole, + avatarUrl: result.user.avatarUrl + }; + + if ( + tenant && + result.user.tenantId !== tenant.id && + result.user.globalRole !== 'SITE_ADMIN' + ) { + event.locals.user = null; + event.locals.session = null; + } + } else { + event.locals.user = null; + event.locals.session = null; + } + } else if (event.cookies.get(sessionCookieName())) { + // Session cookie auth + const sessionId = event.cookies.get(sessionCookieName())!; const session = await validateSession(sessionId); if (session) { event.locals.session = { id: session.id }; @@ -35,7 +65,6 @@ export const handle: Handle = async ({ event, resolve }) => { avatarUrl: session.user.avatarUrl }; - // Ensure user belongs to current tenant (unless site admin) if ( tenant && session.user.tenantId !== tenant.id && @@ -45,7 +74,6 @@ export const handle: Handle = async ({ event, resolve }) => { event.locals.session = null; } } else { - // Invalid/expired session — clear cookie event.cookies.delete(sessionCookieName(), { path: '/' }); event.locals.user = null; event.locals.session = null; diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 567c55e..10b1cfa 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,4 +1,5 @@ import bcrypt from 'bcrypt'; +import crypto from 'crypto'; import { prisma } from './db.js'; import { withBypassRLS } from './rls.js'; @@ -70,3 +71,46 @@ export function sessionCookieOptions(expiresAt: Date) { expires: expiresAt }; } + +// ─── API Keys ──────────────────────────────────────────────── + +export function hashApiKey(key: string): string { + return crypto.createHash('sha256').update(key).digest('hex'); +} + +export function generateApiKey(): { key: string; prefix: string } { + const key = `pnc_${crypto.randomBytes(32).toString('hex')}`; + const prefix = key.slice(0, 12); + return { key, prefix }; +} + +export async function validateApiKey(key: string) { + const keyHash = hashApiKey(key); + + const apiKey = await prisma.apiKey.findUnique({ + where: { keyHash }, + include: { + user: { + select: { + id: true, + email: true, + name: true, + globalRole: true, + tenantRole: true, + tenantId: true, + avatarUrl: true + } + } + } + }); + + if (!apiKey) return null; + + // Update last used timestamp (fire and forget) + prisma.apiKey.update({ + where: { id: apiKey.id }, + data: { lastUsedAt: new Date() } + }).catch(() => {}); + + return apiKey; +} diff --git a/src/routes/api/account/api-keys/+server.ts b/src/routes/api/account/api-keys/+server.ts new file mode 100644 index 0000000..a054b07 --- /dev/null +++ b/src/routes/api/account/api-keys/+server.ts @@ -0,0 +1,80 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { prisma } from '$lib/server/db.js'; +import { requireAuth } from '$lib/server/guards.js'; +import { generateApiKey, hashApiKey } from '$lib/server/auth.js'; + +// List API keys for current user +export const GET: RequestHandler = async ({ locals }) => { + requireAuth(locals.user); + + const keys = await prisma.apiKey.findMany({ + where: { userId: locals.user.id }, + select: { + id: true, + name: true, + keyPrefix: true, + lastUsedAt: true, + createdAt: true + }, + orderBy: { createdAt: 'desc' } + }); + + return json({ keys }); +}; + +// Create a new API key +export const POST: RequestHandler = async ({ request, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + requireAuth(locals.user); + + const body = await request.json(); + const name = body.name?.trim(); + if (!name || name.length > 100) throw error(400, 'Name is required (max 100 chars)'); + + // Limit keys per user + const count = await prisma.apiKey.count({ where: { userId: locals.user.id } }); + if (count >= 10) throw error(400, 'Maximum 10 API keys per user'); + + const { key, prefix } = generateApiKey(); + const keyHash = hashApiKey(key); + + const apiKey = await prisma.apiKey.create({ + data: { + tenantId: tenant.id, + userId: locals.user.id, + name, + keyHash, + keyPrefix: prefix + }, + select: { + id: true, + name: true, + keyPrefix: true, + createdAt: true + } + }); + + // Return the full key ONLY on creation — it's never shown again + return json({ apiKey, key }, { status: 201 }); +}; + +// Delete an API key +export const DELETE: RequestHandler = async ({ locals, request }) => { + requireAuth(locals.user); + + const body = await request.json(); + const id = body.id; + if (!id) throw error(400, 'Key ID required'); + + // Ensure user owns this key + const existing = await prisma.apiKey.findFirst({ + where: { id, userId: locals.user.id } + }); + if (!existing) throw error(404, 'API key not found'); + + await prisma.apiKey.delete({ where: { id } }); + + return json({ ok: true }); +};