feat(multiuser): phase 2, multi-user auth pipeline

Adds a parallel multi-user auth branch (the single-user Basic-auth path is
left byte-identical). Off unless CODEMAN_MULTIUSER/--multiuser.

- middleware/auth.ts: mode-selecting registerAuthMiddleware. New async
  multi-user hook verifies username:password against the user store (scrypt),
  mints identity-carrying cookies, decorates req.authUser, enforces a per-IP
  AND per-username failure bucket, and the mustChangePassword lockbox. The
  hook-secret loopback bypass is now a single shared helper used by both
  branches. FastifyRequest.authUser module augmentation.
- ports/auth-port.ts: AuthSessionRecord gains username/role/mustChangePassword.
- user-store.ts: verifyPassword (timing-equalized against user enumeration).
- route-helpers.ts: getAuthUser (synthetic admin fallback), canAccessOwned,
  requireAdmin, revokeUserSessions; findSessionOrFail gains an optional req for
  a NOT_FOUND owner check (dormant until phase 3 wires callers).
- routes/me-routes.ts: GET /api/me (synthetic admin in single-user) and
  POST /api/me/password (verify current, min 8, clear mustChangePassword,
  revoke other sessions).
- QR: QrTokenRecord + AuthSessionRecord carry a username; tunnel-manager
  mintUserToken / consumeTokenWithIdentity / getQrSvgForCode; /q/:code binds
  the cookie to the token's user (rejects identity-less tokens in multi-user);
  GET /api/tunnel/qr mints a per-user token. Single-user keeps the rotating token.
- server.ts: bootstrap the initial admin from CODEMAN_USERNAME/PASSWORD on first
  boot (refuse to start with no users); multi-user with >= 1 user satisfies the
  non-loopback auth requirement and the tunnel-enable guard; userFailures bucket
  disposal.
- types/api.ts: FORBIDDEN, PASSWORD_CHANGE_REQUIRED, USER_EXISTS, USER_NOT_FOUND,
  LAST_ADMIN error codes (message + status wired).
- Session.owner field + getter/setter, SessionState.owner, MuxSession.owner,
  CreateSessionOptions.owner (foundation for phase 3 ownership threading).

