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
@@ -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