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
+18
View File
@@ -412,6 +412,10 @@ export class Session extends EventEmitter {
// local tmux + `docker exec`. The container is per-CASE (shared by sibling sessions).
private readonly _docker?: SessionDocker;
// Owning username in multi-user mode (undefined in single-user). Stamped at create
// from req.authUser and round-tripped through recovery like _remote/_docker.
private _owner?: string;
// Session color for visual differentiation
private _color: import('./types.js').SessionColor = 'default';
@@ -487,6 +491,8 @@ export class Session extends EventEmitter {
remote?: SessionRemote;
/** Docker execution metadata for sessions launched inside a container via local tmux. */
docker?: SessionDocker;
/** Owning username (multi-user mode); undefined in single-user. */
owner?: string;
}
) {
super();
@@ -561,6 +567,7 @@ export class Session extends EventEmitter {
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
this._remote = config.remote;
this._docker = config.docker;
this._owner = config.owner;
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
this.restoreAttachmentHistory(config.attachmentHistory);
}
@@ -667,6 +674,16 @@ export class Session extends EventEmitter {
return this._docker;
}
/** Owning username in multi-user mode, else undefined. */
get owner(): string | undefined {
return this._owner;
}
/** Set the owning username (used by recovery to restore ownership). */
set owner(username: string | undefined) {
this._owner = username;
}
// Adopt a Claude conversation ID observed from an external source (e.g. hook
// payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so
// `_handleJsonMessage` never sees `session_id`; hooks are the only signal
@@ -1027,6 +1044,7 @@ export class Session extends EventEmitter {
workingDir: this.workingDir,
remote: this._remote,
docker: this._docker,
owner: this._owner,
currentTaskId: this._currentTaskId,
createdAt: this.createdAt,
lastActivityAt: this._lastActivityAt,