Files
ruvnet--RuView/docs/adr/ADR-272-websocket-authentication-tickets.md
T
Dragan Spiridonov eb68e07a2c feat(ui): fetch WebSocket tickets, prefix-match WS paths, and write ADR-272
Completes ADR-272. Three parts.

1. PREFIX MATCHING, not an allowlist. Anything under `/ws/` is a WebSocket path,
   plus `/api/v1/stream/pose` which lives outside it. An allowlist means every
   WebSocket route added later ships ungated until someone remembers to extend
   it — the same bug reintroduced on a delay. Not hypothetical:
   `/ws/train/progress` (ADR-186, arriving with PR #1387) is ALREADY referenced
   by ui/services/training.service.js and would have shipped unauthenticated.
   Pinned by a test that asserts it is gated before it exists.

2. UI wiring. A shared `withWsTicket()` helper mints a ticket immediately before
   each connection attempt — never cached, because a ticket is single-use and
   expires in seconds, so reusing one across reconnects fails on the second
   attempt. Wired into the three sites that open gated sockets:
   sensing.service.js, websocket-client.js, observatory/js/main.js.

   It degrades in both directions on purpose: no stored token means auth is off
   and no ticket is needed; a 404 from /api/v1/ws-ticket means a server
   predating this ADR, which still exempts WebSockets, so connecting without a
   ticket is correct there. The same UI therefore works against old and new
   servers, which is what makes the escape hatch removable later rather than
   permanent.

   The long-lived bearer token is still never put in a URL — only the ticket is.

3. ADR-272 itself. Previously cited in five places without existing; the same
   dangling-reference mistake made with ADR-271 earlier today, so it is written
   before this lands rather than after someone notices.

   It records the measured before/after, why a credential in a URL is
   acceptable here specifically (single use, seconds-long, not the credential),
   why the escape hatch exists and why it is deliberately uncomfortable, and
   what is deliberately NOT done — including that /health/metrics stays ungated,
   with the caveat that this should be revisited if metrics ever carry
   occupancy-derived values, since that would make them sensing data wearing an
   ops label.

Tests: 526 sensing-server (4 new path-matching), 82 ruview-auth. JS
syntax-checked with `node --check`; there is no UI test suite to extend.

Co-Authored-By: Ruflo & AQE
2026-07-22 19:15:20 +02:00

8.1 KiB

ADR-272: WebSocket authentication tickets

  • Status: accepted
  • Date: 2026-07-22
  • Deciders: RuView maintainers
  • Tags: auth, websocket, security, sensing-server
  • Related: ADR-271 (Cognitum OAuth resource server), ADR-055 (integrated sensing server), PR #1313 (the exemption this supersedes), cognitum-one/dashboard ADR-060

Context

bearer_auth gates /api/v1/*. WebSocket upgrade endpoints were exempt, for a real reason: a browser's WebSocket constructor cannot attach an Authorization header to the handshake, so a gated socket is simply unreachable from page JavaScript. /ws/sensing and /ws/introspection sat outside PROTECTED_PREFIX entirely; /api/v1/stream/pose was added to an explicit EXEMPT_PATHS list by PR #1313.

The reasoning was sound. The consequence was not, and it was measured rather than argued. On a server with RUVIEW_API_TOKEN set — an operator who believes authentication is ON — a real WebSocket handshake carrying no credential at all:

/ws/sensing            -> 101 Switching Protocols
/ws/introspection      -> 101 Switching Protocols
/api/v1/stream/pose    -> 101 Switching Protocols
/api/v1/models         -> 401 Unauthorized        (control)

The control plane was locked and the data plane was open. /ws/sensing carries the live sensing output — presence, pose, breathing and heart rate. /ws/introspection exposes internal pipeline state. For the ADR-055 desktop topology (server bundled in the app, loopback only) that is bounded. For the LAN/hub deployment RuView also supports, anyone who can reach the port can watch the sensor.

ADR-271 sharpened the contrast rather than causing it: the REST surface is now genuinely strong — offline-verified Cognitum tokens, scope-separated destructive routes — which makes an ungated data plane the obvious way in.

Precision about the evidence: the handshake completing was verified. A payload frame was not captured in that window, so the finding is "the connection is established without a credential", not "data was read".

Decision

Gate every WebSocket upgrade. Accept either of two credentials, chosen to match what each kind of client can actually do.

1. Native clients send a bearer on the upgrade

The Python client, the Rust CLI and the TypeScript MCP client are not browsers and have never been subject to the header limitation. They send a normal Authorization: Bearer on the handshake. Routing them through a ticket would add a round-trip and a second credential path for no benefit.

2. Browsers exchange their credential for a single-use ticket

POST /api/v1/ws-ticket is an ordinary authenticated request — where headers do work — and returns an opaque ticket the page appends as ?ticket=<value> on the socket URL.

A credential in a URL is normally a mistake. URLs reach access logs, Referer headers and browser history. Three properties bound this one, and all three are load-bearing:

Property Why it matters
Single use — consumed on the first upgrade attempt, valid or not A ticket found in a log is already spent
~30 second TTL Long enough to open a socket; not long enough to harvest
Not the credential — authorizes one WebSocket Cannot be replayed against /api/v1/*, cannot be refreshed, carries no reusable identity

The long-lived bearer token is still never placed in a URL.

A ticket inherits the issuing principal's scopes, so a sensing:read session cannot mint one that outranks itself, and a ticket from a token without sensing:read is refused at the upgrade.

3. WebSocket paths are matched by prefix, not by an allowlist

Anything under /ws/ is treated as an upgrade path, plus the one endpoint that lives outside it (/api/v1/stream/pose).

This is the most important detail in the ADR. An allowlist means every WebSocket route added later is ungated until someone remembers to extend it — the same bug, reintroduced on a delay. It is not hypothetical: /ws/train/progress (ADR-186, arriving with PR #1387) is already referenced by ui/services/training.service.js and would have shipped unauthenticated under an allowlist. Prefix matching gates it on arrival.

New WebSocket routes should live under /ws/ and inherit gating for free.

4. A migration escape hatch, deliberately uncomfortable

RUVIEW_WS_LEGACY_UNAUTHENTICATED=1 restores the previous behaviour. Gating these paths breaks a browser UI that has not yet been updated to fetch a ticket, and not every deployment can update server and UI in lockstep.

It is a migration aid, not a supported configuration:

  • It logs a warning on every boot naming the actual exposure — "the live sensing stream — presence, pose and vital signs — is readable by anyone who can reach this port" — rather than something an operator can skim past.
  • Its blast radius is exactly the WebSocket paths. A test pins that it does not weaken /api/v1/*.
  • It is read once at construction, so changing the environment cannot silently open the paths on a running server.

The alternative — a clean break with no hatch — was considered and rejected as sequencing, not principle: a hard break tempts an operator into turning auth off entirely, which is strictly worse than a narrow, loudly-announced exception. The hatch should be removed once the shipped UI fetches tickets.

5. Deployments with auth off are unchanged

No credential configured ⇒ the middleware is the same no-op it has always been. Pinned by a test.

Consequences

  • The measured hole is closed: all three paths now return 401 to a credential-less handshake, while a bearer or a valid ticket returns 101.
  • Browser UIs need updating. Shipped in the same change for sensing.service.js, websocket-client.js and observatory/js/main.js via a shared withWsTicket() helper; a ticket is minted per connection attempt and never cached, because it is single-use and short-lived.
  • A UI running against a server that predates this ADR still works: the helper treats 404 from /api/v1/ws-ticket as "no ticket needed".
  • One more round-trip before a browser opens a socket. Negligible against a stream that then runs for minutes.
  • Tickets live in memory, capped at 512 outstanding and self-healing as they expire, so an authenticated but misbehaving caller cannot grow the store without bound. In-memory is correct rather than convenient: a ticket surviving a restart would outlive the server that vouched for it.

Supersedes

PR #1313's enabled_exempts_pose_stream_websocket, which asserted the exemption. Its premise about browsers was correct and is preserved here; its conclusion is replaced. The test was renamed and inverted rather than deleted, with the history in its doc comment, and the half that still matters — the WebSocket rule must not leak to other /api/v1/* paths — is kept.

Deliberately not done

  • /health* stays ungated. Orchestrator probes hit it anonymously, and that is the point of a liveness endpoint. /health/metrics is included in that exemption; if metrics ever carry occupancy-derived values this should be revisited, because that would make them sensing data wearing an ops label.
  • /ui/* stays ungated. It is static assets; the data behind them is gated.
  • No revocation of an issued ticket. It expires in seconds and is single-use; a revocation path would be more machinery than the exposure justifies.
  • No ticket for native clients. They can send a header, so they should.

Implementation

v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs (store), src/bearer_auth.rs (gating), src/main.rs (POST /api/v1/ws-ticket), ui/services/ws-ticket.js plus the three call sites.

Tests: 12 store, 9 gating, 4 path-matching. Store coverage includes single-use enforcement, replay refusal, expiry refusal and pruning, 256-bit unpredictability, cap enforcement and self-healing, and ?myticket=x not being read as ?ticket=x. Gating coverage includes every known WS path refusing an unauthenticated upgrade, bearer acceptance, ticket single-use, a ticket being useless against REST, the escape hatch working and not weakening REST, and auth-off behaviour unchanged.