Files
Toju/agents-docs/features/authentication.md
Myx 182828bb1e fix: Bug - Two devices sharing same user says "Shared from your device"
Gate the "Shared from your device" label and the hidden download
affordance on whether this device actually holds the file bytes, not on
whether the current user uploaded it. uploaderPeerId is the user id, so
the old check claimed ownership on every device of the uploader,
blocking view/download on second devices that only synced metadata.

Also include attachment metadata in the account_sync chat-sync-batch so
sibling devices learn about synced attachments at all.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 03:29:47 +02:00

127 lines
8.2 KiB
Markdown

# Authentication
Session-token authentication for the signaling server and product client.
## 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 |
| Electron Local API | Separate in-memory bearer tokens | Proxies login to allowed signaling servers only |
| Product client local DB | OS user account | SQLite and attachments are plaintext at rest |
## 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`:
- `PUT/POST/DELETE` under `/api/servers/*` (except public `GET`)
- `PUT /api/requests/:id`
- Plugin-support mutations under `/api/servers/:serverId/plugins/*`
- `/api/users/device-tokens/*`
- `POST /api/users/logout`
## 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>`) |
| 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 |
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.
## 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.