Skip to content

Latest commit

 

History

History
17 lines (12 loc) · 5.94 KB

auth.md

File metadata and controls

17 lines (12 loc) · 5.94 KB

Auth — first-run setup, signup control, domain restrictions

Who may create an account in this instance, and how the very first one is created. Three env switches (SETUP_TOKEN_REQUIRED, SIGNUP_ENABLED, ALLOWED_SIGNUP_DOMAINS/ADMIN_SIGNUP_DOMAINS) and one invariant that holds under concurrency.

Invariant: the first account is always created via /setup (as ADMIN). Every other registration channel (password signup, first-time Google Sign-In) requires setup to be complete and SIGNUP_ENABLED=true. Login and account linking for existing users are never gated by any of these.

  • First-run setup (/setup). When the database has no users, the frontend (SetupGate in src/client/components/SetupGate.tsx) redirects anonymous traffic to /setup, where the operator creates the initial admin via POST /api/auth/setup. That endpoint first calls refreshSetupState() (in setup.service.ts) to self-heal a stale in-memory flag from the DB, then creates the user as ADMIN (with lastLoginAt stamped, since it auto-logs-in), bypassing ALLOWED_SIGNUP_DOMAINS (the operator is trusted), inside a transaction with a Postgres advisory lock + count re-check (createInitialAdmin in auth.service.ts) so the /setup-vs-/setup bootstrap can only ever happen once even under concurrency. On success it sets the auth cookie and completeSetup() flips the in-memory flag. State lives in src/api/features/auth/setup.service.ts; initSetupState() runs once at boot from src/index.ts (not app.onStart, which would re-run under bun --hot).
    • Scope of the advisory lock. It serializes /setup posts against each other (across replicas sharing the DB). It does not serialize against the bun set-admin CLI, which writes outside this lock; running bun set-admin and /setup concurrently can produce a regular user and a /setup-created admin. The invariant we actually care about ("/setup produces at most one admin") still holds; flagging this in case future code grows other admin-creation paths.
    • Operational caveats. The setup token and the setupComplete flag are per-process in memory. (1) Multi-instance: each replica logs its own token, so the browser must reach the instance that generated it (single instance or sticky sessions for the first run); the DB advisory lock still guarantees a single admin. A stale replica self-heals on its next POST /auth/setup because refreshSetupState() queries the DB before the token check and flips the local flag if a user already exists, so the operator gets a clean 409 (which the frontend converts into a redirect to /login via AuthContext.refresh()) instead of a permanent 401 from local-token mismatch. (2) Re-opening setup: if every user is deleted while the app runs, the flag stays "complete" until the next restart, when initSetupState() re-detects the empty table and reopens /setup. (3) Boot-time DB outage: initSetupState() errors are logged as warnings and the flag stays false; refreshSetupState() on the first /setup POST self-heals once the DB recovers, so this no longer produces a permanent dead-end.
  • SETUP_TOKEN_REQUIRED (default true) protects the setup window. At boot, when no users exist, the server logs a one-time token and a ready-to-use ${PUBLIC_URL}/setup?token=... URL; the setup form requires that token (verified timing-safe). The SetupPage pre-fills it from the query string then strips it from the URL. Set to false only on trusted networks to allow /setup without a token. The token is held in memory (regenerated on each restart while setup is pending) and cleared on completion. /auth/me exposes setupRequired and setupTokenRequired for the frontend.
  • SIGNUP_ENABLED (default false) is the master switch for public registration. When false, both POST /api/auth/signup and first-time Google Sign-In that would create a new account return 403 (errors.signupDisabled); existing users keep logging in. /auth/me exposes signupEnabled so the UI hides the signup link and /signup redirects to /login. To add users without opening public signup: enable Google (existing-account linking still works), flip SIGNUP_ENABLED=true, or use bun set-admin.
  • ALLOWED_SIGNUP_DOMAINS (comma-separated, default empty = allow any) gates POST /api/auth/signup and first-time Google Sign-In via POST /api/auth/google (only relevant when SIGNUP_ENABLED=true). Existing users are never affected: login and account linking skip the check so pre-existing accounts keep working even if their domain is later removed from the allowlist. The first-run /setup admin bypasses this allowlist.
  • ADMIN_SIGNUP_DOMAINS (comma-separated, default empty = no auto-promotion) auto-promotes new accounts from listed domains to ADMIN on signup. Only fires for first-time Google Sign-In with email_verified: true. Password signups via POST /api/auth/signup are always created as USER (no proof of address ownership); use bun set-admin <email> to promote them. If ALLOWED_SIGNUP_DOMAINS is set, admin domains must be in the allowlist as well.

Threat model for ADMIN_SIGNUP_DOMAINS. "The user controls this email address" is not the same as "the user is entitled to ADMIN in this application". Anyone who can obtain a Google ID token with email_verified: true for a listed domain becomes ADMIN on first sign-in: any Workspace admin of the domain, any insider with an account, anyone who compromises the Workspace credentials or DNS, anyone who exploits a Google provisioning bug. Only enable this when the group that operates the Google Workspace is the same group that should have admin control over the application. Otherwise leave it empty and promote admins explicitly with bun set-admin.

To narrow the takeover window for bun set-admin-created accounts, the Google linking flow refuses to attach a Google identity to an ADMIN row that has never logged in. The pre-created admin must complete a password login at least once before Google linking becomes available.