import crypto from "node:crypto"; import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import argon2 from "argon2"; import { ulid } from "ulid"; import { z } from "zod"; import type { User } from "@pubscript/core"; import type { Db } from "./db.js"; import { toUser, type UserRow } from "./mapping.js"; const SESSION_COOKIE = "ps_session"; const SESSION_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days // ---- password hashing ---- export const hashPassword = (pw: string): Promise => argon2.hash(pw, { type: argon2.argon2id }); export const verifyPassword = (hash: string, pw: string): Promise => argon2.verify(hash, pw); // ---- session tokens (store only the SHA-256 hash) ---- export function newToken(): string { return crypto.randomBytes(32).toString("hex"); } function tokenHash(token: string): string { return crypto.createHash("sha256").update(token).digest("hex"); } export function createSession(db: Db, userId: string): string { const token = newToken(); const now = Date.now(); db.prepare("INSERT INTO sessions (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)").run( tokenHash(token), userId, new Date(now + SESSION_TTL_MS).toISOString(), new Date(now).toISOString(), ); return token; } export function destroySession(db: Db, token: string): void { db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash(token)); } /** Resolve a presented token to a live, non-disabled user (or null). */ export function userFromToken(db: Db, token: string | null | undefined): UserRow | null { if (!token) return null; const row = db .prepare( `SELECT u.id, u.email, u.pass_hash, u.display_name, u.role, u.disabled, u.created_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires_at > ? AND u.disabled = 0`, ) .get(tokenHash(token), new Date().toISOString()) as UserRow | undefined; return row ?? null; } // ---- tiny in-memory fixed-window rate limiter ---- export function makeRateLimiter(windowMs: number, max: number): (key: string) => boolean { const hits = new Map(); return (key: string): boolean => { const now = Date.now(); const e = hits.get(key); if (!e || now > e.resetAt) { hits.set(key, { count: 1, resetAt: now + windowMs }); return true; } e.count += 1; return e.count <= max; }; } // ---- request hook: populate req.user from bearer header or cookie ---- export function bearerToken(req: FastifyRequest): string | null { const h = req.headers.authorization; if (h?.startsWith("Bearer ")) return h.slice(7); return null; } export function registerAuthHook(app: FastifyInstance): void { app.addHook("onRequest", async (req) => { const db = app.db; const token = bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null; const row = userFromToken(db, token); req.user = row ? toUser(row) : null; }); } export function setSessionCookie(reply: FastifyReply, token: string): void { reply.setCookie(SESSION_COOKIE, token, { path: "/", httpOnly: true, sameSite: "lax", maxAge: SESSION_TTL_MS / 1000, }); } export function clearSessionCookie(reply: FastifyReply): void { reply.clearCookie(SESSION_COOKIE, { path: "/" }); } // ---- guards ---- export function requireAuth(req: FastifyRequest): void { if (!req.user) throw req.server.httpErrors.create(401, { error: "unauthorized" }); } export function requireAdmin(req: FastifyRequest): void { requireAuth(req); if (req.user!.role !== "admin") throw req.server.httpErrors.create(403, { error: "forbidden" }); } export function canAccessProject(db: Db, user: User, projectId: string, needWrite = false): boolean { const proj = db.prepare("SELECT owner_id FROM projects WHERE id = ?").get(projectId) as | { owner_id: string } | undefined; if (!proj) return false; if (proj.owner_id === user.id) return true; const share = db .prepare("SELECT perm FROM project_shares WHERE project_id = ? AND user_id = ?") .get(projectId, user.id) as { perm: "read" | "edit" } | undefined; if (!share) return false; return needWrite ? share.perm === "edit" : true; } // ---- auth routes ---- const registerSchema = z.object({ email: z.string().email(), password: z.string().min(8), displayName: z.string().min(1).max(80), }); const loginSchema = z.object({ email: z.string().email(), password: z.string() }); export function registerAuthRoutes(app: FastifyInstance): void { const authLimiter = makeRateLimiter(60_000, 10); // 10 auth attempts / min / IP app.post("/api/auth/register", async (req, reply) => { if (!authLimiter(req.ip)) throw app.httpErrors.create(429, { error: "rate_limited" }); const body = registerSchema.parse(req.body); const db = app.db; const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(body.email); if (existing) throw app.httpErrors.create(409, { error: "email_taken" }); const isFirst = (db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }).n === 0; const role = isFirst ? "admin" : "author"; const id = ulid(); const now = new Date().toISOString(); const passHash = await hashPassword(body.password); db.prepare( "INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)", ).run(id, body.email, passHash, body.displayName, role, now); db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'auth.register', ?)").run( ulid(), now, id, JSON.stringify({ email: body.email, first: isFirst }), ); const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow; const token = createSession(db, id); setSessionCookie(reply, token); return { token, user: toUser(row) }; }); app.post("/api/auth/login", async (req, reply) => { if (!authLimiter(req.ip)) throw app.httpErrors.create(429, { error: "rate_limited" }); const body = loginSchema.parse(req.body); const db = app.db; const row = db.prepare("SELECT * FROM users WHERE email = ?").get(body.email) as UserRow | undefined; if (!row || row.disabled || !(await verifyPassword(row.pass_hash, body.password))) { throw app.httpErrors.create(401, { error: "bad_credentials" }); } const token = createSession(db, row.id); setSessionCookie(reply, token); return { token, user: toUser(row) }; }); app.post("/api/auth/logout", async (req, reply) => { const token = bearerToken(req) ?? (req.cookies[SESSION_COOKIE] as string | undefined) ?? null; if (token) destroySession(app.db, token); clearSessionCookie(reply); return { ok: true }; }); app.get("/api/auth/me", async (req) => { requireAuth(req); return { user: req.user }; }); // Bootstrap or sign into the local admin account when no admin session is active yet. app.post("/api/auth/bootstrap-admin", async (_req, reply) => { const db = app.db; let adminRow = db .prepare("SELECT * FROM users WHERE role = 'admin' AND disabled = 0 ORDER BY created_at ASC LIMIT 1") .get() as UserRow | undefined; if (!adminRow) { const id = ulid(); const now = new Date().toISOString(); const email = process.env.PUBSCRIPT_ADMIN_EMAIL ?? "admin@pubscript.local"; const password = process.env.PUBSCRIPT_ADMIN_PASSWORD ?? "admin12345"; const passHash = await hashPassword(password); db.prepare( "INSERT INTO users (id, email, pass_hash, display_name, role, disabled, created_at) VALUES (?, ?, ?, ?, 'admin', 0, ?)", ).run(id, email, passHash, "Local Admin", now); db.prepare("INSERT INTO audit (id, at, user_id, action, detail) VALUES (?, ?, ?, 'admin.bootstrap', ?)").run( ulid(), now, id, JSON.stringify({ email }), ); adminRow = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow; } const token = createSession(db, adminRow.id); setSessionCookie(reply, token); return { token, user: toUser(adminRow) }; }); }