Files
Toju/agents-docs/features/authentication.md
T
2026-07-14 00:41:05 +02:00

227 lines
14 KiB
Markdown

# Authentication
> **Area:** authentication
> **Status:** Active
> **Last updated:** 2026-07-05
## Overview
Session-token authentication binds REST mutations and WebSocket `identify` to a user identity on each signaling server. The product client may hold **multiple** server credentials (home + foreign auto-provisioned accounts) while keeping one local user profile. Multi-device tabs share one identity via separate `clientInstanceId` values and `account_sync` relay.
WebSocket details: [signaling.md](signaling.md). Local API tokens: [desktop-local-api.md](desktop-local-api.md).
## Trust boundaries
| Surface | Identity proof | Notes |
|---|---|---|
| Signaling server REST (mutations) | `Authorization: Bearer <token>` | Actor user IDs in request bodies are ignored; server derives `authUserId` from the token |
| Signaling server REST (discovery) | None | `GET /api/servers`, featured/trending/search remain public |
| Signaling server WebSocket | `identify.token` | Connections must identify before any other message type — see [signaling.md](signaling.md) |
| Electron Local API | Separate in-memory bearer tokens | Proxies login to allowed signaling servers only — see [desktop-local-api.md](desktop-local-api.md) |
| Product client local DB | OS user account | SQLite and attachments are plaintext at rest |
## Client logout
- Desktop: title-bar menu **Logout** (`UserLogoutService`).
- Mobile / all platforms: settings modal footer **Logout** (`data-testid="settings-logout-button"`) — required because the title bar is hidden on mobile breakpoints.
- Logout disconnects realtime sessions, clears the persisted current-user id, resets NgRx room/user/message state, and navigates to `/login`.
## Login / register response
```json
{
"id": "<uuid>",
"username": "alice",
"displayName": "Alice",
"token": "<opaque-hex>",
"expiresAt": 1710000000000
}
```
- Tokens are opaque 64-character hex strings stored in server SQLite (`session_tokens`).
- Default TTL: 10 years (`SESSION_TOKEN_TTL_MS` env override supported on the signaling server).
- Passwords are stored with bcrypt; legacy SHA-256 hashes are upgraded transparently on successful login.
## Protected REST routes
Require `Authorization: Bearer` (`requireAuth` middleware). Public routes are listed for contrast.
### Users (`/api/users`)
| Method | Path | Auth |
|--------|------|------|
| POST | `/register` | Public |
| POST | `/login` | Public |
| GET | `/:id/signing-public-key` | Public |
| PUT | `/me/signing-key` | Bearer |
| POST | `/logout` | Bearer |
### Device tokens (`/api/users/device-tokens`)
All routes require bearer; `userId` in body or path must equal `authUserId` (`403` otherwise).
| Method | Path |
|--------|------|
| POST | `/` |
| GET | `/:userId` |
| POST | `/:userId/dispatch` |
### Servers (`/api/servers`)
| Method | Path | Auth |
|--------|------|------|
| GET | `/`, `/featured`, `/trending`, `/:id` | Public |
| POST | `/` | Bearer |
| PUT | `/:id` | Bearer |
| DELETE | `/:id` | Bearer |
| POST | `/:id/join` | Bearer |
| POST | `/:id/leave` | Bearer |
| POST | `/:id/heartbeat` | Bearer |
| POST | `/:id/invites` | Bearer |
| GET | `/:id/requests` | Bearer |
| POST | `/:id/moderation/kick` | Bearer |
| POST | `/:id/moderation/ban` | Bearer |
| POST | `/:id/moderation/unban` | Bearer |
### Join requests (`/api/requests`)
| Method | Path | Auth |
|--------|------|------|
| PUT | `/:id` | Bearer (approve/deny) |
### Plugin support (`/api/servers/:serverId/plugins`)
| Method | Path | Auth |
|--------|------|------|
| GET | `/` | Public (metadata read) |
| PUT | `/:pluginId/requirement` | Bearer |
| DELETE | `/:pluginId/requirement` | Bearer |
| PUT | `/:pluginId/events/:eventName` | Bearer |
| DELETE | `/:pluginId/events/:eventName` | Bearer |
| GET/PUT/DELETE | `/:pluginId/data/*` | **410 Gone** (server plugin data disabled) |
### Public (no bearer)
- `GET /api/health`, `/api/time`
- `GET /api/link-metadata`, `/api/image-proxy`
- `GET /api/klipy/config`, `/api/klipy/gifs`
- `POST /api/games/match`
- `GET /api/invites/:id`
- `GET /invite/:id` (HTML invite page)
- OpenAPI docs routes (`/api/openapi.json`, `/api/docs`, …) — gated by server config, not session auth
Full server-directory semantics: [server-directory.md](server-directory.md).
## Message signing key registration
Ed25519 signing keys for [message-integrity.md](message-integrity.md) register via `PUT /api/users/me/signing-key` with `{ publicKeyJwk }`.
- **When registered:** `AuthenticationService` calls `MessageSigningService.registerSigningPublicKeyIfNeeded()` after successful **home** `POST /login` and `POST /register` only (`authentication.service.ts`).
- **Scope:** registration uses the **active** signaling server's API base (`ServerDirectoryFacade.activeServer()`). Foreign-server auto-provision (`authorizeSignalServer` / `SignalServerProvisionerService`) does **not** currently call signing-key registration — message integrity on foreign servers depends on a later login path or manual registration when that server becomes active.
- **Storage:** private key in `localStorage` (`metoyou.messageSigningKeyPair`); public key directory on server SQLite only.
## WebSocket identify contract
```json
{
"type": "identify",
"token": "<session-token>",
"oderId": "<user-id>",
"displayName": "Alice",
"connectionScope": "ws://host:3001",
"clientInstanceId": "<per-install-uuid>"
}
```
- `oderId` must match the token's user id when provided.
- `clientInstanceId` is a stable per-tab UUID generated by the product client (`metoyou.clientInstanceId` in `sessionStorage`). The signaling server uses it to distinguish multiple WebSocket connections for the same user and to route voice ownership.
- Server responds with `auth_error` or `auth_required` when authentication fails.
- **Per-connection message ordering (invariant):** the server processes WebSocket messages for one connection strictly in arrival order (`handleWebSocketMessage` chains them per connection id). `identify` awaits a DB token lookup, and clients send `identify` + `join_server` back-to-back (often one TCP segment); concurrent handling let the join run mid-identify, get rejected as unauthenticated, and silently drop room membership — that connection then missed all `user_joined` / `chat_message` broadcasts (root cause of "chats don't sync for multi-client users").
## Multi-device sessions
- Each login/register issues a **new** session token; prior tokens remain valid until they expire or the client calls `POST /api/users/logout` with that token.
- The same user may keep multiple WebSocket connections open (different devices or browser profiles). Server broadcasts (chat, typing, voice state, status) exclude only the **sending connection**, so other connections for that identity still receive updates.
- Voice/WebRTC is exclusive per user: only one `clientInstanceId` may own active voice at a time. Other connections show passive UI and can send `voice_client_takeover` to move voice to the local device.
- Stale reconnect hygiene: when a client re-identifies with the same `(oderId, connectionScope, clientInstanceId)` tuple, the server closes the older socket for that tuple.
### Account-owned state sync (`account_sync`)
When the same account is logged in on multiple devices, account-owned data is kept in sync through the signaling server:
| Data | Mechanism |
|---|---|
| Server chat messages (live) | `chat_message` signaling relay (connection-scoped broadcast) **plus** `account_sync` `chat-message` / `message-revision` to sibling devices |
| Server chat messages (catch-up) | `account_sync` `chat-sync-batch` pushed when a sibling device comes online (`account_sync_peer_online`); each batch carries its messages' **attachment metadata** (`attachments` map, local paths stripped) so sibling devices learn about synced attachments — they are then requestable/downloadable but never marked "Shared from your device" unless the bytes are local |
| Voice / typing | Existing `voice_state` / `user_typing` relays |
| Saved servers (join/leave) | `account_sync` payload `saved-room-sync` / `saved-room-remove` |
| Profile avatar + card text | `account_sync` `user-avatar-full` + `user-avatar-chunk` |
| Custom emoji library | `account_sync` `custom-emoji-full` + `custom-emoji-chunk` |
| Friends list | `account_sync` `friend-added` / `friend-removed` |
| Server icons, edits, reactions | `account_sync` relay of existing P2P broadcast event types |
Client rules:
- `broadcastMessage()` still fans out over peer data channels; relayable events are **also** wrapped in `account_sync` and sent on the WebSocket.
- The server forwards `account_sync` to every other open connection for the same `oderId` via `notifyOtherConnectionsForOderId`.
- Receivers ignore payloads whose `clientInstanceId` matches the local tab id.
- When a new device identifies, the server notifies existing connections with `account_sync_peer_online`; those devices push a full snapshot (saved rooms, **room message history**, friends, profile, emoji library).
WebSocket envelope:
```json
{
"type": "account_sync",
"clientInstanceId": "<per-tab-uuid>",
"payload": { "type": "saved-room-sync", "room": { "...": "..." } }
}
```
Server response to other connections includes `fromUserId` set to the sender's `oderId`.
## Client storage
The product client stores tokens per signaling-server base URL in `localStorage` (`metoyou.authTokens`). An HTTP interceptor attaches the bearer token to `/api/*` requests targeting that server.
Per-server credentials (`metoyou.signalServerCredentials`) map each normalized signal-server URL to the authenticated user id, username, display name, session token, expiry, and whether the account was auto-provisioned. The home user profile in SQLite/NgRx remains the device-local identity (`homeSignalServerUrl`); foreign-server credentials are a side map used for REST and WebSocket identify on that URL.
A per-install **provision secret** enables silent account creation on newly added or encountered signal servers. It is generated on home register/login, stored in Electron `safeStorage` when available (sessionStorage fallback on web), and never persisted as the user's visible login password.
### Multi-signal-server auth flows
| Flow | Action | Effect |
|---|---|---|
| Home login/register | `authenticateUser` | Resets local state, stores home credential + provision secret |
| Foreign login/register | `authorizeSignalServer` | Upserts credential for that URL only; home session unchanged |
| Auto-provision | `SignalServerProvisionerService` | Registers or logs in on foreign server using provision secret; on username collision tries suffixed username (`alice-<homeUserIdPrefix>`) and prefixes the display name with `#<homeUserIdPrefix> #<signalServerTag>` so same-name accounts stay distinguishable |
| Create/join on foreign server | `RoomsEffects.createRoom$`, invite/join flows | `ensureCredentialForServerUrl` provisions (or reuses) the per-server session token first; REST/WebSocket calls use the **actor user id** for that signal URL, not the home registration id |
| Foreign auth failure | `signalServerAuthFailed` | Clears that URL's credential and re-provisions when home token is still valid; global logout only when home server rejects auth |
Unreachable or offline signal servers must **not** open `/login?mode=authorize`. `ensureEndpointVersionCompatibility()` treats only `online` endpoints as connectable, and `ensureCredentialForServerUrl()` skips authorize navigation when health checks report the server offline (or provisioning fails over the network).
Authorize UI: `/login?mode=authorize&serverId=…&returnUrl=…` (also supported on `/register`). Settings → Network shows per-endpoint `Authorized` / `Needs sign-in` badges.
Persisted local user state (`metoyou_currentUserId` + IndexedDB/SQLite profile) is **not** sufficient to use chat or presence. On startup, `loadCurrentUser$` requires a non-expired session token for the user's home signaling server (or any stored token as a fallback). Missing or rejected **home** tokens dispatch `SESSION_EXPIRED` and redirect to `/login`. Foreign-server `auth_required` / `auth_error` responses clear only that server's credential and attempt re-provision.
Startup routing for signed-out visitors is decided by `resolveUnauthenticatedStartupRedirect(currentUrl)` (`auth-navigation.rules.ts`), called from `App.ngOnInit`: any non-public route is redirected to `/login` (carrying a safe `returnUrl`), while public routes (`/login`, `/register`, `/invite/...`) are left alone. This is **platform-agnostic** — mobile is intentionally not special-cased, so a signed-out mobile user is greeted with the login screen on startup rather than a logged-out `/dashboard`.
## Security considerations
- Rate limits: login/register (100 / 15 min), server join (30 / min).
- CORS allowlist: optional `corsAllowlist` in `server/data/variables.json` or `CORS_ALLOWLIST` env (comma-separated). Empty allowlist keeps permissive CORS for local development.
- Push-token routes require bearer auth and user-id match.
- RTC relay: direct-message/direct-call types always relay; server-icon types require shared server membership; WebRTC offer/answer/ice remain open for cross-server DM WebRTC.
## Related
- [signaling.md](signaling.md) — WebSocket `identify`, `account_sync`, ordering invariants
- [desktop-local-api.md](desktop-local-api.md) — Electron Local API bearer tokens
- [message-integrity.md](message-integrity.md) — signing keys and revision chains
- [server-directory.md](server-directory.md) — protected server REST mutations
## Changelog
| Date | Change |
|------|--------|
| 2026-07-05 | Expanded protected-route inventory; clarified signing-key registration scope; cross-links |