CRUMB a card from devarno-cloud

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
end

State 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

CodeWhen
TOKEN_INVALIDToken missing/empty/malformed
TOKEN_EXPIREDexp claim is in the past
TOKEN_VERIFICATION_FAILEDSignature mismatch
AUTH_REQUIREDA privileged message arrived before auth_result success
UNSUPPORTED_PROTOCOL_VERSIONprotocol_version < MIN_PROTOCOL_VERSION

Key Terms

  • WsParamsrelay/src/handlers/websocket.rs query DTO; only color and since_version are honoured, user_id is logged as deprecated.
  • SessionState → per-connection mutable state held in Arc<Mutex<...>>; gates document/presence broadcasts behind is_authenticated() && document_joined.
  • AuthResultMsg.session_id → relay-assigned ID returned on success; not the same as the session_id claim 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