A client that could not authorize against a foreign signal server was redirected into a dead end with no way to retry, so servers joined from another signal route became unreachable. The home server now stores a per-user provision secret, clients keep it in their own store, and a recovery service records why authorization failed per server URL. Invite, server browser, and chat room surface that reason and offer a retry instead of silently redirecting.
13 KiB
User Story: Silent cross–signal-server account auth
Status: Open (research complete — not fixed)
Priority / Severity: Critical
Area: authentication, realtime, server-directory
Last researched: 2026-08-12
Related docs: features/authentication.md,toju-app/src/app/domains/authentication/
User story
As a signed-in Toju user
I want the app to automatically create (or reuse) my account on any additional signal server as soon as I need that server
So that I never see a login / authorize prompt again after my initial home-server login, and chat / presence / joins keep working across the whole multi-server network.
Problem statement
The product supports multiple signaling servers. A user registers/logs in once on a home signal server. When they later interact with a foreign signal server (join/create a room hosted there, open an invite, activate another endpoint, etc.), the client is supposed to silently provision a linked account on that server using a local provision secret, store a per-server session credential, and continue — without interrupting the UI.
In practice, the login / authorize screen keeps appearing (/login?mode=authorize&serverId=…) even though the user is already authenticated locally. That breaks the “one login, whole app” contract and feels like the session is constantly dying.
Desired behavior (acceptance criteria)
- After a successful home login or register, the user is never prompted for credentials again solely because they touched another signal server.
- The first time the user has business with signal server N (N ≠ home):
- The client ensures a valid per-URL credential exists (register-or-login with the provision secret).
- WebSocket
identifyand protected REST calls use that credential’s actor user id + token. - The home NgRx / local profile stays unchanged.
- If the preferred username is already taken on the foreign server, the client silently uses the designed suffix strategy (
alice-<homeUserIdPrefix>) and optional display-name disambiguation — still without opening/login. - Transient
auth_required(message raced ahead of identify) never opens login and never tears down the home session while a valid local credential exists. - Rejected foreign tokens trigger re-provision (or credential refresh), not a home logout and not a blocking authorize form when silent provision is possible.
- Offline / unreachable / incompatible endpoints never open
/login?mode=authorize. - Session restore after app restart still silently provisions foreign servers (provision secret and credentials survive restart on desktop).
- Settings → Network may show
Authorized/Needs sign-infor diagnostics, but “Needs sign-in” must not become the default path for a normal logged-in user who simply joined a room on another host.
Explicit non-goals (for this story)
- Changing the home-server password / register UX for first-time users.
- Merging foreign actor ids into a single global server-side identity (home id ≠ foreign provisioned id is expected).
- Removing the authorize UI entirely — it may remain as a last resort (e.g. true username collision exhaustion, or user-initiated “Sign in” from Network settings).
Current intended architecture (as designed)
| Concept | Role |
|---|---|
| Home session | Local profile + credential for homeSignalServerUrl |
| Provision secret | Per-install secret generated on home login/register; used as the password when auto-registering/logging into foreign servers |
| Per-signal credential store | metoyou.signalServerCredentials — token + actor userId per normalized server URL |
| Legacy token store | metoyou.authTokens — still used for REST interceptor / session restore fallback |
ensureProvisioned |
Register-or-login on a foreign URL using the provision secret |
ensureCredentialForServerUrl |
Gate before foreign room connect / invite / join — provision first; only then optionally navigate to authorize |
authorize login mode |
Manual login that only upserts a foreign credential (authorizeSignalServer) without resetting home state |
Primary call sites that demand a foreign credential:
- Room signaling connect (
room-signaling-connection.ts) - Invite / server-browser join flows
- Active endpoint health → opportunistic
ensureProvisioned provisionActiveSignalServers$afterloadCurrentUserSuccess
Authorize navigation is gated by shouldNavigateToAuthorizeSignalServer:
- Endpoint must look online
- Provision result is
collisionorskippedwith reasonno-provision-secret
Research findings — likely causes
These are code-backed hypotheses ranked by how directly they produce a login prompt while the user still has a home session.
Cause A — Missing provision secret → authorize login (primary)
Mechanism
SignalServerAuthService.ensureProvisionedreturns{ kind: 'skipped', reason: 'no-provision-secret' }whenProvisionSecretStoreService.getSecret(homeUser.id)is null.SignalServerAuthorizeService.ensureCredentialForServerUrlthen callsnavigateToAuthorize→/login?mode=authorize.- Login’s authorize mode does not auto-redirect away when
currentUseris set (the leave-login effect explicitly returns early in authorize mode), so the prompt stays on screen.
Why the secret is often missing
- Secret is created only in
prepareAuthenticatedUserStorageviaensureHomeProvisionSecret, and only when bothuser.homeSignalServerUrlandloginResponseare present. - Session restore (
loadCurrentUserSuccess→provisionActiveSignalServers$) callsensureProvisionedbut never callsensureHomeProvisionSecretto create a missing secret. - Web / non-Electron fallback stores the secret in sessionStorage (
metoyou.provisionSecret.<userId>), which dies when the tab/session ends. - Accounts created before this feature, wiped Electron
userData/provision-secrets/, or logins that never received aloginResponse+ home URL pair never get a secret.
Evidence in code
signal-server-authorize.rules.ts—no-provision-secret⇒ navigate to authorizesignal-server-authorize.service.spec.ts— “still provisions foreign servers and navigates to authorize when the secret is missing”users.effects.ts—ensureHomeProvisionSecretonly insideprepareAuthenticatedUserStoragewithloginResponse
Cause B — Username collision exhaustion → authorize login
Mechanism
SignalServerProvisionerService tries preferred username, then suffixed candidates. If every register returns 409 and every login with the provision secret returns 401, it throws ProvisionUsernameCollisionError → kind: 'collision' → authorize UI.
When it shows up
Another user already owns those usernames on the foreign server with different passwords (not our provisioned accounts). Silent recovery is impossible without a different identity strategy or manual credentials.
Cause C — Home session false expiry → full /login (not just authorize)
Mechanism
signalServerAuthFailed$ clears the credential for the failing URL, then:
expire-home-sessionif the failure is classified as the home server →clearStoredCurrentUserId+SESSION_EXPIRED→redirectOnSessionExpired$→/loginprovision-foreignotherwise → silentensureProvisioned(no login UI by itself)
False home classification risks
- Missing / stale
homeSignalServerUrlon the restored user → foreign failures compared with empty home URL →isSameSignalServerUrlis false, so this path usually prefers foreign provision; but home failures with no resolvable credential after retries still expire the session. - Exhausted re-identify retry budget on home while credential lookup fails (empty credential store + broken legacy fallback) →
auth_required/auth_errortreated as unrecoverable home expiry. - Past regressions (see lessons): identifying only from the new credential store, or treating
auth_requiredas logout — partially mitigated, but restore edge cases still matter.
Cause D — Credential present locally but identify never runs / races
Mechanism
Without a resolvable token for the foreign URL, the socket sends non-identify traffic → server auth_required. If the client then cannot re-identify or re-provision (Cause A), user-facing flows that gate on ensureCredentialForServerUrl open authorize login. Presence/chat then look “broken” even though the home profile still shows logged in.
Related lesson: identify must fall back to legacy AuthTokenStoreService for home; foreign servers cannot be reconstructed from the legacy store (actor id differs) — so foreign URLs must be provisioned, not guessed.
Cause E — Opportunistic provision fails quietly; later gate opens login
Mechanism
provisionActiveSignalServers$ and server health ensureProvisioned(...).catch(() => undefined) swallow errors. A later user action (join room) hits ensureCredentialForServerUrl with the same missing secret / collision and then navigates to authorize — so login appears mid-flow rather than at startup.
User-visible scenarios
Happy path (required)
- Alice registers on Signal Server 1.
- Alice browses/joins a community hosted on Signal Server 2.
- Client silently registers
alice(oralice-<prefix>) on Server 2 with the provision secret. - Alice lands in the room; no login modal/page; peers see her presence under the Server 2 actor id.
Failure path today (bug)
- Alice is logged in (user bar / local profile show her).
- Alice opens an invite or room whose
sourceUrlis Signal Server 2. - Client cannot provision (no secret / collision).
- App navigates to
/login?mode=authorize&serverId=…&returnUrl=…. - Alice believes she was logged out; re-entering home credentials may even bind the wrong server if she is not careful with the server picker.
Restart path (required)
- Alice fully quits the desktop app and reopens.
- Home session restores from local DB + token stores.
- Touching Server 2 again still silent-provisions or reuses the stored foreign credential — no authorize prompt.
Proof of done (when implementing)
Prefer behavior-level proof over mocks shaped like the provisioner:
- Integration / focused effect+service tests
- Missing secret on restore → secret is ensured, then foreign provision succeeds, and
Router.navigate(['/login'])is never called. - Foreign
auth_errorwith home session intact → re-provision + re-identify; noSESSION_EXPIRED. - Online foreign endpoint + successful provision →
ensureCredentialForServerUrlreturnstrue.
- Missing secret on restore → secret is ensured, then foreign provision succeeds, and
- Manual / E2E
- Two live signal servers; register on #1; join room on #2 without typing a password again; reload app; rejoin still silent.
- Negative
- Offline foreign endpoint must not open authorize login.
Likely fix directions (for a later interview — not approved yet)
| Option | Idea | Tradeoff |
|---|---|---|
| A (recommended) | On session restore / before any foreign ensureProvisioned, call ensureHomeProvisionSecret so a missing secret is generated once and persisted; keep authorize UI only for true collision / user-initiated sign-in |
New secret cannot unlock accounts previously provisioned with an old lost secret — may need re-register with suffix or collision path |
| B | Stop navigating to authorize on no-provision-secret; surface a non-blocking Network badge / toast and retry when secret becomes available |
User may join without credential and hit silent presence failures |
| C | Derive a stable provision secret from a durable local key (not sessionStorage) on web so restarts keep the same secret | Crypto/key-storage design; still need migration for existing installs |
| D | For collisions, auto-pick a stronger unique username (e.g. always include fuller home user id) before opening authorize | Reduces but does not eliminate collision UX |
Key files
toju-app/src/app/domains/authentication/application/services/signal-server-authorize.service.tstoju-app/src/app/domains/authentication/application/services/signal-server-auth.service.tstoju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.tstoju-app/src/app/domains/authentication/application/services/provision-secret-store.service.tstoju-app/src/app/domains/authentication/domain/logic/signal-server-authorize.rules.tstoju-app/src/app/domains/authentication/domain/logic/signal-server-auth-failure.rules.tstoju-app/src/app/store/users/users.effects.ts(signalServerAuthFailed$,provisionActiveSignalServers$,redirectOnSessionExpired$,prepareAuthenticatedUserStorage)toju-app/src/app/store/rooms/room-signaling-connection.tselectron/api/provision-secret-store.tsagents-docs/features/authentication.md
Lessons already adjacent
- Identify must fall back to the legacy session token (home restore).
- Keep per-signal-URL identify credentials resolvable from the store.
- Persisted local user state still requires a session token.
- Do not open authorize login for offline endpoints.
- Distinguish
auth_requiredvsauth_errorso home session is not falsely expired.