/** * Authentication middleware. * * When AUTH_ENABLED=true in the environment, every protected route requires * either: * - An active session (set by POST /api/v1/auth/login), or * - A valid Bearer token in the Authorization header. * * When AUTH_ENABLED is unset or any other value, the middleware is a no-op * (with a synthetic admin user attached) so the stack can run unprotected. * * Exposed on `req` after success: * req.user { id, username, role, is_client } * req.tokenBoundHostname hostname binding from api_tokens, or null */ import crypto from 'crypto'; import pool from '../db/pool.js'; const SYNTHETIC_USER = Object.freeze({ id: null, username: 'operator', role: 'admin', is_client: false, synthetic: true, }); export const requireAuth = async (req, res, next) => { if (process.env.AUTH_ENABLED !== 'true') { req.user = SYNTHETIC_USER; return next(); } // ── Session-based auth ───────────────────────────────────────── if (req.session?.userId) { req.user = { id: req.session.userId, username: req.session.username, role: req.session.role, is_client: !!req.session.isClient, }; return next(); } // ── Bearer token auth ────────────────────────────────────────── const authHeader = req.headers.authorization; if (authHeader?.startsWith('Bearer ')) { const raw = authHeader.slice(7).trim(); const hash = crypto.createHash('sha256').update(raw).digest('hex'); try { const { rows } = await pool.query( `SELECT t.user_id AS id, u.username, u.role, u.is_client, t.bound_hostname FROM api_tokens t JOIN users u ON u.id = t.user_id WHERE t.token_hash = $1 AND (t.expires_at IS NULL OR t.expires_at > NOW())`, [hash] ); if (rows.length > 0) { const row = rows[0]; req.user = { id: row.id, username: row.username, role: row.role, is_client: !!row.is_client, }; req.tokenBoundHostname = row.bound_hostname || null; pool.query( 'UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1', [hash] ).catch(() => {}); return next(); } } catch (err) { return next(err); } } return res.status(401).json({ error: 'Unauthorized' }); }; export const requireAdmin = (req, res, next) => { if (process.env.AUTH_ENABLED !== 'true') return next(); if (req.user?.role === 'admin') return next(); return res.status(403).json({ error: 'Admin access required' }); }; /** * Role gate: `requireRole(['admin','editor'])` rejects with 403 unless the * user's role is in the allow list. Always passes when AUTH_ENABLED=false. * Optionally pass { rejectClients: true } to also block users with * is_client=true regardless of role — used for routes that touch the * recorder / cluster / infra surface. */ export const requireRole = (allowed, opts = {}) => { const set = new Set(Array.isArray(allowed) ? allowed : [allowed]); const { rejectClients = false } = opts; return (req, res, next) => { if (process.env.AUTH_ENABLED !== 'true') return next(); if (!req.user || !set.has(req.user.role)) { return res.status(403).json({ error: `Requires role: ${[...set].join(' or ')}` }); } if (rejectClients && req.user.is_client) { return res.status(403).json({ error: 'This action is not available to client accounts' }); } next(); }; };