Tests: test/multiuser-auth.test.ts (10, live server on 3170/3171). Existing auth
suite (auth-security, qr-auth, cod54-hook-event, network-auth-policy) unchanged
and green; full test:ci sweep passes (3519 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-20 03:26:21 +02:00
parent f496e35d71
commit 4d8857f72a
15 changed files with 780 additions and 77 deletions
+234 -57
View File
@@ -8,7 +8,7 @@
* - CORS (localhost only)
*/
import type { FastifyInstance, FastifyReply } from 'fastify';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { StaleExpirationMap } from '../../utils/index.js';
import type { AuthSessionRecord } from '../ports/auth-port.js';
@@ -20,6 +20,17 @@ import {
AUTH_FAILURE_WINDOW_MS,
} from '../../config/auth-config.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
// Request-scoped identity (multi-user). Single-user leaves it undefined and the
// ownership helpers default to a synthetic admin (see route-helpers).
declare module 'fastify' {
interface FastifyRequest {
authUser?: AuthUser;
}
}
// Auth session cookie name
export const AUTH_COOKIE_NAME = 'codeman_session';
@@ -30,6 +41,80 @@ interface AuthState {
authFailures: StaleExpirationMap<string, number> | null;
qrAuthFailures: StaleExpirationMap<string, number> | null;
hookSecretFailures: StaleExpirationMap<string, number> | null;
/** Per-username Basic-auth failure bucket (multi-user only). */
userFailures: StaleExpirationMap<string, number> | null;
}
/** Rate-limit response for a client that exceeded the failure cap. */
function sendAuthRateLimit(reply: FastifyReply, failures: StaleExpirationMap<string, number>, key: string): void {
const remainingMs = failures.getRemainingTtl(key) ?? AUTH_FAILURE_WINDOW_MS;
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
reply.header('Retry-After', String(retryAfterSeconds));
reply.code(429).send('Too Many Requests — try again later');
}
/** Parse a `Basic base64(user:pass)` header into its parts, or null if malformed. */
function parseBasicAuth(header?: string): { username: string; password: string } | null {
if (!header || !header.startsWith('Basic ')) return null;
try {
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf-8');
const idx = decoded.indexOf(':');
if (idx < 0) return null;
return { username: decoded.slice(0, idx), password: decoded.slice(idx + 1) };
} catch {
return null;
}
}
/**
* The `/api/hook-event` + `/api/status-telemetry` localhost bypass, shared by the
* single-user and multi-user auth hooks so the security-critical logic has ONE
* source of truth. Returns:
* - 'bypass' : loopback + valid hook secret; the caller should allow the request
* - 'rejected' : a reply was already sent (wrong secret rate-limited / 401)
* - 'continue' : not a hook request (or non-loopback); fall through to normal auth
*
* COD-91: the shared hook secret is required UNCONDITIONALLY on the loopback bypass
* (a user's own loopback reverse proxy is indistinguishable from a real local hook).
*/
function checkHookSecretBypass(
req: FastifyRequest,
reply: FastifyReply,
hookSecretFailures: StaleExpirationMap<string, number>
): 'bypass' | 'rejected' | 'continue' {
if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') {
const ip = req.ip;
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
if (isLoopback) {
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
const expected = Buffer.from(getHookSecret());
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
return 'bypass';
}
const hookIp = req.ip;
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
if (hookFailures >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, hookSecretFailures, hookIp);
return 'rejected';
}
hookSecretFailures.set(hookIp, hookFailures + 1);
reply.code(401).send('Unauthorized: hook secret required');
return 'rejected';
}
// Non-localhost hook requests fall through to normal auth
}
return 'continue';
}
/**
* Requests that a `mustChangePassword` user may still reach: the identity probe,
* the password-change endpoint, and any non-API path (static assets / index.html,
* so the browser can load the app and render the change-password modal).
*/
function isPasswordChangeExempt(req: FastifyRequest): boolean {
const url = (req.url ?? '').split('?')[0];
if (url === '/api/me' || url === '/api/me/password') return true;
return !url.startsWith('/api/');
}
/**
@@ -47,13 +132,20 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
authFailures: null,
qrAuthFailures: null,
hookSecretFailures: null,
userFailures: null,
};
const authPassword = process.env.CODEMAN_PASSWORD;
if (!authPassword) return state;
// Always declare req.authUser so downstream reads are safe (single-user leaves it
// undefined; the ownership helpers then default to a synthetic admin).
if (!app.hasRequestDecorator('authUser')) app.decorateRequest('authUser', undefined);
const authUsername = process.env.CODEMAN_USERNAME || 'admin';
const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
const multiUser = isMultiUserMode();
const authPassword = process.env.CODEMAN_PASSWORD;
// No auth at all: single-user with no password (byte-identical to legacy). In
// multi-user mode auth is ALWAYS active (users authenticate individually), even
// without CODEMAN_PASSWORD.
if (!multiUser && !authPassword) return state;
// Session token store — active sessions extend TTL on access
state.authSessions = new StaleExpirationMap<string, AuthSessionRecord>({
@@ -87,57 +179,28 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
const authFailures = state.authFailures;
const hookSecretFailures = state.hookSecretFailures;
function sendAuthRateLimit(
reply: FastifyReply,
clientIp: string,
failures: StaleExpirationMap<string, number> = authFailures
): void {
const remainingMs = failures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
reply.header('Retry-After', String(retryAfterSeconds));
reply.code(429).send('Too Many Requests — try again later');
if (multiUser) {
// Per-username failure bucket: a botnet can't brute-force one account across
// many IPs, and one user behind a NAT can't lock out everyone else.
state.userFailures = new StaleExpirationMap<string, number>({
ttlMs: AUTH_FAILURE_WINDOW_MS,
refreshOnGet: false,
});
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures);
return state;
}
// ── Single-user Basic Auth (unchanged behavior; CODEMAN_PASSWORD required) ──
const authUsername = process.env.CODEMAN_USERNAME || 'admin';
const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
app.addHook('onRequest', (req, reply, done) => {
// Hook events + statusline telemetry come from local Claude Code (curl from
// localhost) — no Basic-Auth credentials available. Validated downstream by
// HookEventSchema / StatusTelemetrySchema. Same loopback+hook-secret gate.
//
// COD-54: the bare localhost bypass is unsafe while a tunnel is running, because
// `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
// loopback origin, so a tunneled request arrives with req.ip === 127.0.0.1 and
// would pass. COD-91: require the shared hook secret on the loopback bypass
// UNCONDITIONALLY (not just while the managed tunnel is up). Codeman can't detect
// a user's own loopback reverse proxy (their own `cloudflared --url`, `tailscale
// serve`, nginx → 127.0.0.1), so tunnel-gating left that path with the unsafe plain
// bypass. Managed-session hooks always present the secret (X-Codeman-Hook-Secret,
// from $CODEMAN_HOOK_SECRET_FILE — generated for every instance), so requiring it
// always closes the gap without breaking the legitimate hook channel.
if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') {
const ip = req.ip;
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
if (isLoopback) {
// Always require the shared secret (constant-time compare).
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
const expected = Buffer.from(getHookSecret());
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
done();
return;
}
// Wrong/absent secret — rate-limit per IP in the DEDICATED hook bucket
// (never authFailures, which would lock out the login path).
const hookIp = req.ip;
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
if (hookFailures >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, hookIp, hookSecretFailures);
return;
}
hookSecretFailures.set(hookIp, hookFailures + 1);
reply.code(401).send('Unauthorized: hook secret required');
return;
}
// Non-localhost hook requests fall through to normal auth
const bypass = checkHookSecretBypass(req, reply, hookSecretFailures);
if (bypass === 'bypass') {
done();
return;
}
if (bypass === 'rejected') return;
// QR auth path — handled by the route itself (token validation + rate limiting)
if (req.url?.startsWith('/q/')) {
@@ -153,10 +216,6 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
if (sessionToken && authSessions.get(sessionToken) !== undefined) {
// Sliding cookie: re-issue on every authenticated request so the browser
// cookie lifetime tracks the server-side sliding TTL (refreshOnGet above).
// Without this the cookie has a fixed lifetime from login; the browser
// drops it mid-use, the next request arrives cookie-less and falls through
// to Basic Auth — popping the native username/password dialog, which reads
// as a random logout while actively working.
reply.setCookie(AUTH_COOKIE_NAME, sessionToken, {
httpOnly: true,
secure: https,
@@ -206,7 +265,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
// Rate limit only requests that failed to authenticate on this attempt.
const failures = authFailures.get(clientIp) ?? 0;
if (failures >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, clientIp);
sendAuthRateLimit(reply, authFailures, clientIp);
return;
}
@@ -220,6 +279,124 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
return state;
}
/**
* Multi-user auth hook (async, because password verification runs scrypt). Verifies
* `username:password` against the user store, mints an identity-carrying cookie,
* decorates `req.authUser`, enforces the per-IP + per-username rate limits, and the
* `mustChangePassword` lockbox. The single-user hook above is left untouched.
*/
function registerMultiUserAuthHook(
app: FastifyInstance,
https: boolean,
authSessions: StaleExpirationMap<string, AuthSessionRecord>,
authFailures: StaleExpirationMap<string, number>,
hookSecretFailures: StaleExpirationMap<string, number>,
userFailures: StaleExpirationMap<string, number>
): void {
const setSessionCookie = (reply: FastifyReply, token: string) =>
reply.setCookie(AUTH_COOKIE_NAME, token, {
httpOnly: true,
secure: https,
sameSite: 'lax',
maxAge: AUTH_SESSION_TTL_MS / 1000,
path: '/',
});
// Evict the oldest cookie session of the SAME user first (so one user logging in
// 100 times cannot flush everyone else's sessions), falling back to global-oldest.
const evictForCapacity = (username: string) => {
let userKey: string | undefined;
let userTs = Infinity;
let globalKey: string | undefined;
let globalTs = Infinity;
for (const [k, v] of authSessions) {
if (v.createdAt < globalTs) {
globalTs = v.createdAt;
globalKey = k;
}
if (v.username === username && v.createdAt < userTs) {
userTs = v.createdAt;
userKey = k;
}
}
const key = userKey ?? globalKey;
if (key !== undefined) authSessions.delete(key);
};
const enforcePasswordChange = (req: FastifyRequest, reply: FastifyReply, mustChange: boolean): boolean => {
if (mustChange && !isPasswordChangeExempt(req)) {
reply.code(403).send(createErrorResponse(ApiErrorCode.PASSWORD_CHANGE_REQUIRED));
return true;
}
return false;
};
app.addHook('onRequest', async (req, reply) => {
const bypass = checkHookSecretBypass(req, reply, hookSecretFailures);
if (bypass === 'bypass' || bypass === 'rejected') return;
// QR redemption path — handled by the route itself.
if (req.url?.startsWith('/q/')) return;
const clientIp = req.ip;
// 1. Cookie session (carries identity + mustChangePassword snapshot).
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
const record = sessionToken ? authSessions.get(sessionToken) : undefined;
if (record && record.username) {
req.authUser = { username: record.username, role: record.role ?? 'user' };
setSessionCookie(reply, sessionToken!); // sliding re-issue
enforcePasswordChange(req, reply, !!record.mustChangePassword);
return;
}
// 2. Basic Auth against the user store (scrypt verify).
const ipFail = authFailures.get(clientIp) ?? 0;
if (ipFail >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, authFailures, clientIp);
return;
}
const creds = parseBasicAuth(req.headers.authorization);
if (creds) {
const normUser = creds.username.trim().toLowerCase();
const uFail = userFailures.get(normUser) ?? 0;
if (uFail >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, userFailures, normUser);
return;
}
const result = await verifyPassword(creds.username, creds.password);
if (result) {
const { user, needsRehash: rehash } = result;
if (rehash) void setPassword(user.username, creds.password).catch(() => {});
void touchLastLogin(user.username).catch(() => {});
authFailures.delete(clientIp);
userFailures.delete(normUser);
const token = randomBytes(32).toString('hex');
if (authSessions.size >= MAX_AUTH_SESSIONS) evictForCapacity(user.username);
authSessions.set(token, {
ip: clientIp,
ua: req.headers['user-agent'] ?? '',
createdAt: Date.now(),
method: 'basic',
username: user.username,
role: user.role,
mustChangePassword: !!user.mustChangePassword,
});
req.authUser = { username: user.username, role: user.role };
setSessionCookie(reply, token);
enforcePasswordChange(req, reply, !!user.mustChangePassword);
return;
}
userFailures.set(normUser, uFail + 1);
}
authFailures.set(clientIp, ipFail + 1);
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
reply.code(401).send('Unauthorized');
});
}
/** Methods that don't change server state and so skip the cross-site Origin check. */
const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);