Security Dashboard

Per-user security settings at /overview/settings/security.

Two-Factor Authentication

UI component: src/app/overview/settings/security/two-factor.tsx.

Enable flow

  1. Click Enable 2FAPOST /api/internal/two-factor/setup.
  2. Page shows QR (rendered via api.qrserver.com from the otpauth:// URI) plus the raw base32 secret.
  3. User adds to authenticator app, enters the 6-digit code, submits.
  4. POST /api/internal/two-factor/verify { code } — server validates TOTP, hashes and stores 8 backup codes, flips enabled: true.
  5. Page displays backup codes in a one-time yellow panel ("I've saved them" to dismiss).

Disable flow

  1. Click Disable 2FA → panel asks for password.
  2. POST /api/internal/two-factor/disable { password } — server bcrypt-compares, deletes the row on success.

Backup codes

  • Generated at verify time.
  • Each is 10 hex chars (40 bits).
  • Stored as bcrypt hashes in two_factor_secrets.backup_codes (text array).
  • Login comparison iterates and bcrypt.compares — used hash is filtered out.
  • Rendered plaintext only once. No way to re-reveal — user must disable and re-enable if lost.

Login

When 2FA is enabled, the credentials provider:

  • Accepts a third field totp alongside email + password.
  • No totp → throws "2FA_REQUIRED" so client can re-prompt.
  • TOTP match (window: ±1 × 30s) → success.
  • Backup code match (bcrypt) → success + invalidate that code.

Password reset invalidates sessions

See password-reset.md. On successful reset the user's session_version increments; any JWT issued before that version is rejected on the next request.

Session version check

Implemented in jwt callback of src/lib/auth/options.ts:

const [u] = await db.select({ sessionVersion: users.sessionVersion })...
if (token.sessionVersion !== undefined && token.sessionVersion !== current) {
  return { userId: "", orgId: "", sessionVersion: current };
}
token.sessionVersion = current;

Stale tokens return an empty-identity token, which the session callback maps to an empty session → the next requireSession() returns null → user is redirected to login.

SSO enforcement (Enterprise)

Owners of orgs with an active SAML connection can require SSO-only login — toggle on /overview/sso, persisted as sso_connections.enforce_sso, enforced in src/lib/auth/sso-enforcement.ts.

Binding rules — each closes a specific abuse path:

  • Existing users are bound only by orgs they're a member of. A stranger org pinning gmail.com cannot lock other tenants' users out.
  • Org owners are exempt (break-glass): a broken IdP can't lock out the person who can turn enforcement off. Owners should keep 2FA on.
  • Unknown emails are bound by a domain-pinned connection only when the org has a verified sending domain with the same name — ownership proof, so the JIT-race protection can't block public-domain signups platform-wide.

Rejection surfaces as SSO_REQUIRED — the credentials provider throws it before the password compare (correct passwords get the same answer), and the OAuth signIn callback redirects to /login?error=SSO_REQUIRED. The SAML token-exchange arm is exempt: it is the SSO path.

Known tradeoff: for domain-less connections, SSO_REQUIRED confirms the email belongs to a member of an SSO-enforced org. This matches GitHub/Stripe behavior (the SSO hint must be shown pre-auth to be useful) and is bounded by the login rate limit.

Enabling enforcement bumps session_version for all members except the acting owner — existing password/OAuth sessions end immediately; members re-auth through the IdP.

Managed via PATCH /api/internal/sso { enforce_sso } (owner + enterprise plan), audited as org.security_policy_updated.

Session policies (Enterprise)

Org-level columns on organizations, managed from the same SSO page + PATCH /api/internal/sso:

  • session_max_age_hours — hard ceiling on session lifetime. The JWT callback stamps loginAt at fresh login and rejects tokens older than the policy on refresh (cached alongside the session-version check, so no extra round-trip). Tokens minted before the feature carry no loginAt and age out at their next fresh login.
  • max_sessions_per_user — concurrent-device cap. Enforced at login in enforceConcurrentSessionCap: the fresh session plus the cap - 1 most recently seen sessions survive; older ones are revoked and their session cache entries invalidated (felt within 30 s).

Both policies evaluate the strictest value across all of the user's org memberships — a personal default org doesn't exempt anyone from their employer's policy.

SP metadata + SCIM groups (Enterprise)

  • SP metadata: GET /api/auth/saml/metadata serves importable SAML SP metadata XML (entity ID, ACS URL, POST binding, WantAssertionsSigned). IdP admins import the URL instead of hand-copying fields; surfaced on /overview/sso.
  • SCIM Groups: /api/scim/v2/Groups (GET/POST + GET/PATCH/PUT/DELETE by id) accepts Okta/Azure AD group push — stored in scim_groups + scim_group_members. Groups are inert until an owner maps them to a role on the SSO page (PATCH /api/internal/scim/groups, audited as scim.group.mapped).
  • Mapping policy (src/lib/auth/scim-groups.ts): highest mapped role across a user's groups wins; owners are never touched; owner is not mappable — SCIM can never grant ownership (enforced at the zod layer, the rank table, and a DB CHECK constraint). Losing all mapped groups leaves the role as-is (deprovisioning stays the Users endpoint's job).
  • Known tradeoff: for non-owners in mapped groups, IdP group state is the source of truth — a manual promotion is overwritten on the next sync. That's the point of directory sync; promote via IdP groups, not the dashboard, once mapping is on.
  • Directory default_role is capped at member/developer/viewer — the Users endpoint also clamps legacy owner directories to member at provision time.