import { randomUUID } from 'node:crypto' import type { AppDatabase } from './database.js' import { hashPassword, keyedDigest, randomToken, verifyPassword } from './security.js' type AdminRow = { id: string email: string display_name: string password_hash: string active: number } type SessionRow = { token_hash: string admin_id: string csrf_token: string expires_at: string email: string display_name: string active: number } const dummyPasswordHash = 'scrypt$16384$8$1$ZvSK6l0GBRPxN-shgTmzUg$mAidkO9abocDjDqu3b_HX-rlkKYQAEGDRX3TKWc48doWD6Y8Ej9_Kp72M1M67kAP46vRIFXANs8kGlsMMdAXXg' export type AuthenticatedAdmin = { id: string email: string displayName: string csrfToken: string } export type CreatedSession = AuthenticatedAdmin & { token: string expiresAt: string } export class AuthService { private lastSessionPruneAt = 0 constructor( private readonly database: AppDatabase, private readonly secret: string, private readonly sessionTtlMs: number ) {} async createAdmin(input: { email: string displayName: string password: string }): Promise { const email = input.email.trim().toLowerCase() const now = new Date().toISOString() const passwordHash = await hashPassword(input.password) this.database.transaction(() => { this.database .prepare( `INSERT INTO admins ( id, email, display_name, password_hash, active, created_at, updated_at ) VALUES (?, ?, ?, ?, 1, ?, ?) ON CONFLICT(email) DO UPDATE SET display_name = excluded.display_name, password_hash = excluded.password_hash, active = 1, updated_at = excluded.updated_at` ) .run( randomUUID(), email, input.displayName.trim(), passwordHash, now, now ) const admin = this.database .prepare('SELECT id FROM admins WHERE email = ?') .get(email) as { id: string } this.database .prepare('DELETE FROM admin_sessions WHERE admin_id = ?') .run(admin.id) })() } async login(email: string, password: string): Promise { this.pruneExpiredSessionsIfDue() const admin = this.database .prepare( `SELECT id, email, display_name, password_hash, active FROM admins WHERE email = ?` ) .get(email.trim().toLowerCase()) as AdminRow | undefined if (!admin || admin.active !== 1) { await verifyPassword(password, dummyPasswordHash) return undefined } if (!(await verifyPassword(password, admin.password_hash))) { return undefined } const token = randomToken() const csrfToken = randomToken(24) const now = new Date() const expiresAt = new Date(now.getTime() + this.sessionTtlMs).toISOString() this.database .prepare( `INSERT INTO admin_sessions ( token_hash, admin_id, csrf_token, expires_at, created_at ) VALUES (?, ?, ?, ?, ?)` ) .run( keyedDigest(this.secret, token), admin.id, csrfToken, expiresAt, now.toISOString() ) return { id: admin.id, email: admin.email, displayName: admin.display_name, csrfToken, token, expiresAt } } authenticate(token: string | undefined): AuthenticatedAdmin | undefined { this.pruneExpiredSessionsIfDue() if (!token) { return undefined } const tokenHash = keyedDigest(this.secret, token) const session = this.database .prepare( `SELECT s.token_hash, s.admin_id, s.csrf_token, s.expires_at, a.email, a.display_name, a.active FROM admin_sessions s JOIN admins a ON a.id = s.admin_id WHERE s.token_hash = ?` ) .get(tokenHash) as SessionRow | undefined if ( !session || session.active !== 1 || Date.parse(session.expires_at) <= Date.now() ) { if (session) { this.database .prepare('DELETE FROM admin_sessions WHERE token_hash = ?') .run(tokenHash) } return undefined } return { id: session.admin_id, email: session.email, displayName: session.display_name, csrfToken: session.csrf_token } } logout(token: string | undefined): void { if (!token) { return } this.database .prepare('DELETE FROM admin_sessions WHERE token_hash = ?') .run(keyedDigest(this.secret, token)) } pruneExpiredSessions(now = new Date().toISOString()): void { this.database .prepare('DELETE FROM admin_sessions WHERE expires_at <= ?') .run(now) this.lastSessionPruneAt = Date.now() } private pruneExpiredSessionsIfDue(now = Date.now()): void { if (now - this.lastSessionPruneAt >= 60 * 60 * 1_000) { this.pruneExpiredSessions(new Date(now).toISOString()) } } }