a9c71cd905
Usage panel (status bar): - every account shows its plan (Claude subscriptionType, Codex chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier) - new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's own file, sent only to its own host while valid, never refreshed - in-flight quota reads are shared between tabs AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added; Gemini CLI, Hermes and OpenClaw removed. Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat on an editor selection, Settings in three groups, JetBrains-style Changes panel (ticked files/hunks, ignore, blame, file history), terminal font. --no-verify: :3000 is taken by another dev server (lane-detect tests); both suites run by hand — server 2636/2639 (the 3 are :3000), client 1131/1131.
477 lines
17 KiB
JavaScript
477 lines
17 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.
|
||
* - 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).
|
||
*
|
||
* 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",
|
||
"/users",
|
||
];
|
||
|
||
/**
|
||
* 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) };
|
||
}
|
||
|
||
/**
|
||
* 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, 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,
|
||
confirmPassword,
|
||
endSession,
|
||
cookieToken,
|
||
setLoginCookie,
|
||
clearLoginCookie,
|
||
authGuard,
|
||
isWebSocketLoggedIn,
|
||
publicUser,
|
||
};
|