Files
Toju/agents-docs/user-stories/silent-cross-signal-server-auth.md
T
myxelium f9e8538c80 feat(auth): recover cross-signal authorization with provision secrets
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.
2026-08-14 03:19:29 +02:00

13 KiB
Raw Blame History

User Story: Silent crosssignal-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)

  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 credentials 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. Logins 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 (loadCurrentUserSuccessprovisionActiveSignalServers$) 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.tsno-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.tsensureHomeProvisionSecret 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 ProvisionUsernameCollisionErrorkind: '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_EXPIREDredirectOnSessionExpired$/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.