// Session cookie and current-user helpers for the web auth flow. const { normalizePermissionKeys } = require('#src/rbac'); function getRequestOrigin(req) { const forwardedProto = String(req && req.headers && req.headers['x-forwarded-proto'] || '').trim().split(',')[0]; const protocol = forwardedProto || (req && req.socket && req.socket.encrypted ? 'https' : 'http'); const forwardedHost = String(req && req.headers && req.headers['x-forwarded-host'] || '').trim().split(',')[0]; const host = forwardedHost || String(req && req.headers && req.headers.host || '').trim(); return host ? `${protocol}://${host}`.replace(/\/$/, '') : 'http://localhost'; } function normalizeReturnToPath(value, baseUrl) { const rawValue = String(value || '').trim(); if (!rawValue) { return ''; } const normalizedBaseUrl = String(baseUrl || '').trim().replace(/\/$/, '') || 'http://localhost'; try { const parsed = new URL(rawValue, normalizedBaseUrl); const baseOrigin = new URL(normalizedBaseUrl).origin; if (parsed.origin !== baseOrigin) { return ''; } return parsed.pathname + parsed.search + parsed.hash; } catch (_error) { return rawValue.charAt(0) === '/' ? rawValue : ''; } } function createSessionService(options) { const sessionCookieName = String(options && options.sessionCookieName || '').trim(); const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs); const getConfiguredSessionMaxAgeMs = options && options.getConfiguredSessionMaxAgeMs; const getConfiguredMaxActiveSessions = options && options.getConfiguredMaxActiveSessions; const hashSessionToken = options && options.hashSessionToken; const createSessionToken = options && options.createSessionToken; const authMessageCookieName = 'pulse_auth_message'; if (!sessionCookieName || !Number.isFinite(sessionMaxAgeMs) || typeof hashSessionToken !== 'function' || typeof createSessionToken !== 'function') { throw new Error('createSessionService requires the session dependencies.'); } function parseCookies(cookieHeader) { return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) { if (!pair) { return cookies; } const separatorIndex = pair.indexOf('='); if (separatorIndex === -1) { return cookies; } const name = decodeURIComponent(pair.slice(0, separatorIndex).trim()); const value = decodeURIComponent(pair.slice(separatorIndex + 1).trim()); if (name) { cookies[name] = value; } return cookies; }, {}); } function serializeCookie(name, value, options) { const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`]; if (options && options.maxAge !== undefined) { parts.push(`Max-Age=${Math.max(0, Math.trunc(Number(options.maxAge) / 1000))}`); } parts.push('Path=/'); parts.push('HttpOnly'); parts.push('SameSite=Lax'); return parts.join('; '); } function appendCookieHeader(res, cookieValue) { const existingCookieHeader = res.getHeader('Set-Cookie'); if (!existingCookieHeader) { res.setHeader('Set-Cookie', cookieValue); return; } const cookies = Array.isArray(existingCookieHeader) ? existingCookieHeader.slice() : [existingCookieHeader]; cookies.push(cookieValue); res.setHeader('Set-Cookie', cookies); } function clearSessionCookie(res) { appendCookieHeader(res, serializeCookie(sessionCookieName, '', { maxAge: 0 })); } function setSessionCookie(res, token) { const hasRequestedMaxAge = arguments.length > 2; const requestedMaxAgeMs = hasRequestedMaxAge ? Number(arguments[2]) : sessionMaxAgeMs; const cookieOptions = hasRequestedMaxAge && arguments[2] === null ? {} : { maxAge: Number.isFinite(requestedMaxAgeMs) && requestedMaxAgeMs > 0 ? requestedMaxAgeMs : sessionMaxAgeMs }; appendCookieHeader(res, serializeCookie(sessionCookieName, token, cookieOptions)); } async function getSessionMaxAgeMs() { const configuredValue = typeof getConfiguredSessionMaxAgeMs === 'function' ? await getConfiguredSessionMaxAgeMs() : sessionMaxAgeMs; const normalizedValue = Number(configuredValue); return Number.isFinite(normalizedValue) && normalizedValue > 0 ? normalizedValue : sessionMaxAgeMs; } function setAuthMessageCookie(res, message) { appendCookieHeader(res, serializeCookie(authMessageCookieName, message, { maxAge: 60 * 1000 })); } function consumeAuthMessageCookie(req, res) { const cookies = parseCookies(req.headers.cookie || ''); const message = String(cookies[authMessageCookieName] || '').trim(); if (message) { appendCookieHeader(res, serializeCookie(authMessageCookieName, '', { maxAge: 0 })); } return message; } async function loadCurrentUser(pool, req) { const cookies = parseCookies(req.headers.cookie || ''); const token = cookies[sessionCookieName]; if (!token) { return null; } const tokenHash = hashSessionToken(token); const [rows] = await pool.query( `SELECT s.user_id, u.id, u.name, u.username, u.must_change_password FROM a_sessions s JOIN a_users u ON u.id = s.user_id WHERE s.session_hash = ? AND s.expires_at > NOW() LIMIT 1`, [tokenHash] ); if (!rows.length) { return null; } const userId = Number(rows[0].id); const [roleRows] = await pool.query( `SELECT r.role_key FROM a_user_roles ur JOIN a_roles r ON r.id = ur.role_id WHERE ur.user_id = ? ORDER BY r.name ASC`, [userId] ); const [permissionRows] = await pool.query( `SELECT p.permission_key FROM a_user_roles ur JOIN a_role_permissions rp ON rp.role_id = ur.role_id JOIN a_permissions p ON p.id = rp.permission_id WHERE ur.user_id = ? ORDER BY p.section_name ASC, p.name ASC`, [userId] ); await pool.query('UPDATE a_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]); return Object.assign({}, rows[0], { mustChangePassword: Boolean(rows[0].must_change_password), roleKeys: roleRows.map(function (row) { return String(row.role_key || '').trim(); }).filter(Boolean), permissionKeys: normalizePermissionKeys(permissionRows.map(function (row) { return String(row.permission_key || '').trim(); })) }); } async function createUserSession(pool, userId, configuredMaxAgeMs, metadata) { const token = createSessionToken(); const tokenHash = hashSessionToken(token); const maxAgeMs = Number.isFinite(Number(configuredMaxAgeMs)) && Number(configuredMaxAgeMs) > 0 ? Number(configuredMaxAgeMs) : await getSessionMaxAgeMs(); const expiresAt = new Date(Date.now() + maxAgeMs); const sessionMetadata = metadata && typeof metadata === 'object' ? metadata : {}; await pool.query( 'INSERT INTO a_sessions (session_hash, user_id, ip_address, user_agent, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', [tokenHash, userId, String(sessionMetadata.ipAddress || '').slice(0, 255) || null, String(sessionMetadata.userAgent || '').slice(0, 512) || null, expiresAt, userId, userId] ); if (typeof getConfiguredMaxActiveSessions === 'function') { const maxActiveSessions = Number(await getConfiguredMaxActiveSessions()); if (Number.isInteger(maxActiveSessions) && maxActiveSessions > 0) { const [sessionRows] = await pool.query( 'SELECT id, session_hash FROM a_sessions WHERE user_id = ? AND expires_at > NOW() ORDER BY last_used_at ASC, created_at ASC, id ASC', [userId] ); const sessionsToRemove = (sessionRows || []).filter(function (session) { return session.session_hash !== tokenHash; }).slice(0, Math.max(0, sessionRows.length - maxActiveSessions)); for (const session of sessionsToRemove) { await pool.query('DELETE FROM a_sessions WHERE id = ? AND user_id = ?', [session.id, userId]); } } } return token; } function requireAuth(req, res, next) { if (req.currentUser) { return next(); } setAuthMessageCookie(res, 'Please sign in to continue.'); const returnTo = normalizeReturnToPath(req && (req.originalUrl || req.url || req.path), getRequestOrigin(req)); res.redirect(returnTo ? '/login?returnTo=' + encodeURIComponent(returnTo) : '/login'); } return { parseCookies: parseCookies, serializeCookie: serializeCookie, clearSessionCookie: clearSessionCookie, setSessionCookie: setSessionCookie, setAuthMessageCookie: setAuthMessageCookie, consumeAuthMessageCookie: consumeAuthMessageCookie, loadCurrentUser: loadCurrentUser, createUserSession: createUserSession, getSessionMaxAgeMs: getSessionMaxAgeMs, requireAuth: requireAuth, getRequestOrigin: getRequestOrigin }; } module.exports = { createSessionService, normalizeReturnToPath, getRequestOrigin };