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
+5
View File
@@ -37,6 +37,11 @@
"defaultServerName": "Signal Server"
},
"provision": {
"credentialsRejected": "This server already has an account that the restored session cannot unlock. Your home account is still signed in.",
"reconnectTitle": "Reconnect to {{serverName}}",
"retry": "Retry",
"retrying": "Retrying…",
"serverUnavailable": "This server is currently unavailable. Retry when the connection is restored.",
"usernameCollision": "Username {{preferredUsername}} was taken on {{serverName}}. Created {{provisionedUsername}} instead."
}
}
@@ -1,6 +1,6 @@
# Authentication Domain
Handles user authentication (login and registration) against the configured server endpoint. Provides the login, register, and user-bar UI components.
Handles the durable home session plus per-signal-server credentials used for cross-server identity. Provides login, registration, silent foreign provisioning, contextual recovery, and user-bar UI.
## Module map
@@ -8,7 +8,11 @@ Handles user authentication (login and registration) against the configured serv
authentication/
├── application/
│ └── services/
── authentication.service.ts HTTP login/register against the active server endpoint
── authentication.service.ts HTTP login/register against the active endpoint
│ ├── signal-server-auth.service.ts Home migration and silent foreign provisioning
│ ├── signal-server-authorize.service.ts Explicit authorization and credential checks
│ ├── signal-server-auth-recovery.service.ts Contextual per-server recovery state
│ └── home-provision-secret.service.ts Account-wide secret issued by the home server
├── domain/
│ └── models/
@@ -26,6 +30,8 @@ authentication/
`AuthenticationService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component dispatches `UsersActions.authenticateUser`, and the users effects prepare the local persistence boundary before exposing the new user in the NgRx store.
`SignalServerAuthService` keeps one credential per normalized signal-server URL. Provision failures never expire the valid home session or automatically redirect to generic login. They publish a contextual issue rendered on server/join surfaces with Retry.
```mermaid
graph TD
Login[LoginComponent]
@@ -64,7 +70,7 @@ sequenceDiagram
Login->>Auth: login(username, password)
Auth->>SD: getApiBaseUrl()
SD-->>Auth: https://server/api
Auth->>API: POST /api/auth/login
Auth->>API: POST /api/users/login
API-->>Auth: { userId, displayName }
Auth-->>Login: success
Login->>Store: UsersActions.authenticateUser
@@ -75,7 +81,32 @@ sequenceDiagram
## Registration flow
Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same authenticated-user transition runs, switching the browser persistence layer to that user's local scope before the app reloads rooms and user state.
Registration follows the same pattern but posts to `/api/users/register` with an additional `displayName` field. On success the user is treated as logged in and the same authenticated-user transition runs, switching the browser persistence layer to that user's local scope before the app reloads rooms and user state.
## One human, one account per signal server
A linked account on a foreign signal server is an ordinary account whose password is the user's **provision secret**. That secret is issued and stored by the **home** signal server (`GET /api/users/me/provision-secret`, created on first use), so it is identical on every device the person signs in from. `HomeProvisionSecretService` fetches it with the home session token and caches it in memory only.
This matters because the secret decides identity. Older builds generated a secret per device; the second device could not sign in to the account the first one had created, fell through to the `username-<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
@@ -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.';
/**
* Storage for the legacy per-device provision secret.
*
* New provisioning uses the account-wide secret issued by the home signal
* server (`HomeProvisionSecretService`); a per-device secret cannot unlock the
* foreign accounts the user's other devices created. This slot is kept so
* accounts registered by older builds can still be reclaimed and moved onto
* the canonical secret. Nothing should write a freshly generated secret here.
*/
@Injectable({ providedIn: 'root' })
export class ProvisionSecretStoreService {
private readonly electronBridge: ElectronBridgeService;
@@ -42,11 +51,3 @@ export class ProvisionSecretStoreService {
return `${SESSION_STORAGE_PREFIX}${homeUserId}`;
}
}
export function generateProvisionSecret(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
@@ -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 { Store } from '@ngrx/store';
import { firstValueFrom } from 'rxjs';
import { DebuggingService } from '../../../../core/services/debugging/debugging.service';
import type { User } from '../../../../shared-kernel';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import type { LoginResponse } from '../../domain/models/authentication.model';
@@ -9,7 +10,8 @@ import { ProvisionUsernameCollisionError } from '../../domain/logic/signal-serve
import { type ResolvedSignalIdentity, resolveSignalIdentity } from '../../domain/logic/signal-server-credential-resolution.rules';
import { resolveSelfPresenceUserIds } from '../../domain/logic/self-presence-identity.rules';
import { AuthTokenStoreService } from './auth-token-store.service';
import { ProvisionSecretStoreService, generateProvisionSecret } from './provision-secret-store.service';
import { HomeProvisionSecretService } from './home-provision-secret.service';
import { SignalServerAuthRecoveryService } from './signal-server-auth-recovery.service';
import { SignalServerCredentialStoreService } from './signal-server-credential-store.service';
import { SignalServerProvisionerService, type ProvisionResult } from './signal-server-provisioner.service';
import { SignalServerProvisionNoticeService } from './signal-server-provision-notice.service';
@@ -17,7 +19,7 @@ import { SignalServerProvisionNoticeService } from './signal-server-provision-no
export type EnsureProvisionedResult =
| { kind: 'existing'; credential: SignalServerCredential }
| { kind: 'provisioned'; result: ProvisionResult }
| { kind: 'skipped'; reason: 'no-home-user' | 'no-provision-secret' | 'already-valid' }
| { kind: 'skipped'; reason: 'no-home-user' | 'already-valid' }
| { kind: 'collision'; error: ProvisionUsernameCollisionError };
@Injectable({ providedIn: 'root' })
@@ -25,9 +27,11 @@ export class SignalServerAuthService {
private readonly store = inject(Store);
private readonly credentialStore = inject(SignalServerCredentialStoreService);
private readonly authTokenStore = inject(AuthTokenStoreService);
private readonly provisionSecretStore = inject(ProvisionSecretStoreService);
private readonly homeProvisionSecret = inject(HomeProvisionSecretService);
private readonly provisioner = inject(SignalServerProvisionerService);
private readonly provisionNotice = inject(SignalServerProvisionNoticeService);
private readonly recovery = inject(SignalServerAuthRecoveryService);
private readonly debugging = inject(DebuggingService);
private readonly provisionInFlight = new Map<string, Promise<EnsureProvisionedResult>>();
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> {
const normalizedUrl = this.normalizeServerUrl(serverUrl);
const existing = this.credentialStore.getCredential(normalizedUrl);
if (existing) {
this.recovery.clear(normalizedUrl);
this.logProvisionOutcome('credential-existing', normalizedUrl, existing.userId, homeUser?.id);
return { kind: 'existing', credential: existing };
}
@@ -161,17 +154,12 @@ export class SignalServerAuthService {
return { kind: 'skipped', reason: 'no-home-user' };
}
const provisionSecret = await this.provisionSecretStore.getSecret(user.id);
if (!provisionSecret) {
return { kind: 'skipped', reason: 'no-provision-secret' };
}
try {
const secrets = await this.homeProvisionSecret.resolveSecrets(user);
const result = await this.provisioner.provisionOnServer({
serverUrl: normalizedUrl,
homeUser: user,
provisionSecret
secrets
});
if (result.usedSuffix) {
@@ -182,16 +170,49 @@ export class SignalServerAuthService {
});
}
this.recovery.clear(normalizedUrl);
this.logProvisionOutcome('credential-provisioned', normalizedUrl, result.credential.userId, user.id);
return { kind: 'provisioned', result };
} catch (error) {
if (error instanceof ProvisionUsernameCollisionError) {
this.publishRecovery(normalizedUrl, 'credentials-rejected');
this.logProvisionOutcome('credential-rejected', normalizedUrl, undefined, user.id);
return { kind: 'collision', error };
}
this.publishRecovery(normalizedUrl, 'unavailable');
this.logProvisionOutcome('server-unavailable', normalizedUrl, undefined, user.id);
throw error;
}
}
private publishRecovery(
serverUrl: string,
reason: 'credentials-rejected' | 'unavailable'
): void {
this.recovery.publish({
serverName: this.resolveServerDisplayName(serverUrl),
serverUrl,
reason
});
}
private logProvisionOutcome(
outcome: string,
serverUrl: string,
actorUserId: string | undefined,
homeUserId: string | undefined
): void {
this.debugging.info('signal-server-auth', outcome, {
actorUserId,
homeUserId,
serverUrl
});
}
private normalizeServerUrl(serverUrl: string): string {
return serverUrl.trim().replace(/\/+$/, '');
}
@@ -51,7 +51,7 @@ describe('SignalServerAuthorizeService', () => {
};
signalServerAuth = {
ensureProvisioned: vi.fn(() => Promise.resolve({ kind: 'skipped', reason: 'no-provision-secret' })),
ensureProvisioned: vi.fn(() => Promise.resolve({ kind: 'skipped', reason: 'no-home-user' })),
hasValidCredential: vi.fn(() => false),
migrateHomeCredential: vi.fn()
};
@@ -102,13 +102,11 @@ describe('SignalServerAuthorizeService', () => {
expect(router.navigate).not.toHaveBeenCalled();
});
it('still provisions foreign servers and navigates to authorize when the secret is missing', async () => {
it('keeps the home session active when automatic foreign provisioning cannot recover', async () => {
await expect(service.ensureCredentialForServerUrl(FOREIGN_URL)).resolves.toBe(false);
expect(signalServerAuth.ensureProvisioned).toHaveBeenCalledWith(FOREIGN_URL, homeUser);
expect(router.navigate).toHaveBeenCalledWith(['/login'], expect.objectContaining({
queryParams: expect.objectContaining({ mode: 'authorize' })
}));
expect(router.navigate).not.toHaveBeenCalled();
});
it('returns true when foreign provisioning succeeds', async () => {
@@ -5,8 +5,6 @@ import { firstValueFrom } from 'rxjs';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { ServerDirectoryFacade } from '../../../server-directory';
import { AUTH_MODE_AUTHORIZE, buildLoginReturnQueryParams } from '../../domain/logic/auth-navigation.rules';
import { isEndpointOnlineForConnection } from '../../../server-directory/domain/logic/server-endpoint-connectivity.rules';
import { shouldNavigateToAuthorizeSignalServer } from '../../domain/logic/signal-server-authorize.rules';
import { isSameSignalServerUrl } from '../../domain/logic/signal-server-auth-failure.rules';
import { SignalServerAuthService } from './signal-server-auth.service';
@@ -50,31 +48,13 @@ export class SignalServerAuthorizeService {
return true;
}
const endpointStatus = await this.resolveEndpointStatusForAuthorize(serverUrl);
if (shouldNavigateToAuthorizeSignalServer(endpointStatus, result)) {
await this.navigateToAuthorize(serverUrl, this.router.url);
}
// Automatic recovery must never turn a healthy home session into a generic
// foreign-server login redirect. The contextual caller renders the
// per-server recovery action; explicit authorization remains available in
// Network settings.
return false;
}
private async resolveEndpointStatusForAuthorize(serverUrl: string) {
const endpoint = this.serverDirectory.findServerByUrl(serverUrl);
if (!endpoint) {
return null;
}
if (isEndpointOnlineForConnection(endpoint.status) || endpoint.status === 'offline' || endpoint.status === 'incompatible') {
return endpoint.status;
}
await this.serverDirectory.testServer(endpoint.id);
return this.serverDirectory.servers().find((candidate) => candidate.id === endpoint.id)?.status ?? endpoint.status;
}
async navigateToAuthorize(serverUrl: string, returnUrl: string): Promise<void> {
const endpoint = this.serverDirectory.ensureServerEndpoint({
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 type { User } from '../../../../shared-kernel';
const FOREIGN_URL = 'https://foreign.example.com';
const CANONICAL_SECRET = 'canonical-secret';
const LEGACY_SECRET = 'legacy-device-secret';
describe('SignalServerProvisionerService', () => {
let service: SignalServerProvisionerService;
let httpPost: ReturnType<typeof vi.fn>;
@@ -29,6 +33,24 @@ describe('SignalServerProvisionerService', () => {
homeSignalServerUrl: 'https://home.example.com'
};
function foreignAccount(id: string, username: string, token = 'foreign-token') {
return of({
id,
username,
displayName: 'Alice',
token,
expiresAt: Date.now() + 60_000
});
}
function httpError(status: number) {
return throwError(() => new HttpErrorResponse({ status }));
}
function provision(secrets: { canonical: string | null; deviceLocal: string | null }) {
return service.provisionOnServer({ serverUrl: FOREIGN_URL, homeUser, secrets });
}
beforeEach(() => {
const storage = new Map<string, string>();
@@ -48,109 +70,127 @@ describe('SignalServerProvisionerService', () => {
});
it('registers on a foreign server when the preferred username is available', async () => {
httpPost.mockReturnValue(of({
id: 'foreign-user-1',
username: 'alice',
displayName: 'Alice',
token: 'foreign-token',
expiresAt: Date.now() + 60_000
}));
httpPost.mockReturnValue(foreignAccount('foreign-user-1', 'alice'));
const result = await service.provisionOnServer({
serverUrl: 'https://foreign.example.com',
homeUser,
provisionSecret: 'provision-secret'
});
const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: null });
expect(result.username).toBe('alice');
expect(result.usedSuffix).toBe(false);
expect(credentialStore.getCredential('https://foreign.example.com')?.userId).toBe('foreign-user-1');
expect(httpPost).toHaveBeenCalledWith(
'https://foreign.example.com/api/users/register',
{
username: 'alice',
password: 'provision-secret',
displayName: 'Alice'
}
);
expect(credentialStore.getCredential(FOREIGN_URL)?.userId).toBe('foreign-user-1');
expect(httpPost).toHaveBeenCalledWith(`${FOREIGN_URL}/api/users/register`, {
username: 'alice',
password: CANONICAL_SECRET,
displayName: 'Alice'
});
});
it('logs in when the preferred username was provisioned earlier', async () => {
it('signs a second device in to the account the first device created', async () => {
httpPost
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
.mockReturnValueOnce(of({
id: 'foreign-user-1',
username: 'alice',
displayName: 'Alice',
token: 'foreign-token',
expiresAt: Date.now() + 60_000
}));
.mockReturnValueOnce(httpError(409))
.mockReturnValueOnce(foreignAccount('foreign-user-1', 'alice'));
const result = await service.provisionOnServer({
serverUrl: 'https://foreign.example.com',
homeUser,
provisionSecret: 'provision-secret'
});
const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: null });
expect(result.username).toBe('alice');
expect(result.usedSuffix).toBe(false);
expect(credentialStore.getCredential(FOREIGN_URL)?.userId).toBe('foreign-user-1');
expect(httpPost).toHaveBeenNthCalledWith(2, `${FOREIGN_URL}/api/users/login`, {
username: 'alice',
password: CANONICAL_SECRET
});
});
it('reclaims an account created with the legacy secret and moves it to the canonical one', async () => {
httpPost
.mockReturnValueOnce(httpError(409))
.mockReturnValueOnce(httpError(401))
.mockReturnValueOnce(foreignAccount('foreign-user-1', 'alice', 'legacy-session'))
.mockReturnValueOnce(of({ ok: true }));
const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: LEGACY_SECRET });
expect(result.username).toBe('alice');
expect(result.usedSuffix).toBe(false);
expect(httpPost).toHaveBeenNthCalledWith(3, `${FOREIGN_URL}/api/users/login`, {
username: 'alice',
password: LEGACY_SECRET
});
expect(httpPost).toHaveBeenNthCalledWith(
2,
'https://foreign.example.com/api/users/login',
{
username: 'alice',
password: 'provision-secret'
}
4,
`${FOREIGN_URL}/api/users/me/password`,
{ newPassword: CANONICAL_SECRET },
{ headers: { Authorization: 'Bearer legacy-session' } }
);
});
it('registers with a suffixed username when the preferred name belongs to someone else', async () => {
it('keeps the reclaimed credential when the server cannot rotate the password', async () => {
httpPost
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 })))
.mockReturnValueOnce(of({
id: 'foreign-user-2',
username: 'alice-a3f2b1',
displayName: 'Alice',
token: 'foreign-token-2',
expiresAt: Date.now() + 60_000
}));
.mockReturnValueOnce(httpError(409))
.mockReturnValueOnce(httpError(401))
.mockReturnValueOnce(foreignAccount('foreign-user-1', 'alice', 'legacy-session'))
.mockReturnValueOnce(httpError(404));
const result = await service.provisionOnServer({
serverUrl: 'https://foreign.example.com',
homeUser,
provisionSecret: 'provision-secret'
});
const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: LEGACY_SECRET });
expect(result.username).toBe('alice');
expect(credentialStore.getCredential(FOREIGN_URL)?.userId).toBe('foreign-user-1');
});
it('registers a suffixed username only when the preferred name belongs to someone else', async () => {
httpPost
.mockReturnValueOnce(httpError(409))
.mockReturnValueOnce(httpError(401))
.mockReturnValueOnce(foreignAccount('foreign-user-2', 'alice-a3f2b1', 'foreign-token-2'));
const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: null });
expect(result.username).toBe('alice-a3f2b1');
expect(result.usedSuffix).toBe(true);
expect(httpPost).toHaveBeenNthCalledWith(
3,
'https://foreign.example.com/api/users/register',
{
username: 'alice-a3f2b1',
password: 'provision-secret',
displayName: 'Alice'
}
);
expect(httpPost).toHaveBeenNthCalledWith(3, `${FOREIGN_URL}/api/users/register`, {
username: 'alice-a3f2b1',
password: CANONICAL_SECRET,
displayName: 'Alice'
});
});
it('throws when all username candidates are exhausted', async () => {
it('never registers a duplicate when the home server cannot issue a canonical secret', async () => {
httpPost
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 })))
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 })))
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 401 })));
.mockReturnValueOnce(httpError(409))
.mockReturnValueOnce(httpError(401))
.mockReturnValueOnce(httpError(401));
await expect(service.provisionOnServer({
serverUrl: 'https://foreign.example.com',
homeUser,
provisionSecret: 'provision-secret'
})).rejects.toBeInstanceOf(ProvisionUsernameCollisionError);
await expect(provision({ canonical: null, deviceLocal: LEGACY_SECRET }))
.rejects.toBeInstanceOf(ProvisionUsernameCollisionError);
const attemptedUrls = httpPost.mock.calls.map(([url]) => url);
expect(attemptedUrls.filter((url) => url.endsWith('/register'))).toHaveLength(1);
});
it('fails with a collision instead of guessing when every candidate rejects us', async () => {
httpPost
.mockReturnValueOnce(httpError(409))
.mockReturnValueOnce(httpError(401))
.mockReturnValueOnce(httpError(409))
.mockReturnValueOnce(httpError(401));
await expect(provision({ canonical: CANONICAL_SECRET, deviceLocal: null }))
.rejects.toBeInstanceOf(ProvisionUsernameCollisionError);
});
it('surfaces unexpected server failures instead of trying the next candidate', async () => {
httpPost.mockReturnValueOnce(httpError(500));
await expect(provision({ canonical: CANONICAL_SECRET, deviceLocal: null }))
.rejects.toBeInstanceOf(HttpErrorResponse);
expect(httpPost).toHaveBeenCalledOnce();
});
it('returns an existing credential without making network calls', async () => {
credentialStore.upsertCredential({
serverUrl: 'https://foreign.example.com',
serverUrl: FOREIGN_URL,
userId: 'foreign-user-1',
username: 'alice',
displayName: 'Alice',
@@ -159,11 +199,7 @@ describe('SignalServerProvisionerService', () => {
provisioned: true
});
const result = await service.provisionOnServer({
serverUrl: 'https://foreign.example.com',
homeUser,
provisionSecret: 'provision-secret'
});
const result = await provision({ canonical: CANONICAL_SECRET, deviceLocal: null });
expect(result.username).toBe('alice');
expect(httpPost).not.toHaveBeenCalled();
@@ -4,7 +4,14 @@ import { firstValueFrom } from 'rxjs';
import type { User } from '../../../../shared-kernel';
import type { LoginResponse } from '../../domain/models/authentication.model';
import type { SignalServerCredential } from '../../domain/models/signal-server-credential.model';
import { ProvisionUsernameCollisionError, buildProvisionUsernameCandidates } from '../../domain/logic/signal-server-provision.rules';
import {
type ProvisionAttempt,
type ProvisionSecrets,
ProvisionUsernameCollisionError,
buildProvisionPlan,
buildProvisionUsernameCandidates,
shouldAdoptCanonicalSecret
} from '../../domain/logic/signal-server-provision.rules';
import { SignalServerCredentialStoreService } from './signal-server-credential-store.service';
export interface ProvisionResult {
@@ -29,7 +36,7 @@ export class SignalServerProvisionerService {
async provisionOnServer(params: {
serverUrl: string;
homeUser: Pick<User, 'id' | 'username' | 'displayName'>;
provisionSecret: string;
secrets: ProvisionSecrets;
}): Promise<ProvisionResult> {
const normalizedUrl = this.normalizeServerUrl(params.serverUrl);
const existing = this.credentialStore.getCredential(normalizedUrl);
@@ -42,34 +49,32 @@ export class SignalServerProvisionerService {
};
}
const candidates = buildProvisionUsernameCandidates(params.homeUser.username, params.homeUser.id);
const attempts = buildProvisionPlan({
preferredUsername: params.homeUser.username,
homeUserId: params.homeUser.id,
secrets: params.secrets
});
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
const usedSuffix = index > 0;
for (const attempt of attempts) {
const response = await this.runProvisionAttempt(normalizedUrl, attempt, params.homeUser.displayName);
try {
const response = await this.register(normalizedUrl, candidate, params.provisionSecret, params.homeUser.displayName);
return this.persistProvisionResult(normalizedUrl, response, usedSuffix);
} catch (error) {
if (!this.isHttpStatus(error, 409)) {
throw error;
}
try {
const response = await this.login(normalizedUrl, candidate, params.provisionSecret);
return this.persistProvisionResult(normalizedUrl, response, usedSuffix);
} catch (loginError) {
if (!this.isHttpStatus(loginError, 401)) {
throw loginError;
}
}
if (!response) {
continue;
}
const result = this.persistProvisionResult(normalizedUrl, response, attempt.usedSuffix);
if (shouldAdoptCanonicalSecret(attempt, params.secrets.canonical)) {
await this.adoptCanonicalSecret(normalizedUrl, response.token, params.secrets.canonical);
}
return result;
}
throw new ProvisionUsernameCollisionError(normalizedUrl, candidates);
throw new ProvisionUsernameCollisionError(
normalizedUrl,
buildProvisionUsernameCandidates(params.homeUser.username, params.homeUser.id)
);
}
upsertManualCredential(
@@ -91,6 +96,57 @@ export class SignalServerProvisionerService {
return credential;
}
/**
* Runs one planned attempt. Returns `null` for the two "this name is not
* ours to take this way" outcomes so the plan can continue; anything else
* is a real transport or server fault and must surface.
*/
private async runProvisionAttempt(
serverUrl: string,
attempt: ProvisionAttempt,
displayName: string
): Promise<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(
serverUrl: 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';
import {
ProvisionUsernameCollisionError,
buildProvisionPlan,
buildProvisionUsernameCandidates,
shortHomeUserId
shortHomeUserId,
shouldAdoptCanonicalSecret
} from './signal-server-provision.rules';
const HOME_USER_ID = 'a3f2b1c4-5678-90ab-cdef-1234567890ab';
function plan(canonical: string | null, deviceLocal: string | null) {
return buildProvisionPlan({
preferredUsername: 'alice',
homeUserId: HOME_USER_ID,
secrets: { canonical, deviceLocal }
}).map((attempt) => `${attempt.kind}:${attempt.username}:${attempt.secretSource}`);
}
describe('signal-server-provision.rules', () => {
it('derives a stable short id from a home user uuid', () => {
expect(shortHomeUserId('a3f2b1c4-5678-90ab-cdef-1234567890ab')).toBe('a3f2b1');
@@ -26,6 +38,54 @@ describe('signal-server-provision.rules', () => {
).toEqual(['alice-a3f2b1']);
});
it('signs in to the preferred username before ever trying the suffixed one', () => {
expect(plan('canonical-secret', null)).toEqual([
'register:alice:canonical',
'login:alice:canonical',
'register:alice-a3f2b1:canonical',
'login:alice-a3f2b1:canonical'
]);
});
it('tries the legacy device secret before giving the username up as someone else\'s', () => {
expect(plan('canonical-secret', 'legacy-secret')).toEqual([
'register:alice:canonical',
'login:alice:canonical',
'login:alice:device-local',
'register:alice-a3f2b1:canonical',
'login:alice-a3f2b1:canonical',
'login:alice-a3f2b1:device-local'
]);
});
it('never registers a suffixed duplicate without a canonical secret', () => {
expect(plan(null, 'legacy-secret')).toEqual([
'register:alice:device-local',
'login:alice:device-local',
'login:alice-a3f2b1:device-local'
]);
});
it('produces no attempts when no secret is available at all', () => {
expect(plan(null, null)).toEqual([]);
});
it('does not repeat the canonical secret as a legacy attempt', () => {
expect(plan('same-secret', 'same-secret')).toEqual([
'register:alice:canonical',
'login:alice:canonical',
'register:alice-a3f2b1:canonical',
'login:alice-a3f2b1:canonical'
]);
});
it('adopts the canonical secret only after a legacy login', () => {
expect(shouldAdoptCanonicalSecret({ kind: 'login', secretSource: 'device-local' }, 'canonical')).toBe(true);
expect(shouldAdoptCanonicalSecret({ kind: 'login', secretSource: 'canonical' }, 'canonical')).toBe(false);
expect(shouldAdoptCanonicalSecret({ kind: 'register', secretSource: 'device-local' }, 'canonical')).toBe(false);
expect(shouldAdoptCanonicalSecret({ kind: 'login', secretSource: 'device-local' }, null)).toBe(false);
});
it('exposes attempted usernames on collision errors', () => {
const error = new ProvisionUsernameCollisionError('https://signal.example.com', ['alice', 'alice-a3f2b1']);
@@ -32,3 +32,104 @@ export function buildProvisionUsernameCandidates(
return [...new Set(candidates)];
}
/**
* `canonical` is the account-wide secret issued by the home signal server, so
* it is identical on every device of the same human. `device-local` is the
* legacy secret that older builds generated per device; it only ever unlocks
* accounts that this one device created.
*/
export type ProvisionSecretSource = 'canonical' | 'device-local';
export interface ProvisionSecrets {
canonical: string | null;
deviceLocal: string | null;
}
export interface ProvisionAttempt {
kind: 'register' | 'login';
username: string;
secret: string;
secretSource: ProvisionSecretSource;
usedSuffix: boolean;
}
/**
* Orders the provisioning attempts for one foreign signal server.
*
* The ordering exists to guarantee that a human never ends up with two
* accounts on the same server. For each username we first try to claim it,
* then to sign in with the canonical secret (another device of ours already
* claimed it), then with the device-local secret (this device claimed it
* before canonical secrets existed). Only once a username is proven to belong
* to somebody else do we move on to the suffixed name.
*
* Registering the suffixed name requires a canonical secret. Without one we
* cannot tell "another human owns this name" apart from "our own account whose
* secret this device never had", and guessing wrong forks the user's identity.
*/
export function buildProvisionPlan(input: {
preferredUsername: string;
homeUserId: string;
secrets: ProvisionSecrets;
}): ProvisionAttempt[] {
const candidates = buildProvisionUsernameCandidates(input.preferredUsername, input.homeUserId);
const { canonical, deviceLocal } = input.secrets;
const primary = canonical ?? deviceLocal;
if (!primary) {
return [];
}
const attempts: ProvisionAttempt[] = [];
candidates.forEach((username, index) => {
const usedSuffix = index > 0;
if (!usedSuffix || canonical) {
attempts.push({
kind: 'register',
username,
secret: primary,
secretSource: canonical ? 'canonical' : 'device-local',
usedSuffix
});
}
if (canonical) {
attempts.push({
kind: 'login',
username,
secret: canonical,
secretSource: 'canonical',
usedSuffix
});
}
if (deviceLocal && deviceLocal !== canonical) {
attempts.push({
kind: 'login',
username,
secret: deviceLocal,
secretSource: 'device-local',
usedSuffix
});
}
});
return attempts;
}
/**
* A login that succeeded with the legacy device-local secret leaves the
* account unreachable from the user's other devices until its password is
* moved to the canonical secret.
*/
export function shouldAdoptCanonicalSecret(
attempt: Pick<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/auth-token-store.service';
export * from './application/services/user-logout.service';
export * from './application/services/home-provision-secret.service';
export * from './application/services/signal-server-auth.service';
export * from './application/services/signal-server-authorize.service';
export * from './application/services/signal-server-auth-recovery.service';
export * from './application/services/signal-server-credential-store.service';
export * from './application/services/signal-server-provisioner.service';
export * from './application/services/signal-server-provision-notice.service';
@@ -74,6 +74,17 @@
</div>
@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
type="button"
(click)="goToSearch()"
@@ -18,7 +18,11 @@ import { DatabaseService } from '../../../../infrastructure/persistence';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import { User } from '../../../../shared-kernel';
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({
selector: 'app-invite',
@@ -31,6 +35,8 @@ export class InviteComponent implements OnInit {
readonly invite = signal<ServerInviteInfo | null>(null);
readonly status = signal<'loading' | 'redirecting' | 'joining' | 'error'>('loading');
readonly message = signal('');
readonly authRecoveryIssue = signal<SignalServerAuthRecoveryIssue | null>(null);
readonly authRecoveryRetrying = signal(false);
private readonly i18n = inject(AppI18nService);
private readonly route = inject(ActivatedRoute);
@@ -38,9 +44,14 @@ export class InviteComponent implements OnInit {
private readonly store = inject(Store);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly databaseService = inject(DatabaseService);
private readonly signalServerAuthRecovery = inject(SignalServerAuthRecoveryService);
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
async ngOnInit(): Promise<void> {
await this.loadInvite();
}
private async loadInvite(): Promise<void> {
this.message.set(this.i18n.instant('servers.invite.messages.loading'));
const inviteContext = this.resolveInviteContext();
@@ -67,6 +78,22 @@ export class InviteComponent implements OnInit {
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 {
try {
const url = new URL(sourceUrl);
@@ -134,12 +161,22 @@ export class InviteComponent implements OnInit {
const hasCredential = await this.signalServerAuthorize.ensureCredentialForServerUrl(context.sourceUrl);
if (!hasCredential) {
this.status.set('redirecting');
this.message.set(this.i18n.instant('servers.invite.messages.redirectingAuthorize'));
const issue = this.signalServerAuthRecovery.getIssue(context.sourceUrl);
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;
}
this.signalServerAuthRecovery.clear(context.sourceUrl);
this.authRecoveryIssue.set(null);
const joinResponse = await firstValueFrom(this.serverDirectory.requestJoin({
roomId: invite.server.id,
userId: currentUserId,
@@ -228,6 +228,32 @@
}
</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()) {
<div class="border-t border-destructive bg-destructive/10 p-4">
<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 { RealtimeSessionFacade } from '../../../../core/realtime';
import { PluginRequirementService, PluginStoreService } from '../../../plugins';
import { SignalServerAuthRecoveryService } from '../../../authentication/application/services/signal-server-auth-recovery.service';
import { SignalServerAuthorizeService } from '../../../authentication/application/services/signal-server-authorize.service';
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
import type { ServerInfo } from '../../domain/models/server-directory.model';
@@ -116,6 +117,7 @@ function createHarness(options: HarnessOptions = {}) {
{ provide: PluginRequirementService, useValue: pluginRequirements },
{ provide: PluginStoreService, useValue: pluginStore },
{ provide: SignalServerAuthorizeService, useValue: signalServerAuthorize },
SignalServerAuthRecoveryService,
...provideAppI18nForTests()
]
});
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
effect,
inject,
Injector,
@@ -32,6 +32,7 @@ import {
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { setStoredCurrentUserId } from '../../../../core/storage/current-user-storage';
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
import { SignalServerAuthRecoveryService } from '../../../authentication/application/services/signal-server-auth-recovery.service';
import { SignalServerAuthorizeService } from '../../../authentication/application/services/signal-server-authorize.service';
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
@@ -125,6 +126,7 @@ export class ServerBrowserComponent implements OnInit {
private pluginStore = inject(PluginStoreService);
private injector = inject(Injector);
private readonly i18n = inject(AppI18nService);
private readonly signalServerAuthRecovery = inject(SignalServerAuthRecoveryService);
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
private searchSubject = new Subject<string>();
private banLookupRequestVersion = 0;
@@ -215,6 +217,11 @@ export class ServerBrowserComponent implements OnInit {
joinPassword = signal('');
joinPasswordError = 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);
leaveDialogRoom = signal<Room | null>(null);
pluginConsentDialog = signal<JoinPluginConsentDialog | null>(null);
@@ -289,6 +296,22 @@ export class ServerBrowserComponent implements OnInit {
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 {
this.openJoinedRoom(room);
}
@@ -536,8 +559,21 @@ export class ServerBrowserComponent implements OnInit {
const hasCredential = await this.signalServerAuthorize.ensureCredentialForServerUrl(server.sourceUrl);
if (!hasCredential) {
const issue = this.signalServerAuthRecovery.getIssue(server.sourceUrl);
this.authRecoveryServer.set(issue ? server : null);
if (!issue) {
// No per-server issue means the failure was not about this server's
// credentials. Say something rather than leaving the join dead.
this.joinErrorMessage.set(this.i18n.instant('auth.provision.serverUnavailable'));
}
return;
}
this.signalServerAuthRecovery.clear(server.sourceUrl);
this.authRecoveryServer.set(null);
}
const response = await firstValueFrom(
@@ -1,5 +1,29 @@
<div class="flex h-full flex-col bg-background">
@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()) {
<!-- Mobile: Swiper-driven page stack (channels -> main -> members) -->
<swiper-container
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
CUSTOM_ELEMENTS_SCHEMA,
Component,
@@ -40,7 +39,9 @@ import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { VoiceWorkspaceService } from '../../../domains/voice-session';
import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
import { DirectCallService } from '../../../domains/direct-call';
import { SignalServerAuthRecoveryService, SignalServerAuthorizeService } from '../../../domains/authentication';
import { APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
import { RoomsActions } from '../../../store/rooms/rooms.actions';
/** Mobile-only page identifier within the chat-room view. */
export type ChatRoomMobilePage = 'channels' | 'main' | 'members';
@@ -102,6 +103,8 @@ export class ChatRoomComponent {
private readonly theme = inject(ThemeService);
private readonly viewport = inject(ViewportService);
private readonly directCalls = inject(DirectCallService);
private readonly signalServerAuthRecovery = inject(SignalServerAuthRecoveryService);
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
private readonly zone = inject(NgZone);
private voiceWorkspace = inject(VoiceWorkspaceService);
private lastSeenChannelId: string | null = null;
@@ -113,9 +116,13 @@ export class ChatRoomComponent {
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
readonly mobilePage = signal<ChatRoomMobilePage>('channels');
readonly isMobile = this.viewport.isMobile;
readonly isAuthRecoveryRetrying = signal(false);
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
currentRoom = this.store.selectSignal(selectCurrentRoom);
readonly authRecoveryIssue = computed(() =>
this.signalServerAuthRecovery.getIssue(this.currentRoom()?.sourceUrl)
);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
textChannels = this.store.selectSignal(selectTextChannels);
activeChannelId = this.store.selectSignal(selectActiveChannelId);
@@ -229,6 +236,28 @@ export class ChatRoomComponent {
}
}
async retrySignalServerAuthentication(): Promise<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. */
toggleAdminPanel() {
const room = this.currentRoom();
@@ -1,7 +1,7 @@
/**
* Users store effects (load, kick, ban, host election, profile persistence).
*/
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import {
@@ -302,7 +302,6 @@ export class UsersEffects {
if (user.homeSignalServerUrl && loginResponse) {
this.signalServerAuth.upsertCredentialFromLogin(user.homeSignalServerUrl, loginResponse, { provisioned: false });
await this.signalServerAuth.ensureHomeProvisionSecret(user);
}
}