Auth-First WebSocket Handshake
chronicle intermediate 5 min read
ELI5
Connecting to the relay is like entering a vault: the door opens, but every other door inside stays locked until you present a token at the front desk. The relay never trusts the name on your visitor sticker — only the token.
Technical Deep Dive
Rule
Per ADR-001, the relay does not trust identity from WebSocket query parameters. user_id in WsParams is explicitly DEPRECATED and ignored (relay/src/handlers/websocket.rs::WsParams). Identity is established only by an authenticate protocol message.
Allowed Pre-Auth Messages
ProtocolMessage::requires_auth returns false only for Authenticate, Ping, and Pong (relay/src/protocol/schema.rs:478). Every other variant is rejected with AUTH_REQUIRED until the connection transitions to authenticated.
Handshake Sequence
sequenceDiagram autonumber participant Client as app (websocket.ts) participant WS as relay /ws/documents/:id participant Auth as AuthService Client->>WS: WebSocket upgrade (cookie ignored, user_id query ignored) WS-->>Client: socket open (state=Connecting) Client->>WS: { type: "authenticate", token, protocol_version } WS->>Auth: verify(token) alt token valid Auth-->>WS: VerifiedIdentity { user_id, user_name, ... } WS-->>Client: { type: "auth_result", success: true, session_id, user_id, user_name, protocol_version } Note over Client,WS: connection state → Authenticated Client->>WS: { type: "join_document", document_id } WS-->>Client: { type: "joined_document", document_id, current_version, user_count } else token invalid / expired Auth-->>WS: AuthError WS-->>Client: { type: "auth_result", success: false, error_code: "TOKEN_EXPIRED"|... } WS-->>Client: close endState Machine
stateDiagram-v2 [*] --> Connecting: WebSocket upgrade Connecting --> Authenticated: authenticate succeeds Connecting --> Closed: authenticate fails / non-auth msg Authenticated --> Joined: join_document Joined --> Authenticated: leave_document Authenticated --> Closed: socket close Joined --> Closed: socket close Closed --> [*]ConnectionState itself only encodes two values (Connecting, Authenticated) in relay/src/services/connection.rs; the joined substate is tracked separately on SessionState.document_joined in the WebSocket handler.
Error Codes Used at Handshake
| Code | When |
|---|---|
TOKEN_INVALID | Token missing/empty/malformed |
TOKEN_EXPIRED | exp claim is in the past |
TOKEN_VERIFICATION_FAILED | Signature mismatch |
AUTH_REQUIRED | A privileged message arrived before auth_result success |
UNSUPPORTED_PROTOCOL_VERSION | protocol_version < MIN_PROTOCOL_VERSION |
Key Terms
- WsParams →
relay/src/handlers/websocket.rsquery DTO; onlycolorandsince_versionare honoured,user_idis logged as deprecated. - SessionState → per-connection mutable state held in
Arc<Mutex<...>>; gates document/presence broadcasts behindis_authenticated() && document_joined. - AuthResultMsg.session_id → relay-assigned ID returned on success; not the same as the
session_idclaim that may appear inside the JWT.
Q&A
Q: Can I send a ping before authenticating to keep the connection alive?
A: Yes. Ping and Pong are exempt from requires_auth.
Q: Why does the relay still accept user_id in the URL?
A: Backwards compatibility for old clients — the value is logged with a DEPRECATED warning and otherwise ignored (ws_handler in handlers/websocket.rs).
Q: What happens if a client sends operation while still in Connecting state?
A: The relay replies with an Error { code: "AUTH_REQUIRED", ... } and the operation is not stored.
Q: Is the session_id in auth_result durable across reconnects?
A: No — it is generated by the relay per connection. To resume, the client re-authenticates with its JWT.
Examples
The relay’s handler holds a tokio::select! that filters every broadcast through is_authenticated() && document_joined, which is why a client that authenticates but never joins a document silently receives nothing — it is not a bug, it is the gate.
neighbors on the map
- WebSocket Session Lifecycle adding a new privileged WS handler
- Presence Broadcast Channels diagnosing missing cursor updates in the editor
- Multi-Strategy Authentication debugging 401 errors across different clients
- Airlock JWT Handoff & Session Cookies debugging login loops or session expiry