import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { db } from '../db/index.js'; import { ensureAuthSchema } from './schema.js'; import { hashPassword, verifyPassword } from './passwords.js'; import { systemUsername, type AuthUser, type UserRole } from '../../auth.js'; export const SESSION_COOKIE = '__Host-ec_session'; export const SESSION_LIFETIME_MS = 12 * 60 * 60_000; export const SESSION_IDLE_MS = 30 * 60_000; const digest = (value: string) => createHash('sha256').update(value).digest('hex'); function database() { const handle = db(); ensureAuthSchema(handle); return handle; } type UserRow = { id: string; username: string; display_name: string; password_hash: string; role: UserRole; disabled: number; must_change_password: number; created_at: number; updated_at: number; last_login_at: number | null; }; export class AuthError extends Error { constructor( message: string, public status = 400 ) { super(message); } } function present(row: UserRow): AuthUser { return { id: row.id, username: row.username, displayName: row.display_name, role: row.role, disabled: !!row.disabled, mustChangePassword: !!row.must_change_password, createdAt: new Date(row.created_at).toISOString(), updatedAt: new Date(row.updated_at).toISOString(), lastLoginAt: row.last_login_at === null ? null : new Date(row.last_login_at).toISOString() }; } function userRow(id: string) { return database().prepare('SELECT * FROM auth_users WHERE id=?').get(id) as UserRow | undefined; } export function getUser(id: string): AuthUser | null { const row = userRow(id); return row ? present(row) : null; } export function listUsers(): AuthUser[] { return ( database().prepare('SELECT * FROM auth_users ORDER BY username').all() as UserRow[] ).map(present); } export function setupRequired(): boolean { return !database().prepare('SELECT 1 FROM auth_users LIMIT 1').get(); } function username(value: unknown): string { if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{2,63}$/.test(value)) throw new AuthError('Usernames need 3–64 letters, numbers, dots, underscores or hyphens.'); const normalized = value.toLowerCase(); if (normalized === systemUsername.toLowerCase()) throw new AuthError( `${systemUsername} is reserved for system actions. Choose another username.` ); return normalized; } function displayName(value: unknown, fallback: string): string { const name = value === undefined ? fallback : value; if ( typeof name !== 'string' || !name.trim() || name.length > 100 || // eslint-disable-next-line no-control-regex -- Reject control characters in display names. /[\x00-\x1f\x7f]/.test(name) ) throw new AuthError('Display name must contain 1–100 printable characters.'); return name.trim(); } function role(value: unknown): UserRole { if (!['admin', 'operator', 'viewer'].includes(value as string)) throw new AuthError('Choose administrator, operator or viewer.'); return value as UserRole; } export function recordAudit( actorId: string | null, action: string, targetId: string | null = null, details: Record = {} ): void { database() .prepare( 'INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(?,?,?,?,?)' ) .run(actorId, action, targetId, JSON.stringify(details), Date.now()); } export function listAuthAudit() { return database() .prepare( `SELECT a.id,a.actor_id AS actorId,u.username AS actor,a.action,a.target_id AS targetId,a.details,a.created_at AS createdAt FROM auth_audit a LEFT JOIN auth_users u ON u.id=a.actor_id ORDER BY a.id DESC LIMIT 200` ) .all(); } /** Persistent fixed windows are consumed before hashing, including failed setup. */ export function consumeAuthAttempt(clientAddress: string, account: string, kind = 'login'): void { const now = Date.now(); const windowMs = 15 * 60_000; const ipKey = digest(`${kind}:ip:${clientAddress}`); const accountKey = digest(`${kind}:account:${account.toLowerCase()}`); database() .transaction(() => { database() .prepare('DELETE FROM auth_rate_limits WHERE window_start < ?') .run(now - 24 * 60 * 60_000); for (const [key, limit] of [ [ipKey, 30], [accountKey, 10] ] as const) { const previous = database() .prepare('SELECT window_start,attempts FROM auth_rate_limits WHERE key=?') .get(key) as { window_start: number; attempts: number } | undefined; if ( previous && previous.window_start > now - windowMs && previous.attempts >= limit ) throw new AuthError('Too many attempts. Try again in 15 minutes.', 429); } for (const key of [ipKey, accountKey]) database() .prepare( `INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,1) ON CONFLICT(key) DO UPDATE SET attempts=CASE WHEN window_start <= ? THEN 1 ELSE attempts+1 END, window_start=CASE WHEN window_start <= ? THEN excluded.window_start ELSE window_start END` ) .run(key, now, now - windowMs, now - windowMs); }) .immediate(); } function insertUser(input: { username: string; displayName: string; role: UserRole; passwordHash: string; mustChangePassword: boolean; }): AuthUser { if (database().prepare('SELECT 1 FROM auth_users WHERE username=?').get(input.username)) throw new AuthError('That username is already in use.', 409); const id = randomUUID(); const now = Date.now(); database() .prepare( `INSERT INTO auth_users(id,username,display_name,password_hash,role,must_change_password,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)` ) .run( id, input.username, input.displayName, input.passwordHash, input.role, Number(input.mustChangePassword), now, now ); return getUser(id)!; } export interface AuthResult { user: AuthUser; session: { token: string; expiresAt: number }; } export async function bootstrapAdmin( input: Record, clientAddress: string ): Promise { consumeAuthAttempt(clientAddress, 'bootstrap', 'setup'); if (!setupRequired()) throw new AuthError('Initial setup is already complete.', 409); const expected = process.env.EC_SETUP_TOKEN; if (!expected || expected.length < 32) throw new AuthError( 'The controller owner must configure EC_SETUP_TOKEN with at least 32 characters before setup.', 503 ); if ( typeof input.token !== 'string' || !timingSafeEqual( Buffer.from(digest(input.token), 'hex'), Buffer.from(digest(expected), 'hex') ) ) throw new AuthError('Invalid setup token.', 401); const name = username(input.username); const label = displayName(input.displayName, name); const passwordHash = await hashPassword(input.password); return database() .transaction(() => { if (!setupRequired()) throw new AuthError('Initial setup is already complete.', 409); const user = insertUser({ username: name, displayName: label, passwordHash, role: 'admin', mustChangePassword: false }); recordAudit(user.id, 'setup', user.id); return { user, session: createSession(user.id) }; }) .immediate(); } export async function authenticatePassword( input: Record, clientAddress: string ): Promise { const name = typeof input.username === 'string' ? input.username.slice(0, 64).toLowerCase() : ''; consumeAuthAttempt(clientAddress, name); const row = database().prepare('SELECT * FROM auth_users WHERE username=?').get(name) as UserRow | undefined; const valid = await verifyPassword(input.password, row?.password_hash ?? null); const result = database() .transaction(() => { const current = row ? userRow(row.id) : undefined; if ( !valid || !current || current.disabled || current.password_hash !== row?.password_hash ) { recordAudit(null, 'login.failed'); return null; } database() .prepare('UPDATE auth_users SET last_login_at=? WHERE id=?') .run(Date.now(), current.id); recordAudit(current.id, 'login', current.id); return { user: getUser(current.id)!, session: createSession(current.id) }; }) .immediate(); if (!result) throw new AuthError('Invalid username or password.', 401); return result; } export function createSession(userId: string): { token: string; expiresAt: number } { const now = Date.now(); const user = userRow(userId); if (!user || user.disabled) throw new AuthError('Account is unavailable.', 401); const token = randomBytes(32).toString('base64url'); const expiresAt = now + SESSION_LIFETIME_MS; database() .prepare('DELETE FROM auth_sessions WHERE expires_at <= ? OR last_seen_at <= ?') .run(now, now - SESSION_IDLE_MS); database() .prepare( 'INSERT INTO auth_sessions(token_hash,user_id,created_at,expires_at,last_seen_at) VALUES(?,?,?,?,?)' ) .run(digest(token), userId, now, expiresAt, now); return { token, expiresAt }; } export function readSession( token: string | undefined, touch = true ): { user: AuthUser; tokenHash: string; expiresAt: number } | null { if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null; return readSessionHash(digest(token), touch); } export function readSessionHash( tokenHash: string, touch = false ): { user: AuthUser; tokenHash: string; expiresAt: number } | null { const now = Date.now(); const row = database() .prepare( `SELECT u.*,s.expires_at,s.last_seen_at FROM auth_sessions s JOIN auth_users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>? AND s.last_seen_at>? AND u.disabled=0` ) .get(tokenHash, now, now - SESSION_IDLE_MS) as (UserRow & { expires_at: number; last_seen_at: number }) | undefined; if (!row) return null; if (touch && row.last_seen_at < now - 60_000) database() .prepare('UPDATE auth_sessions SET last_seen_at=? WHERE token_hash=?') .run(now, tokenHash); return { user: present(row), tokenHash, expiresAt: row.expires_at }; } export function revokeSession(token: string | undefined): void { if (token) database().prepare('DELETE FROM auth_sessions WHERE token_hash=?').run(digest(token)); } export function revokeUserSessions(actorId: string, userId: string): void { database().transaction(() => { requireAdministrator(actorId); if (!userRow(userId)) throw new AuthError('User not found.', 404); database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId); recordAudit(actorId, 'sessions.revoked', userId); })(); } function requireAdministrator(actorId: string) { const actor = userRow(actorId); if (!actor || actor.disabled || actor.role !== 'admin' || actor.must_change_password) throw new AuthError('Administrator access is required.', 403); } export async function createUser( actorId: string, input: Record ): Promise { requireAdministrator(actorId); const name = username(input.username), label = displayName(input.displayName, name), userRole = role(input.role); const passwordHash = await hashPassword(input.password); return database() .transaction(() => { requireAdministrator(actorId); const user = insertUser({ username: name, displayName: label, role: userRole, passwordHash, mustChangePassword: true }); recordAudit(actorId, 'user.created', user.id, { role: userRole }); return user; }) .immediate(); } export async function updateUser( actorId: string, id: string, input: Record ): Promise { requireAdministrator(actorId); if (input.disabled !== undefined && typeof input.disabled !== 'boolean') throw new AuthError('Disabled must be true or false.'); if (input.password !== undefined && actorId === id) throw new AuthError('Change your own password from Account using your current password.'); const passwordHash = input.password === undefined ? null : await hashPassword(input.password); return database() .transaction(() => { requireAdministrator(actorId); const current = userRow(id); if (!current) throw new AuthError('User not found.', 404); const nextRole = input.role === undefined ? current.role : role(input.role); const disabled = input.disabled === undefined ? current.disabled : Number(input.disabled); const label = displayName(input.displayName, current.display_name); if ( current.role === 'admin' && !current.disabled && (nextRole !== 'admin' || disabled) ) { const other = database() .prepare( "SELECT 1 FROM auth_users WHERE role='admin' AND disabled=0 AND id!=? LIMIT 1" ) .get(id); if (!other) throw new AuthError('Keep at least one enabled administrator.', 409); } database() .prepare( `UPDATE auth_users SET display_name=?,role=?,disabled=?,password_hash=?,must_change_password=?,updated_at=? WHERE id=?` ) .run( label, nextRole, disabled, passwordHash ?? current.password_hash, passwordHash ? 1 : current.must_change_password, Date.now(), id ); if (passwordHash || disabled !== current.disabled || nextRole !== current.role) database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(id); recordAudit(actorId, 'user.updated', id, { role: nextRole, disabled: !!disabled, passwordReset: !!passwordHash }); return getUser(id)!; }) .immediate(); } export async function changePassword( userId: string, currentPassword: unknown, newPassword: unknown ): Promise { const original = userRow(userId); if ( !original || original.disabled || !(await verifyPassword(currentPassword, original.password_hash)) ) throw new AuthError('Current password is incorrect.', 400); if (currentPassword === newPassword) throw new AuthError('Choose a different password.'); const passwordHash = await hashPassword(newPassword); return database() .transaction(() => { const current = userRow(userId); if (!current || current.disabled || current.password_hash !== original.password_hash) throw new AuthError( 'Account changed during password verification. Sign in again.', 409 ); database() .prepare( 'UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=?' ) .run(passwordHash, Date.now(), userId); database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId); recordAudit(userId, 'password.changed', userId); return { user: getUser(userId)!, session: createSession(userId) }; }) .immediate(); }