d8b3935fc5
- 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>
117 lines
2.8 KiB
TypeScript
117 lines
2.8 KiB
TypeScript
import bcrypt from 'bcrypt';
|
|
import crypto from 'crypto';
|
|
import { prisma } from './db.js';
|
|
import { withBypassRLS } from './rls.js';
|
|
|
|
const SALT_ROUNDS = 12;
|
|
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
|
|
export async function hashPassword(password: string): Promise<string> {
|
|
return bcrypt.hash(password, SALT_ROUNDS);
|
|
}
|
|
|
|
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
|
return bcrypt.compare(password, hash);
|
|
}
|
|
|
|
export async function createSession(userId: string): Promise<{ id: string; expiresAt: Date }> {
|
|
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
|
|
const session = await prisma.session.create({
|
|
data: { userId, expiresAt }
|
|
});
|
|
return { id: session.id, expiresAt };
|
|
}
|
|
|
|
export async function validateSession(sessionId: string) {
|
|
// Uses withBypassRLS because this joins to the users table (RLS-protected)
|
|
// and runs before tenant context is established in hooks.server.ts.
|
|
return withBypassRLS(async (tx) => {
|
|
const session = await tx.session.findUnique({
|
|
where: { id: sessionId },
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
name: true,
|
|
globalRole: true,
|
|
tenantRole: true,
|
|
tenantId: true,
|
|
avatarUrl: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if (!session) return null;
|
|
|
|
if (session.expiresAt < new Date()) {
|
|
await tx.session.delete({ where: { id: sessionId } });
|
|
return null;
|
|
}
|
|
|
|
return session;
|
|
});
|
|
}
|
|
|
|
export async function deleteSession(sessionId: string) {
|
|
await prisma.session.delete({ where: { id: sessionId } }).catch(() => {});
|
|
}
|
|
|
|
export function sessionCookieName(): string {
|
|
return 'session';
|
|
}
|
|
|
|
export function sessionCookieOptions(expiresAt: Date) {
|
|
return {
|
|
path: '/',
|
|
httpOnly: true,
|
|
sameSite: 'lax' as const,
|
|
secure: !import.meta.env.DEV,
|
|
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;
|
|
}
|