SPB Git

Repository Public

authentication.md 9.1 KB

# Authentication

Single administrator. Password plus a mandatory second factor (TOTP or passkey); a passkey with user verification is also accepted on its own. Source: apps/server/src/security/*, apps/server/src/modules/auth/*; web pages apps/web/src/app/(auth)/{setup,login}. The threat model and the test mapping are in security.md.

# Route authorisation levels

One global onRequest hook (security/guard.ts) applies to every path under /api/, including WebSocket upgrades. A route is full unless it opts down.

Level Requirement Routes
public none GET /health
preauth none to read; POSTs need Origin = CR_PUBLIC_ORIGIN and Content-Type: application/json GET /auth/status, POST /auth/setup, POST /auth/login, POST /auth/passkey/login/options, POST /auth/passkey/login/verify
restricted a valid session, second factor possibly pending; mutations need Origin + CSRF token POST /auth/totp, POST /auth/logout, TOTP enrolment (`/auth/totp/enroll/start
full a valid session with a completed second factor; mutations need Origin + CSRF token; WebSocket upgrades need Origin everything else

Failures: 401 unauthenticated, 403 second_factor_required, 403 bad_origin, 403 bad_csrf, 403 bad_content_type. Unknown API paths answer a JSON 404.

# First-run setup

  1. While the users table is empty, the server writes a random token (32 bytes, base64url) to $CR_DATA_DIR/setup-token (mode 0600) at every start. It is never logged. A server restart before setup replaces the token, so read it just before use.
  2. The owner reads it on the host (ssh M5M36 'cat ~/claude-remote-data/setup-token') and opens /setup/.
  3. POST /auth/setup with {setupToken, username, password}: username 3–32 characters ([a-z0-9_.-], stored lower-case), password 12–256 characters. A wrong token is 401 invalid_setup_token and counts as a failure for the caller's IP.
  4. The administrator row is created under an advisory lock (two concurrent setups cannot both succeed), the token file is deleted, and a restricted session is issued. The response is {ok: true, next: "enroll_second_factor"}.
  5. The setup page then requires TOTP or passkey enrolment. Until a second factor exists, the session can only reach the restricted routes.
  6. Once an administrator exists, POST /auth/setup answers 404 permanently, and the token file is removed at every start.

# Login flows

POST /auth/login returns a LoginStep ({ok, next}):

Situation next Session issued
Wrong username or password — (401 invalid_credentials; same answer and same work for an unknown user) none
Password correct, no second factor enrolled enroll_second_factor restricted
Password correct, body also carries a valid totp done full
Password correct, TOTP enrolled totp → POST /auth/totp {code} restricted, upgraded on success
Password correct, only passkeys enrolled passkey → passkey login options/verify restricted, upgraded on success

Passkey-first login (no password): POST /auth/passkey/login/options without a session returns options for discoverable credentials (no credential ids are disclosed) and sets a short-lived cookie __Host-cr_pre; the challenge is stored server-side in preauth_challenges, keyed by the hash of that cookie. POST /auth/passkey/login/verify consumes the challenge and, on a valid assertion with user verification, issues a full session.

When a restricted session is upgraded, its token and CSRF token are replaced (new cookie). When a new session is issued to a caller that already presents one, the old one is revoked.

# Second factors

Factor Details
TOTP RFC 6238, HMAC-SHA1, 6 digits, 30 s step, ±1 step accepted. Secret generated server-side, shown once (secret + otpauth:// URL; the web client renders the QR code locally). Enrolment must be confirmed with a valid code within 10 minutes. Stored sealed with AES-256-GCM (CR_SECRET_KEY), bound to the user id. Each time-step counter is accepted once (totp_last_counter, conditional update).
Passkey @simplewebauthn/server v14. RP ID = hostname of CR_PUBLIC_ORIGIN, expected origin = CR_PUBLIC_ORIGIN, resident key and user verification required. Challenges are server-side, single-use, valid 5 minutes. Signature counter stored and checked. Several passkeys can be registered, each with a label.

Rules enforced by the server:

  • Enrolling a new factor requires a full session — or a restricted one only while the account has no factor at all (first run). A password alone can never attach a factor to an account that already has one.
  • The last remaining second factor cannot be removed (409 last_second_factor): DELETE /auth/totp requires at least one passkey; deleting a passkey requires TOTP or another passkey.

# Sessions and cookies

Property Value
Cookie __Host-cr_sid — HttpOnly, Secure, SameSite=Strict, Path=/, no Domain. Outside production on http://localhost only, the name is cr_sid and Secure is off.
Token 32 random bytes; only its SHA-256 is stored (auth_sessions.token_hash)
CSRF token 24 random bytes per session, returned by GET /auth/status, sent as X-CSRF-Token; the web client keeps it in memory only
Idle timeout 12 hours, sliding (last_seen_at refreshed at most once a minute on HTTP requests and WebSocket upgrades)
Absolute lifetime 30 days
Before the second factor 15 minutes
Revocation logout; DELETE /auth/devices/:id for a session; password change revokes every other session
WebSockets registered per auth session; closed with 4401 as soon as the session is revoked, and re-checked every 60 s for expiry

GET /auth/status returns setupRequired, authenticated (true only with a completed second factor), the user, enrolled methods (only for a caller with a session), the CSRF token and the effective expiry.

GET /auth/devices lists passkeys and active browser sessions (user agent, IP, last use) and marks the current one.

# Throttling and rate limits

Failure throttling (security/throttle.ts, table login_attempts, survives restarts) applies to password, TOTP, setup-token, passkey verification and password change:

Key Limit per 15 minutes
username 5 failures
client IP 10 failures
global 100 failures

At the limit the key is locked for 60 s, doubling with each further failure, capped at 15 minutes. The response is 429 with Retry-After and {error:{code:"locked",details:{retryAfterSec}}, ok:false, next:"done", retryAfterSec}. A success clears the username and IP counters (never the global one). A correct password followed by a pending second factor does not count as a success, so re-entering the password cannot reset the counter that protects the 6-digit code.

Request rate limits per minute (app.ts): credential endpoints 20 per client IP (one shared bucket), anonymous GET /auth/status 240 per IP, GET /health 600 per IP, all other API calls 600 per session.

The client IP is the socket peer unless the peer is listed in CR_TRUSTED_PROXIES, in which case X-Forwarded-For is honoured.

# Password

argon2id through Node's built-in crypto.argon2 when available (64 MiB, 3 passes, parallelism 1), otherwise scrypt (N = 2^17, r = 8, p = 1); stored as a PHC-style string with its parameters. Hashes are upgraded at login when the cost parameters changed. POST /auth/password requires the current password and revokes all other sessions.

# Audit

Every authentication event is written to audit_events with actor, client IP and outcome (auth.setup, auth.login, auth.totp, auth.passkey.login, auth.passkey.register, auth.totp.enroll.*, auth.totp.delete, auth.device.delete, auth.password, auth.logout). Secrets never appear in the audit detail or in logs.

# Owner first-run steps on production

  1. ssh M5M36 'cat ~/claude-remote-data/setup-token'
  2. Open https://claude.spboucher.ai/setup/, paste the token, create the administrator (password ≥ 12 characters).
  3. Enrol a passkey and/or TOTP (mandatory).
  4. Create a project, start a Claude session, run /login inside it (Claude Code's own authentication).
  5. Copy ~/.config/claude-remote/backup.pass from the node to a safe place.

# Limitations / not verified

  • As of 2026-11-10 no administrator account exists on production; the steps above are pending.
  • Passkey registration and login were verified against a software authenticator (apps/server/src/testkit/soft-authenticator.ts), not in a real browser or on a real device.
  • There is no "forgot password" or recovery-code flow. Recovery requires direct database access on the node (see backup-recovery.md).
  • Idle time is refreshed by HTTP requests and WebSocket upgrades, not by traffic on an already open WebSocket. A terminal left open is closed (4401) once the session's idle or absolute limit passes without any other request from that browser.
  • There is a single account; no roles.
Files main
Menu

Theme