Files
Toju/agents-docs/features/authentication.md
T
myxeliumandCursor d3d22846e7 fix: Bug - User login status showing as both logged in and logged out
Stop treating transient auth_required as home-session expiry, keep auth scope
consistent across login redirect and server-rail joins, and leave /login when
the in-memory user is still authenticated.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 01:54:01 +02:00

14 KiB

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. Local API tokens: 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
Electron Local API Separate in-memory bearer tokens Proxies login to allowed signaling servers only — see 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

{
  "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.

Message signing key registration

Ed25519 signing keys for 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

{
  "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:

{
  "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 auth_required (message raced ahead of identify) re-identifies or is ignored while a valid local credential exists; auth_error (token rejected) clears that URL's credential and re-provisions on foreign servers or expires the home session

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.

Changelog

Date Change
2026-07-14 Distinguish auth_required vs auth_error on signalServerAuthFailed; stop false home-session expiry; leave /login when in-memory user still authenticated
2026-07-05 Expanded protected-route inventory; clarified signing-key registration scope; cross-links