import { extractBearerToken, verifyToken, TokenError } from './verify.js'; const USER_CACHE_TTL_MS = 60_000; // Source IPs that bypass token auth — used so the office kiosk can render // /small without anyone having to log in daily on the device. Synthetic user // has no permissions, so only endpoints that don't gate on requirePermission() // are reachable. Requires server.js `trust proxy` setting so req.ip is the // real client behind Caddy, not 127.0.0.1. function parseKioskIps(raw) { return new Set( (raw || '') .split(',') .map((s) => s.trim()) .filter(Boolean) ); } function createUserCache() { const entries = new Map(); return { get(userId) { const hit = entries.get(userId); if (!hit) return null; if (Date.now() - hit.cachedAt > USER_CACHE_TTL_MS) { entries.delete(userId); return null; } return hit.user; }, set(userId, user) { entries.set(userId, { user, cachedAt: Date.now() }); }, invalidate(userId) { entries.delete(userId); }, }; } async function loadUser(pool, userId) { const userResult = await pool.query( 'SELECT id, username, email, is_admin, is_active FROM users WHERE id = $1', [userId] ); const user = userResult.rows[0]; if (!user) return null; if (user.is_admin) { user.permissions = []; return user; } const permResult = await pool.query( `SELECT p.code FROM permissions p JOIN user_permissions up ON p.id = up.permission_id WHERE up.user_id = $1`, [userId] ); user.permissions = permResult.rows.map((r) => r.code); return user; } export function authenticate({ pool, secret = process.env.JWT_SECRET, kioskIps = process.env.KIOSK_IPS }) { const cache = createUserCache(); const kioskIpSet = parseKioskIps(kioskIps); return async function authenticateMiddleware(req, res, next) { if (kioskIpSet.size > 0 && kioskIpSet.has(req.ip)) { req.user = { id: 'kiosk', username: 'kiosk', is_admin: false, is_active: true, permissions: [], is_kiosk: true, }; return next(); } let decoded; try { const token = extractBearerToken(req.headers.authorization); decoded = verifyToken(token, secret); } catch (err) { if (err instanceof TokenError) { return res.status(401).json({ error: err.message }); } return res.status(401).json({ error: 'Authentication required' }); } try { let user = cache.get(decoded.userId); if (!user) { user = await loadUser(pool, decoded.userId); if (!user) { return res.status(401).json({ error: 'User not found' }); } cache.set(decoded.userId, user); } if (!user.is_active) { cache.invalidate(decoded.userId); return res.status(403).json({ error: 'Account inactive' }); } req.user = user; next(); } catch (err) { next(err); } }; } export function requirePermission(code) { return function requirePermissionMiddleware(req, res, next) { if (!req.user) return res.status(401).json({ error: 'Authentication required' }); if (req.user.is_admin) return next(); if (Array.isArray(req.user.permissions) && req.user.permissions.includes(code)) { return next(); } res.status(403).json({ error: 'Insufficient permissions' }); }; } export function requireAdmin(req, res, next) { if (!req.user) return res.status(401).json({ error: 'Authentication required' }); if (req.user.is_admin) return next(); res.status(403).json({ error: 'Admin only' }); }