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.
214 lines
13 KiB
Markdown
214 lines
13 KiB
Markdown
# 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-<homeUserIdPrefix>`) 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.<userId>`), 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-<prefix>`) 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.
|