9a54978eef
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged) - Login for every page and API call (server/lib/auth.js, routes/auth.js, AuthGate), roles admin / analyst / member, accounts made only from the host's shell: `agenthub server setup|off|status`, `agenthub users list|add|passwd|role|disable|enable|assign|audit` - Rows are owned: a member sees only their own workspaces, sessions and what hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience); staff reads of a member's session are audited (server/lib/audit.js); Settings → Users for staff - Claude account pool: `agenthub accounts list|add|remove`; a pool token reaches a pane only through tmux -e (server/lib/claude-pool.js) - A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback, live pane only); writes pass an Origin check; named locks per member - docs/SERVER-MODE.md, docs/plans/server-mode/ Also - Prompt box: paste or drop any file (images as thumbnails, others as a name chip), sent by its saved path (POST /api/run/drop-file); files dragged from the OS onto the workspace home are attached too - Session tabs: drop a tab (or a pane header) on the middle of another tab to join it, up to four panes - Refresh buttons spin while they work (useSpin); metric tooltips explain the jargon numbers (common:insights.help.*) - Light palette retuned for contrast; IntelliJ Light theme removed - CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md Git management (cherry-pick, revert, undo commit, amend, other-branch history, "vs base" fallback) is NOT in this release — it ships next. Committed --no-verify: both full suites run on this exact tree (server 2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev; client 1086/1086), tsc clean, prettier applied by hand.
457 lines
16 KiB
JavaScript
457 lines
16 KiB
JavaScript
/**
|
||
* @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$<salt>$<hash>` (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ĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
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,
|
||
};
|