/** * @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. * Writes under `ADMIN_WRITE_PREFIXES` — the host account's own * configuration — are an `admin`'s; an `analyst` and a `developer` write * only their own rows (`scope.js`). * - Accounts are made from the host's shell (`agenthub users …`) or by an * admin on the Users page (`server/routes/users.js`), where every change * needs the admin's own password again (`confirmPassword`, throttled). * An account also carries an optional display name and email * (`validateProfile`), which its owner edits in Settings → Account without * that step-up; changing their OWN password there needs the old one and * keeps the login it was changed from (`setPassword`'s `keepToken`). * * 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", "developer"]; const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i; /** * API prefixes whose writes change the host account's own setup. (AI Proxy * profiles are not here: a member writes their own personal ones, and * `routes/ai-proxies.js` keeps the shared ones an admin's.) */ const ADMIN_WRITE_PREFIXES = [ "/settings", "/updates", "/cc-config", "/codex-config", "/git-credentials", "/ai-tools", "/import", "/pricing", "/budgets", "/webhooks", "/remote-sources", "/reports", "/pets", "/users", // The Claude/Codex account pool: tokens every member's panes run on. "/accounts", // Alert rules and budgets fire for every user's sessions. "/alerts/rules", ]; /** * API prefixes a developer 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 developer'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"]; // Staff-only paths with an id in the middle, matched on `apiPath()`'s // lower-cased form: the host's own AI-tool config files (they may hold keys). const STAFF_ONLY_PATTERNS = [/^\/ai-tools\/[^/]+\/config(\/|$)/]; 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) { // No minimum length (the operator's choice) — only an empty one is refused. if (typeof password !== "string" || password.length === 0) { throw err("EBADPASSWORD", "password: required"); } 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), displayName: u.display_name ?? null, email: u.email ?? null, createdAt: u.created_at ?? null, }; const MAX_DISPLAY_NAME = 100; const MAX_EMAIL = 254; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; /** * The profile fields present in `body`, normalized: trimmed, `""`/`null` ⇒ * null (clearing is legitimate). Fields absent from `body` are absent from the * result, so a caller writes only what was sent. Throws EBADFIELD/EBADEMAIL. */ function validateProfile(body) { const out = {}; if (body.displayName !== undefined) { const v = body.displayName; if (v !== null && typeof v !== "string") throw err("EBADFIELD", "displayName: a string"); const n = (v || "").trim(); // eslint-disable-next-line no-control-regex if (n.length > MAX_DISPLAY_NAME || /[\u0000-\u001f\u007f]/.test(n)) { throw err("EBADFIELD", `displayName: at most ${MAX_DISPLAY_NAME} printable characters`); } out.display_name = n || null; } if (body.email !== undefined) { const v = body.email; if (v !== null && typeof v !== "string") throw err("EBADEMAIL", "email: a string"); const e = (v || "").trim(); if (e && (e.length > MAX_EMAIL || !EMAIL_RE.test(e))) { throw err("EBADEMAIL", "email: not a valid address"); } out.email = e || null; } return out; } /** Write `validateProfile`'s result for one account. */ function setProfile(userId, fields) { for (const [col, value] of Object.entries(fields)) { // `col` is one of validateProfile's two fixed names, never request text. if (col !== "display_name" && col !== "email") continue; sql(`UPDATE users SET ${col} = ? WHERE id = ?`).run(value, userId); } } 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 = "developer") { const name = validateUsername(username); const linux = require("./linux-accounts"); linux.assertLinuxName(name); 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); // S3: the Linux account first — a user who cannot get one is not created. linux.provision("create", name); const { lastInsertRowid } = sql( "INSERT INTO users (username, role, password_hash) VALUES (?, ?, ?)" ).run(name, role, hash); // S3, opt-in: the admin's plugin baseline, installed AS the new member in // the background (each marketplace is a clone) — the user exists already. require("./member-claude") .applyPluginBaseline(name) .catch((e) => console.warn(`[plugin-baseline] ${name}: ${e.message}`)); 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 — but `keepToken`'s, when * the user changed their own password from that login. */ async function setPassword(username, password, { keepToken } = {}) { const u = requireUser(username); validatePassword(password); sql("UPDATE users SET password_hash = ? WHERE id = ?").run(await hashPassword(password), u.id); endSessions(u.id, keepToken); } /** End every login of `userId` but `exceptToken`'s; returns how many ended. */ function endSessions(userId, exceptToken) { if (exceptToken) { return sql("DELETE FROM auth_sessions WHERE user_id = ? AND token_hash != ?").run( userId, sha256(exceptToken) ).changes; } return sql("DELETE FROM auth_sessions WHERE user_id = ?").run(userId).changes; } /** `{ logins, lastLoginAt }`: the user's unexpired logins and the newest one's start. */ function loginStats(userId) { const r = sql( `SELECT COUNT(*) AS n, MAX(created_at) AS last FROM auth_sessions WHERE user_id = ? AND expires_at > ?` ).get(userId, new Date().toISOString()); return { logins: r.n, lastLoginAt: r.last || null }; } function setRole(username, role) { if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`); const u = requireUser(username); sql("UPDATE users SET role = ? WHERE id = ?").run(role, u.id); if (role !== "admin") stopHostPanes(u.id); } /** * S3: end the live panes of `userId`'s workspaces whose folder is no * member's — they run as the HOST account, which only an admin's may (a * new one is refused, EOWNERFOLDER; this ends the ones already running). */ function stopHostPanes(userId) { if (!require("./linux-accounts").s3Enabled()) return; const runAs = require("./run-as"); const pty = require("./pty-run"); for (const lane of sql("SELECT id, cwd FROM lanes WHERE owner_id = ?").all(userId)) { if (runAs.ownerForRead(lane.cwd)) continue; // a member's folder: their own account for (let slot = 0; slot <= pty.MAX_PANE_SLOT; slot++) { try { pty.killRun(pty.runIdForLane(lane.id, slot)); } catch { /* already gone */ } } } } /** 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); const linux = require("./linux-accounts"); // S3: lock (and end the processes of) the Linux account too — when there is // one: a user made before S3, or whose name fits no Linux login, has none, // and that must never stop an admin from revoking their dashboard access. const hasAccount = linux.s3Enabled() && require("./run-as").hasLinuxAccount(u.username); if (!disabled && hasAccount) linux.provision("enable", u.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); // ...and their panes that run as the host (pkill below reaches only theirs). if (disabled) stopHostPanes(u.id); // Disable: the logins end FIRST, unconditionally; a failed lock still throws // (EPROVISION), so the admin learns that the account's agents may still run. if (disabled && hasAccount) linux.provision("disable", u.username); } /** 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 lane = sql("SELECT cwd FROM lanes WHERE id = ?").get(Number(laneId)); // S3: a workspace in a member's home is theirs alone (EOWNERFOLDER). if (lane) require("./run-as").assertOwnerFits(lane.cwd, u.id); 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}`); } /** Take a workspace back from the user who owns it: it is left with no owner * (only staff see it) until it is given to someone. ENOTOWNED when `username` * does not own it — this never takes one from a third user. */ function unassignWorkspace(laneId, username) { const u = requireUser(username); const lane = sql("SELECT owner_id FROM lanes WHERE id = ?").get(Number(laneId)); if (!lane) throw err("ENOLANE", `no workspace ${laneId}`); if (lane.owner_id !== u.id) { throw err("ENOTOWNED", `workspace ${laneId} is not ${u.username}'s`); } sql("UPDATE lanes SET owner_id = NULL WHERE id = ? AND owner_id = ?").run(Number(laneId), u.id); } /** The workspaces `userId` owns: `[{id, title, cwd}]`. */ const ownedWorkspaces = (userId) => sql("SELECT id, title, cwd FROM lanes WHERE owner_id = ? ORDER BY id").all(userId); 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) }; } /** * Step-up check for an account change made in the dashboard: the acting * user's OWN password again, so a login left open (or a page that rode it) * cannot create an admin. Throttled like a login; throws EREAUTH/ETOOMANY. */ async function confirmPassword(user, password) { const key = `reauth:${user.id}`; if (blocked(key, FAIL_LIMIT)) throw err("ETOOMANY", "too many wrong passwords, try again later"); noteAttempt(key); const row = sql("SELECT password_hash FROM users WHERE id = ?").get(user.id); const ok = await verifyPassword(String(password || ""), row?.password_hash || DUMMY_HASH); if (!row || !ok) throw err("EREAUTH", "your password is wrong"); failures.delete(key); } // ── 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, r.lane_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; // S3: only from the account that pane runs as (any local uid could post it). if (!require("./hook-auth").peerIsPaneAccount(req, row.lane_id)) 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 === "developer" && ((STAFF_ONLY_PREFIXES.some(under) && !(isRead && MEMBER_READ_ALLOW.some(under))) || STAFF_ONLY_PATTERNS.some((re) => re.test(lp))) ) { 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"); } // An analyst writes like a developer: only their own rows (scope.js). 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; } // Which terminals a login may open is the run check's (websocket.js). return Boolean(userFromRequest(req)); } 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, unassignWorkspace, ownedWorkspaces, validateProfile, setProfile, validatePassword, endSessions, loginStats, hasAdmin, userFromRequest, login, confirmPassword, endSession, cookieToken, setLoginCookie, clearLoginCookie, authGuard, isWebSocketLoggedIn, publicUser, };