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:
2026-08-14 03:19:29 +02:00
parent e49b3ec112
commit f9e8538c80
37 changed files with 1877 additions and 244 deletions
@@ -0,0 +1,213 @@
# User Story: Silent crosssignal-server account auth
> **Status:** Open (research complete — not fixed)
> **Priority / Severity:** Critical
> **Area:** authentication, realtime, server-directory
> **Last researched:** 2026-08-12
> **Related docs:** [features/authentication.md](../features/authentication.md), `toju-app/src/app/domains/authentication/`
---
## User story
**As a** signed-in Toju user
**I want** the app to automatically create (or reuse) my account on any additional signal server as soon as I need that server
**So that** I never see a login / authorize prompt again after my initial home-server login, and chat / presence / joins keep working across the whole multi-server network.
---
## Problem statement
The product supports multiple signaling servers. A user registers/logs in once on a **home** signal server. When they later interact with a **foreign** signal server (join/create a room hosted there, open an invite, activate another endpoint, etc.), the client is supposed to **silently provision** a linked account on that server using a local **provision secret**, store a per-server session credential, and continue — without interrupting the UI.
In practice, the **login / authorize screen keeps appearing** (`/login?mode=authorize&serverId=…`) even though the user is already authenticated locally. That breaks the “one login, whole app” contract and feels like the session is constantly dying.
---
## Desired behavior (acceptance criteria)
1. After a successful home login or register, the user is never prompted for credentials again solely because they touched another signal server.
2. The first time the user has business with signal server N (N ≠ home):
- The client ensures a valid per-URL credential exists (register-or-login with the provision secret).
- WebSocket `identify` and protected REST calls use that credentials actor user id + token.
- The home NgRx / local profile stays unchanged.
3. If the preferred username is already taken on the foreign server, the client silently uses the designed suffix strategy (`alice-<homeUserIdPrefix>`) and optional display-name disambiguation — still **without** opening `/login`.
4. Transient `auth_required` (message raced ahead of identify) never opens login and never tears down the home session while a valid local credential exists.
5. Rejected foreign tokens trigger **re-provision** (or credential refresh), not a home logout and not a blocking authorize form when silent provision is possible.
6. Offline / unreachable / incompatible endpoints never open `/login?mode=authorize`.
7. Session restore after app restart still silently provisions foreign servers (provision secret and credentials survive restart on desktop).
8. Settings → Network may show `Authorized` / `Needs sign-in` for diagnostics, but “Needs sign-in” must not become the default path for a normal logged-in user who simply joined a room on another host.
### Explicit non-goals (for this story)
- Changing the home-server password / register UX for first-time users.
- Merging foreign actor ids into a single global server-side identity (home id ≠ foreign provisioned id is expected).
- Removing the authorize UI entirely — it may remain as a **last resort** (e.g. true username collision exhaustion, or user-initiated “Sign in” from Network settings).
---
## Current intended architecture (as designed)
| Concept | Role |
|--------|------|
| Home session | Local profile + credential for `homeSignalServerUrl` |
| Provision secret | Per-install secret generated on home login/register; used as the password when auto-registering/logging into foreign servers |
| Per-signal credential store | `metoyou.signalServerCredentials` — token + actor userId per normalized server URL |
| Legacy token store | `metoyou.authTokens` — still used for REST interceptor / session restore fallback |
| `ensureProvisioned` | Register-or-login on a foreign URL using the provision secret |
| `ensureCredentialForServerUrl` | Gate before foreign room connect / invite / join — provision first; only then optionally navigate to authorize |
| `authorize` login mode | Manual login that only upserts a foreign credential (`authorizeSignalServer`) without resetting home state |
Primary call sites that demand a foreign credential:
- Room signaling connect (`room-signaling-connection.ts`)
- Invite / server-browser join flows
- Active endpoint health → opportunistic `ensureProvisioned`
- `provisionActiveSignalServers$` after `loadCurrentUserSuccess`
Authorize navigation is gated by `shouldNavigateToAuthorizeSignalServer`:
- Endpoint must look **online**
- Provision result is `collision` **or** `skipped` with reason `no-provision-secret`
---
## Research findings — likely causes
These are **code-backed hypotheses** ranked by how directly they produce a login prompt while the user still has a home session.
### Cause A — Missing provision secret → authorize login (primary)
**Mechanism**
1. `SignalServerAuthService.ensureProvisioned` returns `{ kind: 'skipped', reason: 'no-provision-secret' }` when `ProvisionSecretStoreService.getSecret(homeUser.id)` is null.
2. `SignalServerAuthorizeService.ensureCredentialForServerUrl` then calls `navigateToAuthorize``/login?mode=authorize`.
3. Logins authorize mode **does not** auto-redirect away when `currentUser` is set (the leave-login effect explicitly returns early in authorize mode), so the prompt stays on screen.
**Why the secret is often missing**
- Secret is created only in `prepareAuthenticatedUserStorage` via `ensureHomeProvisionSecret`, and **only when both** `user.homeSignalServerUrl` **and** `loginResponse` are present.
- Session restore (`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.
+422 -1
View File
@@ -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 });
}
+4
View File
@@ -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);
+2 -1
View File
@@ -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
}; };
} }
+1
View File
@@ -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';
+8
View File
@@ -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"');
}
}
+3 -1
View File
@@ -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
]; ];
+35
View File
@@ -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;
}
+5
View File
@@ -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
@@ -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'
});
});
});
@@ -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;
}
}
}
@@ -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('');
}
@@ -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(/\/+$/, '');
}
}
@@ -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'
});
});
});
@@ -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(/\/+$/, '');
} }
@@ -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 () => {
@@ -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),
@@ -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();
@@ -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,
@@ -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';
}
@@ -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']);
@@ -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,
@@ -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>
@@ -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()
] ]
}); });
@@ -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);
} }
} }