Skip to content

Commit 99700c7

Browse files
Merge #1397: Cognitum OAuth resource server, sign-in, and WebSocket auth (ADR-271/272)
Cognitum OAuth for RuView: resource server, sign-in, and WebSocket authentication (ADR-271/272)
2 parents 544b746 + 89babb0 commit 99700c7

37 files changed

Lines changed: 8769 additions & 82 deletions

.github/workflows/ci.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,41 @@ jobs:
171171
- name: ADR-135 calibration witness proof (determinism guard)
172172
run: bash scripts/verify-calibration-proof.sh
173173

174+
# The workspace runs with --no-default-features, which switches OFF
175+
# ruview-auth's `login` and `pkce` features. That silently excluded 40 of
176+
# its 87 tests — the whole interactive sign-in path: credential storage,
177+
# single-flight refresh, the advisory file lock, the loopback callback, and
178+
# PKCE generation. They were green locally and never executed here.
179+
# Measured: 47 tests with --no-default-features, 87 with --all-features.
180+
- name: Run ruview-auth tests with all features (ADR-271 login path)
181+
working-directory: v2
182+
env:
183+
CARGO_PROFILE_DEV_DEBUG: "0"
184+
CARGO_PROFILE_TEST_DEBUG: "0"
185+
run: cargo test -p ruview-auth --all-features
186+
187+
# Browser-facing JavaScript.
188+
#
189+
# These run the dashboard's own modules in Node with stubbed browser globals.
190+
# They exist because the Rust suite cannot see them at all: two ADR-271/272
191+
# defects (a service worker caching /oauth/status, and the WebSocket ticket
192+
# helper) lived entirely in `ui/` and were invisible to a fully green
193+
# workspace. Blocking, and fast — no browser, no install step.
194+
ui-tests:
195+
name: UI JavaScript Tests
196+
runs-on: ubuntu-latest
197+
steps:
198+
- name: Checkout code
199+
uses: actions/checkout@v4
200+
201+
- name: Set up Node
202+
uses: actions/setup-node@v4
203+
with:
204+
node-version: '22'
205+
206+
- name: Run UI unit tests
207+
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs
208+
174209
# Unit and Integration Tests
175210
# Python pytest matrix — runs against the archived v1 Python tree.
176211
# `continue-on-error: true` for the same reason as code-quality above:

docs/adr/ADR-271-cognitum-oauth-resource-server.md

