Files
Toju/agents-docs/features/authentication.md
brogeby 47beed01ca docs: add cross-context feature docs for auth, presence, access-control, messaging, attachments
Fills the five highest-value gaps under agents-docs/features/ so the index covers
the system's main cross-context contracts. Each doc follows the feature-template
structure and the AGENTS_FEATURES.md contract, with honest TODOs where coverage
or behavior couldn't be confirmed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:33:41 +02:00

195 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Authentication
> **Area:** authentication
> **Status:** Active
> **Last updated:** 2026-05-25
## Overview
User identity in Toju is split into two surfaces. A small **HTTP credential surface** on the signaling server (`/api/users/register`, `/api/users/login`) registers and verifies user accounts persisted in TypeORM. A separate **WebSocket `identify` handshake** binds a *self-asserted* identity (`oderId` + display name + optional description) to a live WebSocket connection so the server can route envelopes. There is no server-issued session token: the client re-asserts identity on every reconnect, and other peers trust the claim as far as the signaling fabric does — i.e. only to the extent that subsequent authorization checks (see [access-control](./access-control.md)) accept it.
The Electron desktop shell adds a third surface — the **Local API token store** in `electron/api/auth-store.ts` — which issues short-lived bearer tokens for the in-process HTTP server that hosts the docs site and OpenAPI bundle. That surface is internal to the desktop process and is documented here only because it shares the "authentication" name.
## Responsibilities
- Register and authenticate user accounts against the signaling server's `users` table.
- Bind a connection-scoped identity to a WebSocket connection via the `identify` envelope, including profile metadata propagation.
- Detect dead WebSocket connections via ping/pong sweeps and reap stale `ConnectedUser` rows.
- Mint and validate short-lived bearer tokens for the Electron Local API server.
This area does **not** own:
- Permissions, roles, bans, or membership state → [access-control](./access-control.md).
- Online / away / busy status, voice presence, game activity → [presence](./presence.md).
- The shape of WebSocket envelopes carrying identity claims → [websocket-envelopes](./websocket-envelopes.md).
- Profile avatar bytes or per-user assets → product-client `profile-avatar` domain.
## Key concepts
- **AuthUser** — server-persisted account: `id` (uuid), `username` (unique), `passwordHash`, `displayName`, `createdAt`. Defined in `server/src/entities/AuthUserEntity.ts`.
- **oderId** — client-asserted user identifier sent on `identify`. Used as the broadcast routing key. The server **trusts** it; there is no cryptographic binding between an AuthUser row and the `oderId` claimed over WebSocket.
- **Identify handshake** — first message a client sends on a WebSocket. Carries `oderId`, `displayName`, optional `description`, optional `profileUpdatedAt`, optional `connectionScope`.
- **Connection scope** — opaque string (typically the signal URL the client connected through). Used together with `oderId` to disambiguate multiple sockets per identity so stale-connection eviction does not loop across signal URLs.
- **Local API token** — bearer token issued by the Electron desktop process, 24 h TTL, kept in-memory and pruned on access.
---
## HTTP credential surface
Mounted at `/api/users` (see `server/src/routes/index.ts:20`). All payloads are JSON.
### `POST /api/users/register`
**Request:**
```json
{
"username": "string (required, unique)",
"password": "string (required)",
"displayName": "string (optional, defaults to username)"
}
```
**Response (201):**
```json
{ "id": "uuid", "username": "string", "displayName": "string" }
```
**Errors:**
- **400** `Missing username/password` — either field absent.
- **409** `Username taken` — username already exists.
### `POST /api/users/login`
**Request:** `{ "username": "string", "password": "string" }`
**Response (200):** `{ "id": "uuid", "username": "string", "displayName": "string" }`
**Errors:**
- **401** `Invalid credentials` — no row matches, or stored hash differs.
No session cookie, JWT, or bearer token is issued — the response is purely informational. The client is expected to remember the username and re-present it via the WebSocket `identify` handshake on every reconnect.
---
## WebSocket `identify` handshake
`handleIdentify` (`server/src/websocket/handler.ts:112`) processes the first envelope a client sends. It:
1. Reads `oderId` (falls back to `connectionId` when absent).
2. Reads `connectionScope` (opaque routing key).
3. Reads / normalizes `displayName`, `description`, `profileUpdatedAt`.
4. Mutates the `ConnectedUser` row in `connectedUsers`.
5. If any of `displayName` / `description` / `profileUpdatedAt` changed, rebroadcasts `user_joined` to every server the user is currently in.
`identify` itself is unauthenticated — the server does not consult `AuthUserEntity` here. Authorization happens later, at `join_server` time, via `authorizeWebSocketJoin` (documented in [access-control](./access-control.md)).
`identify` is the canonical channel for **profile updates**. Renaming yourself or updating your description means resending `identify`; the rebroadcast pushes the new profile to peers without disconnect/reconnect.
---
## Heartbeat and dead-connection sweep
Defined in `server/src/websocket/index.ts:19``75`:
- `PING_INTERVAL_MS = 30_000` — server pings every connection every 30 s.
- `PONG_TIMEOUT_MS = 45_000` — a connection whose `lastPong` is older than 45 s is closed and removed from `connectedUsers`.
`lastPong` is bumped on any inbound frame (not just pong), so an active client cannot be reaped while sending traffic. Eviction triggers `handleLeaveServer` for every server the connection had joined, which in turn emits `user_left` if no other connection of the same `oderId` still holds the server.
---
## Electron Local API token store
`electron/api/auth-store.ts` mints opaque bearer tokens used by the Local API server (`electron/api/router.ts`) to gate calls to `/api/auth/login` and other authenticated routes. Tokens have a 24 h TTL and are kept in-memory only — they do not persist across desktop restarts. Pruning happens lazily on lookup.
This surface is **not** the same identity as the signaling server's AuthUser. It is a desktop-local affordance for the in-process HTTP server that serves docs and plugin APIs; the renderer never sees these tokens.
---
## Business rules and invariants
- Usernames are **unique** at the database level (`@Column('text', { unique: true })` on `AuthUserEntity.username`) AND pre-checked in the route handler. Comparison is **case-sensitive**.
- Passwords are hashed with **unsalted SHA-256** (`crypto.createHash('sha256')`, `routes/users.ts:8`). There is no salting, no peppering, no iteration count, no Argon2/bcrypt. This is a known weakness — see Security below.
- The signaling server never issues a session token. Identity is re-asserted on every reconnect via `identify`.
- The `identify` claim is **not verified** against `AuthUserEntity`. Two clients can claim the same `oderId`; only `(oderId, connectionScope)` is used to deduplicate eviction.
- `user_joined` is only re-broadcast on `identify` when at least one of `displayName` / `description` / `profileUpdatedAt` actually changed — duplicate identifies are silent.
- Dead-connection sweep runs continuously. A client that goes silent for ≥ 45 s is treated as disconnected.
---
## Technical implementation
### Server (signaling)
- HTTP routes: `server/src/routes/users.ts`, mounted at `/api/users` in `server/src/routes/index.ts`.
- CQRS handlers: `server/src/cqrs/commands/handlers/registerUser.ts`, `server/src/cqrs/queries/handlers/getUserByUsername.ts`, `server/src/cqrs/queries/handlers/getUserById.ts`.
- Entity: `server/src/entities/AuthUserEntity.ts` (`users` table).
- Migration: `server/src/migrations/1000000000000-InitialSchema.ts`.
- WebSocket handshake: `server/src/websocket/handler.ts::handleIdentify` (line 112).
- `ConnectedUser` shape: `server/src/websocket/types.ts`.
- Heartbeat sweep: `server/src/websocket/index.ts`.
### Product client
- Domain: `toju-app/src/app/domains/authentication/`.
- Service: `authentication.service.ts` (login / register HTTP calls).
- Components: `login.component.ts`, `register.component.ts`.
- Model: `authentication.model.ts`.
### Electron
- Local API token store: `electron/api/auth-store.ts`.
- Local API router: `electron/api/router.ts` (`/api/auth/login` endpoint).
### Key types
- `AuthUserEntity` — server account row.
- `ConnectedUser` — live WebSocket connection state, including `oderId`, `connectionScope`, `lastPong`.
---
## Testing
- E2E: `e2e/tests/auth/user-session-data-isolation.spec.ts` — verifies session-level data isolation between users.
- TODO: no unit specs were located for `server/src/routes/users.ts`, `handleRegisterUser`, `getUserByUsername`/`getUserById`, `handleIdentify`, the Electron `/api/auth/login` proxy, or the `toju-app` authentication services.
- TODO: no happy-path login/register E2E exists today.
---
## Security considerations
- **Password hashing is unsalted SHA-256.** Vulnerable to rainbow-table and parallel GPU attacks. Replacing with Argon2id or bcrypt is the obvious upgrade path and is currently a TODO.
- **No rate limiting on `/login`.** A `users` table with weak hashes is exposed to credential stuffing and online brute-force.
- **`identify` is unauthenticated.** Any WebSocket can claim any `oderId`. The real authorization gate is `authorizeWebSocketJoin` on `join_server`, which checks membership / invite / password against the access-control tables — until that gate is crossed, an unverified `oderId` cannot do anything meaningful beyond joining the public lobby.
- **No reuse-prevention on `displayName`.** Two distinct accounts may carry the same display name. UI must therefore disambiguate by `oderId` where identity actually matters.
- **Local API tokens** never leave the desktop process and have a 24 h TTL — they are not a credential primitive for the signaling server.
---
## Performance considerations
- `/register` and `/login` are O(1) lookups against a `UNIQUE` index on `username`. No caching layer.
- `identify` is O(serversJoinedByThisConnection) on profile change because of the rebroadcast loop; profile updates are rare so this is negligible.
- Dead-connection sweep is O(connections) per `PING_INTERVAL_MS`; trivially scalable for a single-process signaling server.
---
## Known issues and limitations
- **Unsalted SHA-256 password hashing.** Highest-priority hardening target.
- **No password reset, email confirmation, MFA, or account recovery.**
- **No audit log** of register/login events.
- **No binding between `AuthUserEntity.id` and the claimed `oderId`.** A future hardening pass should require the client to prove possession of an `AuthUser` credential before the server accepts an `identify` payload that names that user — likely via a signed challenge.
- **No spec coverage** for the HTTP credential surface or the identify handshake.
---
## Related features
- **[access-control](./access-control.md)** — consumes the `oderId` claimed via `identify` to authorize server joins, role lookups, and moderation actions.
- **[presence](./presence.md)** — `identify` is the canonical channel for profile-metadata updates that presence broadcasts forward.
- **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire shape of `identify`, `user_joined`, and `access_denied`.
- **[ipc-bridge](./ipc-bridge.md)** — the Electron Local API token store lives behind the same IPC boundary as other privileged operations.
## Changelog
| Date | Change |
|------|--------|
| 2026-05-25 | Initial documentation |