Files
AgentHub/server/lib/auth.js
T
nntrivi2001 750d74a2af chore: v2.8.15 — Oh My Pi login sharing, omp --allow-home, Markdown Preview default, first-login password
- omp-share.js: an admin's switch (S3) runs omp's own auth broker + gateway as
  the host on loopback; a member's omp pane reaches it as a scoped client keyed
  by its pane token (clients file = live omp panes only), runs on
  --model agenthub/<model>, never holds a credential.
- omp catalog: --allow-home, so a workspace at the account's home keeps its
  sessions there (they were recorded under a temp dir and dropped).
- Settings → Appearance → Open Markdown as: Preview (default) or Raw, for
  Files/Changes, the config viewer and a chat's file panel.
- First-login password change and password rules, avatar URLs, Artifacts
  "Mine" includes installed shared skills/plugins, a member's own hook pages.
2026-10-22 10:04:30 +07:00

811 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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.
* 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`).
* - Two password bars: an admin may GIVE any non-empty password (a create or a
* reset — `validatePassword`), and such an account is flagged
* `must_change_password`; a password its OWNER picks must pass every rule of
* `passwordProblems` (`validateOwnPassword`). While the flag is set, a
* cookie login reaches only `/auth/*`, the `/me` reads and
* `POST /me/password` (EPWCHANGE) and opens no WebSocket; a pane token is
* not held back — its agents were started before the reset.
* - Neither the dashboard nor the CLI can leave the install with no enabled
* admin (`assertKeepsAdmin`, ELASTADMIN).
*
* 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", "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 machine's hardware and every user's CPU/memory share.
"/host",
];
// 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");
}
/** Shortest password an account may pick for itself. */
const PASSWORD_MIN_LENGTH = 8;
/**
* The rules a password picked by its OWNER fails, as ids (empty = it passes):
* `length`, `lower`, `upper`, `digit`, `symbol` and `username` (it contains
* the username). The client's checklist (client/src/lib/passwordRules.ts)
* mirrors these; this is the one that decides.
*/
function passwordProblems(password, username) {
const p = String(password ?? "");
const out = [];
if ([...p].length < PASSWORD_MIN_LENGTH) out.push("length");
if (!/\p{Ll}/u.test(p)) out.push("lower");
if (!/\p{Lu}/u.test(p)) out.push("upper");
if (!/\p{N}/u.test(p)) out.push("digit");
if (!/[^\p{L}\p{N}]/u.test(p)) out.push("symbol");
const name = String(username || "").toLowerCase();
if (name && p.toLowerCase().includes(name)) out.push("username");
return out;
}
/** A password the account picks for itself: every rule (EWEAKPASSWORD). */
function validateOwnPassword(password, username) {
validatePassword(password);
const failed = passwordProblems(password, username);
if (failed.length) {
throw Object.assign(err("EWEAKPASSWORD", `password too weak: ${failed.join(", ")}`), {
rules: failed,
});
}
}
// ── 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,
// When the avatar was last set (null = none): the client's cache-buster.
avatarAt: u.avatar_at ?? null,
createdAt: u.created_at ?? null,
// An admin gave this password: the account must pick its own first.
mustChangePassword: Boolean(u.must_change_password),
};
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 || ""));
}
/** `mustChange`: an admin gave the password — the user picks their own at first login. */
async function createUser(username, password, role = "developer", { mustChange = false } = {}) {
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, must_change_password) VALUES (?, ?, ?, ?)"
).run(name, role, hash, mustChange ? 1 : 0);
// 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. `mustChange`: an admin
* gave it, so the user must pick their own next; without it the flag clears. */
async function setPassword(username, password, { keepToken, mustChange = false } = {}) {
const u = requireUser(username);
validatePassword(password);
sql("UPDATE users SET password_hash = ?, must_change_password = ? WHERE id = ?").run(
await hashPassword(password),
mustChange ? 1 : 0,
u.id
);
endSessions(u.id, keepToken);
}
/** EWEAKPASSWORD (`reused`) when `password` is the account's current one. */
async function assertNotCurrent(userId, password) {
const row = sql("SELECT password_hash FROM users WHERE id = ?").get(userId);
if (row && (await verifyPassword(String(password ?? ""), row.password_hash))) {
throw Object.assign(err("EWEAKPASSWORD", "the new password is the current one"), {
rules: ["reused"],
});
}
}
/** 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 their last login
* ever (`users.last_login_at`; the newest login's start for one made before it). */
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());
const at = sql("SELECT last_login_at FROM users WHERE id = ?").get(userId)?.last_login_at;
return { logins: r.n, lastLoginAt: at || r.last || null };
}
/** `loginStats` for every account in two queries: Map(user id → stats). */
function allLoginStats() {
const now = new Date().toISOString();
const live = new Map(
sql(
`SELECT user_id, COUNT(*) AS n, MAX(created_at) AS last FROM auth_sessions
WHERE expires_at > ? GROUP BY user_id`
)
.all(now)
.map((r) => [r.user_id, r])
);
const out = new Map();
for (const u of sql("SELECT id, last_login_at FROM users").all()) {
const r = live.get(u.id);
out.set(u.id, { logins: r?.n || 0, lastLoginAt: u.last_login_at || r?.last || null });
}
return out;
}
/**
* ELASTADMIN when `u` is the only enabled admin and the change would leave it
* no longer one — the CLI included, which has no "yourself" to refuse.
*/
function assertKeepsAdmin(u, what) {
if (u.role !== "admin" || u.disabled) return;
const others = sql(
"SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND disabled = 0 AND id != ?"
).get(u.id).n;
if (!others) throw err("ELASTADMIN", `the last admin cannot be ${what}`);
}
function setRole(username, role) {
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
const u = requireUser(username);
if (role !== "admin") assertKeepsAdmin(u, "demoted");
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 — the reversible way out;
* `deleteUser` below is the one that removes an account for good. */
function setDisabled(username, disabled) {
const u = requireUser(username);
if (disabled) assertKeepsAdmin(u, "disabled");
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);
}
/**
* DELETE a user — the Users page's "Delete user" (an admin, password
* confirmed by the route). In order, so a failure leaves the account usable
* rather than half gone: their logins end and the panes of their workspaces
* stop; under S3 the Linux account `ah-<name>` and its whole home are removed
* (`agenthub-provision delete`; EPROVISION stops here, with no row touched);
* without S3 their own folder under AGENTHUB_USERS_ROOT goes. Then the rows:
* their workspaces' records, prefs, push subscriptions, personal AI Proxy
* profiles and the user. Session history and the audit log stay. Never the
* last enabled admin (ELASTADMIN); the route refuses yourself (ESELF).
* A user made again under the same name gets a NEW, empty Linux account.
*/
function deleteUser(username) {
const u = requireUser(username);
assertKeepsAdmin(u, "deleted");
const lanes = sql("SELECT id, cwd FROM lanes WHERE owner_id = ?").all(u.id);
sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
const pty = require("./pty-run");
for (const lane of lanes) {
for (let slot = 0; slot <= pty.MAX_PANE_SLOT; slot++) {
try {
pty.killRun(pty.runIdForLane(lane.id, slot));
} catch {
/* not running */
}
}
}
const linux = require("./linux-accounts");
if (linux.s3Enabled()) {
// Idempotent: a user with no Linux account (made before S3) or no home
// still passes; anything the helper refuses stops the delete here.
if (linux.LINUX_NAME_RE.test(u.username)) linux.provision("delete", u.username);
} else {
const path = require("node:path");
const fs = require("node:fs");
const root = (process.env.AGENTHUB_USERS_ROOT || "").trim();
if (root && /^[a-z][a-z0-9_-]{0,63}$/i.test(u.username)) {
const home = path.join(path.resolve(root), u.username);
// Only a real folder directly in the users root — never a link.
const st = fs.lstatSync(home, { throwIfNoEntry: false });
if (st?.isDirectory()) fs.rmSync(home, { recursive: true, force: true });
}
}
const lanesLib = require("./lanes");
for (const lane of lanes) lanesLib.deleteLane(lane.id);
sql("DELETE FROM user_prefs WHERE user_key = ?").run(`u${u.id}`);
require("./avatars").removeAvatar(u.id);
try {
sql("DELETE FROM push_subscriptions WHERE user_id = ?").run(u.id);
} catch {
/* an older database without the column */
}
try {
require("node:fs").rmSync(
require("node:path").join(require("./ai-proxies").proxiesDir(), "users", String(u.id)),
{ recursive: true, force: true }
);
} catch {
/* no personal profiles */
}
sql("DELETE FROM account_pins WHERE user_id = ?").run(u.id);
sql("DELETE FROM users WHERE id = ?").run(u.id);
return { id: u.id, username: u.username, workspaces: lanes.length };
}
/** 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);
sql("UPDATE users SET last_login_at = ? WHERE id = ?").run(new Date().toISOString(), u.id);
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 } });
/** What a login flagged `must_change_password` may still call. */
function mayBeforePasswordChange(method, lp) {
if (method === "GET" || method === "HEAD") return lp === "/me" || lp.startsWith("/me/");
return method === "POST" && lp === "/me/password";
}
/** 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 cookieUser = userFromRequest(req);
const user = cookieUser || userFromPaneToken(req);
if (!user) return deny(res, 401, "EAUTH", "log in first");
req.user = user;
// A password an admin gave: the login may only read its own account and
// pick its own password until it has.
if (cookieUser?.mustChangePassword && !mayBeforePasswordChange(req.method, lp)) {
return deny(res, 403, "EPWCHANGE", "choose your own password first");
}
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). None
// until a password an admin gave is replaced.
const user = userFromRequest(req);
return Boolean(user && !user.mustChangePassword);
}
module.exports = {
COOKIE,
ROLES,
ADMIN_WRITE_PREFIXES,
STAFF_ONLY_PREFIXES,
apiPath,
newPaneToken,
userFromPaneToken,
PANE_HEADER,
isServerMode,
hashPassword,
verifyPassword,
listUsers,
findUser,
createUser,
setPassword,
setRole,
setDisabled,
deleteUser,
assignWorkspace,
unassignWorkspace,
ownedWorkspaces,
validateProfile,
setProfile,
validatePassword,
validateOwnPassword,
passwordProblems,
assertNotCurrent,
PASSWORD_MIN_LENGTH,
endSessions,
loginStats,
allLoginStats,
hasAdmin,
userFromRequest,
login,
confirmPassword,
endSession,
cookieToken,
setLoginCookie,
clearLoginCookie,
authGuard,
isWebSocketLoggedIn,
publicUser,
};