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>
195 lines
11 KiB
Markdown
195 lines
11 KiB
Markdown
# 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 |
|