// ── Low-level auth token primitives ─────────────────────────────────────── // // JWT signing/verification, the staged TOTP challenge token, request token // extraction, and cookie helpers. This module is intentionally the *bottom* of // the auth stack: it depends only on jsonwebtoken + the logger, and knows // nothing about sessions, providers, or the database. The session service and // middleware build on top of it, and utils/auth.js re-exports it for backward // compatibility. Keeping these primitives here (rather than in the session // service) avoids a require cycle: session.service → token, never the reverse. const jwt = require('jsonwebtoken') require('dotenv').config() const log = require('../utils/logger')('auth') const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d' const COOKIE_NAME = process.env.COOKIE_NAME || 'uomm_token' // Lifetime of the short-lived "password verified, awaiting TOTP" token. const TOTP_CHALLENGE_TTL = process.env.TOTP_CHALLENGE_TTL || '5m' // Resolve the signing secret. Without one, jwt.sign/verify can't produce or // validate a usable token, so every login is silently broken. Fail fast in // production rather than booting into that state; in dev fall back to a known // insecure secret so login still works locally (with a loud warning). function resolveJwtSecret() { const secret = process.env.JWT_SECRET if (secret) return secret if (process.env.NODE_ENV === 'production') { throw new Error('JWT_SECRET must be set in production') } log.warn('JWT_SECRET is not set — using an insecure development fallback. Set JWT_SECRET in .env before deploying.') return 'dev-insecure-jwt-secret-do-not-use-in-production' } const JWT_SECRET = resolveJwtSecret() // Sign a session token. `extraClaims` lets the session service add fields // (authMethod, jti) on top of the identity claims without this module needing // to know what they mean. Extra claims are additive: an older verifier that // only reads { id, username, role } ignores them, so tokens stay compatible. // `options.expiresIn` overrides the default lifetime (used by short-lived mobile // access tokens); omitting it keeps the historical JWT_EXPIRES_IN behavior. function signToken(user, extraClaims = {}, { expiresIn = JWT_EXPIRES_IN } = {}) { const payload = { id: user.id, username: user.username, role: user.role, ...extraClaims } return jwt.sign(payload, JWT_SECRET, { expiresIn }) } function verifyToken(token) { try { return jwt.verify(token, JWT_SECRET) } catch (err) { return null } } // Short-lived token issued after the password step for users with TOTP enabled. // It is NOT a session: it carries stage:'totp' so session validation rejects it, // and it is only accepted by verifyTotpChallenge to gate the second factor. function signTotpChallenge(user) { return jwt.sign({ id: user.id, stage: 'totp' }, JWT_SECRET, { expiresIn: TOTP_CHALLENGE_TTL }) } function verifyTotpChallenge(token) { const decoded = verifyToken(token) if (!decoded || decoded.stage !== 'totp') return null return decoded } // Short-lived, unguessable link token for previewing a (possibly unpublished) // CMS page. Carries purpose:'page_preview' + the page id and nothing else; it is // NOT a session (session validation rejects it) and only grants read of that one // page's current block state. Default 1h expiry per the page-builder spec. function signPagePreview(pageId, { expiresIn = '1h' } = {}) { return jwt.sign({ pageId, purpose: 'page_preview' }, JWT_SECRET, { expiresIn }) } function verifyPagePreview(token) { const decoded = verifyToken(token) if (!decoded || decoded.purpose !== 'page_preview') return null return decoded } // Rough max-age (ms) for the cookie, parsed from JWT_EXPIRES_IN (e.g. 1d, 12h, 30m). function cookieMaxAge() { const m = /^(\d+)([dhms])$/.exec(String(JWT_EXPIRES_IN).trim()) if (!m) return 24 * 60 * 60 * 1000 const n = Number(m[1]) const unit = { d: 86400000, h: 3600000, m: 60000, s: 1000 }[m[2]] return n * unit } /** * Decide the cookie Secure flag. COOKIE_SECURE=auto (default) uses req.secure, * which is true behind Pangolin (HTTPS, X-Forwarded-Proto) and false over plain * HTTP on the LAN IP — so login works in both. Requires app.set('trust proxy'). */ function cookieSecure(req) { const mode = (process.env.COOKIE_SECURE || 'auto').toLowerCase() if (mode === 'true') return true if (mode === 'false') return false return Boolean(req.secure) } function cookieOptions(req) { return { httpOnly: true, sameSite: 'lax', secure: cookieSecure(req), path: '/', } } function setAuthCookie(req, res, token) { res.cookie(COOKIE_NAME, token, { ...cookieOptions(req), maxAge: cookieMaxAge() }) } function clearAuthCookie(req, res) { res.clearCookie(COOKIE_NAME, cookieOptions(req)) } // Extract a token from the cookie or an Authorization: Bearer header. Supporting // both here is what lets future bearer-token (mobile) clients reuse the exact // same validation path as cookie-based web sessions. function extractToken(req) { if (req.cookies && req.cookies[COOKIE_NAME]) return req.cookies[COOKIE_NAME] const header = req.headers && req.headers.authorization if (header && header.startsWith('Bearer ')) return header.substring(7) return null } module.exports = { COOKIE_NAME, JWT_EXPIRES_IN, resolveJwtSecret, signToken, verifyToken, signTotpChallenge, verifyTotpChallenge, signPagePreview, verifyPagePreview, cookieMaxAge, cookieSecure, cookieOptions, setAuthCookie, clearAuthCookie, extractToken, }