Files
backspace/docs/systems/auth.md
T
Jannis Braun fb9fe326c4 feat(mobile): Wave 4 — RegisterPage responsive + TransferIndicator on settings + MessageInput sheets
- RegisterPage: scroll envelope, iOS-zoom-safe inputs, ≥44px tap targets, mobile-friendly invite chip + swatch row
- TransferIndicator: mounted via MobileScreenHeader.rightActions across every settings/instance screen and inline in MobileChatScreen; touch-close listener; viewport-safe panel width
- MessageInput popovers (emoji/GIF/mention) now route through new InputPopover wrapper — desktop popover, mobile bottom-sheet
- EmojiPicker mobile branch: dynamicWidth + scoped CSS (.emoji-picker-wrapper--mobile) so the <em-emoji-picker> custom element fills the sheet; bigger touch targets (perLine 8, button 40, emoji 28); desktop unchanged
- MessageInput trigger row: mobile-first sizing with md: desktop overrides (40×40 buttons + gap on mobile, 34×34 unchanged on desktop)
- Spec updates: docs/systems/auth.md (RegisterPage responsive contract), docs/systems/uploads.md (TransferIndicator mobile surfaces)
2026-05-07 22:58:57 +02:00

641 lines
33 KiB
Markdown

# Authentication & Session System
Source files:
- `packages/server/src/routes/auth.ts` -- Registration, login, username availability, invite-token check endpoints
- `packages/server/src/routes/invites.ts` -- Admin invite-link CRUD (create / list / patch / revoke / reinstate / delete / redemptions)
- `packages/server/src/utils/inviteService.ts` -- Invite token generation, status derivation, atomic `redeemInvite()` transaction
- `packages/server/src/routes/users.ts` -- Password change, account deletion endpoints (lines 58-156)
- `packages/server/src/routes/admin.ts` -- Admin password reset endpoint (lines 232-265)
- `packages/server/src/utils/auth.ts` -- Password hashing, JWT sign/verify, `authenticate` preHandler, `requireAdmin`
- `packages/server/src/utils/userDeletion.ts` -- `tombstoneUser()` transactional account erasure
- `packages/server/src/utils/sanitize.ts` -- `sanitizeUser()` strips internal fields, anonymizes deleted users
- `packages/server/src/ws/handler.ts` -- WebSocket auth handshake (lines 1282-1374)
- `packages/web/src/stores/authStore.ts` -- Client session state, login/register/logout/password/delete actions
- `packages/web/src/hooks/useAuth.ts` -- Route guard hook (redirect to `/login` when no token)
- `packages/web/src/App.tsx` -- `ProtectedRoute` and `AuthRedirect` route wrappers
- `packages/web/src/utils/identity.ts` -- Federation-aware identity helpers (`parseFederatedUsername`, `isSelf`, `canonicalUserMatch`)
- `packages/web/src/utils/federationOps.ts` -- Cross-instance password sync and account deletion propagation
- `packages/server/src/config.ts` -- `jwtSecret`, `jwtExpiresIn`, `registrationOpen` config
DB tables: `users`, `instanceSettings`. See `database.md` for full schemas.
---
## 1. Password Hashing
**Library:** `bcryptjs`
**Salt rounds:** 12 (constant `SALT_ROUNDS` in `auth.ts`)
```
hashPassword(password: string): Promise<string> -- bcrypt.hash(password, 12)
verifyPassword(password: string, hash: string): Promise<boolean> -- bcrypt.compare
```
**Federation stub marker:** Replicated user stubs have `passwordHash = '!federation-replicated'`. Since bcrypt never produces this value, login is impossible for stubs. See `federation.md` for identity resolution.
---
## 2. JWT Management
### Signing
```typescript
interface JwtPayload {
userId: string;
username: string;
iat?: number; // Auto-set by jsonwebtoken library (seconds since epoch)
}
```
- **Algorithm:** HS256 (enforced on verify via `{ algorithms: ['HS256'] }`)
- **Secret:** `config.jwtSecret` (env `JWT_SECRET`, minimum 32 characters -- startup crash if shorter)
- **Expiry:** `config.jwtExpiresIn` (env `JWT_EXPIRES_IN`, default `'30d'`)
- **Library:** `jsonwebtoken`
`signJwt(payload)` creates a token with `{ expiresIn }` option. The `iat` field is auto-injected by the library.
### Validation (`authenticate` preHandler)
Applied as `preHandler` on all authenticated routes. Flow:
1. Extract `Bearer <token>` from `Authorization` header
2. `verifyJwt(token)` -- checks signature (HS256) and expiry
3. DB lookup: fetch `id`, `isDeleted`, `passwordChangedAt` from `users` table
4. Reject if user not found or `isDeleted === 1`
5. **Token revocation check:** if `passwordChangedAt` is set and `payload.iat` exists, reject if `iat < Math.floor(passwordChangedAt / 1000)` (JWT `iat` is seconds, `passwordChangedAt` is milliseconds)
6. Attach `userId` and `username` to `request` object
### Token Revocation
There is **no token blocklist**. The only revocation mechanism is the `passwordChangedAt` timestamp:
- When a user changes their password (or an admin resets it), `passwordChangedAt` is set to `Date.now()`
- All tokens issued before that timestamp (`iat < passwordChangedAt/1000`) are rejected
- A fresh token is issued after password change
**Exception:** Federation password self-healing (see section 4) does NOT set `passwordChangedAt` -- it is a state correction, not a password change, so existing valid JWTs remain valid.
### WebSocket Auth
`ws/handler.ts:registerWebSocket()` -- WS connection at `/ws`:
1. Client connects, 10-second auth timeout starts
2. First message must be `{ type: 'auth', token: '<jwt>' }`
3. `verifyJwt(token)` validates signature and expiry
4. DB check: reject if user deleted or token revoked (same `passwordChangedAt` logic as REST)
5. On success: clears timeout, sets status to `'online'`, registers connection, sends `ready` payload, broadcasts presence
6. On failure: sends error message and closes socket
---
## 3. Registration Flow
**Endpoint:** `POST /api/auth/register`
**Rate limit:** 10 requests / 2 minutes per IP
**Auth:** None
### Input Validation
| Field | Rules |
|-------|-------|
| `username` | Required string. Trimmed, lowercased. |
| `password` | Required string. Minimum 8 characters. |
| `displayName` | Optional. Trimmed or null. |
| `avatarColor` | Optional. Must be in `AVATAR_COLORS` array, else random. |
| `homeInstance` | Optional (federation only). Max 253 chars, alphanumeric + `.` `-` `_`. |
| `homeUserId` | Optional (federation only). Stored if `homeInstance` is present. |
### Username Validation (Two Paths)
**Local registration** (`homeInstance` absent):
- Length: 3-32 characters
- Pattern: `/^[a-z0-9_]+$/` (lowercase alphanumeric + underscore)
- No `@` allowed
**Federated/replicated registration** (`homeInstance` present):
- MUST use `username@domain` format (plain usernames reserved for native users)
- Local part: 3-32 chars, `/^[a-z0-9_]+$/`
- Domain part: 1-253 chars, `/^[a-zA-Z0-9._-]+$/`
- Total: max 100 characters
### Registration Gate
The `/api/auth/register` route splits its gate by request shape (spec §1.2). There are **two independent toggles** plus an **invite-token bypass** for the local path.
**Local anonymous signup** (no `homeInstance` in body):
1. `instanceSettings.registrationOpen` (DB, id=1) -- if not null, this takes priority
2. `config.registrationOpen` (env `REGISTRATION_OPEN`, default `true`) -- fallback
DB value overrides env when explicitly set by admin. When closed, a valid `inviteToken` bypasses the gate and is **atomically consumed** alongside the user insert (see "Invite Tokens" below). When open, an `inviteToken` field is **silently ignored** (no validation, no consumption).
**Federated identity replication** (request body has `homeInstance`):
- Gated solely by `instanceSettings.federatedRegistrationOpen` (NOT NULL DEFAULT 1).
- `inviteToken` is **ignored entirely** on this path -- tokens never unlock federated creation, even when supplied.
- Closed → 403 `"Federated registration is closed on this instance"`.
**Invariants** (spec §1.3):
- **Login-unaffected invariant.** Neither toggle gates `POST /api/auth/login` for any user. Existing federated accounts always log in regardless of `federatedRegistrationOpen`; existing local accounts always log in regardless of `registrationOpen`. Both gates affect **creation only**. This is why the Connections add-instance form keeps its submit button enabled even when the target instance has `federatedRegistrationOpen = false` (see `client-federation.md`): the request runs through `instanceStore`'s register-then-login fall-through, and the login leg succeeds for users who already have a federated account on that instance.
- The federated stub upgrade flow (below) is gated by `federatedRegistrationOpen`, never by an invite token. Tokens only unlock the local anonymous-signup path.
**Toggle matrix** (spec §5.6):
| `registrationOpen` | `federatedRegistrationOpen` | Local register | Federated register | Connections UI behavior |
|---|---|---|---|---|
| true | true | open | allowed | normal |
| true | false | open | 403 | warning banner; submit enabled (login fall-through) |
| false | true | invite-required | allowed | normal |
| false | false | invite-required | 403 | warning banner; submit enabled (login fall-through) |
| false (any) | (any) | invite bypasses | NOT bypassable by token | — |
S2S DM stub creation (relay path, never `/register`) is gated only by federation peering settings — neither toggle affects it.
### Invite Tokens
When `registrationOpen` is false, the local-signup path accepts an `inviteToken` field on the register body. Token format: 22-char base64url (`crypto.randomBytes(16).toString('base64url')` — 128 bits of entropy; collision probability against the existing space is `~2^-122`, with the DB UNIQUE on `invite_links.token` as the safety net + retry-up-to-3 in the create handler). Admin CRUD lives in `inviteService.ts` and `routes/invites.ts` -- see `docs/systems/admin.md` for the panel UX and the full status state machine.
**Lifecycle:**
```
create → active ──(usedCount = maxUses)─→ exhausted ┐
│ │
│ ┌──(expiresAt < now)──→ expired ──────┤
│ │ │
↓ ↓ │
revoke ──(revokedAt set)──→ revoked │ reinstate
│ ←┘ (Path A: token rotates;
│ Path B: same token)
└──→ active again
DELETE /admin/invites/:id
hard-delete (CASCADE redemptions)
```
Status is **derived** at read time from `(revokedAt, expiresAt, usedCount, maxUses)` — no stored column. Reinstate branches on the pre-reinstate status: revoked → token rotates (`tokenRotated: true`); expired/exhausted → token preserved (`tokenRotated: false`); already-active → 409.
**Audit trail (`invite_redemptions`):** every successful redemption inserts one row with `inviteId` (FK CASCADE — admin hard-delete drops the audit), `userId` (FK SET NULL — defensive against future hard-delete; tombstone keeps it populated), `registrantUsername` (snapshot at registration moment, preserves forensic value when the user is later renamed or tombstoned `!deleted:{uid}`), and `redeemedAt`.
Atomic redemption (spec §2.4):
```
db.transaction(() => {
// 1. Re-fetch invite by token under txn (closes TOCTOU vs /check-invite)
// 2. Reject if status !== 'active' → throw InviteUnavailableError → 403
// 3. INSERT user row
// 4. UPDATE invite_links SET usedCount = usedCount + 1
// 5. INSERT invite_redemptions row (forensic audit, snapshots username)
})
```
If any step throws (concurrent revoke, last-slot race, username collision against the unique index), the entire transaction rolls back -- `usedCount` is never incremented on a failed registration. The route catches `InviteUnavailableError` from `redeemInvite()` and surfaces it as 403 `"Invalid or expired invite"`.
- **Federated stub upgrade and federated new-account paths do NOT enter `redeemInvite`.** They are gated only by `federatedRegistrationOpen` and never consume tokens, even if a token is provided in the request body. This is the structural enforcement of the spec §1.3 invariant "tokens never unlock federated creation".
The `/api/auth/check-invite` debounced UX endpoint pre-validates a token from the register page; the in-txn re-derive inside `redeemInvite()` is the authoritative enforcement point.
### First-User Admin Promotion
```
const userCount = db.select().from(schema.users).all().length;
const isFirstUser = userCount === 0 && !homeInstance;
```
The very first user registered on the instance (and only if local, not replicated) gets `isAdmin = 1`.
### Avatar Color Assignment
```typescript
const AVATAR_COLORS = ['mint', 'sky', 'lavender', 'coral', 'rose', 'teal', 'amber'] as const;
```
If `requestedAvatarColor` is provided and is in `AVATAR_COLORS`, use it. Otherwise, pick randomly from the array.
### Registration Steps
1. Validate inputs (username format, password length)
2. Read both registration gates from `instance_settings`
3. **Branch by request shape** (spec §1.2):
- If `homeInstance` set → reject with 403 unless `federatedRegistrationOpen === true`. `inviteToken` ignored on this path.
- Else (local) → if `registrationOpen` is false, require a valid `inviteToken`; otherwise reject with 403. Pre-flight token check rejects obvious-invalid tokens before bcrypt.
4. **Federated stub upgrade check** (if `homeInstance` is set): call `findFederatedUser` to look for an existing relay-created stub. If found and upgradeable, upgrade it instead of creating a new record (see below).
5. Check username uniqueness (exact match on lowercased username)
6. Hash password (bcrypt, 12 rounds)
7. Generate Snowflake ID
8. Insert user row (status defaults to `'offline'` at the schema level; it is set to `'online'` only when the client establishes a WebSocket via the WS auth path in `ws/handler.ts`). Admin flag set if first user. **When the local-closed-with-token path is in play**, the insert runs inside `redeemInvite()`'s transaction so the user row, the `usedCount` bump, and the `invite_redemptions` row commit atomically (or all roll back).
9. Sign JWT with `{ userId, username }`
10. Return `{ token, user }` (user sanitized via `sanitizeUser(user, true)`)
### Federated Stub Upgrade
When a user registers with `homeInstance` set (federated registration via friend-connect), the registration path checks for an existing relay-created stub using `findFederatedUser`. The stub-upgrade flow is **always gated by `federatedRegistrationOpen`**, never by an invite token (spec §1.3). If found and the stub has `passwordHash = '!federation-replicated'` (not a real account), the stub is upgraded:
- `passwordHash` is set to the new bcrypt hash (enables login)
- `username` is updated to the registration's chosen username (replaces placeholder like `291255103060533248@nova.ddns.net` with `nova@nova.ddns.net`)
- `homeUserId` is backfilled if null
- Missing profile fields (`displayName`, `avatarColor`) are filled
The user's ID remains the same, preserving all existing FK references (DM memberships, messages, reactions, friendships). The user logs in and sees their full history. Returns HTTP 200 (not 201).
If the found user has a real password hash (already registered), the registration returns 409 and the client falls back to login.
### Username Availability Check
**Endpoint:** `GET /api/auth/check-username?username=<name>`
**Rate limit:** 30 requests / 1 minute per IP
**Auth:** None
Validates format (same rules as local registration: 3-32 chars, `/^[a-z0-9_]+$/`), checks registration gate, then queries `users` table for existence. Returns `{ available: boolean, reason?: string }`.
---
## 4. Login Flow
**Endpoint:** `POST /api/auth/login`
**Rate limit:** 15 requests / 2 minutes per IP
**Auth:** None
### Steps
1. Validate `username` and `password` are present strings
2. Look up user by `username` (trimmed, lowercased)
3. Reject if not found (generic "Invalid username or password")
4. Reject if `isDeleted === 1` ("This account has been deleted")
5. Verify password via bcrypt
6. **If password invalid AND user is federated:** attempt self-healing (see below)
7. **If password invalid AND user is local:** reject
8. Sign JWT, return `{ token, user }`. **Note:** Login does NOT mutate `users.status`. A successful login does not by itself imply a live connection (the client may never establish a WebSocket due to network failure, mobile background, error path); writing `'online'` here would produce a permanently stuck-online row that no disconnect timer cleans up. The WebSocket auth path (`ws/handler.ts`) is the single source of truth for `status = 'online'`. See `docs/systems/activity-presence.md` "Boot Reset" for the mitigation that runs on server start.
### Federation Password Self-Healing
When local bcrypt verification fails for a user with `homeInstance` set:
1. Extract base username (strip `@domain` if present)
2. POST to `https://{homeInstance}/api/auth/login` with base username and provided password
3. Timeout: 10 seconds (`AbortController`)
4. **If home instance accepts (200):**
- Re-hash password locally: `hashPassword(password)`
- Update local `passwordHash` -- but **do NOT set `passwordChangedAt`** (this is a state correction, not a password change; setting it would invalidate existing valid JWTs on this instance)
- Log the self-healing event
- Continue with login success
5. **If home instance rejects:** return "Invalid username or password"
6. **If home instance unreachable (network error/timeout):** return "Invalid username or password" (fall back to local-only rejection)
This flow ensures that when a federated user changes their password on their home instance, they can still log in on remote instances even if the remote's hash is stale.
---
## 5. Password Change
### User Password Change
**Endpoint:** `POST /api/users/@me/change-password`
**Rate limit:** 5 requests / 15 minutes
**Auth:** JWT (`authenticate` preHandler)
**Request body:** `{ currentPassword?: string, newPassword: string }`
| User type | `currentPassword` | Behavior |
|-----------|-------------------|----------|
| Local (`homeInstance` is null) | Required | Verified via bcrypt against stored hash |
| Federated (`homeInstance` set) | Not required | JWT auth is sufficient (home instance already verified the change) |
**Steps:**
1. Validate `newPassword` is string, min 8 chars
2. Load user from DB
3. If local: require and verify `currentPassword`
4. Hash new password
5. Update `passwordHash` AND `passwordChangedAt = Date.now()` -- this invalidates all prior tokens
6. Sign fresh JWT, return `{ token }`
### Admin Password Reset
**Endpoint:** `POST /api/admin/users/:id/reset-password`
**Auth:** JWT + `requireAdmin` (instance admin only)
**Guards:**
- Target user must exist
- Target must not be deleted
- Target must not be federated (`homeInstance` must be null -- "Federated users authenticate via their home instance")
**Steps:**
1. Generate temporary password: `crypto.randomBytes(12).toString('base64url')` (16 chars)
2. Hash it and update `passwordHash` + `passwordChangedAt = Date.now()`
3. `connectionManager.forceDisconnectUser(targetId)` -- closes all WS connections, forcing re-auth
4. Return `{ temporaryPassword }` -- admin must relay this to the user out-of-band
### Cross-Instance Password Propagation (Client-Side)
When a user changes their password on their home instance, `authStore.changePassword()`:
1. Changes password on home instance via API
2. Updates local token in localStorage and Zustand state
3. Calls `changePasswordOnRemotes(newPassword)` from `federationOps.ts`
`changePasswordOnRemotes()` flow:
1. Gets all connected remote instances from `instanceStore`
2. Cancels any existing retry timers for those origins
3. For each connected instance, calls `inst.api.users.changePassword({ newPassword })` with retry:
- **Initial retry:** `retryWithBackoff()` -- 3 attempts, exponential backoff starting at 2000ms (2s, 4s, 8s)
- On success: updates cached token for that instance, clears pending sync flag
4. If initial retries fail, starts `scheduleBackgroundRetry()`:
- Schedule: 10 attempts at 30s intervals (5 min), then 12 attempts at 5min intervals (60 min)
- Each attempt looks up current instance from store (avoids stale references)
- Stops if instance disconnected or removed
- On exhaustion: sets `pendingPasswordSync` flag on the instance (UI indicator)
**Timer management:**
- `activeRetryTimers` map tracks per-origin retry timers
- `clearPasswordSyncTimers()` cancels all active retries (called on logout)
- New password change cancels existing retry loops for affected origins
---
## 6. Account Deletion
### Self-Deletion
**Endpoint:** `DELETE /api/users/@me`
**Rate limit:** 3 requests / 15 minutes
**Auth:** JWT (`authenticate` preHandler)
**Request body:** `{ password: string, username: string }`
**Pre-checks:**
1. `username` must match stored username (confirmation safeguard)
2. Local users must provide and verify `password`; federated users rely on JWT auth
3. Must not own any spaces (returns 400 with `ownedSpaces` list)
**Client-side flow** (`authStore.deleteAccount()`):
1. Call `deleteAccountOnRemotes()` first (best-effort, see below)
2. Call `api.users.deleteAccount()` on home instance
3. Clear localStorage token, reset all user-scoped stores
### Federation Account Deletion
`deleteAccountOnRemotes()` runs before home deletion:
- For each connected remote instance, calls `inst.api.users.deleteAccount({ password: '', username: inst.username })`
- Password is empty string (not needed for federated users on remotes)
- Best-effort: failures are caught and returned as `FederationOpResult[]` but do not block home deletion
### Tombstoning (`tombstoneUser()`)
All cleanup runs in a single SQLite transaction:
**Relationship cleanup (deletes):**
- `spaceMembers` -- removes from all spaces
- `memberRoles` -- removes all role assignments
- `friends` -- removes all friendships (both directions)
- `friendRequests` -- removes all friend requests (both directions)
- `dmMembers` -- removes from all DM channels
- `readStates` -- removes all read state records
- `reactions` -- removes all message reactions
- `dmReactions` -- removes all DM reactions
- `spaceFolders` -- removes all space folders
- `bans` -- removes bans where user is target (try/catch for table existence)
- `joinRequests` -- removes join requests (try/catch)
- `voiceRestrictions` -- removes voice restrictions (try/catch)
**Moderator reference cleanup (nullifies):**
- `bans.bannedBy` -- nullified where points to deleted user
- `voiceRestrictions.moderatorId` -- nullified
- `joinRequests.decidedBy` -- nullified
**Channel override cleanup:**
- Deletes `channelOverrides` where `targetType = 'member'` and `targetId = uid`
**Group DM ownership transfer:**
- For each group DM owned by the user, transfers to next remaining member
- If no remaining members, DM becomes orphaned
**Orphaned DM cleanup:**
- Finds DM channels with zero members after removal
- For each: collects attachment filenames, deletes attachments, reactions, messages, and the channel
**User row anonymization:**
```
username: '!deleted:{uid}' -- frees original username for reuse
passwordHash: crypto.randomBytes(32).toString('hex') -- random, unverifiable
displayName: null
avatar: null
banner: null
bio: null
customStatus: null
accentColor: null
avatarColor: null
replicatedInstances: '[]'
isDeleted: 1
status: 'offline'
isAdmin: 0
```
**Return value:** Array of filenames to delete from disk (avatar, banner, orphaned DM attachments). Caller handles disk cleanup.
**Post-transaction (in route handler):**
- Delete files from disk via `deleteUploadFile()`
- `connectionManager.forceDisconnectUser()` -- closes all WS connections, leaves voice rooms, broadcasts presence
### `tombstoneUser()` Options
`tombstoneUser` accepts an optional second argument:
```typescript
interface TombstoneOptions { purgeContent?: boolean }
function tombstoneUser(uid: string, options?: TombstoneOptions): string[]
```
- **`purgeContent: true`** (default / omitted): full tombstone — removes the user from spaces, friends, DM membership, and read-states; then also deletes `reactions`, `dmReactions`, and the user's space `messages` with their attachments and embeds.
- **`purgeContent: false`**: soft tombstone — removes the user from spaces, friends, DM membership (`dm_members`), and read-states. The `purgeContent: false` flag skips only `reactions`, `dm_reactions`, and the user's space `messages` (with attachments + embeds); DM membership cleanup and orphaned-DM purge always run in both modes (per `userDeletion.ts:121-126, 169-202`) because zero-member DM channels are unreachable garbage regardless of authorship retention. Used by the federation identity soft-delete endpoint so remote message history is retained.
### `resolveOrCreateReplicatedUser` and Deleted Users
`resolveOrCreateReplicatedUser` checks whether a user matching `homeUserId + homeInstance` already exists and has `isDeleted = 1`. If so, it returns `null` rather than returning or re-creating the deleted stub. This prevents zombie identities from reappearing after a federation identity deletion.
### Federation Identity Deletion (Home-Side Trigger)
**Endpoint:** `POST /api/users/@me/federation-identity/delete`
**Rate limit:** 5 requests / 15 minutes
**Auth:** JWT (`authenticate` preHandler)
**Request body:** `{ origins: string[], mode: 'soft' | 'full' }`
Fans out HMAC-signed `DELETE /api/federation/identity` requests to each listed remote in parallel. Returns a per-origin results map:
```json
{ "results": { "<origin>": { "success": true } } }
```
On failure for a given origin the entry contains `{ "success": false, "error": "<message>", "ownedSpaces"?: [...] }`. A `409` from a remote means the user owns spaces there that must be resolved before deletion can proceed.
### `sanitizeUser()` for Deleted Users
When `isDeleted === 1`, returns an anonymized profile:
- `username: 'Deleted User'`
- All profile fields null/empty/false
- `status: 'offline'`
- Only `id` and `createdAt` preserved
---
## 7. Client-Side Session Lifecycle
### State (`authStore`)
```typescript
interface AuthState {
token: string | null; // Persisted in localStorage as 'backspace_token'
user: User | null; // Current user object
isLoading: boolean;
error: string | null;
}
```
**Initialization:** `token` is read from `localStorage.getItem('backspace_token')` on store creation.
### `initSession(token, user)`
Called after successful login or registration:
1. `resetUserStores()` -- clears all user-scoped stores (chat, space, social, voice, instance, activity) and `clearSelfIds()` from identity registry
2. Saves token to localStorage
3. Sets token + user in Zustand state
4. Fires `useInstanceStore.autoConnectAll()` (fire-and-forget) for federation
### `loadUser()`
Called by `useAuth()` hook when token exists but user object is null:
1. Calls `api.users.me()` to fetch current user
2. On success: sets user, triggers `autoConnectAll()`
3. On failure: removes token from localStorage, clears state (forces redirect to login)
### `logout()`
1. Removes token from localStorage
2. Calls `resetUserStores()` (clears all stores + self IDs)
3. Sets token and user to null
### Route Guards
**`ProtectedRoute`** (in `App.tsx`):
- Reads `token` from authStore
- If no token: `<Navigate to="/login" replace />`
- Used for `/channels/:spaceId/:channelId?` and `/explore`
**`AuthRedirect`** (in `App.tsx`):
- Reads `token` from authStore
- If token present: redirects to `?redirect` param or `/channels/@me`
- Used for `/login` and `/register` routes
- Prevents authenticated users from seeing auth pages
**`useAuth()` hook:**
- Watches `token`, `user`, `isLoading`
- If no token: navigates to `/login`
- If token but no user and not loading: calls `loadUser()`
- Returns `{ user, isLoading, isAuthenticated }`
### Login Page
- Fields: username, password
- Redirect support: reads `?redirect` param, navigates there on success (validated: must start with `/`, not `//`)
- Rate limit handling: catches `RateLimitError`, shows countdown timer
- Links to register page (preserves redirect param)
### Registration Page (Two-Step)
**Step 1 -- Credentials:**
- Fields: username, password, confirm password
- Client-side validation: 3-32 chars, `/^[a-z0-9_]+$/`, passwords match, min 6 chars
- Debounced username availability check (500ms delay, abort on new input)
- Continue button disabled if username taken or invalid
**Step 2 -- Personalization:**
- Fields: display name (optional), avatar color picker, avatar upload (with crop modal)
- "Get Started" button: registers with personalization
- "Skip for now" button: registers without personalization
- Registration flow:
1. Call `api.auth.register()` -- saves token to localStorage but NOT to Zustand (prevents premature `AuthRedirect`)
2. If avatar file selected: upload file, then `api.users.update({ avatar })` (failure is non-fatal)
3. `initSession(token, finalUser)` -- activates Zustand state, triggers redirect
4. Navigate to redirect param or `/channels/@me`
**Auth-token source of truth.** During the step-2 avatar upload, the JWT lives in `localStorage` only -- `authStore.token` (Zustand) is still null because step 3 hasn't fired. Both the home `api` client (`api/client.ts`) and the home-origin branch of `setTokenForOriginResolver` in `instanceStore.ts` therefore read the home JWT from `localStorage.getItem('backspace_token')`, never from `authStore.token`. This keeps `transferStore.startUpload` (and any other path that resolves a home-origin bearer) authenticated during the registration window. The two stores are written together everywhere else (`initSession`/`logout`), so the divergence only matters between steps 1 and 3 here.
**Responsive contract.** Auth pages render outside `MobileShell` (they are pre-layout). The `RegisterPage` outer wrapper is a self-contained scroll container -- `h-full overflow-y-auto` on the outermost `<div>` because `#root` is `h-full overflow-hidden` (see `globals.css`). An inner `min-h-full flex items-center justify-center` wrapper centers the card vertically when content fits, and falls back to top-aligned scroll when content exceeds the viewport (as on iOS Safari with the keyboard up, where the visible viewport shrinks by ~300 px). Card width is `max-w-[480px]` with `px-4` outer gutters, `p-6 md:p-8` inner padding (smaller on mobile to reclaim 16 px content area at 360 px viewports). All `<input>` elements override the shared `input-standard` class's `text-sm` with `text-base md:text-sm` -- iOS Safari auto-zooms when an input has font-size <16 px. Primary submit buttons use `py-3 md:py-2.5` to satisfy Apple HIG's ≥44 px tap-target rule on mobile. The closed-registration URL-token chip switches from `inline-flex` (desktop pill) to `flex` (mobile full-width banner) so longer error copy ("Invalid invite link -- please request a new one") wraps cleanly inside a 360 px viewport instead of forcing a single-line pill that overflows. The avatar color swatch row uses `gap-2 md:gap-2.5` so the 7 swatches fit within the 360 px content area. **Note:** `LoginPage` does NOT yet apply the same scroll/iOS-zoom/tap-target treatment; this is a known follow-up since LoginPage's shorter form is less likely to clip. Update both together if revisiting.
---
## 8. Federation-Aware Identity Utilities
### `parseFederatedUsername(username)`
Splits a potentially federated username:
```
"youruser@nova.ddns.net" -> { baseName: "youruser", domain: "nova.ddns.net" }
"youruser" -> { baseName: "youruser", domain: null }
```
### Self-ID Registry
Module-level `Set<string>` tracking all Snowflake IDs belonging to the current user across connected instances:
```
registerSelfId(id: string) -- adds ID (called from WS ready events)
clearSelfIds() -- clears all (called on logout/session reset)
```
### `isSelf(user, homeUser)`
Determines if a user object represents the current user. Cascading checks:
1. Same `id` (same instance, trivial)
2. `_knownSelfIds.has(user.id)` (cross-instance via registry)
3. `user.homeInstance === window.location.host` AND base usernames match
### `canonicalUserMatch(a, b)`
Federation-safe comparison of two user-like objects. Cascading strategies:
1. Same `id` -- trivial match
2. `homeUserId` cross-matching (both have it, or one matches the other's `id`)
3. Username + home instance fallback: parse base names, derive home from `homeInstance` or domain part of username, compare
### `resolveDisplayIdentity(user, homeUser)`
If `user` is a replicated alias of `homeUser` (via `isSelf`), returns `homeUser` for display purposes. Otherwise returns `user` unchanged.
---
## 9. Rate Limits Summary
| Endpoint | Max | Window |
|----------|-----|--------|
| `POST /api/auth/register` | 10 | 2 min |
| `GET /api/auth/check-username` | 30 | 1 min |
| `GET /api/auth/check-invite` | 30 | 1 min |
| `POST /api/auth/login` | 15 | 2 min |
| `POST /api/users/@me/change-password` | 5 | 15 min |
| `DELETE /api/users/@me` | 3 | 15 min |
All keyed by `request.ip`.
---
## 10. Configuration Reference
| Config key | Env var | Default | Notes |
|------------|---------|---------|-------|
| `jwtSecret` | `JWT_SECRET` | (required) | Min 32 chars, startup crash if shorter |
| `jwtExpiresIn` | `JWT_EXPIRES_IN` | `'30d'` | Passed to `jsonwebtoken` `expiresIn` option |
| `registrationOpen` | `REGISTRATION_OPEN` | `true` | Overridden by `instanceSettings.registrationOpen` in DB (null DB row = env fallback). Local anonymous signup gate. |
| (no env) | -- | `true` | `instanceSettings.federatedRegistrationOpen` is DB-only (NOT NULL DEFAULT 1) — no env override. Federated identity replication gate. |
---
## 11. `requireAdmin` Guard
`auth.ts:requireAdmin()` -- used as preHandler alongside `authenticate`:
1. Loads full user from DB by `request.userId`
2. Rejects with 403 if user not found or `isAdmin !== 1`
3. Used by admin routes (user management, password reset, federation peer management)