Lines changed: 487 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# ADR-272: WebSocket authentication tickets
2+
3+
- **Status**: accepted
4+
- **Date**: 2026-07-22
5+
- **Deciders**: RuView maintainers
6+
- **Tags**: auth, websocket, security, sensing-server
7+
- **Related**: ADR-271 (Cognitum OAuth resource server), ADR-055 (integrated sensing server), PR #1313 (the exemption this supersedes), cognitum-one/dashboard ADR-060
8+
9+
## Context
10+
11+
`bearer_auth` gates `/api/v1/*`. WebSocket upgrade endpoints were exempt, for a
12+
real reason: a browser's `WebSocket` constructor cannot attach an
13+
`Authorization` header to the handshake, so a gated socket is simply
14+
unreachable from page JavaScript. `/ws/sensing` and `/ws/introspection` sat
15+
outside `PROTECTED_PREFIX` entirely; `/api/v1/stream/pose` was added to an
16+
explicit `EXEMPT_PATHS` list by PR #1313.
17+
18+
The reasoning was sound. The consequence was not, and it was measured rather
19+
than argued. On a server with `RUVIEW_API_TOKEN` set — an operator who believes
20+
authentication is ON — a real WebSocket handshake carrying **no credential at
21+
all**:
22+
23+
```
24+
/ws/sensing -> 101 Switching Protocols
25+
/ws/introspection -> 101 Switching Protocols
26+
/api/v1/stream/pose -> 101 Switching Protocols
27+
/api/v1/models -> 401 Unauthorized (control)
28+
```
29+
30+
**The control plane was locked and the data plane was open.** `/ws/sensing`
31+
carries the live sensing output — presence, pose, breathing and heart rate.
32+
`/ws/introspection` exposes internal pipeline state. For the ADR-055 desktop
33+
topology (server bundled in the app, loopback only) that is bounded. For the
34+
LAN/hub deployment RuView also supports, anyone who can reach the port can
35+
watch the sensor.
36+
37+
ADR-271 sharpened the contrast rather than causing it: the REST surface is now
38+
genuinely strong — offline-verified Cognitum tokens, scope-separated
39+
destructive routes — which makes an ungated data plane the obvious way in.
40+
41+
*Precision about the evidence:* the handshake completing was verified. A
42+
payload frame was not captured in that window, so the finding is "the
43+
connection is established without a credential", not "data was read".
44+
45+
## Decision
46+
47+
Gate every WebSocket upgrade. Accept **either** of two credentials, chosen to
48+
match what each kind of client can actually do.
49+
50+
### 1. Native clients send a bearer on the upgrade
51+
52+
The Python client, the Rust CLI and the TypeScript MCP client are not browsers
53+
and have never been subject to the header limitation. They **can** send a normal
54+
`Authorization: Bearer` on the handshake, so the server accepts one there;
55+
routing them through a ticket would add a round-trip and a second credential
56+
path for no benefit.
57+
58+
> **Correction, 2026-07-23.** This section previously stated that those clients
59+
> **do** send a bearer. The published Python client does not:
60+
> `python/wifi_densepose/client/ws.py` calls `websockets.connect(url,
61+
> ping_interval, ping_timeout, max_size)` and passes no headers at all — the
62+
> file contains zero occurrences of `extra_headers` or `Authorization`. So every
63+
> `wifi-densepose[client]` consumer **401s the moment an operator enables
64+
> auth**, and this ADR told them they would be fine.
65+
>
66+
> The server side of the decision stands — a bearer on the upgrade is accepted,
67+
> and that is the right contract for a non-browser client. What is missing is
68+
> the client implementing it, tracked as ruvnet/RuView#1395. Until then the only
69+
> remedy available to those users is
70+
> `RUVIEW_WS_LEGACY_UNAUTHENTICATED=1`, which reopens the exposure this ADR
71+
> exists to close — so it is a migration aid with a deadline, not an answer.
72+
73+
### 2. Browsers exchange their credential for a single-use ticket
74+
75+
`POST /api/v1/ws-ticket` is an ordinary authenticated request — where headers
76+
*do* work — and returns an opaque ticket the page appends as
77+
`?ticket=<value>` on the socket URL.
78+
79+
**A credential in a URL is normally a mistake.** URLs reach access logs,
80+
`Referer` headers and browser history. Three properties bound this one, and all
81+
three are load-bearing:
82+
83+
| Property | Why it matters |
84+
|---|---|
85+
| **Single use** — consumed on the first upgrade attempt, valid or not | A ticket found in a log is already spent |
86+
| **~30 second TTL** | Long enough to open a socket; not long enough to harvest |
87+
| **Not the credential** — authorizes one WebSocket | Cannot be replayed against `/api/v1/*`, cannot be refreshed, carries no reusable identity |
88+
89+
The long-lived bearer token is still never placed in a URL.
90+
91+
A ticket **inherits the issuing principal's scopes**, so a `sensing:read`
92+
session cannot mint one that outranks itself, and a ticket from a token without
93+
`sensing:read` is refused at the upgrade.
94+
95+
### 3. WebSocket paths are matched by **prefix**, not by an allowlist
96+
97+
Anything under `/ws/` is treated as an upgrade path, plus the one endpoint that
98+
lives outside it (`/api/v1/stream/pose`).
99+
100+
This is the most important detail in the ADR. An allowlist means every
101+
WebSocket route added later is ungated until someone remembers to extend it —
102+
the same bug, reintroduced on a delay. It is not hypothetical:
103+
`/ws/train/progress` (ADR-186, arriving with PR #1387) is already referenced by
104+
`ui/services/training.service.js` and would have shipped unauthenticated under
105+
an allowlist. Prefix matching gates it on arrival.
106+
107+
New WebSocket routes should live under `/ws/` and inherit gating for free.
108+
109+
### 4. A migration escape hatch, deliberately uncomfortable
110+
111+
`RUVIEW_WS_LEGACY_UNAUTHENTICATED=1` restores the previous behaviour. Gating
112+
these paths **breaks a browser UI that has not yet been updated to fetch a
113+
ticket**, and not every deployment can update server and UI in lockstep.
114+
115+
It is a migration aid, not a supported configuration:
116+
117+
- It logs a warning on every boot naming the actual exposure — "the live
118+
sensing stream — presence, pose and vital signs — is readable by anyone who
119+
can reach this port" — rather than something an operator can skim past.
120+
- Its blast radius is exactly the WebSocket paths. A test pins that it does not
121+
weaken `/api/v1/*`.
122+
- It is read **once at construction**, so changing the environment cannot
123+
silently open the paths on a running server.
124+
125+
The alternative — a clean break with no hatch — was considered and rejected as
126+
sequencing, not principle: a hard break tempts an operator into turning auth off
127+
entirely, which is strictly worse than a narrow, loudly-announced exception.
128+
The hatch should be removed once the shipped UI fetches tickets.
129+
130+
### 5. Deployments with auth off are unchanged
131+
132+
No credential configured ⇒ the middleware is the same no-op it has always been.
133+
Pinned by a test.
134+
135+
## Consequences
136+
137+
- The measured hole is closed: all three paths now return `401` to a
138+
credential-less handshake, while a bearer or a valid ticket returns `101`.
139+
- Browser UIs need updating. Shipped in the same change for
140+
`sensing.service.js`, `websocket-client.js` and `observatory/js/main.js` via
141+
a shared `withWsTicket()` helper; a ticket is minted per connection attempt
142+
and never cached, because it is single-use and short-lived.
143+
- A UI running against a server that predates this ADR still works: the helper
144+
treats `404` from `/api/v1/ws-ticket` as "no ticket needed".
145+
- One more round-trip before a browser opens a socket. Negligible against a
146+
stream that then runs for minutes.
147+
- Tickets live in memory, capped at 512 outstanding and self-healing as they
148+
expire, so an authenticated but misbehaving caller cannot grow the store
149+
without bound. In-memory is correct rather than convenient: a ticket
150+
surviving a restart would outlive the server that vouched for it.
151+
152+
## Supersedes
153+
154+
PR #1313's `enabled_exempts_pose_stream_websocket`, which asserted the
155+
exemption. Its premise about browsers was correct and is preserved here; its
156+
conclusion is replaced. The test was renamed and inverted rather than deleted,
157+
with the history in its doc comment, and the half that still matters — the
158+
WebSocket rule must not leak to other `/api/v1/*` paths — is kept.
159+
160+
## Deliberately not done
161+
162+
- **`/health*` stays ungated.** Orchestrator probes hit it anonymously, and
163+
that is the point of a liveness endpoint. `/health/metrics` is included in
164+
that exemption; if metrics ever carry occupancy-derived values this should be
165+
revisited, because that would make them sensing data wearing an ops label.
166+
- **`/ui/*` stays ungated.** It is static assets; the data behind them is
167+
gated.
168+
- **No revocation of an issued ticket.** It expires in seconds and is
169+
single-use; a revocation path would be more machinery than the exposure
170+
justifies.
171+
- **No ticket for native clients.** They can send a header, so they should.
172+
173+
## Implementation
174+
175+
`v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs` (store),
176+
`src/bearer_auth.rs` (gating), `src/main.rs` (`POST /api/v1/ws-ticket`),
177+
`ui/services/ws-ticket.js` plus the three call sites.
178+
179+
Tests: 12 store, 9 gating, 4 path-matching. Store coverage includes single-use
180+
enforcement, replay refusal, expiry refusal *and* pruning, 256-bit
181+
unpredictability, cap enforcement and self-healing, and `?myticket=x` not being
182+
read as `?ticket=x`. Gating coverage includes every known WS path refusing an
183+
unauthenticated upgrade, bearer acceptance, ticket single-use, a ticket being
184+
useless against REST, the escape hatch working *and* not weakening REST, and
185+
auth-off behaviour unchanged.

ui/observatory/js/main.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* - Dot-matrix mist body mass, particle trails, WiFi waves, signal field
99
* - Reflective floor, settings dialog, and practical data HUD
1010
*/
11+
import { withWsTicket } from '../../services/ws-ticket.js';
1112
import * as THREE from 'three';
1213
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
1314

@@ -462,7 +463,7 @@ class Observatory {
462463
console.log('[Observatory] Sensing server detected at', base, '→', wsUrl);
463464
this.settings.dataSource = 'ws';
464465
this.settings.wsUrl = wsUrl;
465-
this._connectWS(wsUrl);
466+
void this._connectWS(wsUrl);
466467
} else {
467468
tryNext(i + 1);
468469
}
@@ -472,10 +473,13 @@ class Observatory {
472473
tryNext(0);
473474
}
474475

475-
_connectWS(url) {
476+
// async: `/ws/sensing` is gated (ADR-272); mint a single-use ticket first.
477+
async _connectWS(url) {
476478
this._disconnectWS();
479+
let wsUrl = url;
480+
try { wsUrl = await withWsTicket(url); } catch { /* auth off or pre-ADR-272 server */ }
477481
try {
478-
this._ws = new WebSocket(url);
482+
this._ws = new WebSocket(wsUrl);
479483
this._ws.onopen = () => {
480484
console.log('[Observatory] WebSocket connected');
481485
this._hud.updateSourceBadge('ws', this._ws);

ui/services/api.service.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,21 @@ export class ApiService {
8686
// Process response through interceptors
8787
const processedResponse = await this.processResponse(response, url);
8888

89+
// NOTE: there is deliberately no step-up re-authentication branch here.
90+
//
91+
// An earlier revision caught the server's RFC 6750 "reauthentication
92+
// required" challenge and redirected to /oauth/start. That challenge can
93+
// never be issued to a browser: browser sign-in requests `sensing:read`
94+
// only and always will (see BROWSER_SIGNIN_SCOPE), so no browser session
95+
// holds `sensing:admin`, so the freshness gate the challenge announces is
96+
// never reached. Admin work goes through the CLI or a pasted bearer.
97+
//
98+
// Removed rather than left inert, because it was not merely dead — it
99+
// ended in a promise that never settles. If any other 401 ever grew that
100+
// header, every caller awaiting this would hang forever with no error.
101+
// The server-side guard stays as a fail-closed backstop; the client has
102+
// nothing to do about a flow that does not exist.
103+
89104
// Handle errors
90105
if (!processedResponse.ok) {
91106
const error = await processedResponse.json().catch(() => ({

ui/services/sensing.service.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { withWsTicket } from './ws-ticket.js';
12
/**
23
* Sensing WebSocket Service
34
*
@@ -65,7 +66,7 @@ class SensingService {
6566

6667
/** Start the service (connect or simulate). */
6768
start() {
68-
this._connect();
69+
void this._connect();
6970
}
7071

7172
/** Stop the service entirely. */
@@ -120,13 +121,26 @@ class SensingService {
120121

121122
// ---- Connection --------------------------------------------------------
122123

123-
_connect() {
124+
// async because the server gates `/ws/sensing` (ADR-272) and a browser
125+
// cannot set an Authorization header on an upgrade — so we mint a
126+
// single-use ticket first. Minted per connect attempt, never cached: a
127+
// ticket is valid once and expires in seconds, so reusing one across
128+
// reconnects would fail on the second attempt.
129+
async _connect() {
124130
if (this._ws && this._ws.readyState <= WebSocket.OPEN) return;
125131

126132
this._setState('connecting');
127133

134+
let url = SENSING_WS_URL;
128135
try {
129-
this._ws = new WebSocket(SENSING_WS_URL);
136+
url = await withWsTicket(SENSING_WS_URL);
137+
} catch {
138+
// Ticket minting is best-effort: against a server with auth off, or one
139+
// predating ADR-272, connecting without a ticket is correct.
140+
}
141+
142+
try {
143+
this._ws = new WebSocket(url);
130144
} catch (err) {
131145
console.warn('[Sensing] WebSocket constructor failed:', err.message);
132146
this._fallbackToSimulation();
@@ -184,7 +198,7 @@ class SensingService {
184198

185199
this._reconnectTimer = setTimeout(() => {
186200
this._reconnectTimer = null;
187-
this._connect();
201+
void this._connect();
188202
}, delay);
189203

190204
// Only start simulation after several failed attempts so a brief hiccup

ui/services/websocket-client.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { withWsTicket } from './ws-ticket.js';
12
// WebSocket Client for Three.js Visualization - WiFi DensePose
23
// Default endpoint is `/ws/sensing` on the same host the page was served from.
34
// Callers (e.g. viz.html) usually pass an explicit `url` derived from
@@ -47,7 +48,9 @@ export class WebSocketClient {
4748
}
4849

4950
// Attempt to connect
50-
connect() {
51+
// async: `/ws/*` is gated (ADR-272) and a browser cannot set an
52+
// Authorization header on an upgrade, so mint a single-use ticket first.
53+
async connect() {
5154
if (this.state === 'connecting' || this.state === 'connected') {
5255
console.warn('[WS-VIZ] Already connected or connecting');
5356
return;
@@ -56,8 +59,12 @@ export class WebSocketClient {
5659
this._setState('connecting');
5760
console.log(`[WS-VIZ] Connecting to ${this.url}`);
5861

62+
// Per attempt, never cached — a ticket is single-use and short-lived.
63+
let url = this.url;
64+
try { url = await withWsTicket(this.url); } catch { /* auth off or pre-ADR-272 server */ }
65+
5966
try {
60-
this.ws = new WebSocket(this.url);
67+
this.ws = new WebSocket(url);
6168
this.ws.binaryType = 'arraybuffer';
6269

6370
this.ws.onopen = () => this._handleOpen();
@@ -235,7 +242,7 @@ export class WebSocketClient {
235242
console.log(`[WS-VIZ] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
236243

237244
this.reconnectTimer = setTimeout(() => {
238-
this.connect();
245+
void this.connect();
239246
}, delay);
240247
}
241248

0 commit comments

Comments
 (0)