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

11 KiB
Raw Blame History

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) 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.
  • Online / away / busy status, voice presence, game activity → presence.
  • The shape of WebSocket envelopes carrying identity claims → websocket-envelopes.
  • 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:

{
  "username": "string (required, unique)",
  "password": "string (required)",
  "displayName": "string (optional, defaults to username)"
}

Response (201):

{ "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).

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:1975:

  • 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.

  • access-control — consumes the oderId claimed via identify to authorize server joins, role lookups, and moderation actions.
  • presenceidentify is the canonical channel for profile-metadata updates that presence broadcasts forward.
  • websocket-envelopes — owns the wire shape of identify, user_joined, and access_denied.
  • ipc-bridge — the Electron Local API token store lives behind the same IPC boundary as other privileged operations.

Changelog

Date Change
2026-05-25 Initial documentation