From f9e8538c80f8a4e5c012310366ced4e9d24a80b8 Mon Sep 17 00:00:00 2001 From: Myx Date: Fri, 14 Aug 2026 03:19:29 +0200 Subject: [PATCH] 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. --- .../silent-cross-signal-server-auth.md | 213 +++++++++ .../auth/multi-signal-server-auth.spec.ts | 423 +++++++++++++++++- .../handlers/updateUserProvisionSecret.ts | 12 + server/src/cqrs/index.ts | 4 + server/src/cqrs/mappers.ts | 3 +- server/src/cqrs/types.ts | 1 + server/src/entities/AuthUserEntity.ts | 8 + .../1000000000013-ProvisionSecret.ts | 13 + server/src/migrations/index.ts | 4 +- server/src/routes/users.ts | 35 ++ .../services/provision-secret.service.spec.ts | 76 ++++ .../src/services/provision-secret.service.ts | 46 ++ toju-app/public/i18n/catalog/auth.json | 5 + .../src/app/domains/authentication/README.md | 39 +- .../home-provision-secret.service.spec.ts | 99 ++++ .../services/home-provision-secret.service.ts | 106 +++++ .../provision-secret-store.service.ts | 17 +- .../signal-server-auth-recovery.service.ts | 51 +++ .../signal-server-auth.service.spec.ts | 152 +++++++ .../services/signal-server-auth.service.ts | 69 ++- .../signal-server-authorize.service.spec.ts | 8 +- .../signal-server-authorize.service.ts | 28 +- .../signal-server-provisioner.service.spec.ts | 198 ++++---- .../signal-server-provisioner.service.ts | 106 +++-- .../signal-server-authorize.rules.spec.ts | 44 -- .../logic/signal-server-authorize.rules.ts | 18 - .../signal-server-provision.rules.spec.ts | 62 ++- .../logic/signal-server-provision.rules.ts | 101 +++++ .../src/app/domains/authentication/index.ts | 2 + .../feature/invite/invite.component.html | 11 + .../feature/invite/invite.component.ts | 43 +- .../server-browser.component.html | 26 ++ .../server-browser.component.spec.ts | 2 + .../server-browser.component.ts | 38 +- .../room/chat-room/chat-room.component.html | 24 + .../room/chat-room/chat-room.component.ts | 31 +- toju-app/src/app/store/users/users.effects.ts | 3 +- 37 files changed, 1877 insertions(+), 244 deletions(-) create mode 100644 agents-docs/user-stories/silent-cross-signal-server-auth.md create mode 100644 server/src/cqrs/commands/handlers/updateUserProvisionSecret.ts create mode 100644 server/src/migrations/1000000000013-ProvisionSecret.ts create mode 100644 server/src/services/provision-secret.service.spec.ts create mode 100644 server/src/services/provision-secret.service.ts create mode 100644 toju-app/src/app/domains/authentication/application/services/home-provision-secret.service.spec.ts create mode 100644 toju-app/src/app/domains/authentication/application/services/home-provision-secret.service.ts create mode 100644 toju-app/src/app/domains/authentication/application/services/signal-server-auth-recovery.service.ts create mode 100644 toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.spec.ts delete mode 100644 toju-app/src/app/domains/authentication/domain/logic/signal-server-authorize.rules.spec.ts delete mode 100644 toju-app/src/app/domains/authentication/domain/logic/signal-server-authorize.rules.ts diff --git a/agents-docs/user-stories/silent-cross-signal-server-auth.md b/agents-docs/user-stories/silent-cross-signal-server-auth.md new file mode 100644 index 0000000..75ce877 --- /dev/null +++ b/agents-docs/user-stories/silent-cross-signal-server-auth.md @@ -0,0 +1,213 @@ +# 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. diff --git a/e2e/tests/auth/multi-signal-server-auth.spec.ts b/e2e/tests/auth/multi-signal-server-auth.spec.ts index c29509a..9416793 100644 --- a/e2e/tests/auth/multi-signal-server-auth.spec.ts +++ b/e2e/tests/auth/multi-signal-server-auth.spec.ts @@ -1,15 +1,19 @@ -import { expect } from '@playwright/test'; +import { expect, type Page } from '@playwright/test'; import { test } from '../../fixtures/multi-client'; import { openSettingsFromMenu } from '../../helpers/app-menu'; import { expectDashboardReady } from '../../helpers/dashboard'; import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint'; import { startTestServer } from '../../helpers/test-server'; import { + authHeaders, readAuthTokenFromPage, readSignalServerCredentialFromPage, registerTestUser } from '../../helpers/auth-api'; +import { expectServerPeerVisible } from '../../helpers/multi-device-session'; +import { LoginPage } from '../../pages/login.page'; import { RegisterPage } from '../../pages/register.page'; +import { ServerSearchPage } from '../../pages/server-search.page'; const PRIMARY_ENDPOINT_ID = 'e2e-multi-auth-primary'; const USER_PASSWORD = 'TestPass123!'; @@ -108,4 +112,421 @@ test.describe('Multi-signal-server authentication', () => { await secondaryServer.stop(); } }); + + test('restored session recreates a missing secret, provisions silently, and joins foreign presence', async ({ createClient }) => { + const primaryServer = await startTestServer(); + const secondaryServer = await startTestServer(); + + try { + const alice = await createClient(); + const bob = await createClient(); + const suffix = `restore_auth_${Date.now()}`; + const aliceUsername = `alice_${suffix}`; + const bobUsername = `bob_${suffix}`; + const serverName = `Foreign Restore ${suffix}`; + + await installTestServerEndpoints(alice.context, [ + { + id: PRIMARY_ENDPOINT_ID, + name: 'E2E Primary Signal', + url: primaryServer.url, + isActive: true, + status: 'online' + } + ]); + + await installTestServerEndpoints(bob.context, [ + { + id: 'e2e-multi-auth-secondary', + name: 'E2E Secondary Signal', + url: secondaryServer.url, + isActive: true, + status: 'online' + } + ]); + + await test.step('Bob creates the foreign-hosted server', async () => { + const register = new RegisterPage(bob.page); + + await register.goto(); + await register.register(bobUsername, 'Bob Restore', USER_PASSWORD); + await expectDashboardReady(bob.page); + + await new ServerSearchPage(bob.page).createServer(serverName, { + description: 'Restore-safe foreign authentication coverage' + }); + + await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 }); + }); + + await test.step('Alice registers only on her home signal server', async () => { + const register = new RegisterPage(alice.page); + + await register.goto(); + await register.register(aliceUsername, 'Alice Restore', USER_PASSWORD); + await expectDashboardReady(alice.page); + }); + + await test.step('A restored tab has no provision secret when the foreign endpoint appears', async () => { + await alice.page.evaluate(() => { + for (let index = sessionStorage.length - 1; index >= 0; index -= 1) { + const key = sessionStorage.key(index); + + if (key?.startsWith('metoyou.provisionSecret.')) { + sessionStorage.removeItem(key); + } + } + }); + + await installTestServerEndpoints(alice.context, [ + { + id: PRIMARY_ENDPOINT_ID, + name: 'E2E Primary Signal', + url: primaryServer.url, + isActive: true, + status: 'online' + }, + { + id: 'e2e-multi-auth-secondary', + name: 'E2E Secondary Signal', + url: secondaryServer.url, + isActive: true, + status: 'online' + } + ]); + + await alice.page.reload({ waitUntil: 'domcontentloaded' }); + await expectDashboardReady(alice.page); + await expect(alice.page).not.toHaveURL(/\/login/); + await expect.poll(async () => + await readSignalServerCredentialFromPage(alice.page, secondaryServer.url), + { timeout: 30_000 } + ).not.toBeNull(); + }); + + await test.step('Alice joins the foreign server and both users see mutual presence', async () => { + await new ServerSearchPage(alice.page).joinServerFromSearch(serverName); + await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 }); + + await expectServerPeerVisible(alice.page, 'Bob Restore'); + await expectServerPeerVisible(bob.page, 'Alice Restore'); + }); + + await test.step('Both users restore mutual presence after the foreign signal server restarts', async () => { + await Promise.all([installRestartSignalingTrace(alice.page), installRestartSignalingTrace(bob.page)]); + + await secondaryServer.restart(); + + await expect.poll(async () => + await hasRestartPresenceRecovery(alice.page, 'Bob Restore'), + { timeout: 30_000 } + ).toBe(true); + + await expect.poll(async () => + await hasRestartPresenceRecovery(bob.page, 'Alice Restore'), + { timeout: 30_000 } + ).toBe(true); + + await expectServerPeerVisible(alice.page, 'Bob Restore'); + await expectServerPeerVisible(bob.page, 'Alice Restore'); + }); + } finally { + await primaryServer.stop(); + await secondaryServer.stop(); + } + }); + + test('two devices of the same human share one account on a foreign signal server', async ({ createClient }) => { + const primaryServer = await startTestServer(); + const secondaryServer = await startTestServer(); + + try { + const suffix = `one_identity_${Date.now()}`; + const username = `alice_${suffix}`; + const endpoints = [ + { + id: PRIMARY_ENDPOINT_ID, + name: 'E2E Primary Signal', + url: primaryServer.url, + isActive: true, + status: 'online' as const + }, + { + id: 'e2e-multi-auth-secondary', + name: 'E2E Secondary Signal', + url: secondaryServer.url, + isActive: true, + status: 'online' as const + } + ]; + const laptop = await createClient(); + + await installTestServerEndpoints(laptop.context, endpoints); + + await test.step('Alice signs in on her laptop and provisions the foreign server', async () => { + const register = new RegisterPage(laptop.page); + + await register.goto(); + await register.register(username, 'Alice One Identity', USER_PASSWORD); + await expectDashboardReady(laptop.page); + await restartApp(laptop.page); + }); + + const laptopCredential = await waitForForeignCredential(laptop.page, secondaryServer.url); + const phone = await createClient(); + + await installTestServerEndpoints(phone.context, endpoints); + + await test.step('Alice signs in on a second device with no shared local storage', async () => { + const login = new LoginPage(phone.page); + + await login.goto(); + await login.login(username, USER_PASSWORD); + await expectDashboardReady(phone.page); + await restartApp(phone.page); + }); + + const phoneCredential = await waitForForeignCredential(phone.page, secondaryServer.url); + + // One human must be one actor on the foreign server. A per-device secret + // made the second device register `alice-` instead, which is + // what showed the same person twice to everybody else. + expect(phoneCredential?.userId).toBe(laptopCredential?.userId); + expect(phoneCredential?.username).toBe(username); + expect(laptopCredential?.username).toBe(username); + } finally { + await primaryServer.stop(); + await secondaryServer.stop(); + } + }); + + test('lost foreign secret shows contextual retry without logging out the home session', async ({ createClient, request }) => { + const primaryServer = await startTestServer(); + const secondaryServer = await startTestServer(); + + try { + const alice = await createClient(); + const suffix = `lost_secret_${Date.now()}`; + const username = `alice_${suffix}`; + + await installTestServerEndpoints(alice.context, [ + { + id: PRIMARY_ENDPOINT_ID, + name: 'E2E Primary Signal', + url: primaryServer.url, + isActive: true, + status: 'online' + } + ]); + + const register = new RegisterPage(alice.page); + + await register.goto(); + await register.register(username, 'Alice Lost Secret', USER_PASSWORD); + await expectDashboardReady(alice.page); + + const homeUserId = await alice.page.evaluate(() => + localStorage.getItem('metoyou_currentUserId') + ); + + if (!homeUserId) { + throw new Error('Expected restored home user id'); + } + + const shortHomeId = homeUserId.replace(/-/g, '').slice(0, 6) + .toLowerCase(); + const oldForeignAccount = await registerTestUser( + request, + secondaryServer.url, + username, + 'OldForeignSecret123!', + 'Alice Lost Secret' + ); + + await registerTestUser( + request, + secondaryServer.url, + `${username}-${shortHomeId}`, + 'OldForeignSecret123!', + 'Alice Lost Secret' + ); + + const serverName = `Lost Secret Recovery ${suffix}`; + const createResponse = await request.post(`${secondaryServer.url}/api/servers`, { + headers: authHeaders(oldForeignAccount.token), + data: { + name: serverName, + description: 'Contextual auth recovery coverage', + ownerId: oldForeignAccount.id, + ownerPublicKey: oldForeignAccount.id + } + }); + + expect(createResponse.ok(), await createResponse.text()).toBe(true); + + await alice.page.evaluate(() => { + for (let index = sessionStorage.length - 1; index >= 0; index -= 1) { + const key = sessionStorage.key(index); + + if (key?.startsWith('metoyou.provisionSecret.')) { + sessionStorage.removeItem(key); + } + } + }); + + await installTestServerEndpoints(alice.context, [ + { + id: PRIMARY_ENDPOINT_ID, + name: 'E2E Primary Signal', + url: primaryServer.url, + isActive: true, + status: 'online' + }, + { + id: 'e2e-multi-auth-secondary', + name: 'E2E Secondary Signal', + url: secondaryServer.url, + isActive: true, + status: 'online' + } + ]); + + await alice.page.reload({ waitUntil: 'domcontentloaded' }); + await expectDashboardReady(alice.page); + await new ServerSearchPage(alice.page).joinServerFromSearch(serverName); + + const recovery = alice.page.getByTestId('signal-server-auth-recovery'); + + await expect(recovery).toBeVisible({ timeout: 20_000 }); + await expect(recovery).toContainText('Reconnect to'); + await expect(alice.page).not.toHaveURL(/\/login/); + + await recovery.getByTestId('signal-server-auth-retry').click(); + await expect(recovery).toBeVisible({ timeout: 20_000 }); + await expect(alice.page).not.toHaveURL(/\/login/); + } finally { + await primaryServer.stop(); + await secondaryServer.stop(); + } + }); }); + +/** Foreign endpoints are provisioned on the bootstrap path, so reload to reach it. */ +async function restartApp(page: Page): Promise { + await page.reload({ waitUntil: 'domcontentloaded' }); + await expectDashboardReady(page); +} + +async function waitForForeignCredential(page: Page, serverUrl: string) { + await expect.poll(async () => + await readSignalServerCredentialFromPage(page, serverUrl), + { timeout: 30_000 } + ).not.toBeNull(); + + return await readSignalServerCredentialFromPage(page, serverUrl); +} + +interface RestartSignalingTraceEvent { + displayName?: string; + direction: 'inbound' | 'outbound'; + type: string; + users?: string[]; +} + +async function installRestartSignalingTrace(page: Page): Promise { + await page.evaluate(() => { + const tracedWindow = window as typeof window & { + __restartSignalingTrace?: RestartSignalingTraceEvent[]; + }; + const OriginalWebSocket = window.WebSocket; + const trace: RestartSignalingTraceEvent[] = []; + const TrackedWebSocket = function( + this: WebSocket, + url: string | URL, + protocols?: string | string[] + ): WebSocket { + const socket = protocols === undefined + ? new OriginalWebSocket(url) + : new OriginalWebSocket(url, protocols); + const originalSend = socket.send.bind(socket); + + socket.send = (data: string | ArrayBufferLike | Blob | ArrayBufferView): void => { + if (typeof data === 'string') { + try { + const message = JSON.parse(data) as { type?: unknown }; + + if (typeof message.type === 'string') { + trace.push({ direction: 'outbound', type: message.type }); + } + } catch { + // Ignore non-JSON websocket traffic. + } + } + + originalSend(data); + }; + + socket.addEventListener('message', (event) => { + if (typeof event.data !== 'string') { + return; + } + + try { + const message = JSON.parse(event.data) as { + displayName?: unknown; + type?: unknown; + users?: { displayName?: unknown }[]; + }; + + if (typeof message.type === 'string') { + trace.push({ + displayName: typeof message.displayName === 'string' + ? message.displayName + : undefined, + direction: 'inbound', + type: message.type, + users: Array.isArray(message.users) + ? message.users + .map((user) => user.displayName) + .filter((displayName): displayName is string => typeof displayName === 'string') + : undefined + }); + } + } catch { + // Ignore non-JSON websocket traffic. + } + }); + + return socket; + }; + + Object.setPrototypeOf(TrackedWebSocket, OriginalWebSocket); + TrackedWebSocket.prototype = OriginalWebSocket.prototype; + tracedWindow.__restartSignalingTrace = trace; + tracedWindow.WebSocket = TrackedWebSocket as unknown as typeof WebSocket; + }); +} + +async function hasRestartPresenceRecovery(page: Page, expectedPeerName: string): Promise { + return await page.evaluate((peerName) => { + const trace = (window as typeof window & { + __restartSignalingTrace?: RestartSignalingTraceEvent[]; + }).__restartSignalingTrace ?? []; + const identifyIndex = trace.findIndex((event) => + event.direction === 'outbound' && event.type === 'identify' + ); + const joinIndex = trace.findIndex((event) => + event.direction === 'outbound' && event.type === 'join_server' + ); + const receivedPeerPresence = trace.some((event) => + event.direction === 'inbound' && ( + (event.type === 'server_users' && event.users?.includes(peerName)) + || (event.type === 'user_joined' && event.displayName === peerName) + ) + ); + + return identifyIndex >= 0 + && joinIndex > identifyIndex + && receivedPeerPresence; + }, expectedPeerName); +} diff --git a/server/src/cqrs/commands/handlers/updateUserProvisionSecret.ts b/server/src/cqrs/commands/handlers/updateUserProvisionSecret.ts new file mode 100644 index 0000000..c138744 --- /dev/null +++ b/server/src/cqrs/commands/handlers/updateUserProvisionSecret.ts @@ -0,0 +1,12 @@ +import { DataSource } from 'typeorm'; +import { AuthUserEntity } from '../../../entities'; + +export async function handleUpdateUserProvisionSecret( + dataSource: DataSource, + userId: string, + provisionSecret: string +): Promise { + const repo = dataSource.getRepository(AuthUserEntity); + + await repo.update({ id: userId }, { provisionSecret }); +} diff --git a/server/src/cqrs/index.ts b/server/src/cqrs/index.ts index b4e532a..cf5faa8 100644 --- a/server/src/cqrs/index.ts +++ b/server/src/cqrs/index.ts @@ -22,6 +22,7 @@ import { handleGetJoinRequestById } from './queries/handlers/getJoinRequestById' import { handleGetPendingRequestsForServer } from './queries/handlers/getPendingRequestsForServer'; import { handleUpdateUserPasswordHash } from './commands/handlers/updateUserPasswordHash'; import { handleUpdateUserSigningPublicKey } from './commands/handlers/updateUserSigningPublicKey'; +import { handleUpdateUserProvisionSecret } from './commands/handlers/updateUserProvisionSecret'; export const registerUser = (user: AuthUserPayload) => handleRegisterUser({ type: CommandType.RegisterUser, payload: { user } }, getDataSource()); @@ -70,3 +71,6 @@ export const updateUserPasswordHash = (userId: string, passwordHash: string) => export const updateUserSigningPublicKey = (userId: string, signingPublicKey: string) => handleUpdateUserSigningPublicKey(getDataSource(), userId, signingPublicKey); + +export const updateUserProvisionSecret = (userId: string, provisionSecret: string) => + handleUpdateUserProvisionSecret(getDataSource(), userId, provisionSecret); diff --git a/server/src/cqrs/mappers.ts b/server/src/cqrs/mappers.ts index a59b923..5a933c4 100644 --- a/server/src/cqrs/mappers.ts +++ b/server/src/cqrs/mappers.ts @@ -15,7 +15,8 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload { passwordHash: row.passwordHash, displayName: row.displayName, createdAt: row.createdAt, - signingPublicKey: row.signingPublicKey ?? null + signingPublicKey: row.signingPublicKey ?? null, + provisionSecret: row.provisionSecret ?? null }; } diff --git a/server/src/cqrs/types.ts b/server/src/cqrs/types.ts index c8cc65a..fb15bf7 100644 --- a/server/src/cqrs/types.ts +++ b/server/src/cqrs/types.ts @@ -29,6 +29,7 @@ export interface AuthUserPayload { displayName: string; createdAt: number; signingPublicKey?: string | null; + provisionSecret?: string | null; } export type ServerChannelType = 'text' | 'voice'; diff --git a/server/src/entities/AuthUserEntity.ts b/server/src/entities/AuthUserEntity.ts index a5b2007..4e6e00d 100644 --- a/server/src/entities/AuthUserEntity.ts +++ b/server/src/entities/AuthUserEntity.ts @@ -23,4 +23,12 @@ export class AuthUserEntity { @Column('text', { nullable: true }) signingPublicKey!: string | null; + + /** + * Password this account uses when it provisions linked accounts on foreign + * signal servers. Held here so every device of the same human resolves to + * one foreign account instead of registering a duplicate. + */ + @Column('text', { nullable: true }) + provisionSecret!: string | null; } diff --git a/server/src/migrations/1000000000013-ProvisionSecret.ts b/server/src/migrations/1000000000013-ProvisionSecret.ts new file mode 100644 index 0000000..7c46515 --- /dev/null +++ b/server/src/migrations/1000000000013-ProvisionSecret.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ProvisionSecret1000000000013 implements MigrationInterface { + name = 'ProvisionSecret1000000000013'; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "users" ADD COLUMN "provisionSecret" text'); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "users" DROP COLUMN "provisionSecret"'); + } +} diff --git a/server/src/migrations/index.ts b/server/src/migrations/index.ts index 04bad4c..2407c6b 100644 --- a/server/src/migrations/index.ts +++ b/server/src/migrations/index.ts @@ -11,6 +11,7 @@ import { ServerIcons1000000000009 } from './1000000000009-ServerIcons'; import { DeviceTokens1000000000010 } from './1000000000010-DeviceTokens'; import { SessionTokens1000000000011 } from './1000000000011-SessionTokens'; import { SigningPublicKey1000000000012 } from './1000000000012-SigningPublicKey'; +import { ProvisionSecret1000000000013 } from './1000000000013-ProvisionSecret'; export const serverMigrations = [ InitialSchema1000000000000, @@ -25,5 +26,6 @@ export const serverMigrations = [ ServerIcons1000000000009, DeviceTokens1000000000010, SessionTokens1000000000011, - SigningPublicKey1000000000012 + SigningPublicKey1000000000012, + ProvisionSecret1000000000013 ]; diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts index 90152e7..d099d70 100644 --- a/server/src/routes/users.ts +++ b/server/src/routes/users.ts @@ -8,6 +8,7 @@ import { updateUserSigningPublicKey } from '../cqrs'; import { hashPasswordForStorage, verifyPassword } from '../services/password-auth.service'; +import { resolveProvisionSecret } from '../services/provision-secret.service'; import { issueSessionToken, revokeSessionToken } from '../services/session-auth.service'; import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth'; import { isDuplicateUsernameError } from './user-registration.rules'; @@ -80,6 +81,40 @@ router.post('/login', async (req, res) => { res.json(buildAuthResponse(user, session.token, session.expiresAt)); }); +/** + * Returns the caller's provision secret, creating it on first use. Every + * device of this account gets the same value, which is what keeps a person to + * a single linked account on each foreign signal server. + */ +router.get('/me/provision-secret', requireAuth, async (req, res) => { + const userId = getAuthenticatedUserId(req); + const provisionSecret = await resolveProvisionSecret(userId); + + if (!provisionSecret) { + return res.status(404).json({ error: 'User not found', errorCode: 'USER_NOT_FOUND' }); + } + + res.json({ provisionSecret }); +}); + +/** + * Rotates the caller's password on this server. Used by clients to move a + * linked account created with a legacy per-device secret onto the account's + * canonical provision secret, so the user's other devices can sign in to it. + */ +router.post('/me/password', requireAuth, async (req, res) => { + const { newPassword } = req.body; + const userId = getAuthenticatedUserId(req); + + if (typeof newPassword !== 'string' || newPassword.length < 8) { + return res.status(400).json({ error: 'Invalid password', errorCode: 'INVALID_PASSWORD' }); + } + + await updateUserPasswordHash(userId, await hashPasswordForStorage(newPassword)); + + res.json({ ok: true }); +}); + router.put('/me/signing-key', requireAuth, async (req, res) => { const { publicKeyJwk } = req.body; const userId = getAuthenticatedUserId(req); diff --git a/server/src/services/provision-secret.service.spec.ts b/server/src/services/provision-secret.service.spec.ts new file mode 100644 index 0000000..b08b077 --- /dev/null +++ b/server/src/services/provision-secret.service.spec.ts @@ -0,0 +1,76 @@ +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +const findOne = vi.fn(); +const update = vi.fn(); + +vi.mock('../db/database', () => ({ + getDataSource: () => ({ + getRepository: () => ({ + findOne, + update + }) + }) +})); + +const { generateProvisionSecret, isUsableProvisionSecret, resolveProvisionSecret } = + await import('./provision-secret.service'); + +describe('provision-secret.service', () => { + beforeEach(() => { + findOne.mockReset(); + update.mockReset(); + }); + + it('generates a 64 character hex secret', () => { + expect(generateProvisionSecret()).toMatch(/^[a-f0-9]{64}$/); + }); + + it('rejects blank secrets', () => { + expect(isUsableProvisionSecret(null)).toBe(false); + expect(isUsableProvisionSecret(' ')).toBe(false); + expect(isUsableProvisionSecret('secret')).toBe(true); + }); + + it('returns the same stored secret on every call so all devices match', async () => { + findOne.mockResolvedValue({ id: 'user-1', provisionSecret: 'stored-secret' }); + + await expect(resolveProvisionSecret('user-1')).resolves.toBe('stored-secret'); + await expect(resolveProvisionSecret('user-1')).resolves.toBe('stored-secret'); + expect(update).not.toHaveBeenCalled(); + }); + + it('creates the secret on first use and returns the persisted value', async () => { + findOne + .mockResolvedValueOnce({ id: 'user-1', provisionSecret: null }) + .mockResolvedValueOnce({ id: 'user-1', provisionSecret: 'created-secret' }); + + await expect(resolveProvisionSecret('user-1')).resolves.toBe('created-secret'); + expect(update).toHaveBeenCalledOnce(); + }); + + it('only writes while the column is empty so concurrent callers converge', async () => { + findOne + .mockResolvedValueOnce({ id: 'user-1', provisionSecret: null }) + .mockResolvedValueOnce({ id: 'user-1', provisionSecret: 'winning-secret' }); + + await resolveProvisionSecret('user-1'); + + const [criteria] = update.mock.calls[0]; + + expect(criteria).toMatchObject({ id: 'user-1' }); + expect(criteria.provisionSecret).toBeDefined(); + }); + + it('returns null for an unknown user', async () => { + findOne.mockResolvedValue(null); + + await expect(resolveProvisionSecret('missing')).resolves.toBeNull(); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/services/provision-secret.service.ts b/server/src/services/provision-secret.service.ts new file mode 100644 index 0000000..3b6aabc --- /dev/null +++ b/server/src/services/provision-secret.service.ts @@ -0,0 +1,46 @@ +import { randomBytes } from 'crypto'; +import { IsNull } from 'typeorm'; +import { getDataSource } from '../db/database'; +import { AuthUserEntity } from '../entities'; + +/** + * The provision secret is the password this account uses when it creates its + * linked accounts on foreign signal servers. It must be identical on every + * device of the same human: a per-device secret makes the second device fail + * to log in to the existing foreign account and register a duplicate one, so + * the same person shows up twice to everyone else. + */ +export function generateProvisionSecret(): string { + return randomBytes(32).toString('hex'); +} + +export function isUsableProvisionSecret(secret: string | null | undefined): secret is string { + return typeof secret === 'string' && secret.trim().length > 0; +} + +/** + * Returns the account's provision secret, creating it on first use. Concurrent + * callers converge on one value: the insert only applies while the column is + * still empty, and the stored value is re-read before returning. + */ +export async function resolveProvisionSecret(userId: string): Promise { + const repo = getDataSource().getRepository(AuthUserEntity); + const existing = await repo.findOne({ where: { id: userId } }); + + if (!existing) { + return null; + } + + if (isUsableProvisionSecret(existing.provisionSecret)) { + return existing.provisionSecret; + } + + await repo.update( + { id: userId, provisionSecret: IsNull() }, + { provisionSecret: generateProvisionSecret() } + ); + + const stored = await repo.findOne({ where: { id: userId } }); + + return isUsableProvisionSecret(stored?.provisionSecret) ? stored.provisionSecret : null; +} diff --git a/toju-app/public/i18n/catalog/auth.json b/toju-app/public/i18n/catalog/auth.json index 0337d60..5733912 100644 --- a/toju-app/public/i18n/catalog/auth.json +++ b/toju-app/public/i18n/catalog/auth.json @@ -37,6 +37,11 @@ "defaultServerName": "Signal Server" }, "provision": { + "credentialsRejected": "This server already has an account that the restored session cannot unlock. Your home account is still signed in.", + "reconnectTitle": "Reconnect to {{serverName}}", + "retry": "Retry", + "retrying": "Retrying…", + "serverUnavailable": "This server is currently unavailable. Retry when the connection is restored.", "usernameCollision": "Username {{preferredUsername}} was taken on {{serverName}}. Created {{provisionedUsername}} instead." } } diff --git a/toju-app/src/app/domains/authentication/README.md b/toju-app/src/app/domains/authentication/README.md index b496c98..2c383a0 100644 --- a/toju-app/src/app/domains/authentication/README.md +++ b/toju-app/src/app/domains/authentication/README.md @@ -1,6 +1,6 @@ # Authentication Domain -Handles user authentication (login and registration) against the configured server endpoint. Provides the login, register, and user-bar UI components. +Handles the durable home session plus per-signal-server credentials used for cross-server identity. Provides login, registration, silent foreign provisioning, contextual recovery, and user-bar UI. ## Module map @@ -8,7 +8,11 @@ Handles user authentication (login and registration) against the configured serv authentication/ ├── application/ │ └── services/ -│ └── authentication.service.ts HTTP login/register against the active server endpoint +│ ├── authentication.service.ts HTTP login/register against the active endpoint +│ ├── signal-server-auth.service.ts Home migration and silent foreign provisioning +│ ├── signal-server-authorize.service.ts Explicit authorization and credential checks +│ ├── signal-server-auth-recovery.service.ts Contextual per-server recovery state +│ └── home-provision-secret.service.ts Account-wide secret issued by the home server │ ├── domain/ │ └── models/ @@ -26,6 +30,8 @@ authentication/ `AuthenticationService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component dispatches `UsersActions.authenticateUser`, and the users effects prepare the local persistence boundary before exposing the new user in the NgRx store. +`SignalServerAuthService` keeps one credential per normalized signal-server URL. Provision failures never expire the valid home session or automatically redirect to generic login. They publish a contextual issue rendered on server/join surfaces with Retry. + ```mermaid graph TD Login[LoginComponent] @@ -64,7 +70,7 @@ sequenceDiagram Login->>Auth: login(username, password) Auth->>SD: getApiBaseUrl() SD-->>Auth: https://server/api - Auth->>API: POST /api/auth/login + Auth->>API: POST /api/users/login API-->>Auth: { userId, displayName } Auth-->>Login: success Login->>Store: UsersActions.authenticateUser @@ -75,7 +81,32 @@ sequenceDiagram ## Registration flow -Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same authenticated-user transition runs, switching the browser persistence layer to that user's local scope before the app reloads rooms and user state. +Registration follows the same pattern but posts to `/api/users/register` with an additional `displayName` field. On success the user is treated as logged in and the same authenticated-user transition runs, switching the browser persistence layer to that user's local scope before the app reloads rooms and user state. + +## One human, one account per signal server + +A linked account on a foreign signal server is an ordinary account whose password is the user's **provision secret**. That secret is issued and stored by the **home** signal server (`GET /api/users/me/provision-secret`, created on first use), so it is identical on every device the person signs in from. `HomeProvisionSecretService` fetches it with the home session token and caches it in memory only. + +This matters because the secret decides identity. Older builds generated a secret per device; the second device could not sign in to the account the first one had created, fell through to the `username-` candidate, and registered a **second account with the same display name**. Everyone else then saw that person twice, DM threads forked, and a 1:1 call looked like a group call. + +`buildProvisionPlan` therefore orders attempts so a duplicate cannot happen by accident: + +1. register the preferred username; +2. on conflict, sign in with the canonical secret — this is another device of ours; +3. then sign in with the legacy device-local secret — an account this device made before canonical secrets existed, which is immediately rotated onto the canonical secret via `POST /api/users/me/password`; +4. only once the preferred name is proven to belong to somebody else, repeat for `username-`. + +Registering the suffixed name requires a canonical secret. Without one the client cannot distinguish "another human owns this name" from "our own account whose secret this device never had", so it raises a contextual recovery issue instead of guessing. + +## Restore and foreign-server recovery + +1. Restore validates the home session token and migrates the home credential. +2. Active foreign endpoints call `ensureProvisioned`. +3. Provisioning resolves the canonical secret from the home server, then follows the plan above. +4. Successful provisioning stores the foreign actor credential; room connection then identifies and joins with that actor id. +5. Rejected credentials or an unavailable endpoint publish per-server recovery state. The home session remains active; Retry re-runs provisioning and reconnects the current room after success. + +Diagnostics record only home user id, foreign actor id, normalized server URL, and outcome. Tokens, passwords, provision secrets, SDP, and message contents must never be logged. ## User bar diff --git a/toju-app/src/app/domains/authentication/application/services/home-provision-secret.service.spec.ts b/toju-app/src/app/domains/authentication/application/services/home-provision-secret.service.spec.ts new file mode 100644 index 0000000..ac55010 --- /dev/null +++ b/toju-app/src/app/domains/authentication/application/services/home-provision-secret.service.spec.ts @@ -0,0 +1,99 @@ +import '@angular/compiler'; +import { HttpClient } from '@angular/common/http'; +import { Injector, runInInjectionContext } from '@angular/core'; +import { of, throwError } from 'rxjs'; +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import { AuthTokenStoreService } from './auth-token-store.service'; +import { HomeProvisionSecretService } from './home-provision-secret.service'; +import { ProvisionSecretStoreService } from './provision-secret-store.service'; + +const HOME_URL = 'https://signal.toju.app'; +const homeUser = { id: 'home-user-1', homeSignalServerUrl: HOME_URL }; + +describe('HomeProvisionSecretService', () => { + let httpGet: ReturnType; + let getToken: ReturnType; + let getSecret: ReturnType; + let service: HomeProvisionSecretService; + + function createService(): HomeProvisionSecretService { + const injector = Injector.create({ + providers: [ + HomeProvisionSecretService, + { provide: HttpClient, useValue: { get: httpGet } }, + { provide: AuthTokenStoreService, useValue: { getToken } }, + { provide: ProvisionSecretStoreService, useValue: { getSecret } } + ] + }); + + return runInInjectionContext(injector, () => injector.get(HomeProvisionSecretService)); + } + + beforeEach(() => { + httpGet = vi.fn(() => of({ provisionSecret: 'canonical-secret' })); + getToken = vi.fn(() => 'home-token'); + getSecret = vi.fn(() => Promise.resolve(null)); + service = createService(); + }); + + it('reads the account-wide secret from the home server with the home session token', async () => { + await expect(service.resolveCanonicalSecret(homeUser)).resolves.toBe('canonical-secret'); + + expect(httpGet).toHaveBeenCalledWith( + `${HOME_URL}/api/users/me/provision-secret`, + { headers: { Authorization: 'Bearer home-token' } } + ); + }); + + it('caches the secret so repeated provisioning does not re-query the home server', async () => { + await service.resolveCanonicalSecret(homeUser); + await service.resolveCanonicalSecret(homeUser); + + expect(httpGet).toHaveBeenCalledOnce(); + }); + + it('collapses concurrent lookups into one request', async () => { + const [first, second] = await Promise.all([service.resolveCanonicalSecret(homeUser), service.resolveCanonicalSecret(homeUser)]); + + expect(first).toBe('canonical-secret'); + expect(second).toBe('canonical-secret'); + expect(httpGet).toHaveBeenCalledOnce(); + }); + + it('re-queries after the cached secret is forgotten', async () => { + await service.resolveCanonicalSecret(homeUser); + service.forget(homeUser.id); + await service.resolveCanonicalSecret(homeUser); + + expect(httpGet).toHaveBeenCalledTimes(2); + }); + + it('returns no canonical secret when the home server is unreachable or too old', async () => { + httpGet.mockReturnValue(throwError(() => new Error('offline'))); + + await expect(service.resolveCanonicalSecret(homeUser)).resolves.toBeNull(); + }); + + it('returns no canonical secret without a home session token', async () => { + getToken.mockReturnValue(null); + + await expect(service.resolveCanonicalSecret(homeUser)).resolves.toBeNull(); + expect(httpGet).not.toHaveBeenCalled(); + }); + + it('reports the legacy device secret alongside the canonical one', async () => { + getSecret.mockResolvedValue('legacy-secret'); + + await expect(service.resolveSecrets(homeUser)).resolves.toEqual({ + canonical: 'canonical-secret', + deviceLocal: 'legacy-secret' + }); + }); +}); diff --git a/toju-app/src/app/domains/authentication/application/services/home-provision-secret.service.ts b/toju-app/src/app/domains/authentication/application/services/home-provision-secret.service.ts new file mode 100644 index 0000000..58fbb0e --- /dev/null +++ b/toju-app/src/app/domains/authentication/application/services/home-provision-secret.service.ts @@ -0,0 +1,106 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import type { User } from '../../../../shared-kernel'; +import type { ProvisionSecrets } from '../../domain/logic/signal-server-provision.rules'; +import { AuthTokenStoreService } from './auth-token-store.service'; +import { ProvisionSecretStoreService } from './provision-secret-store.service'; + +interface ProvisionSecretResponse { + provisionSecret: string; +} + +/** + * Resolves the secret used to provision linked accounts on foreign signal + * servers. + * + * The canonical secret is issued and stored by the home signal server, so it + * is the same on every device the human signs in from. That is what keeps one + * person to one account per foreign server. It is cached in memory only: it is + * re-fetchable whenever the home session is valid, and keeping another copy on + * disk would only widen the blast radius of a stolen device. + * + * `deviceLocal` is the legacy per-device secret written by older builds. It is + * read-only now and exists purely so accounts created with it can be reclaimed + * and moved onto the canonical secret. + */ +@Injectable({ providedIn: 'root' }) +export class HomeProvisionSecretService { + private readonly http = inject(HttpClient); + private readonly authTokenStore = inject(AuthTokenStoreService); + private readonly secretStore = inject(ProvisionSecretStoreService); + private readonly canonicalByHomeUserId = new Map(); + private readonly inFlight = new Map>(); + + async resolveSecrets(homeUser: Pick): Promise { + const [canonical, deviceLocal] = await Promise.all([this.resolveCanonicalSecret(homeUser), this.secretStore.getSecret(homeUser.id)]); + + return { canonical, deviceLocal }; + } + + async resolveCanonicalSecret(homeUser: Pick): Promise { + const cached = this.canonicalByHomeUserId.get(homeUser.id); + + if (cached) { + return cached; + } + + const inFlight = this.inFlight.get(homeUser.id); + + if (inFlight) { + return inFlight; + } + + const request = this.fetchCanonicalSecret(homeUser); + + this.inFlight.set(homeUser.id, request); + + try { + return await request; + } finally { + this.inFlight.delete(homeUser.id); + } + } + + /** Drops the cached secret, e.g. after logout or a home session change. */ + forget(homeUserId: string): void { + this.canonicalByHomeUserId.delete(homeUserId); + } + + private async fetchCanonicalSecret( + homeUser: Pick + ): Promise { + const homeUrl = homeUser.homeSignalServerUrl?.trim().replace(/\/+$/, ''); + + if (!homeUrl) { + return null; + } + + const token = this.authTokenStore.getToken(homeUrl); + + if (!token) { + return null; + } + + try { + const response = await firstValueFrom( + this.http.get(`${homeUrl}/api/users/me/provision-secret`, { + headers: { Authorization: `Bearer ${token}` } + }) + ); + const secret = response?.provisionSecret?.trim(); + + if (!secret) { + return null; + } + + this.canonicalByHomeUserId.set(homeUser.id, secret); + + return secret; + } catch { + // Home server offline or too old to issue secrets. Callers degrade to + // the legacy secret and must not fork a second foreign account. + return null; + } + } +} diff --git a/toju-app/src/app/domains/authentication/application/services/provision-secret-store.service.ts b/toju-app/src/app/domains/authentication/application/services/provision-secret-store.service.ts index c179c24..5f588a0 100644 --- a/toju-app/src/app/domains/authentication/application/services/provision-secret-store.service.ts +++ b/toju-app/src/app/domains/authentication/application/services/provision-secret-store.service.ts @@ -3,6 +3,15 @@ import { ElectronBridgeService } from '../../../../core/platform/electron/electr const SESSION_STORAGE_PREFIX = 'metoyou.provisionSecret.'; +/** + * Storage for the legacy per-device provision secret. + * + * New provisioning uses the account-wide secret issued by the home signal + * server (`HomeProvisionSecretService`); a per-device secret cannot unlock the + * foreign accounts the user's other devices created. This slot is kept so + * accounts registered by older builds can still be reclaimed and moved onto + * the canonical secret. Nothing should write a freshly generated secret here. + */ @Injectable({ providedIn: 'root' }) export class ProvisionSecretStoreService { private readonly electronBridge: ElectronBridgeService; @@ -42,11 +51,3 @@ export class ProvisionSecretStoreService { return `${SESSION_STORAGE_PREFIX}${homeUserId}`; } } - -export function generateProvisionSecret(): string { - const bytes = new Uint8Array(32); - - crypto.getRandomValues(bytes); - - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); -} diff --git a/toju-app/src/app/domains/authentication/application/services/signal-server-auth-recovery.service.ts b/toju-app/src/app/domains/authentication/application/services/signal-server-auth-recovery.service.ts new file mode 100644 index 0000000..0b95690 --- /dev/null +++ b/toju-app/src/app/domains/authentication/application/services/signal-server-auth-recovery.service.ts @@ -0,0 +1,51 @@ +import { Injectable, signal } from '@angular/core'; + +export type SignalServerAuthRecoveryReason = 'credentials-rejected' | 'unavailable'; + +export interface SignalServerAuthRecoveryIssue { + serverName: string; + serverUrl: string; + reason: SignalServerAuthRecoveryReason; +} + +@Injectable({ providedIn: 'root' }) +export class SignalServerAuthRecoveryService { + readonly issues = signal([]); + + publish(issue: SignalServerAuthRecoveryIssue): void { + const normalizedUrl = this.normalizeServerUrl(issue.serverUrl); + + this.issues.update((issues) => [ + ...issues.filter((candidate) => this.normalizeServerUrl(candidate.serverUrl) !== normalizedUrl), + { + ...issue, + serverUrl: normalizedUrl + } + ]); + } + + clear(serverUrl: string): void { + const normalizedUrl = this.normalizeServerUrl(serverUrl); + + this.issues.update((issues) => + issues.filter((issue) => this.normalizeServerUrl(issue.serverUrl) !== normalizedUrl) + ); + } + + getIssue(serverUrl: string | null | undefined): SignalServerAuthRecoveryIssue | null { + if (!serverUrl?.trim()) { + return null; + } + + const normalizedUrl = this.normalizeServerUrl(serverUrl); + + return this.issues().find((issue) => + this.normalizeServerUrl(issue.serverUrl) === normalizedUrl + ) ?? null; + } + + private normalizeServerUrl(serverUrl: string): string { + return serverUrl.trim().replace(/^ws/i, 'http') + .replace(/\/+$/, ''); + } +} diff --git a/toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.spec.ts b/toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.spec.ts new file mode 100644 index 0000000..9a912fe --- /dev/null +++ b/toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.spec.ts @@ -0,0 +1,152 @@ +import '@angular/compiler'; +import { Injector, runInInjectionContext } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import { DebuggingService } from '../../../../core/services/debugging/debugging.service'; +import { AuthTokenStoreService } from './auth-token-store.service'; +import { HomeProvisionSecretService } from './home-provision-secret.service'; +import { SignalServerAuthRecoveryService } from './signal-server-auth-recovery.service'; +import { SignalServerAuthService } from './signal-server-auth.service'; +import { SignalServerCredentialStoreService } from './signal-server-credential-store.service'; +import { SignalServerProvisionerService } from './signal-server-provisioner.service'; +import { SignalServerProvisionNoticeService } from './signal-server-provision-notice.service'; +import { ProvisionUsernameCollisionError } from '../../domain/logic/signal-server-provision.rules'; + +const FOREIGN_URL = 'https://signal-sweden.toju.app'; +const homeUser = { + id: 'home-user-1', + oderId: 'home-user-1', + username: 'alice', + displayName: 'Alice', + status: 'online' as const, + role: 'member' as const, + joinedAt: 1, + homeSignalServerUrl: 'https://signal.toju.app' +}; + +describe('SignalServerAuthService', () => { + let credentialStore: { + getCredential: ReturnType; + hasValidCredential: ReturnType; + }; + let homeProvisionSecret: { + resolveSecrets: ReturnType; + }; + let provisioner: { + provisionOnServer: ReturnType; + }; + let recovery: { + clear: ReturnType; + publish: ReturnType; + }; + let service: SignalServerAuthService; + + beforeEach(() => { + credentialStore = { + getCredential: vi.fn(() => null), + hasValidCredential: vi.fn(() => false) + }; + + homeProvisionSecret = { + resolveSecrets: vi.fn(() => Promise.resolve({ + canonical: 'canonical-secret', + deviceLocal: null + })) + }; + + provisioner = { + provisionOnServer: vi.fn(() => Promise.resolve({ + credential: { + serverUrl: FOREIGN_URL, + userId: 'foreign-user-1', + username: 'alice', + displayName: 'Alice', + token: 'foreign-token', + expiresAt: Date.now() + 60_000, + provisioned: true + }, + username: 'alice', + usedSuffix: false + })) + }; + + recovery = { + clear: vi.fn(), + publish: vi.fn() + }; + + const injector = Injector.create({ + providers: [ + SignalServerAuthService, + { provide: Store, useValue: { select: vi.fn() } }, + { provide: SignalServerCredentialStoreService, useValue: credentialStore }, + { provide: AuthTokenStoreService, useValue: {} }, + { provide: HomeProvisionSecretService, useValue: homeProvisionSecret }, + { provide: SignalServerProvisionerService, useValue: provisioner }, + { provide: SignalServerAuthRecoveryService, useValue: recovery }, + { provide: DebuggingService, useValue: { info: vi.fn() } }, + { provide: SignalServerProvisionNoticeService, useValue: { publish: vi.fn() } } + ] + }); + + service = runInInjectionContext(injector, () => injector.get(SignalServerAuthService)); + }); + + it('provisions a restored session with the account-wide secret from the home server', async () => { + const result = await service.ensureProvisioned(FOREIGN_URL, homeUser); + + expect(result.kind).toBe('provisioned'); + expect(homeProvisionSecret.resolveSecrets).toHaveBeenCalledWith(homeUser); + expect(provisioner.provisionOnServer).toHaveBeenCalledWith({ + serverUrl: FOREIGN_URL, + homeUser, + secrets: { canonical: 'canonical-secret', deviceLocal: null } + }); + }); + + it('passes the legacy device secret through so old foreign accounts can be reclaimed', async () => { + homeProvisionSecret.resolveSecrets.mockResolvedValue({ + canonical: 'canonical-secret', + deviceLocal: 'legacy-secret' + }); + + await service.ensureProvisioned(FOREIGN_URL, homeUser); + + expect(provisioner.provisionOnServer).toHaveBeenCalledWith(expect.objectContaining({ + secrets: { canonical: 'canonical-secret', deviceLocal: 'legacy-secret' } + })); + }); + + it('publishes contextual recovery when no candidate account can be reclaimed', async () => { + provisioner.provisionOnServer.mockRejectedValue( + new ProvisionUsernameCollisionError(FOREIGN_URL, ['alice', 'alice-homeus']) + ); + + const result = await service.ensureProvisioned(FOREIGN_URL, homeUser); + + expect(result.kind).toBe('collision'); + expect(recovery.publish).toHaveBeenCalledWith({ + serverName: 'signal-sweden.toju.app', + serverUrl: FOREIGN_URL, + reason: 'credentials-rejected' + }); + }); + + it('publishes a non-blocking unavailable issue without expiring the home session', async () => { + provisioner.provisionOnServer.mockRejectedValue(new Error('connect ECONNREFUSED')); + + await expect(service.ensureProvisioned(FOREIGN_URL, homeUser)).rejects.toThrow('ECONNREFUSED'); + expect(recovery.publish).toHaveBeenCalledWith({ + serverName: 'signal-sweden.toju.app', + serverUrl: FOREIGN_URL, + reason: 'unavailable' + }); + }); +}); diff --git a/toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.ts b/toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.ts index 67bd57c..1a71292 100644 --- a/toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.ts +++ b/toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.ts @@ -1,6 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { Store } from '@ngrx/store'; import { firstValueFrom } from 'rxjs'; +import { DebuggingService } from '../../../../core/services/debugging/debugging.service'; import type { User } from '../../../../shared-kernel'; import { selectCurrentUser } from '../../../../store/users/users.selectors'; import type { LoginResponse } from '../../domain/models/authentication.model'; @@ -9,7 +10,8 @@ import { ProvisionUsernameCollisionError } from '../../domain/logic/signal-serve import { type ResolvedSignalIdentity, resolveSignalIdentity } from '../../domain/logic/signal-server-credential-resolution.rules'; import { resolveSelfPresenceUserIds } from '../../domain/logic/self-presence-identity.rules'; import { AuthTokenStoreService } from './auth-token-store.service'; -import { ProvisionSecretStoreService, generateProvisionSecret } from './provision-secret-store.service'; +import { HomeProvisionSecretService } from './home-provision-secret.service'; +import { SignalServerAuthRecoveryService } from './signal-server-auth-recovery.service'; import { SignalServerCredentialStoreService } from './signal-server-credential-store.service'; import { SignalServerProvisionerService, type ProvisionResult } from './signal-server-provisioner.service'; import { SignalServerProvisionNoticeService } from './signal-server-provision-notice.service'; @@ -17,7 +19,7 @@ import { SignalServerProvisionNoticeService } from './signal-server-provision-no export type EnsureProvisionedResult = | { kind: 'existing'; credential: SignalServerCredential } | { kind: 'provisioned'; result: ProvisionResult } - | { kind: 'skipped'; reason: 'no-home-user' | 'no-provision-secret' | 'already-valid' } + | { kind: 'skipped'; reason: 'no-home-user' | 'already-valid' } | { kind: 'collision'; error: ProvisionUsernameCollisionError }; @Injectable({ providedIn: 'root' }) @@ -25,9 +27,11 @@ export class SignalServerAuthService { private readonly store = inject(Store); private readonly credentialStore = inject(SignalServerCredentialStoreService); private readonly authTokenStore = inject(AuthTokenStoreService); - private readonly provisionSecretStore = inject(ProvisionSecretStoreService); + private readonly homeProvisionSecret = inject(HomeProvisionSecretService); private readonly provisioner = inject(SignalServerProvisionerService); private readonly provisionNotice = inject(SignalServerProvisionNoticeService); + private readonly recovery = inject(SignalServerAuthRecoveryService); + private readonly debugging = inject(DebuggingService); private readonly provisionInFlight = new Map>(); getCredential(serverUrl: string): SignalServerCredential | null { @@ -74,25 +78,14 @@ export class SignalServerAuthService { }); } - async ensureHomeProvisionSecret(homeUser: Pick, existingSecret?: string | null): Promise { - const stored = existingSecret ?? await this.provisionSecretStore.getSecret(homeUser.id); - - if (stored) { - return stored; - } - - const generated = generateProvisionSecret(); - - await this.provisionSecretStore.storeSecret(homeUser.id, generated); - - return generated; - } - async ensureProvisioned(serverUrl: string, homeUser?: User | null): Promise { const normalizedUrl = this.normalizeServerUrl(serverUrl); const existing = this.credentialStore.getCredential(normalizedUrl); if (existing) { + this.recovery.clear(normalizedUrl); + this.logProvisionOutcome('credential-existing', normalizedUrl, existing.userId, homeUser?.id); + return { kind: 'existing', credential: existing }; } @@ -161,17 +154,12 @@ export class SignalServerAuthService { return { kind: 'skipped', reason: 'no-home-user' }; } - const provisionSecret = await this.provisionSecretStore.getSecret(user.id); - - if (!provisionSecret) { - return { kind: 'skipped', reason: 'no-provision-secret' }; - } - try { + const secrets = await this.homeProvisionSecret.resolveSecrets(user); const result = await this.provisioner.provisionOnServer({ serverUrl: normalizedUrl, homeUser: user, - provisionSecret + secrets }); if (result.usedSuffix) { @@ -182,16 +170,49 @@ export class SignalServerAuthService { }); } + this.recovery.clear(normalizedUrl); + this.logProvisionOutcome('credential-provisioned', normalizedUrl, result.credential.userId, user.id); + return { kind: 'provisioned', result }; } catch (error) { if (error instanceof ProvisionUsernameCollisionError) { + this.publishRecovery(normalizedUrl, 'credentials-rejected'); + this.logProvisionOutcome('credential-rejected', normalizedUrl, undefined, user.id); + return { kind: 'collision', error }; } + this.publishRecovery(normalizedUrl, 'unavailable'); + this.logProvisionOutcome('server-unavailable', normalizedUrl, undefined, user.id); + throw error; } } + private publishRecovery( + serverUrl: string, + reason: 'credentials-rejected' | 'unavailable' + ): void { + this.recovery.publish({ + serverName: this.resolveServerDisplayName(serverUrl), + serverUrl, + reason + }); + } + + private logProvisionOutcome( + outcome: string, + serverUrl: string, + actorUserId: string | undefined, + homeUserId: string | undefined + ): void { + this.debugging.info('signal-server-auth', outcome, { + actorUserId, + homeUserId, + serverUrl + }); + } + private normalizeServerUrl(serverUrl: string): string { return serverUrl.trim().replace(/\/+$/, ''); } diff --git a/toju-app/src/app/domains/authentication/application/services/signal-server-authorize.service.spec.ts b/toju-app/src/app/domains/authentication/application/services/signal-server-authorize.service.spec.ts index 20b2c11..e8579ee 100644 --- a/toju-app/src/app/domains/authentication/application/services/signal-server-authorize.service.spec.ts +++ b/toju-app/src/app/domains/authentication/application/services/signal-server-authorize.service.spec.ts @@ -51,7 +51,7 @@ describe('SignalServerAuthorizeService', () => { }; signalServerAuth = { - ensureProvisioned: vi.fn(() => Promise.resolve({ kind: 'skipped', reason: 'no-provision-secret' })), + ensureProvisioned: vi.fn(() => Promise.resolve({ kind: 'skipped', reason: 'no-home-user' })), hasValidCredential: vi.fn(() => false), migrateHomeCredential: vi.fn() }; @@ -102,13 +102,11 @@ describe('SignalServerAuthorizeService', () => { expect(router.navigate).not.toHaveBeenCalled(); }); - it('still provisions foreign servers and navigates to authorize when the secret is missing', async () => { + it('keeps the home session active when automatic foreign provisioning cannot recover', async () => { await expect(service.ensureCredentialForServerUrl(FOREIGN_URL)).resolves.toBe(false); expect(signalServerAuth.ensureProvisioned).toHaveBeenCalledWith(FOREIGN_URL, homeUser); - expect(router.navigate).toHaveBeenCalledWith(['/login'], expect.objectContaining({ - queryParams: expect.objectContaining({ mode: 'authorize' }) - })); + expect(router.navigate).not.toHaveBeenCalled(); }); it('returns true when foreign provisioning succeeds', async () => { diff --git a/toju-app/src/app/domains/authentication/application/services/signal-server-authorize.service.ts b/toju-app/src/app/domains/authentication/application/services/signal-server-authorize.service.ts index ced596a..eba0901 100644 --- a/toju-app/src/app/domains/authentication/application/services/signal-server-authorize.service.ts +++ b/toju-app/src/app/domains/authentication/application/services/signal-server-authorize.service.ts @@ -5,8 +5,6 @@ import { firstValueFrom } from 'rxjs'; import { selectCurrentUser } from '../../../../store/users/users.selectors'; import { ServerDirectoryFacade } from '../../../server-directory'; import { AUTH_MODE_AUTHORIZE, buildLoginReturnQueryParams } from '../../domain/logic/auth-navigation.rules'; -import { isEndpointOnlineForConnection } from '../../../server-directory/domain/logic/server-endpoint-connectivity.rules'; -import { shouldNavigateToAuthorizeSignalServer } from '../../domain/logic/signal-server-authorize.rules'; import { isSameSignalServerUrl } from '../../domain/logic/signal-server-auth-failure.rules'; import { SignalServerAuthService } from './signal-server-auth.service'; @@ -50,31 +48,13 @@ export class SignalServerAuthorizeService { return true; } - const endpointStatus = await this.resolveEndpointStatusForAuthorize(serverUrl); - - if (shouldNavigateToAuthorizeSignalServer(endpointStatus, result)) { - await this.navigateToAuthorize(serverUrl, this.router.url); - } - + // Automatic recovery must never turn a healthy home session into a generic + // foreign-server login redirect. The contextual caller renders the + // per-server recovery action; explicit authorization remains available in + // Network settings. return false; } - private async resolveEndpointStatusForAuthorize(serverUrl: string) { - const endpoint = this.serverDirectory.findServerByUrl(serverUrl); - - if (!endpoint) { - return null; - } - - if (isEndpointOnlineForConnection(endpoint.status) || endpoint.status === 'offline' || endpoint.status === 'incompatible') { - return endpoint.status; - } - - await this.serverDirectory.testServer(endpoint.id); - - return this.serverDirectory.servers().find((candidate) => candidate.id === endpoint.id)?.status ?? endpoint.status; - } - async navigateToAuthorize(serverUrl: string, returnUrl: string): Promise { const endpoint = this.serverDirectory.ensureServerEndpoint({ name: this.buildEndpointName(serverUrl), diff --git a/toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.spec.ts b/toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.spec.ts index e6dad6d..d92b625 100644 --- a/toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.spec.ts +++ b/toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.spec.ts @@ -13,6 +13,10 @@ import { SignalServerCredentialStoreService } from './signal-server-credential-s import { ProvisionUsernameCollisionError } from '../../domain/logic/signal-server-provision.rules'; import type { User } from '../../../../shared-kernel'; +const FOREIGN_URL = 'https://foreign.example.com'; +const CANONICAL_SECRET = 'canonical-secret'; +const LEGACY_SECRET = 'legacy-device-secret'; + describe('SignalServerProvisionerService', () => { let service: SignalServerProvisionerService; let httpPost: ReturnType; @@ -29,6 +33,24 @@ describe('SignalServerProvisionerService', () => { homeSignalServerUrl: 'https://home.example.com' }; + function foreignAccount(id: string, username: string, token = 'foreign-token') { + return of({ + id, + username, + displayName: 'Alice', + token, + expiresAt: Date.now() + 60_000 + }); + } + + function httpError(status: number) { + return throwError(() => new HttpErrorResponse({ status })); + } + + function provision(secrets: { canonical: string | null; deviceLocal: string | null }) { + return service.provisionOnServer({ serverUrl: FOREIGN_URL, homeUser, secrets }); + } + beforeEach(() => { const storage = new Map(); @@ -48,109 +70,127 @@ describe('SignalServerProvisionerService', () => { }); it('registers on a foreign server when the preferred username is available', async () => { - httpPost.mockReturnValue(of({ - id: 'foreign-user-1', - username: 'alice', - displayName: 'Alice', - token: 'foreign-token', - expiresAt: Date.now() + 60_000 - })); + httpPost.mockReturnValue(foreignAccount('foreign-user-1', 'alice')); - const result = await service.provisionOnServer({ - serverUrl: 'https://foreign.example.com', - homeUser, - provisionSecret: 'provision-secret' - }); + const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: null }); expect(result.username).toBe('alice'); expect(result.usedSuffix).toBe(false); - expect(credentialStore.getCredential('https://foreign.example.com')?.userId).toBe('foreign-user-1'); - expect(httpPost).toHaveBeenCalledWith( - 'https://foreign.example.com/api/users/register', - { - username: 'alice', - password: 'provision-secret', - displayName: 'Alice' - } - ); + expect(credentialStore.getCredential(FOREIGN_URL)?.userId).toBe('foreign-user-1'); + expect(httpPost).toHaveBeenCalledWith(`${FOREIGN_URL}/api/users/register`, { + username: 'alice', + password: CANONICAL_SECRET, + displayName: 'Alice' + }); }); - it('logs in when the preferred username was provisioned earlier', async () => { + it('signs a second device in to the account the first device created', async () => { httpPost - .mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 }))) - .mockReturnValueOnce(of({ - id: 'foreign-user-1', - username: 'alice', - displayName: 'Alice', - token: 'foreign-token', - expiresAt: Date.now() + 60_000 - })); + .mockReturnValueOnce(httpError(409)) + .mockReturnValueOnce(foreignAccount('foreign-user-1', 'alice')); - const result = await service.provisionOnServer({ - serverUrl: 'https://foreign.example.com', - homeUser, - provisionSecret: 'provision-secret' - }); + const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: null }); expect(result.username).toBe('alice'); + expect(result.usedSuffix).toBe(false); + expect(credentialStore.getCredential(FOREIGN_URL)?.userId).toBe('foreign-user-1'); + expect(httpPost).toHaveBeenNthCalledWith(2, `${FOREIGN_URL}/api/users/login`, { + username: 'alice', + password: CANONICAL_SECRET + }); + }); + + it('reclaims an account created with the legacy secret and moves it to the canonical one', async () => { + httpPost + .mockReturnValueOnce(httpError(409)) + .mockReturnValueOnce(httpError(401)) + .mockReturnValueOnce(foreignAccount('foreign-user-1', 'alice', 'legacy-session')) + .mockReturnValueOnce(of({ ok: true })); + + const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: LEGACY_SECRET }); + + expect(result.username).toBe('alice'); + expect(result.usedSuffix).toBe(false); + expect(httpPost).toHaveBeenNthCalledWith(3, `${FOREIGN_URL}/api/users/login`, { + username: 'alice', + password: LEGACY_SECRET + }); + expect(httpPost).toHaveBeenNthCalledWith( - 2, - 'https://foreign.example.com/api/users/login', - { - username: 'alice', - password: 'provision-secret' - } + 4, + `${FOREIGN_URL}/api/users/me/password`, + { newPassword: CANONICAL_SECRET }, + { headers: { Authorization: 'Bearer legacy-session' } } ); }); - it('registers with a suffixed username when the preferred name belongs to someone else', async () => { + it('keeps the reclaimed credential when the server cannot rotate the password', async () => { httpPost - .mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 }))) - .mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 }))) - .mockReturnValueOnce(of({ - id: 'foreign-user-2', - username: 'alice-a3f2b1', - displayName: 'Alice', - token: 'foreign-token-2', - expiresAt: Date.now() + 60_000 - })); + .mockReturnValueOnce(httpError(409)) + .mockReturnValueOnce(httpError(401)) + .mockReturnValueOnce(foreignAccount('foreign-user-1', 'alice', 'legacy-session')) + .mockReturnValueOnce(httpError(404)); - const result = await service.provisionOnServer({ - serverUrl: 'https://foreign.example.com', - homeUser, - provisionSecret: 'provision-secret' - }); + const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: LEGACY_SECRET }); + + expect(result.username).toBe('alice'); + expect(credentialStore.getCredential(FOREIGN_URL)?.userId).toBe('foreign-user-1'); + }); + + it('registers a suffixed username only when the preferred name belongs to someone else', async () => { + httpPost + .mockReturnValueOnce(httpError(409)) + .mockReturnValueOnce(httpError(401)) + .mockReturnValueOnce(foreignAccount('foreign-user-2', 'alice-a3f2b1', 'foreign-token-2')); + + const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: null }); expect(result.username).toBe('alice-a3f2b1'); expect(result.usedSuffix).toBe(true); - expect(httpPost).toHaveBeenNthCalledWith( - 3, - 'https://foreign.example.com/api/users/register', - { - username: 'alice-a3f2b1', - password: 'provision-secret', - displayName: 'Alice' - } - ); + expect(httpPost).toHaveBeenNthCalledWith(3, `${FOREIGN_URL}/api/users/register`, { + username: 'alice-a3f2b1', + password: CANONICAL_SECRET, + displayName: 'Alice' + }); }); - it('throws when all username candidates are exhausted', async () => { + it('never registers a duplicate when the home server cannot issue a canonical secret', async () => { httpPost - .mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 }))) - .mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 }))) - .mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 }))) - .mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 }))); + .mockReturnValueOnce(httpError(409)) + .mockReturnValueOnce(httpError(401)) + .mockReturnValueOnce(httpError(401)); - await expect(service.provisionOnServer({ - serverUrl: 'https://foreign.example.com', - homeUser, - provisionSecret: 'provision-secret' - })).rejects.toBeInstanceOf(ProvisionUsernameCollisionError); + await expect(provision({ canonical: null, deviceLocal: LEGACY_SECRET })) + .rejects.toBeInstanceOf(ProvisionUsernameCollisionError); + + const attemptedUrls = httpPost.mock.calls.map(([url]) => url); + + expect(attemptedUrls.filter((url) => url.endsWith('/register'))).toHaveLength(1); + }); + + it('fails with a collision instead of guessing when every candidate rejects us', async () => { + httpPost + .mockReturnValueOnce(httpError(409)) + .mockReturnValueOnce(httpError(401)) + .mockReturnValueOnce(httpError(409)) + .mockReturnValueOnce(httpError(401)); + + await expect(provision({ canonical: CANONICAL_SECRET, deviceLocal: null })) + .rejects.toBeInstanceOf(ProvisionUsernameCollisionError); + }); + + it('surfaces unexpected server failures instead of trying the next candidate', async () => { + httpPost.mockReturnValueOnce(httpError(500)); + + await expect(provision({ canonical: CANONICAL_SECRET, deviceLocal: null })) + .rejects.toBeInstanceOf(HttpErrorResponse); + + expect(httpPost).toHaveBeenCalledOnce(); }); it('returns an existing credential without making network calls', async () => { credentialStore.upsertCredential({ - serverUrl: 'https://foreign.example.com', + serverUrl: FOREIGN_URL, userId: 'foreign-user-1', username: 'alice', displayName: 'Alice', @@ -159,11 +199,7 @@ describe('SignalServerProvisionerService', () => { provisioned: true }); - const result = await service.provisionOnServer({ - serverUrl: 'https://foreign.example.com', - homeUser, - provisionSecret: 'provision-secret' - }); + const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: null }); expect(result.username).toBe('alice'); expect(httpPost).not.toHaveBeenCalled(); diff --git a/toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.ts b/toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.ts index 7d26ab4..70c6147 100644 --- a/toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.ts +++ b/toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.ts @@ -4,7 +4,14 @@ import { firstValueFrom } from 'rxjs'; import type { User } from '../../../../shared-kernel'; import type { LoginResponse } from '../../domain/models/authentication.model'; import type { SignalServerCredential } from '../../domain/models/signal-server-credential.model'; -import { ProvisionUsernameCollisionError, buildProvisionUsernameCandidates } from '../../domain/logic/signal-server-provision.rules'; +import { + type ProvisionAttempt, + type ProvisionSecrets, + ProvisionUsernameCollisionError, + buildProvisionPlan, + buildProvisionUsernameCandidates, + shouldAdoptCanonicalSecret +} from '../../domain/logic/signal-server-provision.rules'; import { SignalServerCredentialStoreService } from './signal-server-credential-store.service'; export interface ProvisionResult { @@ -29,7 +36,7 @@ export class SignalServerProvisionerService { async provisionOnServer(params: { serverUrl: string; homeUser: Pick; - provisionSecret: string; + secrets: ProvisionSecrets; }): Promise { const normalizedUrl = this.normalizeServerUrl(params.serverUrl); const existing = this.credentialStore.getCredential(normalizedUrl); @@ -42,34 +49,32 @@ export class SignalServerProvisionerService { }; } - const candidates = buildProvisionUsernameCandidates(params.homeUser.username, params.homeUser.id); + const attempts = buildProvisionPlan({ + preferredUsername: params.homeUser.username, + homeUserId: params.homeUser.id, + secrets: params.secrets + }); - for (let index = 0; index < candidates.length; index += 1) { - const candidate = candidates[index]; - const usedSuffix = index > 0; + for (const attempt of attempts) { + const response = await this.runProvisionAttempt(normalizedUrl, attempt, params.homeUser.displayName); - try { - const response = await this.register(normalizedUrl, candidate, params.provisionSecret, params.homeUser.displayName); - - return this.persistProvisionResult(normalizedUrl, response, usedSuffix); - } catch (error) { - if (!this.isHttpStatus(error, 409)) { - throw error; - } - - try { - const response = await this.login(normalizedUrl, candidate, params.provisionSecret); - - return this.persistProvisionResult(normalizedUrl, response, usedSuffix); - } catch (loginError) { - if (!this.isHttpStatus(loginError, 401)) { - throw loginError; - } - } + if (!response) { + continue; } + + const result = this.persistProvisionResult(normalizedUrl, response, attempt.usedSuffix); + + if (shouldAdoptCanonicalSecret(attempt, params.secrets.canonical)) { + await this.adoptCanonicalSecret(normalizedUrl, response.token, params.secrets.canonical); + } + + return result; } - throw new ProvisionUsernameCollisionError(normalizedUrl, candidates); + throw new ProvisionUsernameCollisionError( + normalizedUrl, + buildProvisionUsernameCandidates(params.homeUser.username, params.homeUser.id) + ); } upsertManualCredential( @@ -91,6 +96,57 @@ export class SignalServerProvisionerService { return credential; } + /** + * Runs one planned attempt. Returns `null` for the two "this name is not + * ours to take this way" outcomes so the plan can continue; anything else + * is a real transport or server fault and must surface. + */ + private async runProvisionAttempt( + serverUrl: string, + attempt: ProvisionAttempt, + displayName: string + ): Promise { + try { + return attempt.kind === 'register' + ? await this.register(serverUrl, attempt.username, attempt.secret, displayName) + : await this.login(serverUrl, attempt.username, attempt.secret); + } catch (error) { + const expected = attempt.kind === 'register' + ? this.isHttpStatus(error, 409) + : this.isHttpStatus(error, 401) || this.isHttpStatus(error, 404); + + if (!expected) { + throw error; + } + + return null; + } + } + + /** + * Moves a linked account created with a legacy per-device secret onto the + * account-wide secret, so the user's other devices can sign in to it instead + * of registering a second account. Best effort: this device already holds a + * working credential either way. + */ + private async adoptCanonicalSecret( + serverUrl: string, + token: string, + canonicalSecret: string + ): Promise { + try { + await firstValueFrom( + this.http.post( + `${serverUrl}/api/users/me/password`, + { newPassword: canonicalSecret }, + { headers: { Authorization: `Bearer ${token}` } } + ) + ); + } catch { + // Older servers have no rotation endpoint; keep the working credential. + } + } + private async register( serverUrl: string, username: string, diff --git a/toju-app/src/app/domains/authentication/domain/logic/signal-server-authorize.rules.spec.ts b/toju-app/src/app/domains/authentication/domain/logic/signal-server-authorize.rules.spec.ts deleted file mode 100644 index 6c65a48..0000000 --- a/toju-app/src/app/domains/authentication/domain/logic/signal-server-authorize.rules.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - describe, - expect, - it -} from 'vitest'; -import { shouldNavigateToAuthorizeSignalServer } from './signal-server-authorize.rules'; - -describe('signal-server-authorize rules', () => { - it('does not navigate to authorize when the signal server is offline', () => { - expect(shouldNavigateToAuthorizeSignalServer('offline', { - kind: 'skipped', - reason: 'no-provision-secret' - })).toBe(false); - - expect(shouldNavigateToAuthorizeSignalServer('offline', { - kind: 'collision', - error: new Error('collision') as never - })).toBe(false); - }); - - it('navigates to authorize on online servers that need manual sign-in', () => { - expect(shouldNavigateToAuthorizeSignalServer('online', { - kind: 'skipped', - reason: 'no-provision-secret' - })).toBe(true); - - expect(shouldNavigateToAuthorizeSignalServer('online', { - kind: 'collision', - error: new Error('collision') as never - })).toBe(true); - }); - - it('does not navigate for unknown endpoint status or non-authorize provision outcomes', () => { - expect(shouldNavigateToAuthorizeSignalServer('unknown', { - kind: 'skipped', - reason: 'no-provision-secret' - })).toBe(false); - - expect(shouldNavigateToAuthorizeSignalServer('online', { - kind: 'skipped', - reason: 'no-home-user' - })).toBe(false); - }); -}); diff --git a/toju-app/src/app/domains/authentication/domain/logic/signal-server-authorize.rules.ts b/toju-app/src/app/domains/authentication/domain/logic/signal-server-authorize.rules.ts deleted file mode 100644 index b9728af..0000000 --- a/toju-app/src/app/domains/authentication/domain/logic/signal-server-authorize.rules.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { EnsureProvisionedResult } from '../../application/services/signal-server-auth.service'; -import type { ServerEndpointStatus } from '../../../server-directory/domain/models/server-directory.model'; -import { isEndpointOnlineForConnection } from '../../../server-directory/domain/logic/server-endpoint-connectivity.rules'; - -export function shouldNavigateToAuthorizeSignalServer( - endpointStatus: ServerEndpointStatus | undefined | null, - provisionResult: EnsureProvisionedResult -): boolean { - if (!isEndpointOnlineForConnection(endpointStatus)) { - return false; - } - - if (provisionResult.kind === 'collision') { - return true; - } - - return provisionResult.kind === 'skipped' && provisionResult.reason === 'no-provision-secret'; -} diff --git a/toju-app/src/app/domains/authentication/domain/logic/signal-server-provision.rules.spec.ts b/toju-app/src/app/domains/authentication/domain/logic/signal-server-provision.rules.spec.ts index a1c4149..d8fdb80 100644 --- a/toju-app/src/app/domains/authentication/domain/logic/signal-server-provision.rules.spec.ts +++ b/toju-app/src/app/domains/authentication/domain/logic/signal-server-provision.rules.spec.ts @@ -5,10 +5,22 @@ import { } from 'vitest'; import { ProvisionUsernameCollisionError, + buildProvisionPlan, buildProvisionUsernameCandidates, - shortHomeUserId + shortHomeUserId, + shouldAdoptCanonicalSecret } from './signal-server-provision.rules'; +const HOME_USER_ID = 'a3f2b1c4-5678-90ab-cdef-1234567890ab'; + +function plan(canonical: string | null, deviceLocal: string | null) { + return buildProvisionPlan({ + preferredUsername: 'alice', + homeUserId: HOME_USER_ID, + secrets: { canonical, deviceLocal } + }).map((attempt) => `${attempt.kind}:${attempt.username}:${attempt.secretSource}`); +} + describe('signal-server-provision.rules', () => { it('derives a stable short id from a home user uuid', () => { expect(shortHomeUserId('a3f2b1c4-5678-90ab-cdef-1234567890ab')).toBe('a3f2b1'); @@ -26,6 +38,54 @@ describe('signal-server-provision.rules', () => { ).toEqual(['alice-a3f2b1']); }); + it('signs in to the preferred username before ever trying the suffixed one', () => { + expect(plan('canonical-secret', null)).toEqual([ + 'register:alice:canonical', + 'login:alice:canonical', + 'register:alice-a3f2b1:canonical', + 'login:alice-a3f2b1:canonical' + ]); + }); + + it('tries the legacy device secret before giving the username up as someone else\'s', () => { + expect(plan('canonical-secret', 'legacy-secret')).toEqual([ + 'register:alice:canonical', + 'login:alice:canonical', + 'login:alice:device-local', + 'register:alice-a3f2b1:canonical', + 'login:alice-a3f2b1:canonical', + 'login:alice-a3f2b1:device-local' + ]); + }); + + it('never registers a suffixed duplicate without a canonical secret', () => { + expect(plan(null, 'legacy-secret')).toEqual([ + 'register:alice:device-local', + 'login:alice:device-local', + 'login:alice-a3f2b1:device-local' + ]); + }); + + it('produces no attempts when no secret is available at all', () => { + expect(plan(null, null)).toEqual([]); + }); + + it('does not repeat the canonical secret as a legacy attempt', () => { + expect(plan('same-secret', 'same-secret')).toEqual([ + 'register:alice:canonical', + 'login:alice:canonical', + 'register:alice-a3f2b1:canonical', + 'login:alice-a3f2b1:canonical' + ]); + }); + + it('adopts the canonical secret only after a legacy login', () => { + expect(shouldAdoptCanonicalSecret({ kind: 'login', secretSource: 'device-local' }, 'canonical')).toBe(true); + expect(shouldAdoptCanonicalSecret({ kind: 'login', secretSource: 'canonical' }, 'canonical')).toBe(false); + expect(shouldAdoptCanonicalSecret({ kind: 'register', secretSource: 'device-local' }, 'canonical')).toBe(false); + expect(shouldAdoptCanonicalSecret({ kind: 'login', secretSource: 'device-local' }, null)).toBe(false); + }); + it('exposes attempted usernames on collision errors', () => { const error = new ProvisionUsernameCollisionError('https://signal.example.com', ['alice', 'alice-a3f2b1']); diff --git a/toju-app/src/app/domains/authentication/domain/logic/signal-server-provision.rules.ts b/toju-app/src/app/domains/authentication/domain/logic/signal-server-provision.rules.ts index 3242ec2..9af4347 100644 --- a/toju-app/src/app/domains/authentication/domain/logic/signal-server-provision.rules.ts +++ b/toju-app/src/app/domains/authentication/domain/logic/signal-server-provision.rules.ts @@ -32,3 +32,104 @@ export function buildProvisionUsernameCandidates( return [...new Set(candidates)]; } + +/** + * `canonical` is the account-wide secret issued by the home signal server, so + * it is identical on every device of the same human. `device-local` is the + * legacy secret that older builds generated per device; it only ever unlocks + * accounts that this one device created. + */ +export type ProvisionSecretSource = 'canonical' | 'device-local'; + +export interface ProvisionSecrets { + canonical: string | null; + deviceLocal: string | null; +} + +export interface ProvisionAttempt { + kind: 'register' | 'login'; + username: string; + secret: string; + secretSource: ProvisionSecretSource; + usedSuffix: boolean; +} + +/** + * Orders the provisioning attempts for one foreign signal server. + * + * The ordering exists to guarantee that a human never ends up with two + * accounts on the same server. For each username we first try to claim it, + * then to sign in with the canonical secret (another device of ours already + * claimed it), then with the device-local secret (this device claimed it + * before canonical secrets existed). Only once a username is proven to belong + * to somebody else do we move on to the suffixed name. + * + * Registering the suffixed name requires a canonical secret. Without one we + * cannot tell "another human owns this name" apart from "our own account whose + * secret this device never had", and guessing wrong forks the user's identity. + */ +export function buildProvisionPlan(input: { + preferredUsername: string; + homeUserId: string; + secrets: ProvisionSecrets; +}): ProvisionAttempt[] { + const candidates = buildProvisionUsernameCandidates(input.preferredUsername, input.homeUserId); + const { canonical, deviceLocal } = input.secrets; + const primary = canonical ?? deviceLocal; + + if (!primary) { + return []; + } + + const attempts: ProvisionAttempt[] = []; + + candidates.forEach((username, index) => { + const usedSuffix = index > 0; + + if (!usedSuffix || canonical) { + attempts.push({ + kind: 'register', + username, + secret: primary, + secretSource: canonical ? 'canonical' : 'device-local', + usedSuffix + }); + } + + if (canonical) { + attempts.push({ + kind: 'login', + username, + secret: canonical, + secretSource: 'canonical', + usedSuffix + }); + } + + if (deviceLocal && deviceLocal !== canonical) { + attempts.push({ + kind: 'login', + username, + secret: deviceLocal, + secretSource: 'device-local', + usedSuffix + }); + } + }); + + return attempts; +} + +/** + * A login that succeeded with the legacy device-local secret leaves the + * account unreachable from the user's other devices until its password is + * moved to the canonical secret. + */ +export function shouldAdoptCanonicalSecret( + attempt: Pick, + canonicalSecret: string | null +): canonicalSecret is string { + return attempt.kind === 'login' + && attempt.secretSource === 'device-local' + && !!canonicalSecret; +} diff --git a/toju-app/src/app/domains/authentication/index.ts b/toju-app/src/app/domains/authentication/index.ts index 9214a75..48afc01 100644 --- a/toju-app/src/app/domains/authentication/index.ts +++ b/toju-app/src/app/domains/authentication/index.ts @@ -1,8 +1,10 @@ export * from './application/services/authentication.service'; export * from './application/services/auth-token-store.service'; export * from './application/services/user-logout.service'; +export * from './application/services/home-provision-secret.service'; export * from './application/services/signal-server-auth.service'; export * from './application/services/signal-server-authorize.service'; +export * from './application/services/signal-server-auth-recovery.service'; export * from './application/services/signal-server-credential-store.service'; export * from './application/services/signal-server-provisioner.service'; export * from './application/services/signal-server-provision-notice.service'; diff --git a/toju-app/src/app/domains/server-directory/feature/invite/invite.component.html b/toju-app/src/app/domains/server-directory/feature/invite/invite.component.html index 6a47027..df0ae75 100644 --- a/toju-app/src/app/domains/server-directory/feature/invite/invite.component.html +++ b/toju-app/src/app/domains/server-directory/feature/invite/invite.component.html @@ -74,6 +74,17 @@ @if (status() === 'error') { + @if (authRecoveryIssue()) { + + } + + } + @if (joinErrorMessage() || error()) {

