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.
This commit is contained in:
@@ -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-<homeUserIdPrefix>`) and optional display-name disambiguation — still **without** opening `/login`.
|
||||||
|
4. Transient `auth_required` (message raced ahead of identify) never opens login and never tears down the home session while a valid local credential exists.
|
||||||
|
5. Rejected foreign tokens trigger **re-provision** (or credential refresh), not a home logout and not a blocking authorize form when silent provision is possible.
|
||||||
|
6. Offline / unreachable / incompatible endpoints never open `/login?mode=authorize`.
|
||||||
|
7. Session restore after app restart still silently provisions foreign servers (provision secret and credentials survive restart on desktop).
|
||||||
|
8. Settings → Network may show `Authorized` / `Needs sign-in` for diagnostics, but “Needs sign-in” must not become the default path for a normal logged-in user who simply joined a room on another host.
|
||||||
|
|
||||||
|
### Explicit non-goals (for this story)
|
||||||
|
|
||||||
|
- Changing the home-server password / register UX for first-time users.
|
||||||
|
- Merging foreign actor ids into a single global server-side identity (home id ≠ foreign provisioned id is expected).
|
||||||
|
- Removing the authorize UI entirely — it may remain as a **last resort** (e.g. true username collision exhaustion, or user-initiated “Sign in” from Network settings).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current intended architecture (as designed)
|
||||||
|
|
||||||
|
| Concept | Role |
|
||||||
|
|--------|------|
|
||||||
|
| Home session | Local profile + credential for `homeSignalServerUrl` |
|
||||||
|
| Provision secret | Per-install secret generated on home login/register; used as the password when auto-registering/logging into foreign servers |
|
||||||
|
| Per-signal credential store | `metoyou.signalServerCredentials` — token + actor userId per normalized server URL |
|
||||||
|
| Legacy token store | `metoyou.authTokens` — still used for REST interceptor / session restore fallback |
|
||||||
|
| `ensureProvisioned` | Register-or-login on a foreign URL using the provision secret |
|
||||||
|
| `ensureCredentialForServerUrl` | Gate before foreign room connect / invite / join — provision first; only then optionally navigate to authorize |
|
||||||
|
| `authorize` login mode | Manual login that only upserts a foreign credential (`authorizeSignalServer`) without resetting home state |
|
||||||
|
|
||||||
|
Primary call sites that demand a foreign credential:
|
||||||
|
|
||||||
|
- Room signaling connect (`room-signaling-connection.ts`)
|
||||||
|
- Invite / server-browser join flows
|
||||||
|
- Active endpoint health → opportunistic `ensureProvisioned`
|
||||||
|
- `provisionActiveSignalServers$` after `loadCurrentUserSuccess`
|
||||||
|
|
||||||
|
Authorize navigation is gated by `shouldNavigateToAuthorizeSignalServer`:
|
||||||
|
|
||||||
|
- Endpoint must look **online**
|
||||||
|
- Provision result is `collision` **or** `skipped` with reason `no-provision-secret`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Research findings — likely causes
|
||||||
|
|
||||||
|
These are **code-backed hypotheses** ranked by how directly they produce a login prompt while the user still has a home session.
|
||||||
|
|
||||||
|
### Cause A — Missing provision secret → authorize login (primary)
|
||||||
|
|
||||||
|
**Mechanism**
|
||||||
|
|
||||||
|
1. `SignalServerAuthService.ensureProvisioned` returns `{ kind: 'skipped', reason: 'no-provision-secret' }` when `ProvisionSecretStoreService.getSecret(homeUser.id)` is null.
|
||||||
|
2. `SignalServerAuthorizeService.ensureCredentialForServerUrl` then calls `navigateToAuthorize` → `/login?mode=authorize`.
|
||||||
|
3. Login’s authorize mode **does not** auto-redirect away when `currentUser` is set (the leave-login effect explicitly returns early in authorize mode), so the prompt stays on screen.
|
||||||
|
|
||||||
|
**Why the secret is often missing**
|
||||||
|
|
||||||
|
- Secret is created only in `prepareAuthenticatedUserStorage` via `ensureHomeProvisionSecret`, and **only when both** `user.homeSignalServerUrl` **and** `loginResponse` are present.
|
||||||
|
- Session restore (`loadCurrentUserSuccess` → `provisionActiveSignalServers$`) calls `ensureProvisioned` but **never** calls `ensureHomeProvisionSecret` to create a missing secret.
|
||||||
|
- Web / non-Electron fallback stores the secret in **sessionStorage** (`metoyou.provisionSecret.<userId>`), which dies when the tab/session ends.
|
||||||
|
- Accounts created before this feature, wiped Electron `userData/provision-secrets/`, or logins that never received a `loginResponse` + home URL pair never get a secret.
|
||||||
|
|
||||||
|
**Evidence in code**
|
||||||
|
|
||||||
|
- `signal-server-authorize.rules.ts` — `no-provision-secret` ⇒ navigate to authorize
|
||||||
|
- `signal-server-authorize.service.spec.ts` — “still provisions foreign servers and navigates to authorize when the secret is missing”
|
||||||
|
- `users.effects.ts` — `ensureHomeProvisionSecret` only inside `prepareAuthenticatedUserStorage` with `loginResponse`
|
||||||
|
|
||||||
|
### Cause B — Username collision exhaustion → authorize login
|
||||||
|
|
||||||
|
**Mechanism**
|
||||||
|
|
||||||
|
`SignalServerProvisionerService` tries preferred username, then suffixed candidates. If every register returns 409 and every login with the provision secret returns 401, it throws `ProvisionUsernameCollisionError` → `kind: 'collision'` → authorize UI.
|
||||||
|
|
||||||
|
**When it shows up**
|
||||||
|
|
||||||
|
Another user already owns those usernames on the foreign server with different passwords (not our provisioned accounts). Silent recovery is impossible without a different identity strategy or manual credentials.
|
||||||
|
|
||||||
|
### Cause C — Home session false expiry → full `/login` (not just authorize)
|
||||||
|
|
||||||
|
**Mechanism**
|
||||||
|
|
||||||
|
`signalServerAuthFailed$` clears the credential for the failing URL, then:
|
||||||
|
|
||||||
|
- `expire-home-session` if the failure is classified as the **home** server → `clearStoredCurrentUserId` + `SESSION_EXPIRED` → `redirectOnSessionExpired$` → `/login`
|
||||||
|
- `provision-foreign` otherwise → silent `ensureProvisioned` (no login UI by itself)
|
||||||
|
|
||||||
|
**False home classification risks**
|
||||||
|
|
||||||
|
- Missing / stale `homeSignalServerUrl` on the restored user → foreign failures compared with empty home URL → `isSameSignalServerUrl` is false, so this path usually prefers foreign provision; but home failures with no resolvable credential after retries still expire the session.
|
||||||
|
- Exhausted re-identify retry budget on home while credential lookup fails (empty credential store + broken legacy fallback) → `auth_required` / `auth_error` treated as unrecoverable home expiry.
|
||||||
|
- Past regressions (see lessons): identifying only from the new credential store, or treating `auth_required` as logout — partially mitigated, but restore edge cases still matter.
|
||||||
|
|
||||||
|
### Cause D — Credential present locally but identify never runs / races
|
||||||
|
|
||||||
|
**Mechanism**
|
||||||
|
|
||||||
|
Without a resolvable token for the foreign URL, the socket sends non-identify traffic → server `auth_required`. If the client then cannot re-identify or re-provision (Cause A), user-facing flows that gate on `ensureCredentialForServerUrl` open authorize login. Presence/chat then look “broken” even though the home profile still shows logged in.
|
||||||
|
|
||||||
|
Related lesson: identify must fall back to legacy `AuthTokenStoreService` for **home**; foreign servers **cannot** be reconstructed from the legacy store (actor id differs) — so foreign URLs **must** be provisioned, not guessed.
|
||||||
|
|
||||||
|
### Cause E — Opportunistic provision fails quietly; later gate opens login
|
||||||
|
|
||||||
|
**Mechanism**
|
||||||
|
|
||||||
|
`provisionActiveSignalServers$` and server health `ensureProvisioned(...).catch(() => undefined)` swallow errors. A later user action (join room) hits `ensureCredentialForServerUrl` with the same missing secret / collision and **then** navigates to authorize — so login appears mid-flow rather than at startup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User-visible scenarios
|
||||||
|
|
||||||
|
### Happy path (required)
|
||||||
|
|
||||||
|
1. Alice registers on Signal Server 1.
|
||||||
|
2. Alice browses/joins a community hosted on Signal Server 2.
|
||||||
|
3. Client silently registers `alice` (or `alice-<prefix>`) on Server 2 with the provision secret.
|
||||||
|
4. Alice lands in the room; no login modal/page; peers see her presence under the Server 2 actor id.
|
||||||
|
|
||||||
|
### Failure path today (bug)
|
||||||
|
|
||||||
|
1. Alice is logged in (user bar / local profile show her).
|
||||||
|
2. Alice opens an invite or room whose `sourceUrl` is Signal Server 2.
|
||||||
|
3. Client cannot provision (no secret / collision).
|
||||||
|
4. App navigates to `/login?mode=authorize&serverId=…&returnUrl=…`.
|
||||||
|
5. Alice believes she was logged out; re-entering home credentials may even bind the wrong server if she is not careful with the server picker.
|
||||||
|
|
||||||
|
### Restart path (required)
|
||||||
|
|
||||||
|
1. Alice fully quits the desktop app and reopens.
|
||||||
|
2. Home session restores from local DB + token stores.
|
||||||
|
3. Touching Server 2 again still silent-provisions or reuses the stored foreign credential — no authorize prompt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proof of done (when implementing)
|
||||||
|
|
||||||
|
Prefer behavior-level proof over mocks shaped like the provisioner:
|
||||||
|
|
||||||
|
1. **Integration / focused effect+service tests**
|
||||||
|
- Missing secret on restore → secret is ensured, then foreign provision succeeds, **and** `Router.navigate(['/login'])` is never called.
|
||||||
|
- Foreign `auth_error` with home session intact → re-provision + re-identify; no `SESSION_EXPIRED`.
|
||||||
|
- Online foreign endpoint + successful provision → `ensureCredentialForServerUrl` returns `true`.
|
||||||
|
2. **Manual / E2E**
|
||||||
|
- Two live signal servers; register on #1; join room on #2 without typing a password again; reload app; rejoin still silent.
|
||||||
|
3. **Negative**
|
||||||
|
- Offline foreign endpoint must not open authorize login.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Likely fix directions (for a later interview — not approved yet)
|
||||||
|
|
||||||
|
| Option | Idea | Tradeoff |
|
||||||
|
|--------|------|----------|
|
||||||
|
| **A (recommended)** | On session restore / before any foreign `ensureProvisioned`, call `ensureHomeProvisionSecret` so a missing secret is generated once and persisted; keep authorize UI only for true collision / user-initiated sign-in | New secret cannot unlock accounts previously provisioned with an old lost secret — may need re-register with suffix or collision path |
|
||||||
|
| **B** | Stop navigating to authorize on `no-provision-secret`; surface a non-blocking Network badge / toast and retry when secret becomes available | User may join without credential and hit silent presence failures |
|
||||||
|
| **C** | Derive a stable provision secret from a durable local key (not sessionStorage) on web so restarts keep the same secret | Crypto/key-storage design; still need migration for existing installs |
|
||||||
|
| **D** | For collisions, auto-pick a stronger unique username (e.g. always include fuller home user id) before opening authorize | Reduces but does not eliminate collision UX |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
- `toju-app/src/app/domains/authentication/application/services/signal-server-authorize.service.ts`
|
||||||
|
- `toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.ts`
|
||||||
|
- `toju-app/src/app/domains/authentication/application/services/signal-server-provisioner.service.ts`
|
||||||
|
- `toju-app/src/app/domains/authentication/application/services/provision-secret-store.service.ts`
|
||||||
|
- `toju-app/src/app/domains/authentication/domain/logic/signal-server-authorize.rules.ts`
|
||||||
|
- `toju-app/src/app/domains/authentication/domain/logic/signal-server-auth-failure.rules.ts`
|
||||||
|
- `toju-app/src/app/store/users/users.effects.ts` (`signalServerAuthFailed$`, `provisionActiveSignalServers$`, `redirectOnSessionExpired$`, `prepareAuthenticatedUserStorage`)
|
||||||
|
- `toju-app/src/app/store/rooms/room-signaling-connection.ts`
|
||||||
|
- `electron/api/provision-secret-store.ts`
|
||||||
|
- `agents-docs/features/authentication.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lessons already adjacent
|
||||||
|
|
||||||
|
- Identify must fall back to the legacy session token (home restore).
|
||||||
|
- Keep per-signal-URL identify credentials resolvable from the store.
|
||||||
|
- Persisted local user state still requires a session token.
|
||||||
|
- Do not open authorize login for offline endpoints.
|
||||||
|
- Distinguish `auth_required` vs `auth_error` so home session is not falsely expired.
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
import { expect } from '@playwright/test';
|
import { expect, type Page } from '@playwright/test';
|
||||||
import { test } from '../../fixtures/multi-client';
|
import { test } from '../../fixtures/multi-client';
|
||||||
import { openSettingsFromMenu } from '../../helpers/app-menu';
|
import { openSettingsFromMenu } from '../../helpers/app-menu';
|
||||||
import { expectDashboardReady } from '../../helpers/dashboard';
|
import { expectDashboardReady } from '../../helpers/dashboard';
|
||||||
import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint';
|
import { installTestServerEndpoints } from '../../helpers/seed-test-endpoint';
|
||||||
import { startTestServer } from '../../helpers/test-server';
|
import { startTestServer } from '../../helpers/test-server';
|
||||||
import {
|
import {
|
||||||
|
authHeaders,
|
||||||
readAuthTokenFromPage,
|
readAuthTokenFromPage,
|
||||||
readSignalServerCredentialFromPage,
|
readSignalServerCredentialFromPage,
|
||||||
registerTestUser
|
registerTestUser
|
||||||
} from '../../helpers/auth-api';
|
} from '../../helpers/auth-api';
|
||||||
|
import { expectServerPeerVisible } from '../../helpers/multi-device-session';
|
||||||
|
import { LoginPage } from '../../pages/login.page';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
|
||||||
const PRIMARY_ENDPOINT_ID = 'e2e-multi-auth-primary';
|
const PRIMARY_ENDPOINT_ID = 'e2e-multi-auth-primary';
|
||||||
const USER_PASSWORD = 'TestPass123!';
|
const USER_PASSWORD = 'TestPass123!';
|
||||||
@@ -108,4 +112,421 @@ test.describe('Multi-signal-server authentication', () => {
|
|||||||
await secondaryServer.stop();
|
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-<shortHomeId>` 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { AuthUserEntity } from '../../../entities';
|
||||||
|
|
||||||
|
export async function handleUpdateUserProvisionSecret(
|
||||||
|
dataSource: DataSource,
|
||||||
|
userId: string,
|
||||||
|
provisionSecret: string
|
||||||
|
): Promise<void> {
|
||||||
|
const repo = dataSource.getRepository(AuthUserEntity);
|
||||||
|
|
||||||
|
await repo.update({ id: userId }, { provisionSecret });
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import { handleGetJoinRequestById } from './queries/handlers/getJoinRequestById'
|
|||||||
import { handleGetPendingRequestsForServer } from './queries/handlers/getPendingRequestsForServer';
|
import { handleGetPendingRequestsForServer } from './queries/handlers/getPendingRequestsForServer';
|
||||||
import { handleUpdateUserPasswordHash } from './commands/handlers/updateUserPasswordHash';
|
import { handleUpdateUserPasswordHash } from './commands/handlers/updateUserPasswordHash';
|
||||||
import { handleUpdateUserSigningPublicKey } from './commands/handlers/updateUserSigningPublicKey';
|
import { handleUpdateUserSigningPublicKey } from './commands/handlers/updateUserSigningPublicKey';
|
||||||
|
import { handleUpdateUserProvisionSecret } from './commands/handlers/updateUserProvisionSecret';
|
||||||
|
|
||||||
export const registerUser = (user: AuthUserPayload) =>
|
export const registerUser = (user: AuthUserPayload) =>
|
||||||
handleRegisterUser({ type: CommandType.RegisterUser, payload: { user } }, getDataSource());
|
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) =>
|
export const updateUserSigningPublicKey = (userId: string, signingPublicKey: string) =>
|
||||||
handleUpdateUserSigningPublicKey(getDataSource(), userId, signingPublicKey);
|
handleUpdateUserSigningPublicKey(getDataSource(), userId, signingPublicKey);
|
||||||
|
|
||||||
|
export const updateUserProvisionSecret = (userId: string, provisionSecret: string) =>
|
||||||
|
handleUpdateUserProvisionSecret(getDataSource(), userId, provisionSecret);
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
|||||||
passwordHash: row.passwordHash,
|
passwordHash: row.passwordHash,
|
||||||
displayName: row.displayName,
|
displayName: row.displayName,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
signingPublicKey: row.signingPublicKey ?? null
|
signingPublicKey: row.signingPublicKey ?? null,
|
||||||
|
provisionSecret: row.provisionSecret ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface AuthUserPayload {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
signingPublicKey?: string | null;
|
signingPublicKey?: string | null;
|
||||||
|
provisionSecret?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerChannelType = 'text' | 'voice';
|
export type ServerChannelType = 'text' | 'voice';
|
||||||
|
|||||||
@@ -23,4 +23,12 @@ export class AuthUserEntity {
|
|||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
signingPublicKey!: string | null;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class ProvisionSecret1000000000013 implements MigrationInterface {
|
||||||
|
name = 'ProvisionSecret1000000000013';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('ALTER TABLE "users" ADD COLUMN "provisionSecret" text');
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('ALTER TABLE "users" DROP COLUMN "provisionSecret"');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { ServerIcons1000000000009 } from './1000000000009-ServerIcons';
|
|||||||
import { DeviceTokens1000000000010 } from './1000000000010-DeviceTokens';
|
import { DeviceTokens1000000000010 } from './1000000000010-DeviceTokens';
|
||||||
import { SessionTokens1000000000011 } from './1000000000011-SessionTokens';
|
import { SessionTokens1000000000011 } from './1000000000011-SessionTokens';
|
||||||
import { SigningPublicKey1000000000012 } from './1000000000012-SigningPublicKey';
|
import { SigningPublicKey1000000000012 } from './1000000000012-SigningPublicKey';
|
||||||
|
import { ProvisionSecret1000000000013 } from './1000000000013-ProvisionSecret';
|
||||||
|
|
||||||
export const serverMigrations = [
|
export const serverMigrations = [
|
||||||
InitialSchema1000000000000,
|
InitialSchema1000000000000,
|
||||||
@@ -25,5 +26,6 @@ export const serverMigrations = [
|
|||||||
ServerIcons1000000000009,
|
ServerIcons1000000000009,
|
||||||
DeviceTokens1000000000010,
|
DeviceTokens1000000000010,
|
||||||
SessionTokens1000000000011,
|
SessionTokens1000000000011,
|
||||||
SigningPublicKey1000000000012
|
SigningPublicKey1000000000012,
|
||||||
|
ProvisionSecret1000000000013
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
updateUserSigningPublicKey
|
updateUserSigningPublicKey
|
||||||
} from '../cqrs';
|
} from '../cqrs';
|
||||||
import { hashPasswordForStorage, verifyPassword } from '../services/password-auth.service';
|
import { hashPasswordForStorage, verifyPassword } from '../services/password-auth.service';
|
||||||
|
import { resolveProvisionSecret } from '../services/provision-secret.service';
|
||||||
import { issueSessionToken, revokeSessionToken } from '../services/session-auth.service';
|
import { issueSessionToken, revokeSessionToken } from '../services/session-auth.service';
|
||||||
import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth';
|
import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth';
|
||||||
import { isDuplicateUsernameError } from './user-registration.rules';
|
import { isDuplicateUsernameError } from './user-registration.rules';
|
||||||
@@ -80,6 +81,40 @@ router.post('/login', async (req, res) => {
|
|||||||
res.json(buildAuthResponse(user, session.token, session.expiresAt));
|
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) => {
|
router.put('/me/signing-key', requireAuth, async (req, res) => {
|
||||||
const { publicKeyJwk } = req.body;
|
const { publicKeyJwk } = req.body;
|
||||||
const userId = getAuthenticatedUserId(req);
|
const userId = getAuthenticatedUserId(req);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -37,6 +37,11 @@
|
|||||||
"defaultServerName": "Signal Server"
|
"defaultServerName": "Signal Server"
|
||||||
},
|
},
|
||||||
"provision": {
|
"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."
|
"usernameCollision": "Username {{preferredUsername}} was taken on {{serverName}}. Created {{provisionedUsername}} instead."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Authentication Domain
|
# 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
|
## Module map
|
||||||
|
|
||||||
@@ -8,7 +8,11 @@ Handles user authentication (login and registration) against the configured serv
|
|||||||
authentication/
|
authentication/
|
||||||
├── application/
|
├── application/
|
||||||
│ └── services/
|
│ └── 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/
|
├── domain/
|
||||||
│ └── models/
|
│ └── 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.
|
`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
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
Login[LoginComponent]
|
Login[LoginComponent]
|
||||||
@@ -64,7 +70,7 @@ sequenceDiagram
|
|||||||
Login->>Auth: login(username, password)
|
Login->>Auth: login(username, password)
|
||||||
Auth->>SD: getApiBaseUrl()
|
Auth->>SD: getApiBaseUrl()
|
||||||
SD-->>Auth: https://server/api
|
SD-->>Auth: https://server/api
|
||||||
Auth->>API: POST /api/auth/login
|
Auth->>API: POST /api/users/login
|
||||||
API-->>Auth: { userId, displayName }
|
API-->>Auth: { userId, displayName }
|
||||||
Auth-->>Login: success
|
Auth-->>Login: success
|
||||||
Login->>Store: UsersActions.authenticateUser
|
Login->>Store: UsersActions.authenticateUser
|
||||||
@@ -75,7 +81,32 @@ sequenceDiagram
|
|||||||
|
|
||||||
## Registration flow
|
## 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-<shortHomeId>` 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-<shortHomeId>`.
|
||||||
|
|
||||||
|
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
|
## User bar
|
||||||
|
|
||||||
|
|||||||
+99
@@ -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<typeof vi.fn>;
|
||||||
|
let getToken: ReturnType<typeof vi.fn>;
|
||||||
|
let getSecret: ReturnType<typeof vi.fn>;
|
||||||
|
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'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+106
@@ -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<string, string>();
|
||||||
|
private readonly inFlight = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
async resolveSecrets(homeUser: Pick<User, 'id' | 'homeSignalServerUrl'>): Promise<ProvisionSecrets> {
|
||||||
|
const [canonical, deviceLocal] = await Promise.all([this.resolveCanonicalSecret(homeUser), this.secretStore.getSecret(homeUser.id)]);
|
||||||
|
|
||||||
|
return { canonical, deviceLocal };
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveCanonicalSecret(homeUser: Pick<User, 'id' | 'homeSignalServerUrl'>): Promise<string | null> {
|
||||||
|
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<User, 'id' | 'homeSignalServerUrl'>
|
||||||
|
): Promise<string | null> {
|
||||||
|
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<ProvisionSecretResponse>(`${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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-8
@@ -3,6 +3,15 @@ import { ElectronBridgeService } from '../../../../core/platform/electron/electr
|
|||||||
|
|
||||||
const SESSION_STORAGE_PREFIX = 'metoyou.provisionSecret.';
|
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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ProvisionSecretStoreService {
|
export class ProvisionSecretStoreService {
|
||||||
private readonly electronBridge: ElectronBridgeService;
|
private readonly electronBridge: ElectronBridgeService;
|
||||||
@@ -42,11 +51,3 @@ export class ProvisionSecretStoreService {
|
|||||||
return `${SESSION_STORAGE_PREFIX}${homeUserId}`;
|
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('');
|
|
||||||
}
|
|
||||||
|
|||||||
+51
@@ -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<readonly SignalServerAuthRecoveryIssue[]>([]);
|
||||||
|
|
||||||
|
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(/\/+$/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
+152
@@ -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<typeof vi.fn>;
|
||||||
|
hasValidCredential: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let homeProvisionSecret: {
|
||||||
|
resolveSecrets: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let provisioner: {
|
||||||
|
provisionOnServer: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let recovery: {
|
||||||
|
clear: ReturnType<typeof vi.fn>;
|
||||||
|
publish: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
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'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+45
-24
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { DebuggingService } from '../../../../core/services/debugging/debugging.service';
|
||||||
import type { User } from '../../../../shared-kernel';
|
import type { User } from '../../../../shared-kernel';
|
||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
import type { LoginResponse } from '../../domain/models/authentication.model';
|
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 { type ResolvedSignalIdentity, resolveSignalIdentity } from '../../domain/logic/signal-server-credential-resolution.rules';
|
||||||
import { resolveSelfPresenceUserIds } from '../../domain/logic/self-presence-identity.rules';
|
import { resolveSelfPresenceUserIds } from '../../domain/logic/self-presence-identity.rules';
|
||||||
import { AuthTokenStoreService } from './auth-token-store.service';
|
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 { SignalServerCredentialStoreService } from './signal-server-credential-store.service';
|
||||||
import { SignalServerProvisionerService, type ProvisionResult } from './signal-server-provisioner.service';
|
import { SignalServerProvisionerService, type ProvisionResult } from './signal-server-provisioner.service';
|
||||||
import { SignalServerProvisionNoticeService } from './signal-server-provision-notice.service';
|
import { SignalServerProvisionNoticeService } from './signal-server-provision-notice.service';
|
||||||
@@ -17,7 +19,7 @@ import { SignalServerProvisionNoticeService } from './signal-server-provision-no
|
|||||||
export type EnsureProvisionedResult =
|
export type EnsureProvisionedResult =
|
||||||
| { kind: 'existing'; credential: SignalServerCredential }
|
| { kind: 'existing'; credential: SignalServerCredential }
|
||||||
| { kind: 'provisioned'; result: ProvisionResult }
|
| { 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 };
|
| { kind: 'collision'; error: ProvisionUsernameCollisionError };
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -25,9 +27,11 @@ export class SignalServerAuthService {
|
|||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
private readonly credentialStore = inject(SignalServerCredentialStoreService);
|
private readonly credentialStore = inject(SignalServerCredentialStoreService);
|
||||||
private readonly authTokenStore = inject(AuthTokenStoreService);
|
private readonly authTokenStore = inject(AuthTokenStoreService);
|
||||||
private readonly provisionSecretStore = inject(ProvisionSecretStoreService);
|
private readonly homeProvisionSecret = inject(HomeProvisionSecretService);
|
||||||
private readonly provisioner = inject(SignalServerProvisionerService);
|
private readonly provisioner = inject(SignalServerProvisionerService);
|
||||||
private readonly provisionNotice = inject(SignalServerProvisionNoticeService);
|
private readonly provisionNotice = inject(SignalServerProvisionNoticeService);
|
||||||
|
private readonly recovery = inject(SignalServerAuthRecoveryService);
|
||||||
|
private readonly debugging = inject(DebuggingService);
|
||||||
private readonly provisionInFlight = new Map<string, Promise<EnsureProvisionedResult>>();
|
private readonly provisionInFlight = new Map<string, Promise<EnsureProvisionedResult>>();
|
||||||
|
|
||||||
getCredential(serverUrl: string): SignalServerCredential | null {
|
getCredential(serverUrl: string): SignalServerCredential | null {
|
||||||
@@ -74,25 +78,14 @@ export class SignalServerAuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureHomeProvisionSecret(homeUser: Pick<User, 'id'>, existingSecret?: string | null): Promise<string> {
|
|
||||||
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<EnsureProvisionedResult> {
|
async ensureProvisioned(serverUrl: string, homeUser?: User | null): Promise<EnsureProvisionedResult> {
|
||||||
const normalizedUrl = this.normalizeServerUrl(serverUrl);
|
const normalizedUrl = this.normalizeServerUrl(serverUrl);
|
||||||
const existing = this.credentialStore.getCredential(normalizedUrl);
|
const existing = this.credentialStore.getCredential(normalizedUrl);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
this.recovery.clear(normalizedUrl);
|
||||||
|
this.logProvisionOutcome('credential-existing', normalizedUrl, existing.userId, homeUser?.id);
|
||||||
|
|
||||||
return { kind: 'existing', credential: existing };
|
return { kind: 'existing', credential: existing };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,17 +154,12 @@ export class SignalServerAuthService {
|
|||||||
return { kind: 'skipped', reason: 'no-home-user' };
|
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 {
|
try {
|
||||||
|
const secrets = await this.homeProvisionSecret.resolveSecrets(user);
|
||||||
const result = await this.provisioner.provisionOnServer({
|
const result = await this.provisioner.provisionOnServer({
|
||||||
serverUrl: normalizedUrl,
|
serverUrl: normalizedUrl,
|
||||||
homeUser: user,
|
homeUser: user,
|
||||||
provisionSecret
|
secrets
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.usedSuffix) {
|
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 };
|
return { kind: 'provisioned', result };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ProvisionUsernameCollisionError) {
|
if (error instanceof ProvisionUsernameCollisionError) {
|
||||||
|
this.publishRecovery(normalizedUrl, 'credentials-rejected');
|
||||||
|
this.logProvisionOutcome('credential-rejected', normalizedUrl, undefined, user.id);
|
||||||
|
|
||||||
return { kind: 'collision', error };
|
return { kind: 'collision', error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.publishRecovery(normalizedUrl, 'unavailable');
|
||||||
|
this.logProvisionOutcome('server-unavailable', normalizedUrl, undefined, user.id);
|
||||||
|
|
||||||
throw error;
|
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 {
|
private normalizeServerUrl(serverUrl: string): string {
|
||||||
return serverUrl.trim().replace(/\/+$/, '');
|
return serverUrl.trim().replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-5
@@ -51,7 +51,7 @@ describe('SignalServerAuthorizeService', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
signalServerAuth = {
|
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),
|
hasValidCredential: vi.fn(() => false),
|
||||||
migrateHomeCredential: vi.fn()
|
migrateHomeCredential: vi.fn()
|
||||||
};
|
};
|
||||||
@@ -102,13 +102,11 @@ describe('SignalServerAuthorizeService', () => {
|
|||||||
expect(router.navigate).not.toHaveBeenCalled();
|
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);
|
await expect(service.ensureCredentialForServerUrl(FOREIGN_URL)).resolves.toBe(false);
|
||||||
|
|
||||||
expect(signalServerAuth.ensureProvisioned).toHaveBeenCalledWith(FOREIGN_URL, homeUser);
|
expect(signalServerAuth.ensureProvisioned).toHaveBeenCalledWith(FOREIGN_URL, homeUser);
|
||||||
expect(router.navigate).toHaveBeenCalledWith(['/login'], expect.objectContaining({
|
expect(router.navigate).not.toHaveBeenCalled();
|
||||||
queryParams: expect.objectContaining({ mode: 'authorize' })
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true when foreign provisioning succeeds', async () => {
|
it('returns true when foreign provisioning succeeds', async () => {
|
||||||
|
|||||||
+4
-24
@@ -5,8 +5,6 @@ import { firstValueFrom } from 'rxjs';
|
|||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||||
import { AUTH_MODE_AUTHORIZE, buildLoginReturnQueryParams } from '../../domain/logic/auth-navigation.rules';
|
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 { isSameSignalServerUrl } from '../../domain/logic/signal-server-auth-failure.rules';
|
||||||
import { SignalServerAuthService } from './signal-server-auth.service';
|
import { SignalServerAuthService } from './signal-server-auth.service';
|
||||||
|
|
||||||
@@ -50,31 +48,13 @@ export class SignalServerAuthorizeService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const endpointStatus = await this.resolveEndpointStatusForAuthorize(serverUrl);
|
// Automatic recovery must never turn a healthy home session into a generic
|
||||||
|
// foreign-server login redirect. The contextual caller renders the
|
||||||
if (shouldNavigateToAuthorizeSignalServer(endpointStatus, result)) {
|
// per-server recovery action; explicit authorization remains available in
|
||||||
await this.navigateToAuthorize(serverUrl, this.router.url);
|
// Network settings.
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
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<void> {
|
async navigateToAuthorize(serverUrl: string, returnUrl: string): Promise<void> {
|
||||||
const endpoint = this.serverDirectory.ensureServerEndpoint({
|
const endpoint = this.serverDirectory.ensureServerEndpoint({
|
||||||
name: this.buildEndpointName(serverUrl),
|
name: this.buildEndpointName(serverUrl),
|
||||||
|
|||||||
+112
-76
@@ -13,6 +13,10 @@ import { SignalServerCredentialStoreService } from './signal-server-credential-s
|
|||||||
import { ProvisionUsernameCollisionError } from '../../domain/logic/signal-server-provision.rules';
|
import { ProvisionUsernameCollisionError } from '../../domain/logic/signal-server-provision.rules';
|
||||||
import type { User } from '../../../../shared-kernel';
|
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', () => {
|
describe('SignalServerProvisionerService', () => {
|
||||||
let service: SignalServerProvisionerService;
|
let service: SignalServerProvisionerService;
|
||||||
let httpPost: ReturnType<typeof vi.fn>;
|
let httpPost: ReturnType<typeof vi.fn>;
|
||||||
@@ -29,6 +33,24 @@ describe('SignalServerProvisionerService', () => {
|
|||||||
homeSignalServerUrl: 'https://home.example.com'
|
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(() => {
|
beforeEach(() => {
|
||||||
const storage = new Map<string, string>();
|
const storage = new Map<string, string>();
|
||||||
|
|
||||||
@@ -48,109 +70,127 @@ describe('SignalServerProvisionerService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('registers on a foreign server when the preferred username is available', async () => {
|
it('registers on a foreign server when the preferred username is available', async () => {
|
||||||
httpPost.mockReturnValue(of({
|
httpPost.mockReturnValue(foreignAccount('foreign-user-1', 'alice'));
|
||||||
id: 'foreign-user-1',
|
|
||||||
username: 'alice',
|
|
||||||
displayName: 'Alice',
|
|
||||||
token: 'foreign-token',
|
|
||||||
expiresAt: Date.now() + 60_000
|
|
||||||
}));
|
|
||||||
|
|
||||||
const result = await service.provisionOnServer({
|
const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: null });
|
||||||
serverUrl: 'https://foreign.example.com',
|
|
||||||
homeUser,
|
|
||||||
provisionSecret: 'provision-secret'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.username).toBe('alice');
|
expect(result.username).toBe('alice');
|
||||||
expect(result.usedSuffix).toBe(false);
|
expect(result.usedSuffix).toBe(false);
|
||||||
expect(credentialStore.getCredential('https://foreign.example.com')?.userId).toBe('foreign-user-1');
|
expect(credentialStore.getCredential(FOREIGN_URL)?.userId).toBe('foreign-user-1');
|
||||||
expect(httpPost).toHaveBeenCalledWith(
|
expect(httpPost).toHaveBeenCalledWith(`${FOREIGN_URL}/api/users/register`, {
|
||||||
'https://foreign.example.com/api/users/register',
|
|
||||||
{
|
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
password: 'provision-secret',
|
password: CANONICAL_SECRET,
|
||||||
displayName: 'Alice'
|
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
|
httpPost
|
||||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
|
.mockReturnValueOnce(httpError(409))
|
||||||
.mockReturnValueOnce(of({
|
.mockReturnValueOnce(foreignAccount('foreign-user-1', 'alice'));
|
||||||
id: 'foreign-user-1',
|
|
||||||
username: 'alice',
|
|
||||||
displayName: 'Alice',
|
|
||||||
token: 'foreign-token',
|
|
||||||
expiresAt: Date.now() + 60_000
|
|
||||||
}));
|
|
||||||
|
|
||||||
const result = await service.provisionOnServer({
|
const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: null });
|
||||||
serverUrl: 'https://foreign.example.com',
|
|
||||||
homeUser,
|
|
||||||
provisionSecret: 'provision-secret'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.username).toBe('alice');
|
expect(result.username).toBe('alice');
|
||||||
expect(httpPost).toHaveBeenNthCalledWith(
|
expect(result.usedSuffix).toBe(false);
|
||||||
2,
|
expect(credentialStore.getCredential(FOREIGN_URL)?.userId).toBe('foreign-user-1');
|
||||||
'https://foreign.example.com/api/users/login',
|
expect(httpPost).toHaveBeenNthCalledWith(2, `${FOREIGN_URL}/api/users/login`, {
|
||||||
{
|
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
password: 'provision-secret'
|
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(
|
||||||
|
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
|
httpPost
|
||||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
|
.mockReturnValueOnce(httpError(409))
|
||||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 })))
|
.mockReturnValueOnce(httpError(401))
|
||||||
.mockReturnValueOnce(of({
|
.mockReturnValueOnce(foreignAccount('foreign-user-1', 'alice', 'legacy-session'))
|
||||||
id: 'foreign-user-2',
|
.mockReturnValueOnce(httpError(404));
|
||||||
username: 'alice-a3f2b1',
|
|
||||||
displayName: 'Alice',
|
|
||||||
token: 'foreign-token-2',
|
|
||||||
expiresAt: Date.now() + 60_000
|
|
||||||
}));
|
|
||||||
|
|
||||||
const result = await service.provisionOnServer({
|
const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: LEGACY_SECRET });
|
||||||
serverUrl: 'https://foreign.example.com',
|
|
||||||
homeUser,
|
expect(result.username).toBe('alice');
|
||||||
provisionSecret: 'provision-secret'
|
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.username).toBe('alice-a3f2b1');
|
||||||
expect(result.usedSuffix).toBe(true);
|
expect(result.usedSuffix).toBe(true);
|
||||||
expect(httpPost).toHaveBeenNthCalledWith(
|
expect(httpPost).toHaveBeenNthCalledWith(3, `${FOREIGN_URL}/api/users/register`, {
|
||||||
3,
|
|
||||||
'https://foreign.example.com/api/users/register',
|
|
||||||
{
|
|
||||||
username: 'alice-a3f2b1',
|
username: 'alice-a3f2b1',
|
||||||
password: 'provision-secret',
|
password: CANONICAL_SECRET,
|
||||||
displayName: 'Alice'
|
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
|
httpPost
|
||||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
|
.mockReturnValueOnce(httpError(409))
|
||||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 })))
|
.mockReturnValueOnce(httpError(401))
|
||||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
|
.mockReturnValueOnce(httpError(401));
|
||||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 })));
|
|
||||||
|
|
||||||
await expect(service.provisionOnServer({
|
await expect(provision({ canonical: null, deviceLocal: LEGACY_SECRET }))
|
||||||
serverUrl: 'https://foreign.example.com',
|
.rejects.toBeInstanceOf(ProvisionUsernameCollisionError);
|
||||||
homeUser,
|
|
||||||
provisionSecret: 'provision-secret'
|
const attemptedUrls = httpPost.mock.calls.map(([url]) => url);
|
||||||
})).rejects.toBeInstanceOf(ProvisionUsernameCollisionError);
|
|
||||||
|
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 () => {
|
it('returns an existing credential without making network calls', async () => {
|
||||||
credentialStore.upsertCredential({
|
credentialStore.upsertCredential({
|
||||||
serverUrl: 'https://foreign.example.com',
|
serverUrl: FOREIGN_URL,
|
||||||
userId: 'foreign-user-1',
|
userId: 'foreign-user-1',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
displayName: 'Alice',
|
displayName: 'Alice',
|
||||||
@@ -159,11 +199,7 @@ describe('SignalServerProvisionerService', () => {
|
|||||||
provisioned: true
|
provisioned: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.provisionOnServer({
|
const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: null });
|
||||||
serverUrl: 'https://foreign.example.com',
|
|
||||||
homeUser,
|
|
||||||
provisionSecret: 'provision-secret'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.username).toBe('alice');
|
expect(result.username).toBe('alice');
|
||||||
expect(httpPost).not.toHaveBeenCalled();
|
expect(httpPost).not.toHaveBeenCalled();
|
||||||
|
|||||||
+79
-23
@@ -4,7 +4,14 @@ import { firstValueFrom } from 'rxjs';
|
|||||||
import type { User } from '../../../../shared-kernel';
|
import type { User } from '../../../../shared-kernel';
|
||||||
import type { LoginResponse } from '../../domain/models/authentication.model';
|
import type { LoginResponse } from '../../domain/models/authentication.model';
|
||||||
import type { SignalServerCredential } from '../../domain/models/signal-server-credential.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';
|
import { SignalServerCredentialStoreService } from './signal-server-credential-store.service';
|
||||||
|
|
||||||
export interface ProvisionResult {
|
export interface ProvisionResult {
|
||||||
@@ -29,7 +36,7 @@ export class SignalServerProvisionerService {
|
|||||||
async provisionOnServer(params: {
|
async provisionOnServer(params: {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
homeUser: Pick<User, 'id' | 'username' | 'displayName'>;
|
homeUser: Pick<User, 'id' | 'username' | 'displayName'>;
|
||||||
provisionSecret: string;
|
secrets: ProvisionSecrets;
|
||||||
}): Promise<ProvisionResult> {
|
}): Promise<ProvisionResult> {
|
||||||
const normalizedUrl = this.normalizeServerUrl(params.serverUrl);
|
const normalizedUrl = this.normalizeServerUrl(params.serverUrl);
|
||||||
const existing = this.credentialStore.getCredential(normalizedUrl);
|
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) {
|
for (const attempt of attempts) {
|
||||||
const candidate = candidates[index];
|
const response = await this.runProvisionAttempt(normalizedUrl, attempt, params.homeUser.displayName);
|
||||||
const usedSuffix = index > 0;
|
|
||||||
|
|
||||||
try {
|
if (!response) {
|
||||||
const response = await this.register(normalizedUrl, candidate, params.provisionSecret, params.homeUser.displayName);
|
continue;
|
||||||
|
|
||||||
return this.persistProvisionResult(normalizedUrl, response, usedSuffix);
|
|
||||||
} catch (error) {
|
|
||||||
if (!this.isHttpStatus(error, 409)) {
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const result = this.persistProvisionResult(normalizedUrl, response, attempt.usedSuffix);
|
||||||
const response = await this.login(normalizedUrl, candidate, params.provisionSecret);
|
|
||||||
|
|
||||||
return this.persistProvisionResult(normalizedUrl, response, usedSuffix);
|
if (shouldAdoptCanonicalSecret(attempt, params.secrets.canonical)) {
|
||||||
} catch (loginError) {
|
await this.adoptCanonicalSecret(normalizedUrl, response.token, params.secrets.canonical);
|
||||||
if (!this.isHttpStatus(loginError, 401)) {
|
|
||||||
throw loginError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ProvisionUsernameCollisionError(normalizedUrl, candidates);
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ProvisionUsernameCollisionError(
|
||||||
|
normalizedUrl,
|
||||||
|
buildProvisionUsernameCandidates(params.homeUser.username, params.homeUser.id)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
upsertManualCredential(
|
upsertManualCredential(
|
||||||
@@ -91,6 +96,57 @@ export class SignalServerProvisionerService {
|
|||||||
return credential;
|
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<LoginResponse | null> {
|
||||||
|
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<void> {
|
||||||
|
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(
|
private async register(
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
username: string,
|
username: string,
|
||||||
|
|||||||
-44
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
+61
-1
@@ -5,10 +5,22 @@ import {
|
|||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import {
|
import {
|
||||||
ProvisionUsernameCollisionError,
|
ProvisionUsernameCollisionError,
|
||||||
|
buildProvisionPlan,
|
||||||
buildProvisionUsernameCandidates,
|
buildProvisionUsernameCandidates,
|
||||||
shortHomeUserId
|
shortHomeUserId,
|
||||||
|
shouldAdoptCanonicalSecret
|
||||||
} from './signal-server-provision.rules';
|
} 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', () => {
|
describe('signal-server-provision.rules', () => {
|
||||||
it('derives a stable short id from a home user uuid', () => {
|
it('derives a stable short id from a home user uuid', () => {
|
||||||
expect(shortHomeUserId('a3f2b1c4-5678-90ab-cdef-1234567890ab')).toBe('a3f2b1');
|
expect(shortHomeUserId('a3f2b1c4-5678-90ab-cdef-1234567890ab')).toBe('a3f2b1');
|
||||||
@@ -26,6 +38,54 @@ describe('signal-server-provision.rules', () => {
|
|||||||
).toEqual(['alice-a3f2b1']);
|
).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', () => {
|
it('exposes attempted usernames on collision errors', () => {
|
||||||
const error = new ProvisionUsernameCollisionError('https://signal.example.com', ['alice', 'alice-a3f2b1']);
|
const error = new ProvisionUsernameCollisionError('https://signal.example.com', ['alice', 'alice-a3f2b1']);
|
||||||
|
|
||||||
|
|||||||
+101
@@ -32,3 +32,104 @@ export function buildProvisionUsernameCandidates(
|
|||||||
|
|
||||||
return [...new Set(candidates)];
|
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<ProvisionAttempt, 'kind' | 'secretSource'>,
|
||||||
|
canonicalSecret: string | null
|
||||||
|
): canonicalSecret is string {
|
||||||
|
return attempt.kind === 'login'
|
||||||
|
&& attempt.secretSource === 'device-local'
|
||||||
|
&& !!canonicalSecret;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
export * from './application/services/authentication.service';
|
export * from './application/services/authentication.service';
|
||||||
export * from './application/services/auth-token-store.service';
|
export * from './application/services/auth-token-store.service';
|
||||||
export * from './application/services/user-logout.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-auth.service';
|
||||||
export * from './application/services/signal-server-authorize.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-credential-store.service';
|
||||||
export * from './application/services/signal-server-provisioner.service';
|
export * from './application/services/signal-server-provisioner.service';
|
||||||
export * from './application/services/signal-server-provision-notice.service';
|
export * from './application/services/signal-server-provision-notice.service';
|
||||||
|
|||||||
@@ -74,6 +74,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (status() === 'error') {
|
@if (status() === 'error') {
|
||||||
|
@if (authRecoveryIssue()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="signal-server-auth-retry"
|
||||||
|
[disabled]="authRecoveryRetrying()"
|
||||||
|
(click)="retrySignalServerAuthentication()"
|
||||||
|
class="inline-flex min-h-11 w-full items-center justify-center rounded-2xl border border-amber-500/50 bg-amber-500/10 px-4 py-3 text-sm font-semibold text-foreground transition-colors hover:bg-amber-500/20 disabled:cursor-wait disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{{ (authRecoveryRetrying() ? 'auth.provision.retrying' : 'auth.provision.retry') | translate }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="goToSearch()"
|
(click)="goToSearch()"
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ import { DatabaseService } from '../../../../infrastructure/persistence';
|
|||||||
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
||||||
import { User } from '../../../../shared-kernel';
|
import { User } from '../../../../shared-kernel';
|
||||||
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
|
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
|
||||||
import { SignalServerAuthorizeService } from '../../../authentication/application/services/signal-server-authorize.service';
|
import {
|
||||||
|
type SignalServerAuthRecoveryIssue,
|
||||||
|
SignalServerAuthRecoveryService,
|
||||||
|
SignalServerAuthorizeService
|
||||||
|
} from '../../../authentication';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-invite',
|
selector: 'app-invite',
|
||||||
@@ -31,6 +35,8 @@ export class InviteComponent implements OnInit {
|
|||||||
readonly invite = signal<ServerInviteInfo | null>(null);
|
readonly invite = signal<ServerInviteInfo | null>(null);
|
||||||
readonly status = signal<'loading' | 'redirecting' | 'joining' | 'error'>('loading');
|
readonly status = signal<'loading' | 'redirecting' | 'joining' | 'error'>('loading');
|
||||||
readonly message = signal('');
|
readonly message = signal('');
|
||||||
|
readonly authRecoveryIssue = signal<SignalServerAuthRecoveryIssue | null>(null);
|
||||||
|
readonly authRecoveryRetrying = signal(false);
|
||||||
|
|
||||||
private readonly i18n = inject(AppI18nService);
|
private readonly i18n = inject(AppI18nService);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
@@ -38,9 +44,14 @@ export class InviteComponent implements OnInit {
|
|||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||||
private readonly databaseService = inject(DatabaseService);
|
private readonly databaseService = inject(DatabaseService);
|
||||||
|
private readonly signalServerAuthRecovery = inject(SignalServerAuthRecoveryService);
|
||||||
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
|
await this.loadInvite();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadInvite(): Promise<void> {
|
||||||
this.message.set(this.i18n.instant('servers.invite.messages.loading'));
|
this.message.set(this.i18n.instant('servers.invite.messages.loading'));
|
||||||
|
|
||||||
const inviteContext = this.resolveInviteContext();
|
const inviteContext = this.resolveInviteContext();
|
||||||
@@ -67,6 +78,22 @@ export class InviteComponent implements OnInit {
|
|||||||
this.router.navigate(['/dashboard']).catch(() => {});
|
this.router.navigate(['/dashboard']).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async retrySignalServerAuthentication(): Promise<void> {
|
||||||
|
if (this.authRecoveryRetrying()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.authRecoveryRetrying.set(true);
|
||||||
|
this.status.set('loading');
|
||||||
|
this.authRecoveryIssue.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.loadInvite();
|
||||||
|
} finally {
|
||||||
|
this.authRecoveryRetrying.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private buildEndpointName(sourceUrl: string): string {
|
private buildEndpointName(sourceUrl: string): string {
|
||||||
try {
|
try {
|
||||||
const url = new URL(sourceUrl);
|
const url = new URL(sourceUrl);
|
||||||
@@ -134,12 +161,22 @@ export class InviteComponent implements OnInit {
|
|||||||
const hasCredential = await this.signalServerAuthorize.ensureCredentialForServerUrl(context.sourceUrl);
|
const hasCredential = await this.signalServerAuthorize.ensureCredentialForServerUrl(context.sourceUrl);
|
||||||
|
|
||||||
if (!hasCredential) {
|
if (!hasCredential) {
|
||||||
this.status.set('redirecting');
|
const issue = this.signalServerAuthRecovery.getIssue(context.sourceUrl);
|
||||||
this.message.set(this.i18n.instant('servers.invite.messages.redirectingAuthorize'));
|
|
||||||
|
this.authRecoveryIssue.set(issue);
|
||||||
|
this.status.set('error');
|
||||||
|
this.message.set(this.i18n.instant(
|
||||||
|
issue?.reason === 'credentials-rejected'
|
||||||
|
? 'auth.provision.credentialsRejected'
|
||||||
|
: 'auth.provision.serverUnavailable'
|
||||||
|
));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.signalServerAuthRecovery.clear(context.sourceUrl);
|
||||||
|
this.authRecoveryIssue.set(null);
|
||||||
|
|
||||||
const joinResponse = await firstValueFrom(this.serverDirectory.requestJoin({
|
const joinResponse = await firstValueFrom(this.serverDirectory.requestJoin({
|
||||||
roomId: invite.server.id,
|
roomId: invite.server.id,
|
||||||
userId: currentUserId,
|
userId: currentUserId,
|
||||||
|
|||||||
+26
@@ -228,6 +228,32 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (authRecoveryIssue(); as issue) {
|
||||||
|
<section
|
||||||
|
data-testid="signal-server-auth-recovery"
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3 border-t border-amber-500/40 bg-amber-500/10 p-4"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-foreground">
|
||||||
|
{{ 'auth.provision.reconnectTitle' | translate: { serverName: issue.serverName } }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{{ (issue.reason === 'credentials-rejected' ? 'auth.provision.credentialsRejected' : 'auth.provision.serverUnavailable') | translate }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="signal-server-auth-retry"
|
||||||
|
class="min-h-11 rounded-lg border border-amber-500/50 bg-background px-4 py-2 text-sm font-semibold text-foreground transition-colors hover:bg-amber-500/10 disabled:cursor-wait disabled:opacity-60"
|
||||||
|
[disabled]="authRecoveryRetrying()"
|
||||||
|
(click)="retrySignalServerAuthentication()"
|
||||||
|
>
|
||||||
|
{{ (authRecoveryRetrying() ? 'auth.provision.retrying' : 'auth.provision.retry') | translate }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
@if (joinErrorMessage() || error()) {
|
@if (joinErrorMessage() || error()) {
|
||||||
<div class="border-t border-destructive bg-destructive/10 p-4">
|
<div class="border-t border-destructive bg-destructive/10 p-4">
|
||||||
<p class="text-sm text-destructive">{{ joinErrorMessage() || error() }}</p>
|
<p class="text-sm text-destructive">{{ joinErrorMessage() || error() }}</p>
|
||||||
|
|||||||
+2
@@ -23,6 +23,7 @@ import { DatabaseService } from '../../../../infrastructure/persistence';
|
|||||||
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
import { PluginRequirementService, PluginStoreService } from '../../../plugins';
|
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 { SignalServerAuthorizeService } from '../../../authentication/application/services/signal-server-authorize.service';
|
||||||
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
|
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
|
||||||
import type { ServerInfo } from '../../domain/models/server-directory.model';
|
import type { ServerInfo } from '../../domain/models/server-directory.model';
|
||||||
@@ -116,6 +117,7 @@ function createHarness(options: HarnessOptions = {}) {
|
|||||||
{ provide: PluginRequirementService, useValue: pluginRequirements },
|
{ provide: PluginRequirementService, useValue: pluginRequirements },
|
||||||
{ provide: PluginStoreService, useValue: pluginStore },
|
{ provide: PluginStoreService, useValue: pluginStore },
|
||||||
{ provide: SignalServerAuthorizeService, useValue: signalServerAuthorize },
|
{ provide: SignalServerAuthorizeService, useValue: signalServerAuthorize },
|
||||||
|
SignalServerAuthRecoveryService,
|
||||||
...provideAppI18nForTests()
|
...provideAppI18nForTests()
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
+37
-1
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
computed,
|
||||||
effect,
|
effect,
|
||||||
inject,
|
inject,
|
||||||
Injector,
|
Injector,
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||||
import { setStoredCurrentUserId } from '../../../../core/storage/current-user-storage';
|
import { setStoredCurrentUserId } from '../../../../core/storage/current-user-storage';
|
||||||
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
|
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 { SignalServerAuthorizeService } from '../../../authentication/application/services/signal-server-authorize.service';
|
||||||
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
||||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||||
@@ -125,6 +126,7 @@ export class ServerBrowserComponent implements OnInit {
|
|||||||
private pluginStore = inject(PluginStoreService);
|
private pluginStore = inject(PluginStoreService);
|
||||||
private injector = inject(Injector);
|
private injector = inject(Injector);
|
||||||
private readonly i18n = inject(AppI18nService);
|
private readonly i18n = inject(AppI18nService);
|
||||||
|
private readonly signalServerAuthRecovery = inject(SignalServerAuthRecoveryService);
|
||||||
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
||||||
private searchSubject = new Subject<string>();
|
private searchSubject = new Subject<string>();
|
||||||
private banLookupRequestVersion = 0;
|
private banLookupRequestVersion = 0;
|
||||||
@@ -215,6 +217,11 @@ export class ServerBrowserComponent implements OnInit {
|
|||||||
joinPassword = signal('');
|
joinPassword = signal('');
|
||||||
joinPasswordError = signal<string | null>(null);
|
joinPasswordError = signal<string | null>(null);
|
||||||
joinErrorMessage = signal<string | null>(null);
|
joinErrorMessage = signal<string | null>(null);
|
||||||
|
authRecoveryServer = signal<ServerInfo | null>(null);
|
||||||
|
authRecoveryRetrying = signal(false);
|
||||||
|
authRecoveryIssue = computed(() =>
|
||||||
|
this.signalServerAuthRecovery.getIssue(this.authRecoveryServer()?.sourceUrl)
|
||||||
|
);
|
||||||
joinedServerMenuId = signal<string | null>(null);
|
joinedServerMenuId = signal<string | null>(null);
|
||||||
leaveDialogRoom = signal<Room | null>(null);
|
leaveDialogRoom = signal<Room | null>(null);
|
||||||
pluginConsentDialog = signal<JoinPluginConsentDialog | null>(null);
|
pluginConsentDialog = signal<JoinPluginConsentDialog | null>(null);
|
||||||
@@ -289,6 +296,22 @@ export class ServerBrowserComponent implements OnInit {
|
|||||||
await this.attemptJoinServer(server);
|
await this.attemptJoinServer(server);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async retrySignalServerAuthentication(): Promise<void> {
|
||||||
|
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 {
|
joinSavedRoom(room: Room): void {
|
||||||
this.openJoinedRoom(room);
|
this.openJoinedRoom(room);
|
||||||
}
|
}
|
||||||
@@ -536,8 +559,21 @@ export class ServerBrowserComponent implements OnInit {
|
|||||||
const hasCredential = await this.signalServerAuthorize.ensureCredentialForServerUrl(server.sourceUrl);
|
const hasCredential = await this.signalServerAuthorize.ensureCredentialForServerUrl(server.sourceUrl);
|
||||||
|
|
||||||
if (!hasCredential) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.signalServerAuthRecovery.clear(server.sourceUrl);
|
||||||
|
this.authRecoveryServer.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
<div class="flex h-full flex-col bg-background">
|
<div class="flex h-full flex-col bg-background">
|
||||||
@if (currentRoom()) {
|
@if (currentRoom()) {
|
||||||
|
@if (authRecoveryIssue(); as issue) {
|
||||||
|
<section
|
||||||
|
data-testid="signal-server-auth-recovery"
|
||||||
|
class="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-foreground"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="font-semibold">{{ 'auth.provision.reconnectTitle' | translate: { serverName: issue.serverName } }}</p>
|
||||||
|
<p class="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{{ (issue.reason === 'credentials-rejected' ? 'auth.provision.credentialsRejected' : 'auth.provision.serverUnavailable') | translate }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="signal-server-auth-retry"
|
||||||
|
class="min-h-11 rounded-lg border border-amber-500/50 bg-background px-4 py-2 text-sm font-semibold transition-colors hover:bg-amber-500/10 disabled:cursor-wait disabled:opacity-60"
|
||||||
|
[disabled]="isAuthRecoveryRetrying()"
|
||||||
|
(click)="retrySignalServerAuthentication()"
|
||||||
|
>
|
||||||
|
{{ (isAuthRecoveryRetrying() ? 'auth.provision.retrying' : 'auth.provision.retry') | translate }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
@if (isMobile()) {
|
@if (isMobile()) {
|
||||||
<!-- Mobile: Swiper-driven page stack (channels -> main -> members) -->
|
<!-- Mobile: Swiper-driven page stack (channels -> main -> members) -->
|
||||||
<swiper-container
|
<swiper-container
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
CUSTOM_ELEMENTS_SCHEMA,
|
CUSTOM_ELEMENTS_SCHEMA,
|
||||||
Component,
|
Component,
|
||||||
@@ -40,7 +39,9 @@ import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
|||||||
import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||||
import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
|
import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
|
||||||
import { DirectCallService } from '../../../domains/direct-call';
|
import { DirectCallService } from '../../../domains/direct-call';
|
||||||
|
import { SignalServerAuthRecoveryService, SignalServerAuthorizeService } from '../../../domains/authentication';
|
||||||
import { APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
|
import { APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
|
||||||
|
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||||
|
|
||||||
/** Mobile-only page identifier within the chat-room view. */
|
/** Mobile-only page identifier within the chat-room view. */
|
||||||
export type ChatRoomMobilePage = 'channels' | 'main' | 'members';
|
export type ChatRoomMobilePage = 'channels' | 'main' | 'members';
|
||||||
@@ -102,6 +103,8 @@ export class ChatRoomComponent {
|
|||||||
private readonly theme = inject(ThemeService);
|
private readonly theme = inject(ThemeService);
|
||||||
private readonly viewport = inject(ViewportService);
|
private readonly viewport = inject(ViewportService);
|
||||||
private readonly directCalls = inject(DirectCallService);
|
private readonly directCalls = inject(DirectCallService);
|
||||||
|
private readonly signalServerAuthRecovery = inject(SignalServerAuthRecoveryService);
|
||||||
|
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
||||||
private readonly zone = inject(NgZone);
|
private readonly zone = inject(NgZone);
|
||||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||||
private lastSeenChannelId: string | null = null;
|
private lastSeenChannelId: string | null = null;
|
||||||
@@ -113,9 +116,13 @@ export class ChatRoomComponent {
|
|||||||
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
|
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
|
||||||
readonly mobilePage = signal<ChatRoomMobilePage>('channels');
|
readonly mobilePage = signal<ChatRoomMobilePage>('channels');
|
||||||
readonly isMobile = this.viewport.isMobile;
|
readonly isMobile = this.viewport.isMobile;
|
||||||
|
readonly isAuthRecoveryRetrying = signal(false);
|
||||||
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
|
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
|
||||||
|
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
readonly authRecoveryIssue = computed(() =>
|
||||||
|
this.signalServerAuthRecovery.getIssue(this.currentRoom()?.sourceUrl)
|
||||||
|
);
|
||||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||||
textChannels = this.store.selectSignal(selectTextChannels);
|
textChannels = this.store.selectSignal(selectTextChannels);
|
||||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||||
@@ -229,6 +236,28 @@ export class ChatRoomComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async retrySignalServerAuthentication(): Promise<void> {
|
||||||
|
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. */
|
/** Open the settings modal to the Server admin page for the current room. */
|
||||||
toggleAdminPanel() {
|
toggleAdminPanel() {
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Users store effects (load, kick, ban, host election, profile persistence).
|
* Users store effects (load, kick, ban, host election, profile persistence).
|
||||||
*/
|
*/
|
||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import {
|
import {
|
||||||
@@ -302,7 +302,6 @@ export class UsersEffects {
|
|||||||
|
|
||||||
if (user.homeSignalServerUrl && loginResponse) {
|
if (user.homeSignalServerUrl && loginResponse) {
|
||||||
this.signalServerAuth.upsertCredentialFromLogin(user.homeSignalServerUrl, loginResponse, { provisioned: false });
|
this.signalServerAuth.upsertCredentialFromLogin(user.homeSignalServerUrl, loginResponse, { provisioned: false });
|
||||||
await this.signalServerAuth.ensureHomeProvisionSecret(user);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user