# 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](../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) 1. After a successful home login or register, the user is never prompted for credentials again solely because they touched another signal server. 2. 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 `identify` and protected REST calls use that credential’s actor user id + token. - The home NgRx / local profile stays unchanged. 3. If the preferred username is already taken on the foreign server, the client silently uses the designed suffix strategy (`alice-`) and optional display-name disambiguation — still **without** opening `/login`. 4. Transient `auth_required` (message raced ahead of identify) never opens login and never tears down the home session while a valid local credential exists. 5. Rejected foreign tokens trigger **re-provision** (or credential refresh), not a home logout and not a blocking authorize form when silent provision is possible. 6. Offline / unreachable / incompatible endpoints never open `/login?mode=authorize`. 7. Session restore after app restart still silently provisions foreign servers (provision secret and credentials survive restart on desktop). 8. Settings → Network may show `Authorized` / `Needs sign-in` for 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$` after `loadCurrentUserSuccess` Authorize navigation is gated by `shouldNavigateToAuthorizeSignalServer`: - Endpoint must look **online** - Provision result is `collision` **or** `skipped` with reason `no-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** 1. `SignalServerAuthService.ensureProvisioned` returns `{ kind: 'skipped', reason: 'no-provision-secret' }` when `ProvisionSecretStoreService.getSecret(homeUser.id)` is null. 2. `SignalServerAuthorizeService.ensureCredentialForServerUrl` then calls `navigateToAuthorize` → `/login?mode=authorize`. 3. Login’s authorize mode **does not** auto-redirect away when `currentUser` is 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 `prepareAuthenticatedUserStorage` via `ensureHomeProvisionSecret`, and **only when both** `user.homeSignalServerUrl` **and** `loginResponse` are present. - Session restore (`loadCurrentUserSuccess` → `provisionActiveSignalServers$`) calls `ensureProvisioned` but **never** calls `ensureHomeProvisionSecret` to create a missing secret. - Web / non-Electron fallback stores the secret in **sessionStorage** (`metoyou.provisionSecret.`), which dies when the tab/session ends. - Accounts created before this feature, wiped Electron `userData/provision-secrets/`, or logins that never received a `loginResponse` + home URL pair never get a secret. **Evidence in code** - `signal-server-authorize.rules.ts` — `no-provision-secret` ⇒ navigate to authorize - `signal-server-authorize.service.spec.ts` — “still provisions foreign servers and navigates to authorize when the secret is missing” - `users.effects.ts` — `ensureHomeProvisionSecret` only inside `prepareAuthenticatedUserStorage` with `loginResponse` ### 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-session` if the failure is classified as the **home** server → `clearStoredCurrentUserId` + `SESSION_EXPIRED` → `redirectOnSessionExpired$` → `/login` - `provision-foreign` otherwise → silent `ensureProvisioned` (no login UI by itself) **False home classification risks** - Missing / stale `homeSignalServerUrl` on the restored user → foreign failures compared with empty home URL → `isSameSignalServerUrl` is 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_error` treated as unrecoverable home expiry. - Past regressions (see lessons): identifying only from the new credential store, or treating `auth_required` as 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) 1. Alice registers on Signal Server 1. 2. Alice browses/joins a community hosted on Signal Server 2. 3. Client silently registers `alice` (or `alice-`) on Server 2 with the provision secret. 4. Alice lands in the room; no login modal/page; peers see her presence under the Server 2 actor id. ### Failure path today (bug) 1. Alice is logged in (user bar / local profile show her). 2. Alice opens an invite or room whose `sourceUrl` is Signal Server 2. 3. Client cannot provision (no secret / collision). 4. App navigates to `/login?mode=authorize&serverId=…&returnUrl=…`. 5. 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) 1. Alice fully quits the desktop app and reopens. 2. Home session restores from local DB + token stores. 3. 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: 1. **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_error` with home session intact → re-provision + re-identify; no `SESSION_EXPIRED`. - Online foreign endpoint + successful provision → `ensureCredentialForServerUrl` returns `true`. 2. **Manual / E2E** - Two live signal servers; register on #1; join room on #2 without typing a password again; reload app; rejoin still silent. 3. **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.ts` - `toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.ts` - `toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.ts` - `toju-app/src/app/domains/authentication/application/services/provision-secret-store.service.ts` - `toju-app/src/app/domains/authentication/domain/logic/signal-server-authorize.rules.ts` - `toju-app/src/app/domains/authentication/domain/logic/signal-server-auth-failure.rules.ts` - `toju-app/src/app/store/users/users.effects.ts` (`signalServerAuthFailed$`, `provisionActiveSignalServers$`, `redirectOnSessionExpired$`, `prepareAuthenticatedUserStorage`) - `toju-app/src/app/store/rooms/room-signaling-connection.ts` - `electron/api/provision-secret-store.ts` - `agents-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_required` vs `auth_error` so home session is not falsely expired.