"use strict"; const crypto = require("node:crypto"); const SESSION_COOKIE_NAME = "airwatcher_sid"; const DEFAULT_SESSION_TTL_SEC = 60 * 60 * 24 * 7; function normalizeUsername(value) { if (typeof value !== "string") return ""; return value.trim().toLowerCase(); } function normalizePassword(value) { if (typeof value !== "string") return ""; return value.trim(); } function secureEqual(left, right) { const leftBuffer = Buffer.from(String(left)); const rightBuffer = Buffer.from(String(right)); if (leftBuffer.length !== rightBuffer.length) return false; return crypto.timingSafeEqual(leftBuffer, rightBuffer); } function parseCookieHeader(cookieHeader) { if (typeof cookieHeader !== "string" || cookieHeader.trim() === "") return {}; return cookieHeader.split(";").reduce((acc, pair) => { const index = pair.indexOf("="); if (index <= 0) return acc; const key = pair.slice(0, index).trim(); const value = pair.slice(index + 1).trim(); if (!key) return acc; try { acc[key] = decodeURIComponent(value); } catch (_error) { acc[key] = value; } return acc; }, {}); } function parseBasicAuthorization(headerValue) { if (typeof headerValue !== "string") return null; const matched = headerValue.trim().match(/^Basic\s+(.+)$/i); if (!matched) return null; try { const decoded = Buffer.from(matched[1], "base64").toString("utf8"); const separator = decoded.indexOf(":"); if (separator < 0) return null; return { username: decoded.slice(0, separator), password: decoded.slice(separator + 1), }; } catch (_error) { return null; } } function parsePositiveInt(value, fallback) { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed <= 0) return fallback; return parsed; } function parseUsers(rawUsers) { if (typeof rawUsers !== "string" || rawUsers.trim() === "") { return new Map(); } const users = new Map(); const entries = rawUsers .split(/[\n,]/) .map((item) => item.trim()) .filter(Boolean); for (const entry of entries) { const separator = entry.indexOf(":"); if (separator <= 0) continue; const username = normalizeUsername(entry.slice(0, separator)); const password = normalizePassword(entry.slice(separator + 1)); if (!username || !password) continue; users.set(username, password); } return users; } function parseAdminUsers(rawAdminUsers, knownUsers) { const admins = new Set(); if (typeof rawAdminUsers === "string" && rawAdminUsers.trim() !== "") { for (const part of rawAdminUsers.split(/[\n,]/)) { const username = normalizeUsername(part); if (username) admins.add(username); } } if (admins.size === 0 && knownUsers.size > 0) { const firstUser = knownUsers.keys().next().value; if (firstUser) admins.add(firstUser); } return admins; } function createDashboardAuth(options = {}) { const users = parseUsers( options.users !== undefined ? options.users : process.env.DASHBOARD_USERS || process.env.DASHBOARD_BASIC_AUTH_USERS || "" ); const admins = parseAdminUsers( options.adminUsers !== undefined ? options.adminUsers : process.env.DASHBOARD_ADMIN_USERS, users ); const sessionTtlSec = parsePositiveInt( options.sessionTtlSec !== undefined ? options.sessionTtlSec : process.env.DASHBOARD_SESSION_TTL_SEC, DEFAULT_SESSION_TTL_SEC ); const sessions = new Map(); function isEnabled() { return users.size > 0; } function isAdmin(username) { return admins.has(normalizeUsername(username)); } function toUser(username, authType) { const normalized = normalizeUsername(username); return { username: normalized, isAdmin: isAdmin(normalized), authType, }; } function cleanupExpiredSessions() { const now = Date.now(); for (const [sessionId, session] of sessions.entries()) { if (!session || session.expiresAt <= now) { sessions.delete(sessionId); } } } function verifyCredentials(username, password) { const normalizedUsername = normalizeUsername(username); const normalizedPassword = normalizePassword(password); if (!normalizedUsername || !normalizedPassword) return null; const expectedPassword = users.get(normalizedUsername); if (!expectedPassword) return null; return secureEqual(expectedPassword, normalizedPassword) ? normalizedUsername : null; } function issueSession(username) { const sessionId = crypto.randomBytes(32).toString("hex"); const expiresAt = Date.now() + sessionTtlSec * 1000; sessions.set(sessionId, { username, expiresAt, }); return sessionId; } function readSessionIdFromHeaders(headers = {}) { const cookieHeader = Array.isArray(headers.cookie) ? headers.cookie[0] : headers.cookie; const cookies = parseCookieHeader(cookieHeader); const sessionId = cookies[SESSION_COOKIE_NAME]; return typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : null; } function buildSessionCookie(sessionId) { return `${SESSION_COOKIE_NAME}=${encodeURIComponent(sessionId)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${sessionTtlSec}`; } function buildSessionClearCookie() { return `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; } function login(username, password) { if (!isEnabled()) { throw new Error("Dashboard account login is not configured. Set DASHBOARD_USERS."); } const verifiedUsername = verifyCredentials(username, password); if (!verifiedUsername) { return null; } const sessionId = issueSession(verifiedUsername); return { user: toUser(verifiedUsername, "session"), sessionId, setCookie: buildSessionCookie(sessionId), }; } function getUserFromSession(headers = {}) { cleanupExpiredSessions(); const sessionId = readSessionIdFromHeaders(headers); if (!sessionId) return null; const session = sessions.get(sessionId); if (!session) return null; if (session.expiresAt <= Date.now()) { sessions.delete(sessionId); return null; } return toUser(session.username, "session"); } function getUserFromBasicAuth(headers = {}) { const authHeader = Array.isArray(headers.authorization) ? headers.authorization[0] : headers.authorization; const parsed = parseBasicAuthorization(authHeader); if (!parsed) return null; const verifiedUsername = verifyCredentials(parsed.username, parsed.password); if (!verifiedUsername) return null; return toUser(verifiedUsername, "basic"); } function getUserFromRequest(headers = {}) { if (!isEnabled()) return null; const fromSession = getUserFromSession(headers); if (fromSession) return fromSession; return getUserFromBasicAuth(headers); } function logout(headers = {}) { const sessionId = readSessionIdFromHeaders(headers); if (sessionId) { sessions.delete(sessionId); } return { setCookie: buildSessionClearCookie(), }; } return { enabled: isEnabled(), sessionCookieName: SESSION_COOKIE_NAME, listUsers: () => Array.from(users.keys()), getUserFromRequest, login, logout, toUser, }; } module.exports = { SESSION_COOKIE_NAME, createDashboardAuth, };