// Admin login gate. // // Reads username + scrypt password hash + session secret out of // /data/config/startos-config.json (set by the "Set Admin Password" // StartOS action). When a hash is set, gates /api/* behind a signed // HttpOnly cookie. The static frontend stays open so the login screen // can paint, but every API endpoint except the gate's own + /api/health // returns 401 admin_login_required until the cookie validates. // // Cookie format: . // payload: { u: username, iat: epoch_ms, fp: fingerprint(passwordHash) } // hmac: HMAC-SHA256(payload, sessionSecret) // // Changing the password rotates fp, which invalidates all existing // sessions on the next request. Changing/clearing the session secret // has the same effect. // // ADMIN is exported as a `let` binding so importers see the live value // after each config-poll refresh. import fs from "fs/promises"; import path from "path"; import { randomBytes, scryptSync, createHmac, timingSafeEqual, } from "crypto"; const COOKIE_NAME = "recap_admin_session"; const COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days const SCRYPT_KEYLEN = 64; // Endpoints reachable WITHOUT an admin session, even when the gate is // enabled. The login flow itself + the bare-minimum status endpoints. const ADMIN_OPEN_PATHS = new Set([ "/api/admin/status", "/api/admin/login", "/api/admin/logout", "/api/health", ]); // ── Module state ──────────────────────────────────────────────────────────── // Live snapshot of the admin-auth config. Refreshed from // /data/config/startos-config.json every CONFIG_POLL_MS. `enabled` is // derived (true iff a hash is set). export let ADMIN = { enabled: false, username: "", passwordHash: "", passwordSalt: "", sessionSecret: "", }; let startosConfigPath = null; // ── Init ──────────────────────────────────────────────────────────────────── export async function initAdminAuth({ dataDir }) { startosConfigPath = path.join(dataDir, "config", "startos-config.json"); await refreshAdminConfig("startup"); const pollMs = parseInt( process.env.RECAP_CONFIG_POLL_MS || "3000", 10 ); setInterval(() => { refreshAdminConfig("config poll").catch(() => {}); }, pollMs); } async function refreshAdminConfig(reason) { let next = { enabled: false, username: "", passwordHash: "", passwordSalt: "", sessionSecret: "", }; try { const content = await fs.readFile(startosConfigPath, "utf-8"); const cfg = JSON.parse(content); next = { enabled: !!(cfg.recap_admin_password_hash && cfg.recap_admin_password_salt), username: cfg.recap_admin_username || "", passwordHash: cfg.recap_admin_password_hash || "", passwordSalt: cfg.recap_admin_password_salt || "", sessionSecret: cfg.recap_admin_session_secret || "", }; } catch { // File missing / unreadable — leave gate disabled. } if ( next.enabled !== ADMIN.enabled || next.username !== ADMIN.username || next.passwordHash !== ADMIN.passwordHash || next.sessionSecret !== ADMIN.sessionSecret ) { ADMIN = next; if (reason !== "config poll" || ADMIN.enabled !== false) { console.log( `[admin-auth] refresh (${reason}): enabled=${ADMIN.enabled} user=${ADMIN.username || "(unset)"}` ); } } else { ADMIN = next; } } // ── Cookie helpers ────────────────────────────────────────────────────────── function b64url(buf) { return Buffer.from(buf) .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); } function b64urlDecode(s) { const pad = s.length % 4 === 0 ? 0 : 4 - (s.length % 4); const padded = s + "=".repeat(pad); return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64"); } function hashFingerprint(hash) { // First 16 hex chars of the password hash. Stored in the cookie so // changing the password invalidates all existing sessions. return (hash || "").slice(0, 16); } function signSession({ username, hash, secret }) { const payload = JSON.stringify({ u: username, iat: Date.now(), fp: hashFingerprint(hash), }); const payloadB64 = b64url(payload); const sig = createHmac("sha256", secret).update(payloadB64).digest(); return `${payloadB64}.${b64url(sig)}`; } function verifySession(token, { username, hash, secret }) { if (!token || typeof token !== "string") return false; const dot = token.indexOf("."); if (dot < 0) return false; const payloadB64 = token.slice(0, dot); const sigB64 = token.slice(dot + 1); if (!payloadB64 || !sigB64) return false; let expected; try { expected = createHmac("sha256", secret).update(payloadB64).digest(); } catch { return false; } let provided; try { provided = b64urlDecode(sigB64); } catch { return false; } if (provided.length !== expected.length) return false; if (!timingSafeEqual(provided, expected)) return false; let payload; try { payload = JSON.parse(b64urlDecode(payloadB64).toString("utf-8")); } catch { return false; } if (!payload || payload.u !== username) return false; if (payload.fp !== hashFingerprint(hash)) return false; if (typeof payload.iat !== "number") return false; if (Date.now() - payload.iat > COOKIE_MAX_AGE_MS) return false; return true; } function parseCookies(header) { const out = {}; if (!header || typeof header !== "string") return out; for (const part of header.split(";")) { const eq = part.indexOf("="); if (eq < 0) continue; const k = part.slice(0, eq).trim(); const v = part.slice(eq + 1).trim(); if (k) out[k] = decodeURIComponent(v); } return out; } function buildSetCookie(value, { req, maxAgeMs }) { const parts = [ `${COOKIE_NAME}=${value}`, "HttpOnly", "SameSite=Lax", "Path=/", ]; if (maxAgeMs > 0) { parts.push(`Max-Age=${Math.floor(maxAgeMs / 1000)}`); } else { parts.push("Max-Age=0"); } // Mark Secure only when the request itself is HTTPS, so the cookie // still works on plain-HTTP LAN access (the common StartOS dev setup). const proto = (req.headers["x-forwarded-proto"] || "").toString().toLowerCase(); const isHttps = req.secure || proto.includes("https"); if (isHttps) parts.push("Secure"); return parts.join("; "); } function isSessionAuthed(req) { if (!ADMIN.enabled) return true; const cookies = parseCookies(req.headers.cookie); return verifySession(cookies[COOKIE_NAME], { username: ADMIN.username, hash: ADMIN.passwordHash, secret: ADMIN.sessionSecret, }); } // ── Middleware ────────────────────────────────────────────────────────────── // Register BEFORE setupLicenseMiddleware. When the admin gate is // disabled, this is a no-op pass-through. export function setupAdminAuthMiddleware(app) { app.use((req, res, next) => { if (!ADMIN.enabled) return next(); if (!req.path.startsWith("/api/")) return next(); if (ADMIN_OPEN_PATHS.has(req.path)) return next(); if (isSessionAuthed(req)) return next(); return res.status(401).json({ error: "admin_login_required", message: "Admin login required.", }); }); } // ── Routes ────────────────────────────────────────────────────────────────── export function setupAdminAuthRoutes(app) { app.get("/api/admin/status", (req, res) => { res.json({ enabled: ADMIN.enabled, authed: isSessionAuthed(req), username: ADMIN.enabled ? ADMIN.username : null, }); }); app.post("/api/admin/login", (req, res) => { if (!ADMIN.enabled) { // No password set; treat as success so the frontend doesn't get // stuck on the login screen if the admin clears the password. return res.json({ ok: true, enabled: false }); } const username = (req.body && req.body.username) || ""; const password = (req.body && req.body.password) || ""; if (!username || !password) { return res .status(400) .json({ error: "missing_credentials", message: "Username and password required." }); } if (username !== ADMIN.username) { return res .status(401) .json({ error: "invalid_credentials", message: "Invalid username or password." }); } let computed; try { computed = scryptSync(password, ADMIN.passwordSalt, SCRYPT_KEYLEN); } catch { return res .status(500) .json({ error: "hash_failed", message: "Could not verify password." }); } let stored; try { stored = Buffer.from(ADMIN.passwordHash, "hex"); } catch { return res .status(500) .json({ error: "stored_hash_invalid", message: "Stored password hash is unreadable." }); } if (computed.length !== stored.length || !timingSafeEqual(computed, stored)) { return res .status(401) .json({ error: "invalid_credentials", message: "Invalid username or password." }); } const token = signSession({ username: ADMIN.username, hash: ADMIN.passwordHash, secret: ADMIN.sessionSecret, }); res.setHeader( "Set-Cookie", buildSetCookie(token, { req, maxAgeMs: COOKIE_MAX_AGE_MS }) ); res.json({ ok: true, enabled: true, username: ADMIN.username }); }); app.post("/api/admin/logout", (req, res) => { res.setHeader("Set-Cookie", buildSetCookie("", { req, maxAgeMs: 0 })); res.json({ ok: true }); }); }