'use strict'; const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const rateLimit = require('express-rate-limit'); const { pool } = require('./db'); const SECRET = process.env.AUTH_SECRET; const COOKIE_NAME = 'adeo_session'; const COOKIE_PATH = process.env.COOKIE_PATH || '/'; const COOKIE_SECURE = String(process.env.COOKIE_SECURE ?? 'true').toLowerCase() === 'true'; const TOKEN_TTL_DAYS = 7; if (!SECRET || SECRET.length < 32) { console.error('[auth] AUTH_SECRET must be set and at least 32 chars — refusing to start'); process.exit(1); } // Pre-computed bcrypt hash used to defeat user-enumeration timing oracles // when a login attempt targets a non-existent email. Compare cost matches // real hashes (cost 12). const DUMMY_HASH = bcrypt.hashSync('not-a-real-password', 12); function setSessionCookie(res, token) { res.cookie(COOKIE_NAME, token, { httpOnly: true, secure: COOKIE_SECURE, sameSite: 'strict', path: COOKIE_PATH, maxAge: TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000, }); } function clearSessionCookie(res) { res.clearCookie(COOKIE_NAME, { path: COOKIE_PATH }); } function signToken(user) { return jwt.sign( { sub: user.id, email: user.email, role: user.role }, SECRET, { expiresIn: `${TOKEN_TTL_DAYS}d` } ); } function authenticate(req, res, next) { const token = req.cookies?.[COOKIE_NAME]; if (!token) return res.status(401).json({ error: 'Not authenticated' }); try { const payload = jwt.verify(token, SECRET); req.user = { id: payload.sub, email: payload.email, role: payload.role }; next(); } catch { return res.status(401).json({ error: 'Invalid session' }); } } function requireAdmin(req, res, next) { if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Forbidden' }); next(); } const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many login attempts. Try again in a few minutes.' }, }); function mountAuth(app) { app.post('/api/auth/login', loginLimiter, async (req, res) => { try { const { email, password } = req.body || {}; if (!email || !password) return res.status(400).json({ error: 'Email and password are required' }); const r = await pool.query( 'SELECT id, email, password_hash, role FROM users WHERE lower(email) = lower($1)', [email] ); const user = r.rows[0]; let ok = false; if (user?.password_hash) { ok = await bcrypt.compare(password, user.password_hash); } else { // Burn equivalent CPU on missing/SSO-only users so timing doesn't // leak which emails exist. await bcrypt.compare(password, DUMMY_HASH); } if (!ok) return res.status(401).json({ error: 'Invalid credentials' }); await pool.query('UPDATE users SET last_login_at = now() WHERE id = $1', [user.id]); const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress || ''; pool.query('INSERT INTO access_log (user_email, action, detail, ip) VALUES ($1, $2, $3, $4)', [user.email, 'login', 'Password login', ip]).catch(() => {}); setSessionCookie(res, signToken(user)); res.json({ id: user.id, email: user.email, role: user.role }); } catch (e) { console.error('[auth] login error:', e); res.status(500).json({ error: 'Login failed' }); } }); app.post('/api/auth/logout', (_req, res) => { clearSessionCookie(res); res.json({ ok: true }); }); app.get('/api/auth/me', authenticate, (req, res) => { res.json(req.user); }); // ── Microsoft SSO — scaffolded, not yet wired up ── // // To enable: // 1. npm i @azure/msal-node // 2. Set AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_CLIENT_SECRET, and // AZURE_REDIRECT_URI (e.g. https://optical-dev.oliver.solutions/adeo-maturity/api/auth/msft/callback) // in .env. Register the redirect URI in the Azure app registration. // 3. Replace these stubs with msal-node ConfidentialClientApplication // using getAuthCodeUrl + acquireTokenByCode. // 4. On callback, look up users by azure_oid (the `oid` claim from the // ID token); auto-provision a user row on first SSO login (role // defaults to 'user' — promote to 'admin' manually). Then issue our // own JWT cookie via signToken() so the rest of the app is unchanged. app.get('/api/auth/msft/login', (_req, res) => { res.status(501).json({ error: 'Microsoft SSO is not yet configured on this deployment' }); }); app.get('/api/auth/msft/callback', (_req, res) => { res.status(501).json({ error: 'Microsoft SSO is not yet configured on this deployment' }); }); } module.exports = { authenticate, requireAdmin, mountAuth, signToken };