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 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ model Tenant {
|
|||||||
theme TenantTheme?
|
theme TenantTheme?
|
||||||
invites TenantInvite[]
|
invites TenantInvite[]
|
||||||
webhooks Webhook[]
|
webhooks Webhook[]
|
||||||
|
apiKeys ApiKey[]
|
||||||
|
|
||||||
@@map("tenants")
|
@@map("tenants")
|
||||||
}
|
}
|
||||||
@@ -75,6 +76,7 @@ model User {
|
|||||||
activities Activity[]
|
activities Activity[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
cardWatchers CardWatcher[]
|
cardWatchers CardWatcher[]
|
||||||
|
apiKeys ApiKey[]
|
||||||
|
|
||||||
@@unique([tenantId, email])
|
@@unique([tenantId, email])
|
||||||
@@map("users")
|
@@map("users")
|
||||||
@@ -476,6 +478,24 @@ model CardDependency {
|
|||||||
@@map("card_dependencies")
|
@@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 ───────────────────────────────
|
// ─── Tenant Branding & Theming ───────────────────────────────
|
||||||
|
|
||||||
model TenantTheme {
|
model TenantTheme {
|
||||||
|
|||||||
+34
-6
@@ -1,6 +1,6 @@
|
|||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
import { resolveTenant, seedSystemTemplates } from '$lib/server/tenant.js';
|
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 { logger } from '$lib/server/logger.js';
|
||||||
import { generateTenantCSS } from '$lib/server/theme-css.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);
|
const tenant = await resolveTenant(hostname);
|
||||||
event.locals.tenant = tenant;
|
event.locals.tenant = tenant;
|
||||||
|
|
||||||
// 2. Resolve user session
|
// 2. Resolve user session (API key or session cookie)
|
||||||
const sessionId = event.cookies.get(sessionCookieName());
|
const authHeader = event.request.headers.get('authorization');
|
||||||
if (sessionId) {
|
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);
|
const session = await validateSession(sessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
event.locals.session = { id: session.id };
|
event.locals.session = { id: session.id };
|
||||||
@@ -35,7 +65,6 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
avatarUrl: session.user.avatarUrl
|
avatarUrl: session.user.avatarUrl
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure user belongs to current tenant (unless site admin)
|
|
||||||
if (
|
if (
|
||||||
tenant &&
|
tenant &&
|
||||||
session.user.tenantId !== tenant.id &&
|
session.user.tenantId !== tenant.id &&
|
||||||
@@ -45,7 +74,6 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
event.locals.session = null;
|
event.locals.session = null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Invalid/expired session — clear cookie
|
|
||||||
event.cookies.delete(sessionCookieName(), { path: '/' });
|
event.cookies.delete(sessionCookieName(), { path: '/' });
|
||||||
event.locals.user = null;
|
event.locals.user = null;
|
||||||
event.locals.session = null;
|
event.locals.session = null;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
|
import crypto from 'crypto';
|
||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
import { withBypassRLS } from './rls.js';
|
import { withBypassRLS } from './rls.js';
|
||||||
|
|
||||||
@@ -70,3 +71,46 @@ export function sessionCookieOptions(expiresAt: Date) {
|
|||||||
expires: expiresAt
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user