{{ joinErrorMessage() || error() }}

diff --git a/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.spec.ts b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.spec.ts index 2ad0b52..05bcf96 100644 --- a/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.spec.ts +++ b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.spec.ts @@ -23,6 +23,7 @@ import { DatabaseService } from '../../../../infrastructure/persistence'; import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade'; import { RealtimeSessionFacade } from '../../../../core/realtime'; import { PluginRequirementService, PluginStoreService } from '../../../plugins'; +import { SignalServerAuthRecoveryService } from '../../../authentication/application/services/signal-server-auth-recovery.service'; import { SignalServerAuthorizeService } from '../../../authentication/application/services/signal-server-authorize.service'; import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing'; import type { ServerInfo } from '../../domain/models/server-directory.model'; @@ -116,6 +117,7 @@ function createHarness(options: HarnessOptions = {}) { { provide: PluginRequirementService, useValue: pluginRequirements }, { provide: PluginStoreService, useValue: pluginStore }, { provide: SignalServerAuthorizeService, useValue: signalServerAuthorize }, + SignalServerAuthRecoveryService, ...provideAppI18nForTests() ] }); diff --git a/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts index 2116196..e321331 100644 --- a/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts @@ -1,6 +1,6 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, + computed, effect, inject, Injector, @@ -32,6 +32,7 @@ import { import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; import { setStoredCurrentUserId } from '../../../../core/storage/current-user-storage'; import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules'; +import { SignalServerAuthRecoveryService } from '../../../authentication/application/services/signal-server-auth-recovery.service'; import { SignalServerAuthorizeService } from '../../../authentication/application/services/signal-server-authorize.service'; import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives'; import { RoomsActions } from '../../../../store/rooms/rooms.actions'; @@ -125,6 +126,7 @@ export class ServerBrowserComponent implements OnInit { private pluginStore = inject(PluginStoreService); private injector = inject(Injector); private readonly i18n = inject(AppI18nService); + private readonly signalServerAuthRecovery = inject(SignalServerAuthRecoveryService); private readonly signalServerAuthorize = inject(SignalServerAuthorizeService); private searchSubject = new Subject(); private banLookupRequestVersion = 0; @@ -215,6 +217,11 @@ export class ServerBrowserComponent implements OnInit { joinPassword = signal(''); joinPasswordError = signal(null); joinErrorMessage = signal(null); + authRecoveryServer = signal(null); + authRecoveryRetrying = signal(false); + authRecoveryIssue = computed(() => + this.signalServerAuthRecovery.getIssue(this.authRecoveryServer()?.sourceUrl) + ); joinedServerMenuId = signal(null); leaveDialogRoom = signal(null); pluginConsentDialog = signal(null); @@ -289,6 +296,22 @@ export class ServerBrowserComponent implements OnInit { await this.attemptJoinServer(server); } + async retrySignalServerAuthentication(): Promise { + const server = this.authRecoveryServer(); + + if (!server || this.authRecoveryRetrying()) { + return; + } + + this.authRecoveryRetrying.set(true); + + try { + await this.attemptJoinServer(server); + } finally { + this.authRecoveryRetrying.set(false); + } + } + joinSavedRoom(room: Room): void { this.openJoinedRoom(room); } @@ -536,8 +559,21 @@ export class ServerBrowserComponent implements OnInit { const hasCredential = await this.signalServerAuthorize.ensureCredentialForServerUrl(server.sourceUrl); if (!hasCredential) { + const issue = this.signalServerAuthRecovery.getIssue(server.sourceUrl); + + this.authRecoveryServer.set(issue ? server : null); + + if (!issue) { + // No per-server issue means the failure was not about this server's + // credentials. Say something rather than leaving the join dead. + this.joinErrorMessage.set(this.i18n.instant('auth.provision.serverUnavailable')); + } + return; } + + this.signalServerAuthRecovery.clear(server.sourceUrl); + this.authRecoveryServer.set(null); } const response = await firstValueFrom( diff --git a/toju-app/src/app/features/room/chat-room/chat-room.component.html b/toju-app/src/app/features/room/chat-room/chat-room.component.html index 2b30264..235b215 100644 --- a/toju-app/src/app/features/room/chat-room/chat-room.component.html +++ b/toju-app/src/app/features/room/chat-room/chat-room.component.html @@ -1,5 +1,29 @@
@if (currentRoom()) { + @if (authRecoveryIssue(); as issue) { + + } + @if (isMobile()) { ('channels'); readonly isMobile = this.viewport.isMobile; + readonly isAuthRecoveryRetrying = signal(false); readonly swiperRef = viewChild>('swiperEl'); currentRoom = this.store.selectSignal(selectCurrentRoom); + readonly authRecoveryIssue = computed(() => + this.signalServerAuthRecovery.getIssue(this.currentRoom()?.sourceUrl) + ); isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); textChannels = this.store.selectSignal(selectTextChannels); activeChannelId = this.store.selectSignal(selectActiveChannelId); @@ -229,6 +236,28 @@ export class ChatRoomComponent { } } + async retrySignalServerAuthentication(): Promise { + const room = this.currentRoom(); + const sourceUrl = room?.sourceUrl; + + if (!room || !sourceUrl || this.isAuthRecoveryRetrying()) { + return; + } + + this.isAuthRecoveryRetrying.set(true); + + try { + const recovered = await this.signalServerAuthorize.ensureCredentialForServerUrl(sourceUrl); + + if (recovered) { + this.signalServerAuthRecovery.clear(sourceUrl); + this.store.dispatch(RoomsActions.viewServer({ room, skipBanCheck: true })); + } + } finally { + this.isAuthRecoveryRetrying.set(false); + } + } + /** Open the settings modal to the Server admin page for the current room. */ toggleAdminPanel() { const room = this.currentRoom(); diff --git a/toju-app/src/app/store/users/users.effects.ts b/toju-app/src/app/store/users/users.effects.ts index 6bf8a42..4e05df7 100644 --- a/toju-app/src/app/store/users/users.effects.ts +++ b/toju-app/src/app/store/users/users.effects.ts @@ -1,7 +1,7 @@ /** * Users store effects (load, kick, ban, host election, profile persistence). */ -/* eslint-disable @typescript-eslint/member-ordering */ + import { Injectable, inject } from '@angular/core'; import { Router } from '@angular/router'; import { @@ -302,7 +302,6 @@ export class UsersEffects { if (user.homeSignalServerUrl && loginResponse) { this.signalServerAuth.upsertCredentialFromLogin(user.homeSignalServerUrl, loginResponse, { provisioned: false }); - await this.signalServerAuth.ensureHomeProvisionSecret(user); } }