/** * @file auth.js * @description Server mode's accounts and login (docs/SERVER-MODE.md). Off * unless `AGENTHUB_MODE=server`: in local mode every function here answers * "allowed" and nothing reads the `users`/`auth_sessions` tables. * * - A password is stored as `scrypt$$` (node:crypto, async so a * login never blocks the event loop) and compared in constant time. * - A login is a random 32-byte token in the httpOnly, SameSite=Strict cookie * `agenthub_sid`; only its SHA-256 is stored, for `SESSION_DAYS` days. A * disabled user's sessions stop working at once (the lookup joins `users`). * - `authGuard` (mounted on `/api`) lets through `/health` and `/auth/*`, the * hook routes only from this machine (`isLocalRequest`; `ingest-batch` keeps * its own bearer token), and everything else only with a valid login. An * `analyst` may only read; writes under `ADMIN_WRITE_PREFIXES` — the host * account's own configuration — are an `admin`'s. * * This is login, not isolation: every member still sees every workspace and * session, and every pane runs as the one host account (phases S2/S3 of the * multi-user plan). * @author Nguyễn Ngọc Trí Vĩ */ const crypto = require("node:crypto"); const { promisify } = require("node:util"); // Required on first use: a module that only loads this (websocket.js) must not // open the database in local mode, where nothing here touches it. const sql = (q) => require("../db").db.prepare(q); const { isLocalRequest, isLoopback, isOwnHost } = require("./same-origin"); const { isHostAllowed } = require("./security"); const scrypt = promisify(crypto.scrypt); const COOKIE = "agenthub_sid"; const SESSION_DAYS = 30; const ROLES = ["admin", "analyst", "member"]; const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i; const MIN_PASSWORD = 8; /** API prefixes whose writes change the host account's own setup. */ const ADMIN_WRITE_PREFIXES = [ "/settings", "/updates", "/cc-config", "/codex-config", "/ai-proxies", "/ai-routers", "/git-credentials", "/ai-tools", "/import", "/pricing", "/budgets", "/webhooks", "/remote-sources", "/reports", "/pets", ]; /** * API prefixes a member may not reach at all, reads included: the host's own * configuration and logs, and every view across all users (export, metrics, * reports, webhook deliveries, remote machines, imports). Admin and analyst * pass. `MEMBER_READ_ALLOW` re-opens the few reads a member's own work needs. */ const STAFF_ONLY_PREFIXES = [ "/cc-config", "/codex-config", "/git-credentials", "/remote-sources", "/webhooks", "/reports", "/metrics", "/import", "/settings/export", "/alerts/rules", "/users", "/accounts", ]; // The run form's `/` autocomplete lists commands and skills. const MEMBER_READ_ALLOW = ["/cc-config/commands", "/cc-config/skills"]; const isServerMode = () => process.env.AGENTHUB_MODE === "server"; const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex"); const err = (code, message) => Object.assign(new Error(message), { code }); async function hashPassword(password) { const salt = crypto.randomBytes(16); const hash = await scrypt(password, salt, 64); return `scrypt$${salt.toString("base64")}$${hash.toString("base64")}`; } async function verifyPassword(password, stored) { const [kind, salt, hash] = String(stored || "").split("$"); if (kind !== "scrypt" || !salt || !hash) return false; const want = Buffer.from(hash, "base64"); const got = await scrypt(String(password), Buffer.from(salt, "base64"), want.length); return crypto.timingSafeEqual(got, want); } function validateUsername(name) { const n = String(name || "").trim(); if (!USERNAME_RE.test(n)) { throw err("EBADUSER", "username: 1–64 letters, digits, '.', '_' or '-'"); } return n; } function validatePassword(password) { if (typeof password !== "string" || password.length < MIN_PASSWORD) { throw err("EBADPASSWORD", `password: at least ${MIN_PASSWORD} characters`); } if (password.length > 1024) throw err("EBADPASSWORD", "password too long"); } // ── Users (the CLI's `agenthub users …` and `agenthub server setup`) ──────── const publicUser = (u) => u && { id: u.id, username: u.username, role: u.role, disabled: Boolean(u.disabled) }; function listUsers() { return sql("SELECT * FROM users ORDER BY id").all().map(publicUser); } function findUser(username) { return sql("SELECT * FROM users WHERE username = ?").get(String(username || "")); } async function createUser(username, password, role = "member") { const name = validateUsername(username); validatePassword(password); if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`); if (findUser(name)) throw err("EEXISTS", `user ${name} already exists`); const hash = await hashPassword(password); const { lastInsertRowid } = sql( "INSERT INTO users (username, role, password_hash) VALUES (?, ?, ?)" ).run(name, role, hash); return publicUser(sql("SELECT * FROM users WHERE id = ?").get(lastInsertRowid)); } function requireUser(username) { const u = findUser(username); if (!u) throw err("ENOUSER", `no user ${username}`); return u; } /** New password; every login of that user ends. */ async function setPassword(username, password) { const u = requireUser(username); validatePassword(password); sql("UPDATE users SET password_hash = ? WHERE id = ?").run(await hashPassword(password), u.id); sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id); } function setRole(username, role) { if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`); sql("UPDATE users SET role = ? WHERE id = ?").run(role, requireUser(username).id); } /** Disable (ending every login) or re-enable a user. Users are never deleted: * S2 will hang owned rows off their id. */ function setDisabled(username, disabled) { const u = requireUser(username); sql("UPDATE users SET disabled = ? WHERE id = ?").run(disabled ? 1 : 0, u.id); if (disabled) sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id); } /** Give a workspace (a lane id) to a user — for the ones made before server * mode, which have no owner and so reach no member. */ function assignWorkspace(laneId, username) { const u = requireUser(username); const r = sql("UPDATE lanes SET owner_id = ? WHERE id = ?").run(u.id, Number(laneId)); if (r.changes === 0) throw err("ENOLANE", `no workspace ${laneId}`); } const hasAdmin = () => Boolean(sql("SELECT 1 FROM users WHERE role = 'admin' AND disabled = 0").get()); // ── Login sessions ────────────────────────────────────────────────────────── /** Start a login: the cookie token, returned once, never stored. */ function startSession(userId) { const token = crypto.randomBytes(32).toString("base64url"); const expires = new Date(Date.now() + SESSION_DAYS * 864e5).toISOString(); sql("DELETE FROM auth_sessions WHERE expires_at < ?").run(new Date().toISOString()); sql("INSERT INTO auth_sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)").run( sha256(token), userId, expires ); return token; } function endSession(token) { if (token) sql("DELETE FROM auth_sessions WHERE token_hash = ?").run(sha256(token)); } function cookieToken(req) { for (const part of String(req.headers.cookie || "").split(";")) { const eq = part.indexOf("="); if (eq > 0 && part.slice(0, eq).trim() === COOKIE) return part.slice(eq + 1).trim() || null; } return null; } /** The logged-in user behind this request's cookie, or null. */ function userFromRequest(req) { const token = cookieToken(req); if (!token || token.length > 128) return null; const row = sql( `SELECT u.* FROM auth_sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires_at > ? AND u.disabled = 0` ).get(sha256(token), new Date().toISOString()); return publicUser(row) || null; } function cookieHeader(req, token, maxAgeSec) { const secure = req.secure || req.headers["x-forwarded-proto"] === "https"; return [ `${COOKIE}=${token}`, "Path=/", "HttpOnly", "SameSite=Strict", `Max-Age=${maxAgeSec}`, ...(secure ? ["Secure"] : []), ].join("; "); } const setLoginCookie = (req, res, token) => res.setHeader("Set-Cookie", cookieHeader(req, token, SESSION_DAYS * 86400)); const clearLoginCookie = (req, res) => res.setHeader("Set-Cookie", cookieHeader(req, "", 0)); // ── Failed-login throttle ─────────────────────────────────────────────────── // ponytail: in-memory per-IP counter, reset on restart and not shared between // two dashboards; move it into SQLite if the server ever runs as several processes. const FAIL_WINDOW_MS = 15 * 60 * 1000; const FAIL_LIMIT = 10; const failures = new Map(); /** The client's address: the socket's, or — only behind a proxy on this * machine — the LAST X-Forwarded-For entry, the one that proxy appended (the * earlier ones are whatever the client sent). */ function clientKey(req) { const addr = req.socket?.remoteAddress || "?"; if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(addr)) return addr; const fwd = String(req.headers["x-forwarded-for"] || "").split(","); return fwd[fwd.length - 1].trim() || addr; } // Per username too, higher: many addresses guessing one account's password. const USER_FAIL_LIMIT = 30; const fresh = (f) => f && Date.now() - f.since <= FAIL_WINDOW_MS; function blocked(key, limit) { const f = failures.get(key); return fresh(f) && f.count >= limit; } /** Count an attempt as failed BEFORE it is checked, so parallel guesses all count. */ function noteAttempt(key) { if (failures.size > 10000) { for (const [k, v] of failures) if (!fresh(v)) failures.delete(k); } const f = failures.get(key); if (!fresh(f)) failures.set(key, { since: Date.now(), count: 1 }); else f.count++; } // A well-formed hash no password matches (64 zero bytes), for unknown users. const DUMMY_HASH = `scrypt$${"A".repeat(22)}==$${"A".repeat(86)}==`; /** `{user}` for a right username + password, else throws EBADLOGIN/ETOOMANY. */ async function login(req, username, password) { const name = String(username || "").trim(); const ipKey = clientKey(req); const userKey = `user:${name.toLowerCase()}`; if (blocked(ipKey, FAIL_LIMIT) || blocked(userKey, USER_FAIL_LIMIT)) { throw err("ETOOMANY", "too many failed logins, try again later"); } noteAttempt(ipKey); noteAttempt(userKey); const u = findUser(name); // Hash even for an unknown user, so the answer time does not tell which. const ok = await verifyPassword(String(password || ""), u?.password_hash || DUMMY_HASH); if (!u || !ok || u.disabled) throw err("EBADLOGIN", "wrong username or password"); failures.delete(ipKey); failures.delete(userKey); return { user: publicUser(u), token: startSession(u.id) }; } // ── Pane credentials ──────────────────────────────────────────────────────── const PANE_HEADER = "x-agenthub-pane-token"; /** * Server mode: a fresh credential for a pane the dashboard starts — the token * goes into the pane's env (`AGENTHUB_PANE_TOKEN`, through tmux `-e`), its hash * onto the run row. Null in local mode, where nothing needs one. */ function newPaneToken() { if (!isServerMode()) return null; const token = `ahpane_${crypto.randomBytes(24).toString("base64url")}`; return { token, hash: sha256(token) }; } /** * The workspace owner a request from inside a live pane acts as: its * `x-agenthub-pane-token` names a run that is still running in tmux, on a * workspace someone owns, and the request came straight from this machine. */ function userFromPaneToken(req) { const t = req.headers[PANE_HEADER]; if (typeof t !== "string" || !t.startsWith("ahpane_") || t.length > 200) return null; if (!isLocalRequest(req)) return null; const row = sql( `SELECT r.id, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.pane_token_hash = ? AND r.status IN ('running', 'spawning') AND instr(r.id, '@') = 0` ).get(sha256(t)); if (!row || row.owner_id == null) return null; if (!require("./tmux").hasSession(row.id)) return null; return ( publicUser(sql("SELECT * FROM users WHERE id = ? AND disabled = 0").get(row.owner_id)) || null ); } // ── Gates ─────────────────────────────────────────────────────────────────── /** * The path under `/api` as Express ROUTES it, for every prefix and id check: * each segment percent-decoded, repeated slashes collapsed. Compare prefixes * case-insensitively (`underPrefix`) — Express mounts and routes match * regardless of case, so `/api/Webhooks` reaches the webhooks router. */ function apiPath(req) { return String(req.path || "") .split("/") .map((seg) => { try { return decodeURIComponent(seg); } catch { return seg; } }) .join("/") .replace(/\/{2,}/g, "/"); } const underPrefix = (p, x) => { const lp = p.toLowerCase(); return lp === x || lp.startsWith(x + "/"); }; const deny = (res, status, code, message) => res.status(status).json({ error: { code, message } }); /** Express middleware for `/api`; a no-op in local mode. */ function authGuard(req, res, next) { if (!isServerMode()) return next(); const p = apiPath(req); const lp = p.toLowerCase(); if (lp === "/health" || lp.startsWith("/auth/")) return next(); if (lp === "/hooks/ingest-batch") return next(); // its own bearer token if (underPrefix(p, "/hooks")) { return isLocalRequest(req) ? next() : deny(res, 403, "ELOCALONLY", "hooks are accepted from this machine only"); } const user = userFromRequest(req) || userFromPaneToken(req); if (!user) return deny(res, 401, "EAUTH", "log in first"); req.user = user; const under = (x) => underPrefix(p, x); const isRead = req.method === "GET" || req.method === "HEAD"; if ( user.role === "member" && STAFF_ONLY_PREFIXES.some(under) && !(isRead && MEMBER_READ_ALLOW.some(under)) ) { return deny(res, 403, "EADMIN", "only an admin or analyst may open this"); } if (isRead) return next(); // Every write in server mode passes the same-origin check — the cookie is // SameSite=Strict, which a sibling subdomain or another port on the same // host still satisfies. A browser always sends Origin on a write; a request // with neither Origin nor Referer is not a browser's. const raw = req.headers.origin || req.headers.referer; if (raw && !isLoopback(raw) && !isOwnHost(raw, req)) { return deny(res, 403, "EBADORIGIN", "cross-origin requests are not allowed"); } if (user.role === "analyst") return deny(res, 403, "EREADONLY", "an analyst account is read-only"); if (user.role !== "admin" && ADMIN_WRITE_PREFIXES.some(under)) { return deny(res, 403, "EADMIN", "only an admin may change this"); } return next(); } /** * WebSocket upgrade check (both `/ws` and `/ws-pty/*`); true in local mode. * A login, and — the terminal is behind this — an Origin, when sent, that is * the request's own allowed Host. */ function isWebSocketLoggedIn(req) { if (!isServerMode()) return true; const origin = req.headers.origin; if (origin) { let host = ""; try { host = new URL(origin).host.toLowerCase(); } catch { return false; } if (host !== String(req.headers.host || "").toLowerCase() || !isHostAllowed(host)) return false; } const user = userFromRequest(req); if ( user && user.role === "analyst" && /^\/ws-pty\//.test(new URL(req.url, "http://x").pathname) ) { return false; // a terminal is a write } return Boolean(user); } module.exports = { COOKIE, ROLES, ADMIN_WRITE_PREFIXES, STAFF_ONLY_PREFIXES, apiPath, newPaneToken, userFromPaneToken, PANE_HEADER, isServerMode, hashPassword, verifyPassword, listUsers, findUser, createUser, setPassword, setRole, setDisabled, assignWorkspace, hasAdmin, userFromRequest, login, endSession, cookieToken, setLoginCookie, clearLoginCookie, authGuard, isWebSocketLoggedIn, publicUser, };