Compare commits

..

32 Commits

Author SHA1 Message Date
github-actions[bot] 33bf759b14 chore: update vendor submodules to latest upstream 2026-07-26 07:00:50 +00:00
rUv f783df234e fix release publishing pipelines (#1417)
Make deployment consume the exact published sensing-server image, publish the two Python packages in lock-step, and enforce the production witness gate.
2026-07-24 09:10:52 -07:00
rUv e1e10ad7be fix release publishing pipelines (#1417)
Make deployment consume the exact published sensing-server image, publish the two Python packages in lock-step, and enforce the production witness gate.
2026-07-24 08:57:36 -07:00
Dragan Spiridonov 99700c7851 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)
2026-07-24 16:01:06 +02:00
Dragan Spiridonov 89babb00a9 Merge remote-tracking branch 'origin/main' into feat/ruview-auth-cognitum-oauth-verifier
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-07-24 15:26:15 +02:00
Dragan Spiridonov 56327d0931 decide: browser sessions are read-only permanently; drop the dead step-up client
Decision: browser-side admin is not wanted. `BROWSER_SIGNIN_SCOPE` stays
`sensing:read`, and the escalate-on-demand design sketched while this was open
is not being built. Destructive operations — training, model delete, recording
delete — keep their home in the CLI, where `--admin` is explicit and typed by a
person. Routing them through a browser would mean either asking every user to
consent to delete capability in order to watch a stream, or building a second
consent flow to avoid that.

Consequences, now settled rather than open:

- The UI's admin controls are unreachable from a Cognitum browser session, and
  that is intended. The manual token-paste field is unchanged and still carries
  whatever authority the pasted token has, so nothing that worked before stops
  working.

- REMOVED the client-side step-up redirect from ui/services/api.service.js. It
  caught an RFC 6750 challenge that can never be issued to a browser, and it
  ended in `return new Promise(() => {})` — so if any other 401 had ever grown
  that header, every caller awaiting it would have hung forever with no error
  and no timeout. Dead code with a trap in it is worse than no code.

- KEPT ADMIN_REVERIFY_SECS as a server-side backstop. Fail-closed and free, so
  if the requested scope is ever widened the freshness requirement is already
  in place. Documented at its definition as a backstop specifically so nobody
  reads its passing tests as evidence the control is exercised — the tests
  reach it through a crate-internal seam that mints an admin cookie the real
  flow does not produce.

ADR-271 also stops hedging on the session TTL: chosen is A at one hour. Option B
(server-side refresh-token store) is not built, and the ADR now names the
residual instead of implying it is closed — within one hour a revoked Cognitum
grant still reads sensing data through an existing browser session. There is no
introspection endpoint, so nothing short of B closes that, and one hour is the
size of the hole we accepted.

Adds `the_cookie_max_age_matches_the_session_expiry`, which the previous ADR
revision asked for and nobody had written: Max-Age and the payload's `exp` are
two independent expressions of one lifetime, and drift means either the browser
presents a session we reject or we hold authority the browser discarded.

Verified: workspace 176 suites clean, UI 22, api.service.js parses.

Co-Authored-By: Ruflo & AQE
2026-07-23 14:47:31 +02:00
Dragan Spiridonov 1ed0bc57ef docs(adr): correct a false claim about browser scope; pin the decision in code
A cross-vendor pre-merge sweep found no merge blockers, but did surface an
error in ADR-271 that I wrote — and a coherence gap behind it.

ADR-271 P2 stated that capping the browser session at `sensing:read` was
"considered and rejected, because the dashboard genuinely performs admin
operations". That is wrong. `/oauth/start` (main.rs:9206) already requests
`SENSING_READ` and nothing else, deliberately, with a comment saying so. The
browser session is ALREADY read-only, so the breakage I claimed capping would
cause is simply the current behaviour.

Two consequences, now stated in the ADR instead of left to be discovered:

1. The UI's admin controls do not work from a browser OAuth session.
   `model.service.js:136` issues DELETE /api/v1/models/{id}, which 401s. Admin
   work needs the CLI (`login --admin`) or a pasted admin bearer. A gap in a new
   feature, not a regression — the token-paste path is unchanged.

2. The ADMIN_REVERIFY_SECS step-up control added in the previous commit guards
   a case that cannot currently arise. No browser session holds `sensing:admin`,
   so the freshness branch never fires in production. Its tests pass because the
   crate-internal seam mints an admin cookie the real flow never produces.

That second point is worth being blunt about: it is the same shape as several
defects this branch already fixed — correct code, green tests, unreachable call
site. The difference is that here the guard is deliberately ahead of the need
rather than mistakenly behind it, and saying which one it is matters.

So the constant is now named `BROWSER_SIGNIN_SCOPE` rather than inlined, with
the cost of widening it documented at the definition, and two tests:
`browser_sign_in_stays_read_only_until_someone_decides_otherwise` pins the
value, and `the_authorize_url_actually_carries_that_scope` proves it reaches the
wire — asserting on the constant alone would pass even if `begin` were called
with something else, which is exactly the isolation failure being guarded
against.

The ADR also records the coherent way to add browser-side admin if wanted:
escalate-on-demand via the RFC 6750 challenge, keeping least privilege by
default rather than asking every user to consent to delete capability to watch
a stream. Not bundled here — it needs a scope parameter and a UI affordance.

Sweep verdict: no merge blockers. Two other non-blocking risks it raised are
accurate and unchanged: concurrent JWKS refresh at the stale boundary is not
atomic (a duplicated idempotent GET, already documented as an accepted cost),
and the service worker's SHELL_ASSETS use root-relative paths while the UI
mounts under /ui, so offline shell precaching is incomplete — pre-existing,
unrelated to this branch.

Verified: workspace 176 suites clean.

Co-Authored-By: Ruflo & AQE
2026-07-23 14:31:01 +02:00
Dragan Spiridonov f7cc68bd5c fix(auth): check scope before step-up, so read-only callers are not sent in a circle
Found by an empirical route sweep during the pre-merge pass, not by a test.

The step-up gate fired BEFORE the scope check, so a caller holding only
`sensing:read` who attempted a privileged action with a session older than
ADMIN_REVERIFY_SECS received the RFC 6750 "reauthentication required"
challenge. `api.service.js` acts on that by redirecting through /oauth/start —
and the user returns with exactly the same scopes and is refused again. One
wasted round trip, and a reason for the refusal that was simply untrue: they
were not refused for staleness, they were refused for capability.

Now the scope check comes first, so only a caller who actually HOLDS
`sensing:admin` is ever asked to prove the session is fresh. Not a loop before
(the second refusal is a plain 401 with no challenge) and not a security issue
either way — both paths refuse. It was misleading, and it cost a redirect.

Also confirms the deny-by-default gate does not over-reach. Probed the real
binary with auth off vs on:

  /, /ui/*, /health, /health/{live,ready,metrics,version},
  /oauth/{status,start}    unchanged
  /api/field, /api/v1/*    200 -> 401   (intended)
  /metrics, /favicon.ico   404 -> 401   (never existed; now uniform)

Nothing that returned 2xx before returns 401 now, which is the property that
matters for existing deployments.

Tests: +2. `a_read_only_user_is_not_sent_to_reauthenticate_pointlessly` fails
against the old ordering with the challenge header present; its counterpart
`an_admin_holder_with_a_stale_session_does_get_the_challenge` ensures the fix
does not simply disable the signal.

Verified: workspace 176 suites clean, ruview-auth 62+25+2 --all-features.

Co-Authored-By: Ruflo & AQE
2026-07-23 14:03:54 +02:00
Dragan Spiridonov 89cceaf835 fix(auth): close P1/P2/P3 — JWKS stall, 12h session, cookie shadowing
All three deferred findings from the qe-court round. Each fix is guarded by a
test confirmed to FAIL against the old behaviour.

P1 — JWKS: self-inflicted stall, and a blocking fetch on a tokio worker.

`fetched_at` advances only on SUCCESS, and the only rate limiter sat behind
`if fresh`. So once the TTL elapsed after the last successful fetch, `fresh`
was permanently false, the limiter was never consulted, and EVERY request
performed its own blocking 3s-timeout fetch. A Pi that loses WAN stalled
itself 300s later with no attacker present; an attacker could force the same
state by flooding tokens with an unknown `kid`.

Now: `last_attempt_at` is recorded BEFORE every fetch regardless of outcome,
and gates the stale path too; a stale-but-present key is served rather than
erroring, which is the offline tolerance this module always claimed.
Measured by the new test: 26 outbound fetches before, 1 after.

Kept as TWO independent limiters. Merging them looks tidy and is wrong — a
routine refetch would then suppress the unknown-`kid` path for 30s and delay
pickup of a key rotation inside the TTL. I made that mistake first; two
existing tests caught it.

The blocking call also now runs in `spawn_blocking` at the verify boundary,
matching what `main.rs` already does for the token exchange, where the comment
reads "the same mistake this codebase had to fix in jwks.rs". The hot
verification path had never been given the same treatment. A panicked task
fails closed.

P2 — session lifetime, per decision: 1 hour, plus step-up.

SESSION_TTL_SECS 12h -> 1h, and privileged (`sensing:admin`) actions now
require the user to have authenticated within ADMIN_REVERIFY_SECS (5 min),
tracked by a new `auth_time` claim. Reads ride the full session; only the
routes where a stale session does damage are re-verified, so a dashboard whose
main use is watching a live stream does not re-auth hourly.

`auth_time` is `#[serde(default)]`, so a cookie issued before the field existed
reads as 0 — infinitely stale. Such a session keeps working for reads and
cannot perform privileged actions. Fail-closed and self-healing on next sign-in.

The refusal carries an RFC 6750 `WWW-Authenticate` error code, because the
client's correct response differs from a plain 401: the user IS signed in and
needs to prove it again. `api.service.js` acts on that and redirects through
`/oauth/start` — otherwise a stale-session delete surfaces as a generic
"Request failed" with no hint that signing in again fixes it.

P3 — cookie shadowing.

`read_cookie` returned the FIRST match, and RFC 6265 §5.4 sends longer-`Path`
cookies first. Cookies are not isolated by port or scheme, so any other service
on the host — or a plain-HTTP MITM injecting Set-Cookie — could plant
`ruview_session=<their own validly signed session>; Path=/ui`. The victim sent
both, the attacker's first, and it verified because it genuinely was signed:
silent session takeover, with `/oauth/status` reporting the attacker's account.

The signature was doing its job throughout, which is why "it's signed" never
answered this. `__Host-` would, but requires `Secure`, and RuView is routinely
reached over plain HTTP on a LAN.

So both credential paths now accept only when EXACTLY ONE candidate verifies.
An attacker can still cause a refusal by planting a second valid cookie — a
nuisance — but no longer a takeover. Planting junk changes nothing, so this
does not become a trivial DoS.

Tests: +1 jwks (26-vs-1 fetch amplification), +4 step-up, +4 shadowing, +1
duplicate-name reader. Mutation-verified: reverting the stale-path guard gives
26 fetches; reverting to first-match cookie reads fails
`a_shadowing_cookie_cannot_silently_take_over_the_session`.

Verified: workspace 176 suites clean under CI flags, ruview-auth 62+25+2 with
--all-features, UI 22.

ADR-271: P1/P2/P3 marked RESOLVED with the analysis retained, since it explains
why each fix has the shape it does.

Co-Authored-By: Ruflo & AQE
2026-07-23 13:13:42 +02:00
Dragan Spiridonov 6ce50d5158 test(auth): cover browser sign-in; ADR-271/272 corrections and remediation plans
Browser sign-in was the newest security surface in this PR and had no
executable evidence behind it: browser_session.rs was 534 lines with 13 tests,
every one of which hit a private helper (sign, unsign, cookie, read_cookie,
is_live, has_scope). No test called issue, from_cookie_header, begin,
verifier_for_callback or is_configured, and no test anywhere presented a session
cookie to the gate.

+10 tests in browser_session, +6 in bearer_auth. Three mutants the adversarial
review named, each now verified dead by actually applying the mutation:

  (a) delete the `state` comparison in verifier_for_callback
      -> a_callback_whose_state_does_not_match_is_refused FAILED
      Without it the callback accepts a code from a flow the user never
      started: login CSRF, victim silently lands in the attacker's session.

  (b) `session.is_live().then_some(session)` -> `Some(session)`
      -> an_expired_session_cookie_does_not_authenticate FAILED
      -> an_expired_browser_session_is_refused FAILED
      `is_live` was already unit-tested; nothing asserted the CALLER consults
      it. Same "tested in isolation, call site untested" shape as the earlier
      refresh-never-invoked defect.

  (c) `session.has_scope(required)` -> `true`
      -> a_read_scoped_browser_session_cannot_delete_or_train FAILED
      Without it any browser session could delete models and start training.

Mutant (c) initially appeared to SURVIVE. It did not — there are two
has_scope call sites and the first substitution only hit one. Mutating the
one in `session_or_unauthorized` kills the test. That accident confirmed a
separate finding: the cookie branch inside require_bearer is unreachable when
an Authorization header is present, because the OAuth step returns on both
arms. It fails closed, so it is not a hole, but "try the next credential" is
what the code reads like. Pinned by
a_bad_bearer_beats_a_good_cookie_rather_than_falling_back.

Adds two crate-internal test seams (init_secret_for_tests, test_cookie_value).
test_cookie_value signs through the same path as `issue`, so tests presenting a
cookie exercise real verification rather than a test-only bypass.

ADR-271:
- The "browser cannot obtain an OAuth token" section asserted
  `grep -ril "oauth|cognitum|pkce" ui/` returns nothing. It now returns three
  files, invalidated by commits in this same PR. Marked superseded, original
  retained under a fold, replaced with what actually ships.
- Records the two deferred decisions with designs rather than patches: P1 the
  blocking JWKS fetch on a tokio worker (whose rate limiter is bypassed on
  exactly the stale path that matters, because fetched_at updates only on
  success — so after the TTL every request fetches, and a Pi that loses WAN
  stalls itself with no attacker present); P2 the 12-hour session from a
  15-minute token, with three costed options. Capping the session to
  sensing:read was considered and rejected: the dashboard genuinely issues
  DELETE /api/v1/models/{id}.
- P3 records that dropping `__Host-` costs origin-integrity, not just Secure —
  read_cookie takes the FIRST match and cookies are not port-scoped, so a
  same-host writer can shadow a session. Forgery was never the threat that
  prefix addresses.
- Documents redirect_uri's hardcoded default and the unconsumed CLI credential
  as known-incomplete, per decision to leave both as-is.

ADR-272: corrected a claim that would mislead users into a 401. It stated the
Python client DOES send Authorization: Bearer on the handshake; ws.py passes no
headers at all (zero occurrences of extra_headers or Authorization), so every
published client 401s once auth is enabled. Server-side decision unchanged.

Verified: sensing-server 566 + 179 + 7 + 5 + 8 + 4 + 16 pass under CI flags,
ruview-auth 61 + 25 + 2 with --all-features, auth_wiring 7.

Co-Authored-By: Ruflo & AQE
2026-07-23 12:08:13 +02:00
Dragan Spiridonov c72bbc15dd fix(auth): close /api/field bypass, two fail-opens, and a self-disarming test
Findings from a qe-court adversarial round (4 prosecutors across 2 vendors).
Each was verified against the code before being accepted; the ones below
reproduced, the rest are reported in the PR thread rather than acted on.

FATAL — `/api/field` was reachable with no credential, on both listeners.
The gate protected `/api/v1/*` by prefix. `/api/field` is the REST sibling of
`/ws/field` and serves the same signed FieldEvent stream — live presence, pose,
vitals. `/ws/field` was gated in this PR; its twin one path segment over was
not. Measured with RUVIEW_API_TOKEN set and no credential supplied:

    /api/v1/models  401      (control)
    /ws/field       401      (gated by this PR)
    /api/field      200      on :8080 AND :8765

Fixed by inverting the gate to deny-by-default with an explicit anonymous
allowlist (`/`, `/ui`, `/health`, `/oauth/`). A route added at a new path is
now gated because nobody exposed it, rather than exposed because nobody
protected it — the same inversion already applied to the scope gate.

FATAL — the wiring test disarmed itself exactly when it mattered.
`Server::start` returned an Option that all five tests turned into `return`,
so a server that failed to boot produced "5 passed" with zero assertions run,
and cargo swallows the skip line without --nocapture. The one test that
observes real wiring — the guard against both shipped bypasses — was silent
for any change that breaks startup, including a boot panic in the auth path.
It now panics with the child's stderr.

MAJOR — a malformed client-id list silently disabled the audience check.
An empty allowlist is the opt-out sentinel in verify.rs. `RUVIEW_OAUTH_CLIENT_IDS=","`
is non-empty, passes the guard, then filters to an empty Vec — turning the
audience boundary off with no log and admitting a token minted for any other
Cognitum product. Only a literal `*` may opt out now; anything else that parses
to nothing warns and falls back to the default. Same fail-open shape as the
scope denylist this PR already had to invert.

MAJOR — credentials were world-readable for a window on every refresh.
`fs::write` creates at 0666 & !umask (0644 by default), and both writers
chmodded afterwards. The existing permissions test asserted on the FINAL file
and passed throughout. Affected the CLI refresh token (rotated with reuse
detection — a thief who presents it first takes the session family) and the
browser session secret (the HMAC key for every session; stealing it forges any
account at any scope). Both now create with mode 0600 via OpenOptions.

MAJOR — ui/sw.js cached authenticated API responses.
Closing the /oauth/ leg left the /api/ leg open. `networkFirst` cached every
successful response, keyed by URL alone, purged by nothing at sign-out: sign in
as A, load sensing data, sign out, sign in as B, lose the network, and B is
served A's data with no authorization check. API responses are now network-only
— which is also the correct behaviour for a live sensing dashboard, where
replaying a stale reading can show a room occupied after the person left — plus
a cache purge on sign-out.

CI — 40 of ruview-auth's 87 tests never ran.
The workspace runs --no-default-features, which switches off the `login` and
`pkce` features. Measured: 47 tests vs 87. The whole interactive sign-in path —
credential storage, single-flight refresh, the file lock, the loopback callback
— was green locally and never executed in CI. Added an --all-features step.
(Checked the sensing-server for the same problem and did NOT find it:
bearer_auth's 49 and browser_session's 15 do run under CI flags.)

Tests: +2 wiring tests (one fails against the old gate with
"http port served /api/field to an anonymous caller", passes after), +3 UI
service-worker tests, +3 CLI scope tests, +1 temp-file permission test, +1
client-id parsing test. wifi-densepose-cli/src/auth.rs had zero tests and
builds its own scope string, so the library's least-privilege test said nothing
about what the CLI requests.

Verified: ruview-auth 61+25+2 pass (--all-features), sensing-server bearer_auth
50, browser_session 15, auth_wiring 7, workspace 25 suites clean, UI 22.

Co-Authored-By: Ruflo & AQE
2026-07-23 11:48:59 +02:00
Dragan Spiridonov 9b9754778f fix(ui): service worker cached /oauth/status, freezing browser sign-in
Browser sign-in worked, but the settings panel kept offering "Sign in with
Cognitum" afterwards; only a hard reload showed the true state. The server was
correct throughout.

Root cause: `ui/sw.js` routed cache-first as its CATCH-ALL for every path
outside `/api/` and `/health/`. `/oauth/status` therefore had its first
(signed-out) response stored in the Cache API and replayed to the page forever.
The Cache API is not the HTTP cache and ignores `Cache-Control` entirely, so the
`no-store, no-cache, must-revalidate` the server already sends could not prevent
it. A hard reload bypasses the service worker, which is why that alone appeared
to fix it, and why signing out re-poisoned the entry.

Fixes, in order of blast radius:

- `/oauth/` is never handled by the worker. Not network-first — that still
  writes a copy, which would be replayed the moment the server is briefly
  unreachable, silently reinstating a stale sign-in state.
- Cache-first is now an ALLOWLIST (navigations and static asset extensions)
  rather than the catch-all. This is the underlying defect: any endpoint added
  outside `/api/` was frozen on its first response. Unrecognised paths now go
  to the network untouched.
- `CACHE_NAME` bumped to `ruview-v2`, so `activate` evicts the poisoned
  `ruview-v1` from browsers that already ran the old worker. Verified: the
  cache list went from `[ruview-v1]` to `[ruview-v2]` on update.
- Shell lookups use `ignoreSearch` and store a search-less key, so the
  `?signed_in=<ms>` the callback redirects to does not mint a fresh, never-hit
  cache entry per sign-in.

Also: re-check sign-in state on `pageshow` (bfcache restore, where no script
re-runs) and on `visibilitychange` (signed in or out in another tab). Opening
the panel alone is not sufficient — it may already be open.

Verification, in a real browser driven end-to-end:
- Reproduced with a valid session cookie: server returned `signed_in: true` to
  curl while the page's own fetch got a cached `signed_in: false`, and the
  request never appeared in the server log at all.
- Confirmed the cached entry existed: `caches.open('ruview-v1').match(
  '/oauth/status')` returned the signed-out body.
- After the fix, on a NORMAL (not hard) reload the panel reads
  "Signed in as <account> - sensing:read" with Sign out shown.

Tests: `ui/sw.test.mjs` loads the real `sw.js` with stubbed worker globals and
asserts the routing decision per path. 4 of its 10 tests fail against the
pre-fix worker and pass after; the other 6 pin pre-existing guards (non-GET,
websocket upgrade, cross-origin, API paths, static assets, navigation) and pass
in both, so they track behaviour rather than the rewrite.

CI: adds a `ui-tests` job. Nothing ran the UI JavaScript before, so both this
suite and the ADR-272 ws-ticket suite would have rotted unexecuted — which is
the same blind spot that let this defect ship.

Removes the temporary `/oauth/status` cookie logging and the panel console
diagnostic added while tracking this down.

Co-Authored-By: Ruflo & AQE
2026-07-23 11:20:13 +02:00
Dragan Spiridonov 43737941cb fix(auth): two Set-Cookie headers were collapsing into one; clear the spent transaction
Two bugs, one of which I introduced while fixing the other and caught only by
checking the actual response.

1. THE SPENT TRANSACTION COOKIE WAS NEVER CLEARED ON SUCCESS.
   `clear_transaction` was only used on error paths, so after a successful
   sign-in `ruview_oauth_txn` lingered for its full 10-minute TTL and every
   subsequent request carried a dead cookie. Visible in the logs as
   `names=ruview_oauth_txn,ruview_session`. Now cleared alongside issuing the
   session, and on logout too.

2. THE FIX FOR (1) BROKE SIGN-IN, AND THE TEST CAUGHT IT.
   Axum's array-of-tuples response form REPLACES same-name headers rather than
   appending. Adding a second `Set-Cookie` silently overwrote the first, so the
   logout response emitted only the transaction clear and — had this shipped —
   the CALLBACK would have emitted only the transaction clear too, dropping the
   session cookie and making a successful OAuth round-trip a no-op.

   Caught by looking at the real response headers rather than trusting the
   change: `curl -D-` showed one Set-Cookie where there should have been two.

   Now uses `axum::response::AppendHeaders`. Both cookies verified present.

   Two tests pin this: one DOCUMENTS the footgun (the array form collapses two
   Set-Cookie headers into one) and one asserts AppendHeaders emits both. The
   first exists so the next person to reach for the tidier-looking array form
   finds out here instead of in production.

Also adds a cache-busting query to both redirect targets
(`/ui/?signed_in=<ms>` and `?signed_out=<ms>`). Landing on the same URL let the
browser restore the page from the back/forward cache with a stale panel, which
is why signing in appeared not to work until a hard refresh — the session was
established correctly every time, the page simply was not re-fetched.

Tests: 549 sensing-server lib.

Co-Authored-By: Ruflo & AQE
2026-07-23 10:46:43 +02:00
Dragan Spiridonov 4a704acc02 fix(ui): actually call refreshSignInPanel — VERIFIED IN A REAL BROWSER
`refreshSignInPanel` was exported and never invoked. The Cognitum Account panel
would have rendered "Checking…" forever and the Sign in button would never have
reflected a completed sign-in.

That is the FOURTH defect of this exact shape today — implemented, unit-tested,
and never called. The others: the refresh path no command invoked, scope
resolved only at write time, and a WebSocket listener with no auth layer. The
pattern is consistent: the logic was fine, the caller was missing, and a green
suite could not see it.

Now refreshed on every panel open — not once at construction — because the
session may have been established in another tab or expired since page load.
Buttons are also bound at construction so a click works before the first status
fetch resolves.

VERIFIED END TO END IN A REAL BROWSER (Chrome, http://127.0.0.1:8099/ui/), which
is the gap that has been open all session:

  1. panel showed "This server requires sign-in." + Sign in with Cognitum
  2. clicked -> auth.cognitum.one -> approved
  3. server: "browser sign-in complete" sub=ed3efb51-…
  4. DevTools: ruview_session cookie on 127.0.0.1, Path=/, HttpOnly, SameSite=Lax
  5. after reload the panel reads:
       "Signed in as UyShaIh1B1gL7km9Xli9kCDSdRT2 — sensing:read"  + Sign out

So the browser half of gate G-3 is now proven, not argued: PKCE, consent, the
server-side code exchange, ES256 verification with audience and scope checks,
the signed session cookie, and the UI reading it back.

Diagnosis note worth keeping: the first attempt appeared to fail because the
browser tab had been open since BEFORE the fix — an already-loaded ES module
stays in memory regardless of `Cache-Control: no-store`. The cookie had been
stored correctly the whole time; the panel simply never re-queried. The server
log showed zero /oauth/status probes, which is what distinguished "fetch never
fired" from "fetch got the wrong answer". Temporary cookie diagnostics added to
find that are removed here.

Tests: 547 sensing-server lib, unchanged.

Co-Authored-By: Ruflo & AQE
2026-07-23 10:41:11 +02:00
Dragan Spiridonov 9299a3b137 feat(ui): Sign in with Cognitum, zero-config session secret, executed JS tests
Three loose ends from the browser sign-in work.

--- 1. The last mile: a button ---

The server endpoints existed but nothing in ui/ linked to them, so the feature
was unreachable. QuickSettings gains a "Cognitum Account" panel that renders
from `GET /oauth/status` and offers Sign in / Sign out.

`/oauth/status` is a new endpoint and is deliberately UNGATED — a signed-OUT
browser cannot ask a gated API whether sign-in is available. It returns only
capability flags and, when a session exists, who it belongs to. Never a
credential.

The panel distinguishes four states rather than showing a button that might
404: signed in; sign-in available; auth on but OAuth off (points at the
existing static-token panel); no auth required. A 404 from /oauth/status means
a server predating this work and says so plainly.

Sign-in is a full-page navigation, not fetch(): the server replies 302 and the
browser must follow it carrying the transaction cookie. An XHR would follow the
redirect invisibly and land nowhere.

--- 2. RUVIEW_SESSION_SECRET no longer required ---

Previously `/oauth/start` returned 503 unless an operator invented a secret —
a footgun, since they set RUVIEW_OAUTH_ISSUER, expect sign-in, and get a 503
naming an env var they have never heard of.

Now resolved in order: env var, then `<data_dir>/session-secret`, then generate
one and persist it 0600 (temp file, chmod before rename — the discipline used
for the CLI's credentials). Persisted rather than in-memory so a restart does
not silently sign everyone out.

The env var still wins, which is what a multi-instance deployment needs: several
servers must share a secret or a session issued by one is rejected by the next.
If the file cannot be written we log the reason and continue with an in-memory
secret rather than refusing sign-in outright.

Verified with NO configuration at all: secret generated, file mode 0600,
/oauth/start returns 302, /oauth/status reports browser_signin: true.

--- 3. The JavaScript is now executed by tests ---

`ui/services/ws-ticket.test.mjs`, 9 tests, node:test built-in — no new
dependency and no package.json needed. Run: `node --test ui/services/`.

Covers: no token means no fetch and an unchanged URL; the bearer reaches the
Authorization header and NEVER the URL; `?` vs `&` when a query already exists;
ticket URL-encoding; 404 treated as a pre-ADR-272 server (the property that
lets one UI work against old and new servers, and therefore lets the legacy
escape hatch be removed later); 503 and network failure swallowed rather than
breaking the connect path; and that a fresh ticket is minted per call, since
tickets are single-use and caching one fails on the second reconnect.

STATED PRECISELY, because the distinction matters: this EXECUTES the module in
Node with stubbed fetch/localStorage. It is more than the `node --check` it
replaces and less than a browser — no real WebSocket upgrade, no real cookies,
no page wiring. "The UI JavaScript has never been run" is no longer true of
this module. "Browser-tested" still is not.

Tests: 547 sensing-server lib, 5 wiring integration, 87 ruview-auth, 9 JS.

Co-Authored-By: Ruflo & AQE
2026-07-23 10:07:49 +02:00
Dragan Spiridonov 347698b67c feat(auth): browser sign-in — /oauth/start, /oauth/callback, session cookie
Closes the gap adversarial review found: `wifi-densepose login` writes
~/.ruview/credentials.json, which a BROWSER CANNOT READ. The UI therefore had no
way to obtain a Cognitum token at all, and the WebSocket ticket mechanism
ADR-272 built "for browsers" was only exercisable with the legacy static shared
secret OAuth was meant to replace. The ADRs described a browser story that did
not exist.

Ported from cognitum-one/freetokens (src/auth/oauth.ts, live at
freetokens.cognitum.one), whose shape is not the obvious one and is the whole
point:

  THE BROWSER NEVER HOLDS AN OAUTH TOKEN.

The server generates the PKCE verifier and state, keeps them in an HMAC-signed
cookie, performs the code exchange itself, verifies the token, and issues its
OWN session cookie carrying an assertion — subject, account, scope, expiry — not
a credential. So the access token cannot be read by an XSS, cannot sit in
localStorage, and cannot leak through a URL. A stolen session cookie is useless
against Cognitum or any sibling service.

  GET /oauth/start    -> 302 to auth.cognitum.one + signed transaction cookie
  GET /oauth/callback -> constant-time state check, exchange, verify, session
  GET /oauth/logout   -> clears the local session (not the Cognitum session)

Verified against the running binary: /oauth/start returns the same 302 +
HttpOnly/SameSite=Lax/Max-Age=600 shape freetokens does live; a forged `state`
is refused 400; a forged session cookie is refused 401.

DELIBERATE DEVIATION from freetokens: no `__Host-` cookie prefix. That prefix
REQUIRES `Secure`, and RuView is routinely reached at http://localhost or over
plain HTTP on a LAN, where such a cookie is never sent and sign-in would fail
silently. `Secure` is set only when the request actually arrived over TLS
(direct or via x-forwarded-proto). Every other attribute matches; the HMAC is
what protects the value.

Other decisions worth stating:
- The callback verifies through the SAME `verify_access_token` every other
  request uses — signature, audience (client_id), typ, expiry, scope. A sign-in
  path must not be a softer path.
- The session cookie is checked LAST in the middleware, after bearer and ticket:
  it is the weakest-bound credential, so a presented bearer should win.
- A browser session requests `sensing:read` only. Admin work goes through the
  CLI's explicit `--admin`.
- The token exchange runs in `spawn_blocking` — `ureq` is blocking, and parking
  an async worker is the mistake this codebase just had to fix in jwks.rs.
- `/oauth/*` sits outside `/api/v1/*` on purpose: gating the routes you use to
  obtain a credential would deadlock.

PKCE moved out from behind the `login` feature into its own light `pkce` feature
(rand + sha2 + base64, no HTTP stack), so the server can build an authorize URL
without pulling in the client-side login machinery. `login` now implies `pkce`.

Tests: 13 new browser_session unit tests — signature round-trip, tampered
payload, wrong secret, malformed cookie values, HttpOnly/SameSite/Secure
attributes, exact scope matching with no implied escalation, a cookie name that
merely ends with the target not matching, multi-scope URL encoding, and the
core property that a session cookie never contains the access token.
Totals: 547 sensing-server lib + 5 wiring integration, 87 ruview-auth.

Co-Authored-By: Ruflo & AQE
2026-07-23 09:57:15 +02:00
Dragan Spiridonov 705a167ffe test(auth): boot the real binary and probe BOTH listeners (the wiring gap)
Two authentication bypasses shipped in this PR and 526 green unit tests could
not see either, because every auth test in the crate builds its OWN Router with
a hand-picked subset of routes. A synthetic router cannot observe how the real
one is assembled — and both defects were assembly, not logic:

  1. the dedicated --ws-port listener had no `require_bearer` at all
  2. `/ws/field` was .merge()d AFTER the auth layer, which in axum exempts it

This spawns the actual `sensing-server` binary (via CARGO_BIN_EXE) on ephemeral
ports and speaks raw HTTP/1.1 and real WebSocket upgrades to BOTH listeners. No
mocks, no synthetic router, no in-process shortcuts.

PROVEN TO HAVE TEETH, which matters more than it passing: with main.rs reverted
to eb68e07a (the vulnerable commit), the suite fails —

  assertion `left != right` failed: http port ACCEPTED an unauthenticated
  upgrade to /ws/field — this is the bypass that shipped twice

and passes again once restored. A regression test that has never been shown to
fail is a comment.

Five cases:
- with auth ON, NO listener accepts an unauthenticated upgrade — all four WS
  paths x both ports, with a REST 401 control first so the WS assertions cannot
  pass for the wrong reason
- a bearer on the upgrade is accepted on both listeners (native clients are not
  browser-constrained and must not need the ticket round-trip)
- with auth OFF both listeners stay open — the compatibility promise
- the legacy escape hatch opens WebSockets WITHOUT weakening REST — scoped, or
  it is a bypass wearing a migration label
- /health stays anonymous on both listeners — a documented exemption, pinned so
  it stays a decision rather than an accident

The child inherits no RUVIEW_* variables (env_remove), or a developer's local
export would silently change what the test proves. Ports are reserved by
binding :0 and releasing, so a collision surfaces as a boot failure rather than
a false pass.

Tests: 5 new integration, 534 lib, 87 ruview-auth.

Co-Authored-By: Ruflo & AQE
2026-07-23 09:50:43 +02:00
Dragan Spiridonov b09625ece7 fix(auth): close the four residual findings from the adversarial review
(a) Test fixture still invented an `iss` claim.
    `bearer_auth.rs`'s `token_with_scope` added `"iss": ISSUER` to its tokens.
    Harmless today because the verifier ignores `iss` — but it is the same
    fixture-invents-reality pattern that hid the original `iss` bug for a day,
    sitting in the second-largest auth test suite. Removed, with a pointer to
    ruview-auth's regression test.

(b) Cross-process refresh race -> session revocation.
    The single-flight guarantee was per-PROCESS. Every CLI invocation is a new
    process with its own Session and mutex over one shared credential file, so
    two commands run close together inside the 60s refresh window would each
    present the same rotating refresh token — and the second is replay, which
    identity answers by revoking the whole session family. The user gets logged
    out for running two commands at once.

    Now guarded by an advisory file lock, taken NON-BLOCKING. A busy lock means
    another process is already refreshing, so we wait and re-read its result
    rather than race it (20 x 150ms, then proceed anyway — the lock is advisory,
    not a correctness barrier, and a dead holder must not wedge us). Blocking on
    the lock would have parked the async executor, which is the exact mistake
    just fixed in jwks.rs.

    Unix only. On other platforms it is a documented no-op — a lock that does
    nothing while claiming to protect is worse than none.

(c) One principal could exhaust the global ticket pool.
    The 512 cap was global with no per-caller quota, so a single authenticated
    `sensing:read` client looping on POST /api/v1/ws-ticket could hold every
    slot for 30s and 503 everyone else — denial of service by the
    lowest-privilege account the product issues. Added a 16-ticket
    per-principal cap; a page needs a handful. Test asserts a noisy user hits
    its own cap while a second user is still served and the global pool is
    never exhausted.

(d) Debug impls printed live credentials.
    `AuthState` derived Debug over the raw RUVIEW_API_TOKEN and
    `StoredCredentials` over both OAuth tokens. Not leaking today — I checked
    every call site — but this PR had already hand-written redacting Debug for
    `OAuthState` and `TicketStore` for exactly this reason, and the two types
    actually holding secrets were the ones that missed out. Both now redact.

Tests: 87 ruview-auth (2 new: lock exclusivity + non-blocking, Debug
redaction), 534 sensing-server (1 new: per-principal quota).

Co-Authored-By: Ruflo & AQE
2026-07-23 09:48:09 +02:00
Dragan Spiridonov 0547fd7344 fix(auth): enforce client_id as the audience — Cognitum's stand-in for aud
Found by reading cognitum-one/freetokens, a live sibling service whose browser
OAuth landed while this PR was open. Its integration contract states the
platform rule outright:

  "Cognitum access tokens intentionally use custom `client_id` rather than a
   registered JWT `aud` claim."  -- freetokens docs/AUTH_INTEGRATION.md

and `src/auth/oauth.ts` enforces it on every sign-in:

  payload.client_id !== config.OAUTH_CLIENT_ID  -> reject

RuView did not. An earlier revision here removed the `client_id` check and kept
it only for logging, reasoning that clients borrow one another's registrations
(musica shipped as `meta-proxy` while its own was pending) and that scope alone
must therefore carry the boundary. That reasoned from a TRANSITIONAL state:
RuView has its own registered client (identity migration 0017), and the platform
does have an audience mechanism — it is simply spelled `client_id`.

Consequence of the old behaviour: a Cognitum access token minted for ANY product
— meta-proxy, musica, metaharness, freetokens — was accepted by a RuView server
provided it carried a sensing scope. Scope was the only thing standing between
another product's token and this one. Now there are two boundaries, audience and
capability, which is what the platform intends.

- `VerifierConfig.allowed_client_ids`; empty = accept any (explicit opt-out).
- `RUVIEW_OAUTH_CLIENT_IDS` env, default `ruview`, `*` to disable with a loud
  warning naming what is being given up. Comma-separated for the migration case
  where a borrowed registration must be accepted alongside our own.
- New `VerifyError::WrongAudience`, checked BEFORE scope, so the failure names
  the real reason rather than blaming the scope.

The existing cross-product test now asserts `WrongAudience` rather than
`MissingScope` — the token is refused for the stronger reason. Three new tests:
a correctly-scoped token from another product is still refused; the empty-list
opt-out accepts anything (pinned so it stays deliberate); multiple allowed
clients work.

This also corrects the module docs and ADR-271, which claimed "scope is the ONLY
capability boundary" — true of the code as written, but not of the platform.

Tests: 85 ruview-auth, 533 sensing-server.

Co-Authored-By: Ruflo & AQE
2026-07-23 09:31:24 +02:00
Dragan Spiridonov f67a880a1a docs: correct three doc-vs-code contradictions found by adversarial review
All three are my own drift — the exact failure this session criticised in other
repos' ADRs and then reproduced.

1. ADR-271 §2 still listed "`iss` matches the configured issuer verbatim" in the
   accept-rule. That rule was removed from the code in 12635a85 because Cognitum
   issues no `iss` and it rejected every real token. The ADR's own "Facts about
   the tokens" section 30 lines above already said tokens carry no `iss` — the
   document contradicted itself for a day. Also removed a claim that tests cover
   "issuer mismatch including a trailing-slash-only difference"; that test was
   deleted with the rule and no longer exists.

2. `ruview-auth/src/lib.rs:59` said "**No login flow.** ... this crate only
   verifies" — 15 lines above `pub mod login;`. Now states the accurate thing:
   the login flow is behind the non-default `login` feature, so a verifying
   server never compiles it.

3. ADR-271's scope table still described the old prefix-denylist admin set.
   Replaced with the fail-closed rule that actually ships, including why:
   `/api/v1/adaptive/train` was reachable with `sensing:read`.

Also records a KNOWN INCOMPLETE that the ADRs previously implied was done: the
browser cannot obtain an OAuth token at all. `wifi-densepose login` writes
~/.ruview/credentials.json, which a browser cannot read; the UI reads
localStorage['ruview-api-token'], populated only by the manual-paste
QuickSettings panel. `grep -ril "oauth|cognitum|pkce" ui/` returns nothing.

So the WebSocket ticket mechanism ADR-272 introduces "for browsers" is today
only exercisable with the legacy static shared secret OAuth was meant to
replace. Server-side gating is correct and complete; the browser half of the
story these ADRs tell is not built. Recorded rather than left implied.

Co-Authored-By: Ruflo & AQE
2026-07-23 09:20:31 +02:00
Dragan Spiridonov 7a05417493 fix(auth): scope gate was fail-OPEN; JWKS lock held across a blocking fetch
Two charges from an adversarial multi-vendor review (qe-court). Both verified
in source before fixing.

--- 1. AUTHORIZATION BYPASS: POST /api/v1/adaptive/train reachable with read ---

`required_scope_for` enumerated admin routes by prefix (`/api/v1/train/`, plus
DELETE on models/recording) and let EVERYTHING ELSE fall through to
`sensing:read`.

`POST /api/v1/adaptive/train` (main.rs:8112 -> handler main.rs:5028) calls
`adaptive_classifier::train_from_recordings()`, writes the model to disk with
`model.save()`, and swaps `state.adaptive_model` used by the live inference
pipeline. It does not start with `/api/v1/train/`, so it landed on
`sensing:read` — the scope `wifi-densepose login` requests BY DEFAULT. Exactly
the blast radius the read/admin split exists to prevent, reachable by the
lowest-privilege token the product issues.

Fixed by inverting the polarity, which is the real defect — a denylist for a
security gate keeps missing routes as routes keep being added:

  GET/HEAD/OPTIONS            -> sensing:read
  any other method            -> sensing:admin
  ...unless the exact path is in READ_SAFE_MUTATIONS (an explicit allowlist of
     mutations that change runtime state but destroy nothing: ws-ticket,
     model load/unload/activate, calibration start/stop, recording start/stop,
     vendor event ingest)

A mutating route added tomorrow is now admin-gated by default. Pinned by
`an_unknown_mutating_route_defaults_to_admin`. `POST /api/v1/ws-ticket` is
allowlisted deliberately and has its own test: were it admin, the read scope
could never open a stream from a browser at all.

Enumerated all 17 mutating /api/v1 routes to build the allowlist rather than
guessing. `POST /api/v1/config/ground-truth` now requires admin — a deliberate
tightening, it writes config.

--- 2. DoS: blocking JWKS fetch under std::sync::Mutex in async middleware ---

Filed INDEPENDENTLY by two prosecutors, which is why it gets fixed rather than
argued about.

`decoding_key_for` locked a `std::sync::Mutex` and held it across a blocking
`ureq` call (3s timeout, longer on a dead link), invoked from inside the async
`require_bearer`. `Mutex::lock()` in an async fn is a blocking syscall, not a
yield point — so one slow JWKS fetch blocked EVERY concurrent request on that
mutex, including ones carrying already-cached valid tokens, and parked the
tokio workers running them. On Pi-class hardware with few workers that stalls
the whole server. It fires on the routine 300s TTL rollover whenever the link
is degraded — the exact offline case the module exists to tolerate. Reachable
by anyone able to send a syntactically valid ES256 header with an unknown kid,
since kid lookup precedes signature verification.

Now three phases: read under the lock, RELEASE, network, re-take only to
install. Cost is a possible duplicated idempotent GET during a rollover, which
is strictly better than serialising every request behind one socket. The
codebase already uses spawn_blocking for outbound I/O elsewhere
(main.rs:2490, 5272); moving verification fully onto spawn_blocking remains a
follow-up — this removes the amplification, not every blocking millisecond.

Tests: 533 sensing-server (7 new scope-polarity), 82 ruview-auth.

Co-Authored-By: Ruflo & AQE
2026-07-23 09:18:33 +02:00
Dragan Spiridonov 3347e258e6 fix(ws): gate the dedicated WS port and the merged field routes (ADR-272 was incomplete)
The previous commit claimed WebSocket upgrades were gated. They were not. Found
by an adversarial cross-vendor review, reproduced, and fixed here.

MEASURED BEFORE (auth ON via RUVIEW_API_TOKEN, no credential presented):

  HTTP port :3991  /ws/sensing  -> 401   (what the previous fix covered)
  HTTP port :3991  /ws/field    -> 101   HOLE
  WS   port :3990  /ws/sensing  -> 101   HOLE
  WS   port :3990  /ws/field    -> 101   HOLE
  control   :3991  /api/v1/models -> 401

Two independent defects, both from the same root cause — routes registered
AFTER an axum `.layer()` are silently exempt from it:

1. The dedicated WebSocket server on `--ws-port` was built with ONLY
   `host_validation::require_allowed_host`. `require_bearer` was never applied
   to it at all. My earlier verification only ever probed the HTTP port and I
   generalised from it.

   This is the worse of the two, because it is the port the UI actually uses:
   `ui/services/sensing.service.js` maps HTTP 8080 -> WS 8765. So the previous
   fix protected a path the browser never takes, while the path it does take
   stayed open.

2. On the HTTP router, `/ws/field` (ADR-262) was `.merge()`d AFTER the
   `require_bearer` layer, so it bypassed authentication entirely. The auth
   layer is now applied after the merge, and the comment says why the ordering
   is load-bearing.

MEASURED AFTER, same conditions:

  :3989 /ws/sensing -> 401 · :3989 /ws/field -> 401
  :3988 /ws/sensing -> 401 · :3988 /ws/field -> 401
  with bearer on the upgrade: 101 on both ports
  ticket minted at POST /api/v1/ws-ticket on the HTTP port and redeemed on the
    WS port: 101  (AuthState shares its TicketStore via Arc)
  auth OFF: 101 — unchanged, no regression

526 sensing-server tests still pass.

TEST GAP, stated rather than papered over: no automated test covers this. The
defect is in router WIRING in main.rs, not in logic a unit test reaches — both
holes were invisible to 526 green tests and to the ws_gate_tests suite, which
builds its own Router and therefore cannot see how the real one is assembled.
Catching this class needs an integration test that boots the binary and probes
BOTH ports; that is the honest follow-up.

Co-Authored-By: Ruflo & AQE
2026-07-23 09:14:07 +02:00
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
Dragan Spiridonov 6300b1cbd2 feat(ws): gate WebSocket upgrades behind bearer-or-ticket (ADR-272)
Closes the hole measured in 7d6d6694. Before, with RUVIEW_API_TOKEN set, a real
handshake carrying no credential:

  /ws/sensing 101 · /ws/introspection 101 · /api/v1/stream/pose 101
  (/api/v1/models correctly 401)

After, same server, same handshake:

  /ws/sensing 401 · /ws/introspection 401 · /api/v1/stream/pose 401
  bearer on the upgrade                         -> 101
  POST /api/v1/ws-ticket then ?ticket=<value>   -> 101
  the same ticket replayed                      -> 401
  a bogus ticket                                -> 401
  POST /api/v1/ws-ticket unauthenticated        -> 401

Two ways in, matching what each client can actually do:

- Native clients (Python, Rust CLI, TS MCP) send a normal Authorization header
  on the upgrade. They were never browser-constrained; forcing them through a
  ticket would add a round-trip and a second credential path for nothing.
- Browsers cannot set that header, so they exchange their credential at
  POST /api/v1/ws-ticket — an ordinary request, where they can — for a 30s
  single-use ticket passed as ?ticket=.

A ticket inherits the issuing principal's scopes, so a sensing:read session
cannot mint one that outranks itself, and it is not a REST credential: pinned
by a test that `?ticket=` on /api/v1/models is still 401.

ESCAPE HATCH (RUVIEW_WS_LEGACY_UNAUTHENTICATED=1) restores the old behaviour
for deployments that cannot update server and UI in lockstep. It is a migration
aid, not a supported configuration, and it says so on every boot in a warning
that names the actual exposure ("the live sensing stream — presence, pose and
vital signs — is readable by anyone who can reach this port"). Its blast radius
is exactly the WebSocket paths: a test pins that it does not weaken REST.

The flag is read once at construction, so a mid-flight environment change
cannot silently open these paths on a running server.

SUPERSEDES the PR #1313 test `enabled_exempts_pose_stream_websocket`, which
asserted the old exemption. Its reasoning about browsers was correct; the
conclusion was not. Renamed and inverted rather than deleted, with the history
in the doc comment — and the half that still matters (the WebSocket rule must
not leak to other /api/v1/* paths) is kept.

Deployments with auth OFF see no change at all — pinned by a test.

Tests: 522 passed in the sensing server (9 new WS-gating, 12 ticket-store).

STILL OUTSTANDING for ADR-272: the browser UI does not yet fetch a ticket, so
it needs the escape hatch until ui/services/api.service.js is updated. That is
the next commit, not a permanent state.

Co-Authored-By: Ruflo & AQE
2026-07-22 19:04:44 +02:00
Dragan Spiridonov 7d6d66941a feat(ws): single-use WebSocket ticket store (ADR-272 groundwork)
Audit finding this exists to close — verified empirically, not inferred. With
RUVIEW_API_TOKEN set (operator believes auth is ON), a real WebSocket handshake
carrying NO credential:

  /ws/sensing         -> 101 Switching Protocols
  /ws/introspection   -> 101 Switching Protocols
  /api/v1/stream/pose -> 101 Switching Protocols
  /api/v1/models      -> 401   (control: REST is correctly gated)

The REST control plane is locked while the DATA plane — live presence, pose and
vitals — is open to anyone who can reach the port. Two of those paths sit
outside PROTECTED_PREFIX entirely; the third is the documented EXEMPT_PATHS
entry. The exemption was reasonable when added (a browser cannot set
Authorization on an upgrade) but its blast radius is larger than it looks, and
phase 3 sharpened the contrast by making REST genuinely strong.

(To be precise about what was proven: the handshake is accepted. A payload
frame was not captured in that window, so this is "the connection is
established without a credential", not "data was read".)

This commit adds only the store; wiring it into the middleware is a breaking
change for browser clients and lands with the UI update.

Design notes:
- Single use. `consume` REMOVES the entry, so a replay of the same URL fails
  even inside the TTL. This is what makes a credential-in-a-query tolerable:
  by the time it reaches an access log or a Referer header, it is spent.
- 30-second TTL. Long enough for a page to open a socket; too short to harvest.
- It is not the credential. It authorizes one WebSocket. It cannot be replayed
  against /api/v1/*, cannot be refreshed, and carries no reusable identity.
- The grant captures the ISSUING principal's scopes, so a WebSocket inherits
  exactly the authority of the credential that asked for it — a sensing:read
  session cannot mint a ticket that outranks itself.
- Capped at 512 outstanding, self-healing as tickets expire, so an
  authenticated but misbehaving caller cannot grow the map without bound.
- In-memory because that is correct, not merely convenient: a ticket surviving
  a restart would outlive the server that vouched for it.

Native clients (Python, Rust CLI, TS MCP) are NOT browsers and will send a
normal Authorization header on the upgrade instead — tickets would add a
round-trip and a second credential path for no benefit.

12 tests: single-use enforced, replay refused, expiry refused AND pruned,
unknown ticket refused, 256-bit unpredictability, grant carries issuer scopes,
cap enforced and self-healing, and query parsing including `?myticket=x` not
being read as `?ticket=x`.

Co-Authored-By: Ruflo & AQE
2026-07-22 18:41:34 +02:00
Dragan Spiridonov 6d3fb88677 fix(cli): resolve scope from the token on read, and add whoami --refresh
Two gaps that only appear with a credential file written by an earlier build —
i.e. exactly the case a fresh-login test never exercises.

1. `whoami` printed "Scope: (not reported)" for an existing file. The previous
   commit resolved scope from the token claim at WRITE time only, so files
   written before it stayed blank forever. `effective_scope()` now falls back at
   READ time, which fixes existing files and any client that stored only what
   the token response carried. The token is authoritative either way; the stored
   field is a convenience copy.

2. `whoami` said the token "will refresh on next use" — a promise nothing kept,
   because no command called `ensure_fresh`. The refresh path was implemented
   and unit-tested but never actually run. `--refresh` exercises it, and goes
   through `Session::ensure_fresh` rather than reimplementing a second, subtly
   different refresh, so it inherits the single-flight guarantee and the
   persist-before-return ordering.

   It is a flag, not silent behaviour: refreshing rotates the stored refresh
   token — identity spends the old one — so it is a state change, not a read.

VERIFIED AGAINST PRODUCTION, end to end:
  whoami on a pre-existing file        -> Scope: sensing:read (was "not reported")
  whoami --refresh                     -> both tokens rotated
                                          refresh sha256[:12] 50cfa06b -> 2d37617a
                                          access  sha256[:12] 61eebe8d -> 14d8e8de
                                          status: expired -> valid
  refreshed token GET  /api/v1/models      -> 200
  refreshed token POST /api/v1/train/start -> 401 (still read-scoped)

That exercises the rotating-refresh path against identity's real reuse
detection — the one place a bug costs the user their session rather than a
retry — and confirms the scope gate survives a refresh.

Tests: 82 with --features login, 44 default, 501 sensing-server.

Co-Authored-By: Ruflo & AQE
2026-07-22 18:21:49 +02:00
Dragan Spiridonov 12635a85b2 fix(auth): stop requiring an iss claim Cognitum never issues (G-3 blocker)
The verifier called `Validation::set_issuer` and listed `iss` in
`set_required_spec_claims`. Cognitum access tokens have **no `iss` claim** — the
real claim set is typ, sub, account_id, org_id, workspace_id, client_id, scope,
family_id, jti, iat, exp, setup, workload. So the verifier rejected every
genuine token with a flat 401.

The whole 41-test suite was green throughout, because the test fixtures included
an `iss` the real thing does not have. The tests validated my assumption instead
of the platform. Found only by pointing a real token at a real server; no amount
of additional unit testing against the same wrong fixture would have caught it.

`valid_claims()` now mirrors production exactly, with a comment saying why, so
the next person cannot reintroduce the drift by "fixing" an incomplete-looking
fixture.

What binds a token to its issuer, then: the JWKS. We accept only signatures made
by a key served from the configured jwks_uri, so a valid signature IS proof of
issuer. meta-llm's verifier — the org's only other resource-side verifier —
checks neither `iss` nor `aud`, for the same reason. `VerifierConfig.issuer`
stays, now documented as JWKS-derivation and logging rather than a claim
assertion.

Three tests replace the two that asserted issuer behaviour that cannot exist:
a real-shaped (iss-less) token verifies; an unrelated `iss` changes nothing (so
identity adding one later cannot silently start failing tokens); and a token
signed by a different key is still refused — the complement that proves removing
the issuer check did not remove the boundary.

Also: report the granted scope from the token's `scope` claim when the
/oauth/token envelope omits it, which identity's does. `login` and `whoami` said
"(not reported by the server)" while the authoritative answer sat inside the
token they had just stored. The claim-peek helper is display-only and documented
at length as NOT verification — a client reading its own freshly issued token is
a different situation from a server reading a stranger's.

VERIFIED END TO END against production (gate G-3, previously open):
  no credential            GET  /api/v1/models        -> 401
  garbage bearer           GET  /api/v1/models        -> 401
  real sensing:read token  GET  /api/v1/models        -> 200
  real sensing:read token  GET  /api/v1/recording/list-> 200
  real sensing:read token  POST /api/v1/train/start   -> 401
  real sensing:read token  POST /api/v1/train/stop    -> 401
Token obtained by a human through `wifi-densepose login` against
auth.cognitum.one; server ran with RUVIEW_OAUTH_ISSUER set, JWKS fetched live
at boot (key_count=1).

Tests: 82 with --features login (58 unit + 22 matrix + 2 doctests).

Co-Authored-By: Ruflo & AQE
2026-07-22 18:11:21 +02:00
Dragan Spiridonov 714dae9a2c docs(adr): amend ADR-271 — the login flow lives in ruview-auth behind a feature
The ADR said the login flow was "not in this crate". It is now, gated behind a
non-default `login` feature. The original line existed to keep the sensing
server lean; a feature gate achieves that without forcing the Tauri desktop app
to grow a second copy of a PKCE + rotating-refresh implementation — the kind of
duplication that drifts and then disagrees about something subtle.

Recording the change rather than letting the ADR quietly go stale, which is the
exact failure this session hit twice in other repos' ADRs.

Co-Authored-By: Ruflo & AQE
2026-07-22 17:54:13 +02:00
Dragan Spiridonov 31fb3d53f6 feat(cli): wifi-densepose login — Cognitum sign-in (ADR-271 phase 2)
Phase 1 could verify a token and phase 3 could gate on one, but there was no
way for a user to OBTAIN one. This closes that: sign in with a Cognitum account
and get a token a RuView sensing server accepts, instead of everyone sharing
one static RUVIEW_API_TOKEN string.

Lives in `ruview-auth` behind a non-default `login` feature rather than in the
CLI, so the Tauri desktop app can reuse it instead of growing a second copy.
A server built with default features still gets the verifier and nothing else —
no reqwest, no tokio net, no browser launcher. (This amends ADR-271's "no login
flow in this crate" note; the reason for that line was to keep the server lean,
and a feature gate achieves it without duplication.)

Ported from meta-proxy's src/oauth/, cross-checked against musica's
cognitum_provider.rs — two independent implementations against this same AS.
Where they agree, this follows both: redirect path EXACTLY /oauth/callback,
60-second refresh skew, OOB fallback on SSH/CONTAINER//.dockerenv.

Refresh is the part with teeth. Identity rotates refresh tokens with reuse
detection, so presenting a spent one revokes the whole session family. Both
obvious implementations are wrong: refreshing concurrently looks like replay,
and retrying a failed refresh with the same token IS the replay. So
`Session::ensure_fresh` holds an async mutex across the await, re-checks expiry
after acquiring it (the waiter usually finds the work already done), persists
the rotated token BEFORE returning it, and never retries. A missing expires_at
counts as expired rather than being given a guessed default.

Least scope by default: `login` requests `sensing:read`. `--admin` adds
`sensing:admin` explicitly, and requests both because there is no scope
hierarchy server-side. A session that streams poses should not casually hold
the capability to delete the model it streams through.

Credentials are written atomically and 0600 (temp file, chmod BEFORE rename) —
the same discipline the seed applies to its cloud key. `logout` is local-only
and says so: it makes this machine unable to act as you, but revoking the
session everywhere is an account-level action.

Also `whoami`, which reports whether the stored token is live — an
expired-looking session is the most common reason a command starts 401ing, and
it should be visible directly rather than inferred from a failure elsewhere.

Verified against PRODUCTION, not just locally: authorize URLs built by this
exact code path return HTTP 200 from auth.cognitum.one for both
`sensing:read` and `sensing:read sensing:admin`, which exercises the real
client_id, scope encoding, PKCE parameters and redirect_uri shape.

Tests: 74 with --features login (51 unit + 21 verifier matrix + 2 doctests),
including the RFC 7636 Appendix B vector, multi-scope URL encoding (a space
that is hand-formatted rather than encoded silently truncates the request), a
real TCP callback round-trip, callback timeout, 0600 permissions asserted on
disk, atomic-save leaving no temp file, and refresh-window boundaries.
Unchanged: 43 with default features, 501 in the sensing server.

Co-Authored-By: Ruflo & AQE
2026-07-22 17:53:42 +02:00
Dragan Spiridonov c2bd33e649 feat(sensing-server): accept Cognitum OAuth on /api/v1/*, scope-gated (ADR-271 phase 3)
Wires `ruview-auth` into `bearer_auth.rs`. `RUVIEW_OAUTH_ISSUER` enables it;
unset, nothing changes.

Layering, in order:
  1. `RUVIEW_API_TOKEN` set and the bearer matches exactly -> allow. Byte-for-
     byte today's behaviour.
  2. Otherwise, if OAuth is configured, verify the bearer as a Cognitum access
     token and require the scope the route needs.
  3. Otherwise 401.

The static compare goes first for compatibility, not security: a matching
static token is not a JWT and a JWT never matches the static token. It means an
existing deployment behaves identically even with OAuth switched on.

Scope gate (`required_scope_for`), split by blast radius per ADR-060 —
"can this destroy something", not how many routes it covers:
  sensing:admin  /api/v1/train/* (hours of Pi CPU, writes models)
                 DELETE /api/v1/models/{id}      (irreversible)
                 DELETE /api/v1/recording/{id}   (irreversible)
  sensing:read   everything else
Deliberately NOT admin: model load/unload and recording start. They mutate
server state but destroy nothing, and gating them would push routine dashboard
use into requesting delete capability — the opposite of least privilege.

The legacy static token stays un-scope-gated. It predates scopes and carries no
claims, so narrowing it would be a silent breaking change to deployments using
it; migrating to OAuth is how an operator opts into the finer split.

FAIL CLOSED at boot. If OAuth is requested but cannot work — empty issuer, or a
JWKS we cannot fetch — the server logs why and exits rather than serving.
Starting anyway would silently downgrade an operator who asked for OAuth to
either an open API or a shared-secret one, with no signal it happened. The JWKS
is warmed eagerly for the same reason: a bad `jwks_uri` should die at boot with
a legible message, not surface as a puzzling 401 an hour later.

The verified `Principal` is attached to request extensions, so handlers and
audit logs can attribute a request (`sub`, `account_id`, `org_id`,
`workspace_id`, `jti`) instead of knowing only "someone had the secret". That
is the point of moving off a shared bearer.

Verification failures are logged with the reason and returned as a flat 401 —
the reason is useful to an operator and equally useful to an attacker probing
for which claim to forge next.

Also aligns `ruview_auth::extract_bearer` to match the scheme
case-insensitively (RFC 7235 §2.1). The sensing server has always done this
deliberately, with a comment saying why; the two layers disagreeing about what
a valid header looks like would be a latent bug.

Tests: 16 new in `bearer_auth::oauth_tests`, driving a real Router end to end
(request -> middleware -> verifier -> handler) with ES256 tokens signed by a
runtime-generated key. Covers the scope policy as a pure function, read-scoped
tokens refused on delete and train, admin-scoped tokens allowed, an
`inference`-only token from another Cognitum product refused on every route,
garbage and absent bearers, both legacy-token layering directions, the
principal reaching a handler, and the unset case remaining a no-op.

`cargo test -p wifi-densepose-sensing-server --lib --no-default-features`:
501 passed, 0 failed. `ruview-auth`: 43 passed across both feature configs.

Co-Authored-By: Ruflo & AQE
2026-07-22 15:33:22 +02:00
Dragan Spiridonov 92cbeb0c34 docs(adr): ADR-271 — RuView as a Cognitum OAuth resource server
The previous commit referenced ADR-271 in five places without the ADR existing.
This writes it.

Records the decision and, more importantly, the direction — RuView verifies
tokens users present, it does not obtain tokens to call Cognitum. Every other
Cognitum OAuth integration in the org is the client side of a plane RuView does
not have; the sole relevant precedent is meta-llm's oauthBearer.ts.

Covers: why offline verification is a requirement rather than an optimisation
(Pi-class hosts lose WAN, and no introspection endpoint exists); why the
accept-rule is a port and not a design; why long-lived setup/workload
credentials are refused (no database to check revocation); why scope — not
`aud`, not `client_id` — is the capability boundary; and the alternatives,
including the `cog_`-minting approach that was tried org-wide and found broken
against production.

Co-Authored-By: Ruflo & AQE
2026-07-22 15:10:48 +02:00
Dragan Spiridonov 499cec7914 feat(auth): ruview-auth — offline Cognitum OAuth access-token verification (ADR-271)
RuView's `/api/v1/*` is gated today by `RUVIEW_API_TOKEN`: one shared secret,
no expiry, no per-user attribution. This adds the verifier half of replacing
that with Cognitum identity — RuView as an OAuth **resource server**, so a user
signs in to their own sensing server with their Cognitum account.

Note the direction: RuView does not call Cognitum. It never obtains a token for
its own use; it verifies tokens users present. Every other Cognitum OAuth
integration in the org (meta-proxy, musica, metaharness, the dashboard CLI) is
the client side of a plane RuView doesn't have. The one existing resource-server
verifier is `meta-llm/src/auth/oauthBearer.ts`, and this crate is a port of its
accept-rule — divergence would be a bug, not a preference: a token meta-llm
rejects must not be one RuView accepts.

Verification is OFFLINE, and that is a requirement rather than an optimisation.
RuView runs on Pi-class hardware that loses WAN, and identity publishes no
introspection endpoint even when the network is up. So: ES256 over identity's
published JWKS, cached by `kid`.

What it accepts, mirroring oauthBearer.ts:
  typ == "access" AND NOT setup AND NOT workload AND account_id non-empty AND
  exp in the future AND iss matches verbatim AND the required scope is held.

Long-lived setup (365-day) and workload credentials are refused outright for
identity's own stated reason: their revocation lives in `oauth_setup_tokens`,
and RuView — like meta-llm — has no database to check it. A 15-minute access
token needs no revocation round-trip because it expires faster than revocation
propagates; a 365-day one does.

Scope is the capability boundary, and it has to be. Cognitum access tokens carry
no `aud`, and `client_id` cannot substitute because clients borrow each other's
registrations (musica ships DEFAULT_CLIENT_ID = "meta-proxy"). `sensing:read`
covers streams and inference; `sensing:admin` covers training, model delete and
recording delete. No hierarchy — admin does not imply read; consent means what
it said. Registered in identity migration 0016 (cognitum-one/dashboard#116).

Design notes:
- `ureq`, not `reqwest`: the sensing server deliberately chose ureq as "the
  smallest" HTTP client, and pulling reqwest in here would reverse that for the
  whole graph. Transport sits behind a `JwksFetcher` trait, so a host can supply
  its own and take no HTTP dependency at all (feature `ureq-transport`, default).
- A JWKS refetch failure is survivable while a key set is already cached: a key
  that verified a minute ago has not stopped being valid because the network
  blipped, and failing closed there logs every user out of their own sensing
  server whenever their internet wobbles. We fail closed in exactly one case —
  no key set has ever been fetched.
- Unknown `kid` forces one refetch so rotation is picked up without waiting out
  the TTL, rate-limited so junk-kid tokens can't become a request amplifier
  aimed at identity.
- Expiry is reported distinctly from a bad signature: on an RTC-less Pi that is
  usually a clock-sync fault, and an operator must be able to tell them apart.
- Test keypairs are generated at runtime, never committed. This repo tracks zero
  `.pem` files and committed key material shouldn't start here — it also means
  no fixture can drift out of sync with the JWKS it's served by.

Tests: 41 green (19 unit + 21 matrix + 1 doctest) under BOTH
`cargo test --no-default-features` (the repo's canonical gate) and default
features. The matrix signs real ES256 tokens rather than asserting on strings,
and covers alg:none, forged signature, spliced payload, unknown kid, expired,
leeway boundaries both sides, wrong issuer, issuer differing only by a trailing
slash, missing/!=access typ, setup and workload smuggled onto typ=access,
missing and empty account_id, and scope escalation.

The highest-value case is `g2_a_genuinely_valid_token_from_another_cognitum_
product_cannot_reach_the_sensing_surface`: a correctly signed, unexpired,
right-issuer, right-typ token with client_id=meta-proxy and scope=inference is
rejected. Nothing about its signature or identity claims distinguishes it — only
scope does. A naive verifier accepts it, and an `inference` token becomes a key
to someone's home sensor.

No wiring into the sensing server yet; this crate is verification only, with no
login flow and no outbound Cognitum calls.

Co-Authored-By: Ruflo & AQE
2026-07-22 15:08:40 +02:00
42 changed files with 8881 additions and 125 deletions
+34 -22
View File
@@ -1,14 +1,10 @@
name: Continuous Deployment
on:
push:
branches: [ main ]
tags: [ 'v*' ]
workflow_run:
workflows: ["Continuous Integration"]
workflows: ["wifi-densepose sensing-server → Docker Hub + ghcr.io"]
types:
- completed
branches: [ main ]
workflow_dispatch:
inputs:
environment:
@@ -19,6 +15,11 @@ on:
options:
- staging
- production
image_tag:
description: 'Existing ghcr.io/ruvnet/wifi-densepose tag to deploy'
required: true
default: 'latest'
type: string
force_deploy:
description: 'Force deployment (skip checks)'
required: false
@@ -27,7 +28,7 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
IMAGE_NAME: ruvnet/wifi-densepose
KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_DATA }}
jobs:
@@ -35,7 +36,9 @@ jobs:
pre-deployment:
name: Pre-deployment Checks
runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch'
if: |
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') ||
github.event_name == 'workflow_dispatch'
outputs:
deploy_env: ${{ steps.determine-env.outputs.environment }}
image_tag: ${{ steps.determine-tag.outputs.tag }}
@@ -43,6 +46,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
submodules: recursive
- name: Determine deployment environment
@@ -50,14 +54,12 @@ jobs:
env:
# Use environment variable to prevent shell injection
GITHUB_EVENT_NAME: ${{ github.event_name }}
GITHUB_REF: ${{ github.ref }}
PUBLISHED_REF: ${{ github.event.workflow_run.head_branch }}
GITHUB_INPUT_ENVIRONMENT: ${{ github.event.inputs.environment }}
run: |
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
echo "environment=$GITHUB_INPUT_ENVIRONMENT" >> $GITHUB_OUTPUT
elif [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
echo "environment=staging" >> $GITHUB_OUTPUT
elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then
elif [[ "$PUBLISHED_REF" == v* ]]; then
echo "environment=production" >> $GITHUB_OUTPUT
else
echo "environment=staging" >> $GITHUB_OUTPUT
@@ -65,16 +67,23 @@ jobs:
- name: Determine image tag
id: determine-tag
env:
GITHUB_EVENT_NAME: ${{ github.event_name }}
PUBLISHED_REF: ${{ github.event.workflow_run.head_branch }}
PUBLISHED_SHA: ${{ github.event.workflow_run.head_sha }}
INPUT_IMAGE_TAG: ${{ github.event.inputs.image_tag }}
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
echo "tag=$INPUT_IMAGE_TAG" >> $GITHUB_OUTPUT
elif [[ "$PUBLISHED_REF" == v* ]]; then
echo "tag=$PUBLISHED_REF" >> $GITHUB_OUTPUT
else
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
echo "tag=sha-${PUBLISHED_SHA:0:7}" >> $GITHUB_OUTPUT
fi
- name: Verify image exists
run: |
docker manifest inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.determine-tag.outputs.tag }}
docker manifest inspect "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.determine-tag.outputs.tag }}"
# Deploy to staging
deploy-staging:
@@ -129,7 +138,10 @@ jobs:
name: Deploy to Production
runs-on: ubuntu-latest
needs: [pre-deployment, deploy-staging]
if: needs.pre-deployment.outputs.deploy_env == 'production' || (github.ref == 'refs/tags/v*' && needs.deploy-staging.result == 'success')
if: |
always() &&
needs.pre-deployment.result == 'success' &&
needs.pre-deployment.outputs.deploy_env == 'production'
environment:
name: production
url: https://wifi-densepose.com
@@ -210,7 +222,7 @@ jobs:
# kubectl scale rs -n wifi-densepose -l app=wifi-densepose,version!=green --replicas=0
- name: Upload deployment artifacts
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: production-deployment-${{ github.run_number }}
path: |
@@ -260,7 +272,7 @@ jobs:
post-deployment:
name: Post-deployment Monitoring
runs-on: ubuntu-latest
needs: [deploy-staging, deploy-production]
needs: [pre-deployment, deploy-staging, deploy-production]
if: always() && (needs.deploy-staging.result == 'success' || needs.deploy-production.result == 'success')
steps:
- name: Monitor deployment health
@@ -281,7 +293,7 @@ jobs:
done
- name: Update deployment status
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
script: |
const deployEnv = '${{ needs.pre-deployment.outputs.deploy_env }}';
@@ -300,7 +312,7 @@ jobs:
notify:
name: Notify Deployment Status
runs-on: ubuntu-latest
needs: [deploy-staging, deploy-production, post-deployment]
needs: [pre-deployment, deploy-staging, deploy-production, post-deployment]
if: always()
steps:
- name: Notify Slack on success
@@ -332,7 +344,7 @@ jobs:
- name: Create deployment issue on failure
if: needs.deploy-production.result == 'failure'
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
script: |
github.rest.issues.create({
@@ -355,4 +367,4 @@ jobs:
**Logs:** Check the workflow run for detailed error messages.
`,
labels: ['deployment', 'production', 'urgent']
})
})
+35
View File
@@ -171,6 +171,41 @@ jobs:
- name: ADR-135 calibration witness proof (determinism guard)
run: bash scripts/verify-calibration-proof.sh
# The workspace runs with --no-default-features, which switches OFF
# ruview-auth's `login` and `pkce` features. That silently excluded 40 of
# its 87 tests — the whole interactive sign-in path: credential storage,
# single-flight refresh, the advisory file lock, the loopback callback, and
# PKCE generation. They were green locally and never executed here.
# Measured: 47 tests with --no-default-features, 87 with --all-features.
- name: Run ruview-auth tests with all features (ADR-271 login path)
working-directory: v2
env:
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_PROFILE_TEST_DEBUG: "0"
run: cargo test -p ruview-auth --all-features
# Browser-facing JavaScript.
#
# These run the dashboard's own modules in Node with stubbed browser globals.
# They exist because the Rust suite cannot see them at all: two ADR-271/272
# defects (a service worker caching /oauth/status, and the WebSocket ticket
# helper) lived entirely in `ui/` and were invisible to a fully green
# workspace. Blocking, and fast — no browser, no install step.
ui-tests:
name: UI JavaScript Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Run UI unit tests
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs
# Unit and Integration Tests
# Python pytest matrix — runs against the archived v1 Python tree.
# `continue-on-error: true` for the same reason as code-quality above:
+59 -8
View File
@@ -34,9 +34,8 @@
# dedicated follow-up commit (drop `password:`, add the OIDC id-token
# permission + `environment: pypi`) so there is no capability gap between.
#
# Q3 (witness hash v2 — open in ADR-117 §11.3) MUST be resolved
# before the first v2.0.0 publish. When v2 lands, add a parallel
# step that verifies the v2 hash against the Rust pipeline.
# Production publishing fails closed until the ADR-117 §11.3 v2 witness
# hash exists. TestPyPI remains usable to validate release artifacts.
name: pip-release
@@ -83,7 +82,7 @@ jobs:
arch: x86_64
- os: ubuntu-latest
arch: aarch64
- os: macos-13 # x86_64 runner
- os: macos-15-intel # x86_64 runner
arch: x86_64
- os: macos-14 # arm64 runner
arch: arm64
@@ -152,6 +151,46 @@ jobs:
path: sdist/*.tar.gz
if-no-files-found: error
build-ruview:
name: Build ruview meta-package
if: |
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
startsWith(github.ref, 'refs/tags/v2.')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Verify lock-step package versions
shell: python
run: |
import pathlib
import tomllib
root = pathlib.Path("python")
core = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
meta = tomllib.loads((root / "ruview-meta" / "pyproject.toml").read_text(encoding="utf-8"))
core_version = core["project"]["version"]
meta_version = meta["project"]["version"]
expected_dependency = f"wifi-densepose=={core_version}"
if meta_version != core_version:
raise SystemExit(
f"package versions differ: wifi-densepose={core_version}, ruview={meta_version}"
)
if expected_dependency not in meta["project"]["dependencies"]:
raise SystemExit(f"ruview must depend on {expected_dependency}")
print(f"lock-step version: {core_version}")
- name: Build ruview wheel and sdist
run: |
python -m pip install --upgrade pip build
python -m build python/ruview-meta --outdir ruview-dist
- uses: actions/upload-artifact@v4
with:
name: ruview
path: ruview-dist/*
if-no-files-found: error
# ────────────────────────────────────────────────────────────────
# v1.99.0 — tombstone wheel (pure Python, single sdist + wheel)
# ────────────────────────────────────────────────────────────────
@@ -236,18 +275,29 @@ jobs:
# ────────────────────────────────────────────────────────────────
publish-v2:
name: Publish v2 wheels
needs: [build-wheels, build-sdist]
name: Publish wifi-densepose + ruview
needs: [build-wheels, build-sdist, build-ruview]
if: |
always() &&
needs.build-wheels.result == 'success' &&
needs.build-sdist.result == 'success' &&
needs.build-ruview.result == 'success' &&
(
github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' ||
startsWith(github.ref, 'refs/tags/v2.')
)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enforce production witness gate
if: |
startsWith(github.ref, 'refs/tags/v2.') ||
(github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi')
run: |
test -s archive/v1/data/proof/expected_features_v2.sha256 || {
echo "::error::ADR-117 §11.3 release gate is incomplete: archive/v1/data/proof/expected_features_v2.sha256 is missing or empty"
exit 1
}
- name: Gather all artifacts into dist/
uses: actions/download-artifact@v4
with:
@@ -264,7 +314,7 @@ jobs:
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
password: ${{ secrets.PYPI_API_TOKEN }}
password: ${{ secrets.TESTPYPI_API_TOKEN }}
packages-dir: dist
skip-existing: true
- name: Publish to PyPI
@@ -275,6 +325,7 @@ jobs:
with:
password: ${{ secrets.PYPI_API_TOKEN }}
packages-dir: dist
verbose: true
publish-tombstone:
name: Publish v1.99 tombstone
@@ -299,7 +350,7 @@ jobs:
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
password: ${{ secrets.PYPI_API_TOKEN }}
password: ${{ secrets.TESTPYPI_API_TOKEN }}
packages-dir: dist
skip-existing: true
- name: Publish to PyPI
+2 -2
View File
@@ -8,8 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **`wifi-densepose` promoted to `2.0.0` stable; `ruview` `2.0.0` first stable publish (ADR-184 P2).** Dropped the `a1` alpha suffix on both sibling packages (`python/pyproject.toml`, `python/ruview-meta/pyproject.toml`) and flipped their trove classifier `Development Status :: 3 - Alpha``5 - Production/Stable`; the `ruview` meta-package's `wifi-densepose==2.0.0a1` dependency pins (base + `[client]`) were repointed to `==2.0.0`. Pip-release now authenticates via Trusted Publishing (see the entry below). **Version-metadata prep only — nothing is published by this change**: the actual PyPI upload (ADR-184 P3) is still gated on the one-time manual Trusted Publisher registration on pypi.org that only the repo owner can perform. Justified as "stable": the default (no-extras) wheel builds at 279 KB (`maturin build --release --strip`) and the base non-SOTA suite is green — `pytest python/tests/` (excluding the `[aether]`/`[meridian]`/`[mat]` extra modules) = **185 passed, 0 failed** (smoke / keypoint / pose / vitals / bfld / security / WS+MQTT client).
- **CI (ADR-184): `pip-release.yml` publish job migrated to PyPI OIDC Trusted Publishing** (commit `cc153e8b5`; refs #785, completes ADR-117). The release workflow now authenticates to PyPI via short-lived OIDC tokens (`id-token: write`) instead of a long-lived `PYPI_API_TOKEN` secret. **Not yet active**: publishing will fail until the matching Trusted Publisher is registered manually on pypi.org (a one-time, per-project step that cannot be automated from CI) — ADR-184 P1 tracks this as the remaining gate (status recorded in `dfc4c1abd`).
- **`wifi-densepose` promoted to `2.0.0` stable; `ruview` `2.0.0` prepared for its first stable publish (ADR-184 P2).** Dropped the `a1` alpha suffix on both sibling packages (`python/pyproject.toml`, `python/ruview-meta/pyproject.toml`) and flipped their trove classifier `Development Status :: 3 - Alpha``5 - Production/Stable`; the `ruview` meta-package's `wifi-densepose==2.0.0a1` dependency pins (base + `[client]`) were repointed to `==2.0.0`. **Version-metadata prep only — nothing is published by this change**: the actual PyPI upload remains gated on the ADR-117 v2 witness hash. Justified as "stable": the default (no-extras) wheel builds at 279 KB (`maturin build --release --strip`) and the base non-SOTA suite is green — `pytest python/tests/` (excluding the `[aether]`/`[meridian]`/`[mat]` extra modules) = **185 passed, 0 failed** (smoke / keypoint / pose / vitals / bfld / security / WS+MQTT client).
- **CI (ADR-184): `pip-release.yml` keeps token-based PyPI authentication until Trusted Publishing is registered.** An OIDC migration was attempted in `cc153e8b5` and reverted in `82d5c7339` so releases would not enter a half-configured state. Production currently uses `PYPI_API_TOKEN`; TestPyPI uses its independent `TESTPYPI_API_TOKEN`. The workflow now builds and publishes `wifi-densepose` and `ruview` together, verifies their versions and dependency pin match, and fails closed before production upload when `expected_features_v2.sha256` is absent.
- **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies.
### Deprecated
@@ -0,0 +1,487 @@
# ADR-271: RuView as a Cognitum OAuth resource server
- **Status**: accepted
- **Date**: 2026-07-22
- **Deciders**: RuView maintainers
- **Tags**: auth, oauth, cognitum, security, sensing-server
- **Related**: ADR-055 (integrated sensing server), ADR-102 (edge module registry), ADR-066 (ESP32 seed pairing), cognitum-one/dashboard ADR-060 (OAuth scopes beyond `inference`), cognitum-one/meta-llm ADR-045 (Bearer at completions)
## Context
`/api/v1/*` on `wifi-densepose-sensing-server` is gated by `RUVIEW_API_TOKEN`
(`bearer_auth.rs`): a single shared secret, compared in constant time, with no
expiry, no rotation and no per-user attribution. `homecore-api` has a second,
unrelated scheme (`LongLivedTokenStore` over `HOMECORE_TOKENS`) whose own doc
comment describes it as "no expiry, no rotation, no per-user attribution yet".
That is proportionate for the ADR-055 topology — server bundled in the desktop
app, spawned as a child, localhost only. It is not proportionate for the other
deployment RuView actually has: a sensing server on a Pi or hub, reachable on a
LAN, potentially serving more than one person, exposing live presence, pose,
breathing and heart-rate data plus destructive operations (model training,
model delete, recording delete).
Cognitum operates a live OAuth 2.1 authorization server at `auth.cognitum.one`.
Users of RuView are already Cognitum account holders. The obvious question is
whether RuView can accept that identity instead of a shared string.
### The direction of the integration is the thing most likely to be misread
Every existing Cognitum OAuth integration in the org — meta-proxy, musica,
metaharness, the dashboard CLI — is an OAuth **client**: it obtains a token so
the application can *call* a Cognitum service (the completions plane).
RuView is the opposite. It makes **no authenticated calls to any Cognitum API**.
Its only outbound Cognitum dependency is the ADR-102 registry fetch, which is an
anonymous GET against a public GCS bucket. What RuView wants is to be a
**resource server**: a user signs in to their *own* RuView instance with their
Cognitum identity, and RuView verifies the token they present.
So the client-side prior art in the org, while useful for a future `ruview
login` command, addresses a plane RuView does not have. The only relevant
precedent is `meta-llm/src/auth/oauthBearer.ts` (ADR-045) — the org's sole
resource-server-side verifier of these tokens. It is TypeScript; **RuView is the
first Rust one.**
### Facts about the tokens, verified against a live production token
- **ES256 JWT**, signed by a single P-256 key published at
`https://auth.cognitum.one/.well-known/jwks.json`.
- **15-minute lifetime**, with an opaque refresh token that **rotates with reuse
detection** (presenting a spent one ends the session).
- Claims: `typ`, `sub`, `account_id`, `org_id`, `workspace_id`, `client_id`,
`scope`, `family_id`, `jti`, `iat`, `exp`, `setup`, `workload`.
- **No `aud` claim.** No `/oauth/introspect`. No `/userinfo`. It is an OAuth 2.1
authorization server, not an OpenID Provider, deliberately.
## Decision
Verify Cognitum access tokens **offline**, in a new `ruview-auth` crate, and
gate RuView's own API surface on the **scope** they carry.
### 1. Offline verification is a requirement, not an optimisation
RuView runs on Pi-class hardware that loses WAN, and there is no introspection
endpoint to call even when the network is up. Verification is therefore an
ES256 signature check against a `kid`-indexed JWKS cache. Two consequences we
accept explicitly:
- **Revocation window = token lifetime.** A compromised access token stays
usable until `exp`. This is the same position meta-llm takes, for the same
reason, and it is why §3 refuses long-lived credentials.
- **A JWKS refetch failure is survivable while a key set is cached.** A key that
verified a minute ago has not stopped being valid because the network blipped;
failing closed there would log every user out of their own sensing server
whenever their internet wobbled. We fail closed in exactly one case: no key
set has *ever* been fetched.
### 2. The accept-rule is ported from meta-llm, not designed
```
typ == "access" AND NOT setup AND NOT workload
AND account_id is a non-empty string
AND exp is in the future
AND the scope required by the route is held
```
**Note there is no `iss` check.** An earlier revision of this section listed
"`iss` matches the configured issuer verbatim" — that rule was implemented,
shipped, and rejected EVERY real token, because Cognitum access tokens carry no
`iss` claim (see §"Facts about the tokens" above, which contradicted this
paragraph for a day). Removed in the code; removed here. The JWKS is the issuer
binding.
Divergence from `oauthBearer.ts` would be a bug rather than a preference: a
token meta-llm rejects must not be one RuView accepts. The algorithm is **fixed
to ES256 by our code** — the header's `alg` is only ever compared against that
allowlist, never used to select an algorithm.
### 3. Long-lived setup and workload credentials are refused outright
Identity also issues 365-day *setup* and machine *workload* credentials. Their
revocation state lives in identity's `oauth_setup_tokens` table. RuView — like
meta-llm — has no database and no way to check it, so accepting one would mean
honouring a credential that may already have been revoked. A 15-minute token
needs no revocation round-trip because it expires faster than revocation
propagates; a 365-day one does.
### 4. Scope is the capability boundary, because nothing else can be
Tokens carry no `aud`, so RuView cannot verify a token was minted *for* RuView.
`client_id` cannot substitute: clients borrow each other's registrations when
their own has not been deployed (musica ships `DEFAULT_CLIENT_ID = "meta-proxy"`).
This is not a defect to route around. Cross-product **identity** is intended —
one Cognitum account, every Cognitum product. Cross-product **capability** is
not, and scope is what carries the difference.
RuView registers two scopes (dashboard ADR-060, identity migration `0016`):
| Scope | Grants |
|---|---|
| `sensing:read` | sensing/pose streams, one-shot inference, reading model and recording metadata |
| `sensing:admin` | every mutating route not explicitly allowlisted as read-safe — training (`/api/v1/train/*` AND `/api/v1/adaptive/train`), model and recording deletion, config writes |
**The gate is fail-closed for writes, and that polarity is load-bearing.** An
earlier revision enumerated admin routes by prefix and let everything else fall
through to `sensing:read`. `POST /api/v1/adaptive/train` — which trains a
classifier, overwrites the on-disk model and swaps the live one — does not match
`/api/v1/train/`, so it was reachable with `sensing:read`, the scope
`wifi-densepose login` requests by default. Found by adversarial review. Now:
reads are open, writes require admin unless the exact path is on a short
allowlist of non-destructive mutations. A route added tomorrow is admin-gated
until someone classifies it.
**No hierarchy**: `sensing:admin` does not imply `sensing:read`. Consent means
exactly what it said, and a token needing both must have consented to both.
`client_id` is retained on the principal for logging and attribution only —
never as an authorization input.
### 5. Additive and fail-closed, never a silent downgrade
`RUVIEW_API_TOKEN` and `HOMECORE_TOKENS` deployments keep working unchanged.
OAuth is opt-in; with it unconfigured, behaviour is byte-identical to today.
When OAuth *is* configured but unusable (JWKS unreachable at boot, required
scope not registered), the server must refuse to serve `/api/v1/*` rather than
fall through to an open or single-secret state.
### 6. `ureq`, and a transport seam
`wifi-densepose-sensing-server` deliberately chose `ureq` as "the smallest" HTTP
client. Introducing `reqwest` for a JWKS fetch would silently reverse that for
the whole dependency graph. The fetch sits behind a `JwksFetcher` trait — the
`ureq` implementation is a default-on feature, and a host may supply its own and
take no HTTP dependency at all.
## Consequences
- Requests become attributable: `sub`, `account_id`, `org_id`, `workspace_id`,
`jti`. This closes the gap `homecore-api`'s `tokens.rs` has been deferring as
"P3", using claims rather than new RuView machinery.
- Destructive operations can be separated from observation for the first time.
- **The 15-minute lifetime is the main operational cost.** A long-running client
must refresh, and because refresh tokens rotate with reuse detection, a
concurrent or naively retried refresh **ends the session** — single-flight is a
correctness requirement, not an optimisation. This lands with the login flow,
not this crate.
- Hosts without a battery-backed clock will fail `exp`/`iat` until NTP lands.
The verifier reports that distinguishably so it is diagnosable rather than
presenting as a generic 401.
- A new dependency, `jsonwebtoken` — the same crate, same major version, that
identity itself uses to sign these tokens.
## ~~Known incomplete: the browser cannot obtain an OAuth token~~ — CLOSED 2026-07-23
> **Superseded within this same PR.** The text below described the state when
> this ADR was first written. It is retained because the reasoning still
> explains *why* the browser half was built, but every factual claim in it is
> now false — in particular `grep -ril "oauth|cognitum|pkce" ui/` now returns
> `ui/sw.js`, `ui/sw.test.mjs` and `ui/utils/quick-settings.js`. An adversarial
> review caught the ADR still asserting the old state; see "Browser sign-in"
> below for what actually ships.
<details>
<summary>Original text (no longer accurate)</summary>
`wifi-densepose login` writes to `~/.ruview/credentials.json` — a file a browser
cannot read. The UI's `ws-ticket.js` reads a bearer from
`localStorage['ruview-api-token']`, which is populated **only** by the
QuickSettings manual-paste panel. There is no "Sign in with Cognitum" control,
no redirect flow, and `grep -ril "oauth|cognitum|pkce" ui/` returns nothing.
So a user who signs in via the CLI gets **no benefit in the browser UI**, and
the WebSocket ticket mechanism this ADR's sibling (ADR-272) introduces "for
browsers" is today only exercisable with the legacy static shared secret that
OAuth was meant to replace. The server-side gating is correct and complete; the
browser half of the story these ADRs tell is not built.
</details>
## Browser sign-in
`/oauth/start`, `/oauth/callback`, `/oauth/logout` and `/oauth/status`, plus a
"Cognitum Account" panel in QuickSettings. The server runs the authorization
code + PKCE flow itself and hands the browser a **signed session cookie**
never the access token. The browser gets an assertion that this server already
verified a token, which is nothing replayable anywhere else.
Three things about it are load-bearing and were each found the hard way:
- **The cookie carries the granted scope**, and the gate re-checks it per
request. A `sensing:read` session cannot delete a model.
- **`__Host-` is deliberately NOT used.** That prefix requires `Secure`, and
RuView is routinely reached over plain HTTP on a LAN; a cookie the browser
refuses to set is worse than one without the prefix. The cost is real and is
recorded as P3 under "Open problems" below.
- **The service worker must never cache `/oauth/*` or authenticated `/api/*`.**
The Cache API is not the HTTP cache and ignores `Cache-Control` entirely, so
a cached `/oauth/status` froze sign-in until a hard reload, and cached API
responses could be replayed to a different user after sign-out. `ui/sw.js` is
now deny-by-default with an allowlist.
### Still incomplete
`redirect_uri` defaults to `http://127.0.0.1:8080/oauth/callback` and is
overridden only by `RUVIEW_PUBLIC_BASE_URL`. Browser sign-in therefore works
only on a host reached at exactly that origin: an operator browsing
`http://localhost:8080` or `http://192.168.1.50:8080` cannot complete the flow
(PKCE keeps the code unexchangeable, so this is a broken flow, not a token
leak). Deriving it from the request is the fix; deferred deliberately, since
deriving a redirect URI from attacker-controllable headers is its own class of
bug and deserves its own decision.
The credential `wifi-densepose login` stores is also **not yet consumed by any
shipped client** — no CLI subcommand, MCP server or Python client reads
`~/.ruview/credentials.json`. The token is obtainable and verifiable; wiring the
clients to send it is separate work.
## Open problems — RESOLVED 2026-07-23
Three findings from the 2026-07-23 adversarial review. All three are now
**fixed**; the analysis is retained because it explains why each fix has the
shape it does, and each is guarded by a test that was confirmed to fail against
the old behaviour.
### P1 — the JWKS fetch blocks a tokio worker, and the stale path is unbounded — **FIXED**
`verify.rs:182` calls `JwksCache::decoding_key_for`, which performs a blocking
`ureq` request (`jwks.rs:181`, 3s connect + 3s read) directly on the async
worker running `require_bearer`. The same codebase already knows this is wrong:
`main.rs:9265` wraps the token exchange in `spawn_blocking`, commenting "the
same mistake this codebase had to fix in `jwks.rs`". The hot verification path
did not get the same treatment.
Worse, the rate limiter does not cover the case that matters.
`state.fetched_at` is updated **only on success** (`jwks.rs:188`); the error arm
leaves it untouched. So once the TTL elapses after the last *successful* fetch,
`fresh` is permanently `false`, the `may_force` guard at `:170` is never
consulted, and **every** request performs its own blocking fetch attempt.
This fires with no attacker present. On a Pi that loses WAN — the documented
deployment reality — 300 seconds later every API call and every UI poll starts a
blocking outbound attempt, and with few tokio workers the whole server stalls,
including `/health`. An attacker can reach the same state deliberately by
flooding tokens carrying an unknown `kid`.
**Proposed fix, in dependency order:**
1. **Rate-limit attempts, not successes.** Add `last_attempt_at`, recorded
before the fetch regardless of outcome, and consult it on the stale path too.
This alone converts "every request fetches" into "one request per interval".
2. **Get the blocking call off the runtime.** Either wrap the call in
`spawn_blocking` at the `verify` boundary, or give `JwksCache` an async
transport behind the existing transport seam. The seam already exists —
`JwksCache::new` takes a boxed transport — so this is an added
implementation, not a redesign.
3. **Single-flight the refresh.** Concurrent misses for the same `kid` should
await one shared fetch rather than each issuing their own.
4. **Refresh ahead of expiry** from a background task, so the request path
normally never fetches at all.
Steps 1 and 2 are the ones that remove the stall; 3 and 4 are optimisations.
The test that must accompany this: a transport whose fetch blocks on a barrier,
asserting that a second concurrent verification is not serialised behind it —
the current suite is entirely single-threaded and could not observe a
reintroduction (`jwks::tests` contains no concurrency primitive at all).
### P2 — a 15-minute access token becomes a 12-hour session — **FIXED**
`issue()` sets `exp: now() + SESSION_TTL_SECS` with `SESSION_TTL_SECS = 12 *
3600`, deliberately not inheriting the access token's ~15-minute lifetime. The
session cookie is an assertion that this server verified a token, so it is not
*wrong* for it to outlive the token — but 12 hours is a long time to hold an
authority that cannot be revoked. Cognitum publishes no introspection endpoint
(see "Facts about the tokens"), so RuView has no way to ask whether the grant
behind a session still stands. A disabled account keeps sensing access, and
`sensing:admin` if it had it, until the cookie expires on its own.
**Correction.** An earlier revision of this section said capping the session at
`sensing:read` was "considered and rejected, because the dashboard genuinely
performs admin operations". That was wrong, and a cross-vendor pre-merge sweep
caught it: `/oauth/start` (`main.rs:9206`) already requests `SENSING_READ` and
nothing else, deliberately — "admin work goes through the CLI, which requires an
explicit `--admin`". So a browser session is **already** read-only, and the
consequence I claimed capping would cause is simply the current behaviour.
Two things follow, and both are stated here rather than left for the next reader
to trip over:
1. **The UI's admin controls do not work from a browser OAuth session.**
`model.service.js:136` issues `DELETE /api/v1/models/{id}`; from a
Cognitum-signed-in browser that returns 401. Admin work requires either the
CLI (`wifi-densepose login --admin`) or a manually pasted admin bearer in the
QuickSettings token field. This is a gap in the browser feature, not a
regression — browser sign-in is new here, and the token-paste path still
carries whatever authority the pasted token has.
2. **The step-up control below is therefore a guard ahead of need, not an active
one.** No browser session currently holds `sensing:admin`, so
`session.has_scope(SENSING_ADMIN)` is false and the freshness branch never
fires in production. Its tests pass because the crate-internal test seam
mints an admin cookie the real flow does not produce. That is worth naming
plainly: it is correct code guarding a case that cannot yet arise, and it
becomes load-bearing the moment anyone widens the requested scope — which is
the right time for the guard to already exist, but it is not evidence that
the control is exercised today.
### Decision, 2026-07-23: the browser is read-only, permanently
**Browser-side admin is not wanted.** `BROWSER_SIGNIN_SCOPE` stays
`sensing:read`, and the escalate-on-demand design sketched while this was still
open is **not** being built.
The reasoning holds up on its own terms rather than being a concession to
scope: the destructive operations — training, model delete, recording delete —
already have a home in the CLI, where `--admin` is explicit, typed by a person,
and scoped to the session that needed it. Routing them through a browser would
mean either asking every user to consent to delete capability in order to watch
a stream, or building a second consent flow to avoid that. Neither is worth it
for operations that are administrative by nature and rare by frequency.
What this settles:
- **The UI's admin controls are unreachable from a Cognitum browser session**
and that is now intended, not a gap. `model.service.js` issuing
`DELETE /api/v1/models/{id}` returns 401. The manual token-paste field still
works and carries whatever authority the pasted token has, so nothing that
worked before this change stops working.
- **The client-side step-up redirect has been removed** from
`ui/services/api.service.js`. It caught a challenge that can never be issued,
and it ended in a promise that never settles — so had any other 401 ever grown
that header, every caller would have hung forever. Dead code with a trap in it
is worse than no code.
- **`ADMIN_REVERIFY_SECS` stays as a server-side backstop.** It is fail-closed
and costs nothing, so if the requested scope is ever widened the freshness
requirement is already there rather than something to remember. It is
documented at its definition as a backstop, so nobody mistakes its passing
tests for evidence that it is exercised.
**Three options, with the tradeoff each carries:**
| Option | Effect | Cost |
|---|---|---|
| **A. Shorten the TTL** (e.g. 12h → 4h) | Bounds exposure by a factor of 3, one constant | Re-auth is a full-page navigation, which interrupts a live streaming dashboard. Mostly silent while the Cognitum session is alive, but not free. |
| **B. Server-side session store** with the refresh token, revalidated periodically | Real revocation: a disabled grant fails at the next refresh | The server now stores refresh tokens — a new and higher-value secret at rest — and refresh rotates with reuse detection, so a bug logs users out. |
| **C. Re-verify on privileged operations only** | `sensing:admin` requires a fresh token; reads keep the long session | Best blast-radius-per-unit-cost, but needs a UI affordance for step-up auth that does not exist. |
**Chosen: A, at one hour**`SESSION_TTL_SECS` is 3600, down from 12 hours.
C was implemented too, and then the browser-read-only decision above made it a
backstop rather than an active control: with no browser session holding
`sensing:admin`, there is no privileged operation to re-verify. It is kept
because it is fail-closed and free, not because it is doing work today.
B is not built. It is only worth its cost — storing refresh tokens at rest,
against an authorization server that rotates them with reuse detection — if
RuView later needs true cross-device sign-out. Shortening the window addresses
the same risk for a fraction of the exposure.
That leaves a residual this ADR should not pretend away: **within one hour, a
revoked Cognitum grant still reads sensing data through an existing browser
session.** Cognitum publishes no introspection endpoint, so nothing short of B
closes that, and one hour is the size of the hole we accepted.
### P3 — dropping `__Host-` costs cookie origin-integrity, not just `Secure` — **FIXED**
The decision above frames omitting `__Host-` as trading away a `Secure`
requirement that RuView cannot meet on a plain-HTTP LAN. That framing is
incomplete: `__Host-` also guarantees the cookie was set by *this* origin with
`Path=/` and no `Domain`. Without it, cookies are not port-scoped and are not
integrity-protected against a same-host writer.
`read_cookie` returns the **first** match in the header, and RFC 6265 §5.4 sends
longer-`Path` cookies first. So an attacker who can set a cookie on the same
host — any other service on any port on that appliance, or a plain-HTTP MITM
injecting `Set-Cookie` — can plant `ruview_session=<their own validly signed
session>; Path=/ui`. The victim's browser then sends both, the attacker's first,
and it verifies correctly because it *is* genuinely signed. The victim ends up
operating inside the attacker's session; `/oauth/status` reports the attacker's
account, and anything the victim records is attributed to them.
Note the shape: the signature is doing its job. Forgery was never the threat
`__Host-` addresses, so "the signature is what protects the value" does not
answer this.
**Proposed fix (cheap, no prefix needed):** have `read_cookie` collect *all*
values for the name and accept only if exactly one verifies — or, more strictly,
reject outright when more than one `ruview_session` is present, since a browser
should never legitimately send two. Add `Secure` and the `__Host-` prefix
conditionally when the server knows it is behind TLS, keeping the plain-HTTP LAN
case working.
## Alternatives considered
**Keep `RUVIEW_API_TOKEN` only.** Zero work, and adequate for a single-user
localhost install. Rejected because it cannot express who did what, cannot be
revoked without a restart, and cannot separate "watch the stream" from "delete
the model" — all of which matter the moment the server is on a LAN.
**Exchange the OAuth token for a `cog_` key.** The pattern ADR-316 (meta-proxy)
and ADR-119 (metaharness) originally described. Rejected: it cannot work.
`/v1/me/keys` requires a *Firebase* ID token, not an OAuth token — meta-proxy
hit the resulting 401 in production, replaced the approach with Bearer-direct
under ADR-045, and deleted `mint.rs` as dead code.
**Call identity to introspect each token.** Rejected: no introspection endpoint
exists, and a network round-trip per request would be wrong for an edge sensing
server regardless.
**Wait for an `aud` claim before shipping.** Rejected as sequencing. `aud` would
touch every issued token and every verifier in the org; scope is additive and
independently correct. Tracked separately; adding `aud` later strengthens this
design rather than invalidating it.
**Use OAuth for the ESP32 device plane too.** Rejected as a category error.
Devices have no browser, no user and no human present; they already pair with a
`seed_token` bearer (ADR-066) plus a device-bound PSK. Cognitum OAuth is for the
API plane only.
## Implementation
`v2/crates/ruview-auth``jwks` (fetch, TTL cache, `kid` index, one
rate-limited forced refetch on an unknown `kid` so rotation is picked up without
waiting out the TTL), `verify` (the §2 accept-rule), `principal` (the verified
caller and its scopes).
41 tests pass under both `cargo test --no-default-features` (the repo's
canonical gate) and default features. The matrix signs real ES256 tokens with a
runtime-generated key — no key material is committed — and covers `alg:none`,
forged signatures, spliced payloads, unknown `kid`, expiry on both sides of the
leeway, `typ` confusion, `setup`/`workload` smuggled onto a `typ=access` token, missing and
empty `account_id`, and scope escalation.
The load-bearing case is
`g2_a_genuinely_valid_token_from_another_cognitum_product_cannot_reach_the_sensing_surface`:
a correctly signed, unexpired, right-issuer, right-`typ` token bearing
`client_id=meta-proxy` and `scope=inference` is rejected. Nothing about its
signature or identity claims distinguishes it — only scope does. A naive
verifier accepts it, and an `inference` token becomes a key to someone's home
sensor.
**Not in this crate**: WebSocket authentication (ADR-272) and any outbound
Cognitum call.
### Amendment, 2026-07-22 — the login flow lives here after all, behind a feature
The paragraph above originally also excluded the login flow. That was written
to keep the sensing server lean, which is the right goal but not a reason to put
the code somewhere else: the Tauri desktop app needs the same flow, and a second
copy of a PKCE + rotating-refresh implementation is exactly the kind of
duplication that drifts apart and then disagrees about something subtle.
So `login` is a **non-default feature** of this crate. A server built with
default features gets the verifier and nothing more — no `reqwest`, no tokio
networking, no browser launcher. The CLI opts in with
`features = ["login"]`, and the desktop app can do the same.
Shipped as `wifi-densepose login` / `logout` / `whoami`. Two properties worth
restating because they are easy to get wrong:
* **Refresh is serialised and never retried.** Identity rotates refresh tokens
with reuse detection, so a concurrent refresh looks like replay and a retry
*is* replay — either revokes the session family. `Session::ensure_fresh`
holds an async mutex across the network call, re-checks expiry after
acquiring it, and persists the rotated token before returning it.
* **Least scope by default.** `login` requests `sensing:read`; `--admin` is an
explicit escalation and requests both scopes, since there is no hierarchy
server-side.
@@ -0,0 +1,185 @@
# 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 **can** send a normal
`Authorization: Bearer` on the handshake, so the server accepts one there;
routing them through a ticket would add a round-trip and a second credential
path for no benefit.
> **Correction, 2026-07-23.** This section previously stated that those clients
> **do** send a bearer. The published Python client does not:
> `python/wifi_densepose/client/ws.py` calls `websockets.connect(url,
> ping_interval, ping_timeout, max_size)` and passes no headers at all — the
> file contains zero occurrences of `extra_headers` or `Authorization`. So every
> `wifi-densepose[client]` consumer **401s the moment an operator enables
> auth**, and this ADR told them they would be fine.
>
> The server side of the decision stands — a bearer on the upgrade is accepted,
> and that is the right contract for a non-browser client. What is missing is
> the client implementing it, tracked as ruvnet/RuView#1395. Until then the only
> remedy available to those users is
> `RUVIEW_WS_LEGACY_UNAUTHENTICATED=1`, which reopens the exposure this ADR
> exists to close — so it is a migration aid with a deadline, not an answer.
### 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.
+16 -10
View File
@@ -4,9 +4,12 @@ Operations doc for the `.github/workflows/pip-release.yml` CI workflow.
## Auth
The workflow uses one GitHub Actions secret named `PYPI_API_TOKEN`.
It's a project-token issued by the rUv PyPI account with upload
scope for both `wifi-densepose` and `ruview`.
Production uses the GitHub Actions secret `PYPI_API_TOKEN`. It is a
project token issued by the rUv PyPI account with upload scope for both
`wifi-densepose` and `ruview`.
TestPyPI uses a separate `TESTPYPI_API_TOKEN` secret issued by
test.pypi.org. PyPI and TestPyPI accounts and tokens are independent.
## Refreshing the token
@@ -47,16 +50,19 @@ Per ADR-117 §7.3, the tombstone publishes first so it claims the
tombstone live at `https://pypi.org/project/wifi-densepose/1.99.0/`
2. Verify: `pip install wifi-densepose==1.99.0; python -c "import
wifi_densepose"` → ImportError with migration URL.
3. `git tag v2.0.0-pip && git push origin v2.0.0-pip` → v2 wheel
matrix live at `https://pypi.org/project/wifi-densepose/2.0.0/`.
4. (Optional, in lock-step) build + publish a matching `ruview`
release from `python/ruview-meta/` so the meta-package version
stays pinned to the same wifi-densepose version.
3. Confirm `archive/v1/data/proof/expected_features_v2.sha256` is
committed and non-empty. Production publishing fails closed without it.
4. `git tag v2.0.0-pip && git push origin v2.0.0-pip` → the v2
`wifi-densepose` wheel matrix and matching `ruview` wheel/sdist are
published together. Their versions and dependency pin are checked in CI.
5. Verify both `https://pypi.org/project/wifi-densepose/2.0.0/` and
`https://pypi.org/project/ruview/2.0.0/`.
## Off-loop manual gates
- **Q3** (ADR-117 §11.3) — generate `expected_features_v2.sha256`
from the v2 Rust pipeline before any v2 publish.
- **Q3** (ADR-117 §11.3) — generate
`archive/v1/data/proof/expected_features_v2.sha256` from the v2 Rust
pipeline before a production v2 publish. The workflow enforces this gate.
- **OIDC Trusted Publisher** — not used. The workflow is token-based;
this is a deliberate choice to keep the secret refresh entirely in
GCP. If the project migrates to OIDC later, remove `password:`
+7 -3
View File
@@ -8,6 +8,7 @@
* - Dot-matrix mist body mass, particle trails, WiFi waves, signal field
* - Reflective floor, settings dialog, and practical data HUD
*/
import { withWsTicket } from '../../services/ws-ticket.js';
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
@@ -462,7 +463,7 @@ class Observatory {
console.log('[Observatory] Sensing server detected at', base, '→', wsUrl);
this.settings.dataSource = 'ws';
this.settings.wsUrl = wsUrl;
this._connectWS(wsUrl);
void this._connectWS(wsUrl);
} else {
tryNext(i + 1);
}
@@ -472,10 +473,13 @@ class Observatory {
tryNext(0);
}
_connectWS(url) {
// async: `/ws/sensing` is gated (ADR-272); mint a single-use ticket first.
async _connectWS(url) {
this._disconnectWS();
let wsUrl = url;
try { wsUrl = await withWsTicket(url); } catch { /* auth off or pre-ADR-272 server */ }
try {
this._ws = new WebSocket(url);
this._ws = new WebSocket(wsUrl);
this._ws.onopen = () => {
console.log('[Observatory] WebSocket connected');
this._hud.updateSourceBadge('ws', this._ws);
+15
View File
@@ -86,6 +86,21 @@ export class ApiService {
// Process response through interceptors
const processedResponse = await this.processResponse(response, url);
// NOTE: there is deliberately no step-up re-authentication branch here.
//
// An earlier revision caught the server's RFC 6750 "reauthentication
// required" challenge and redirected to /oauth/start. That challenge can
// never be issued to a browser: browser sign-in requests `sensing:read`
// only and always will (see BROWSER_SIGNIN_SCOPE), so no browser session
// holds `sensing:admin`, so the freshness gate the challenge announces is
// never reached. Admin work goes through the CLI or a pasted bearer.
//
// Removed rather than left inert, because it was not merely dead — it
// ended in a promise that never settles. If any other 401 ever grew that
// header, every caller awaiting this would hang forever with no error.
// The server-side guard stays as a fail-closed backstop; the client has
// nothing to do about a flow that does not exist.
// Handle errors
if (!processedResponse.ok) {
const error = await processedResponse.json().catch(() => ({
+18 -4
View File
@@ -1,3 +1,4 @@
import { withWsTicket } from './ws-ticket.js';
/**
* Sensing WebSocket Service
*
@@ -65,7 +66,7 @@ class SensingService {
/** Start the service (connect or simulate). */
start() {
this._connect();
void this._connect();
}
/** Stop the service entirely. */
@@ -120,13 +121,26 @@ class SensingService {
// ---- Connection --------------------------------------------------------
_connect() {
// async because the server gates `/ws/sensing` (ADR-272) and a browser
// cannot set an Authorization header on an upgrade — so we mint a
// single-use ticket first. Minted per connect attempt, never cached: a
// ticket is valid once and expires in seconds, so reusing one across
// reconnects would fail on the second attempt.
async _connect() {
if (this._ws && this._ws.readyState <= WebSocket.OPEN) return;
this._setState('connecting');
let url = SENSING_WS_URL;
try {
this._ws = new WebSocket(SENSING_WS_URL);
url = await withWsTicket(SENSING_WS_URL);
} catch {
// Ticket minting is best-effort: against a server with auth off, or one
// predating ADR-272, connecting without a ticket is correct.
}
try {
this._ws = new WebSocket(url);
} catch (err) {
console.warn('[Sensing] WebSocket constructor failed:', err.message);
this._fallbackToSimulation();
@@ -184,7 +198,7 @@ class SensingService {
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
this._connect();
void this._connect();
}, delay);
// Only start simulation after several failed attempts so a brief hiccup
+10 -3
View File
@@ -1,3 +1,4 @@
import { withWsTicket } from './ws-ticket.js';
// WebSocket Client for Three.js Visualization - WiFi DensePose
// Default endpoint is `/ws/sensing` on the same host the page was served from.
// Callers (e.g. viz.html) usually pass an explicit `url` derived from
@@ -47,7 +48,9 @@ export class WebSocketClient {
}
// Attempt to connect
connect() {
// async: `/ws/*` is gated (ADR-272) and a browser cannot set an
// Authorization header on an upgrade, so mint a single-use ticket first.
async connect() {
if (this.state === 'connecting' || this.state === 'connected') {
console.warn('[WS-VIZ] Already connected or connecting');
return;
@@ -56,8 +59,12 @@ export class WebSocketClient {
this._setState('connecting');
console.log(`[WS-VIZ] Connecting to ${this.url}`);
// Per attempt, never cached — a ticket is single-use and short-lived.
let url = this.url;
try { url = await withWsTicket(this.url); } catch { /* auth off or pre-ADR-272 server */ }
try {
this.ws = new WebSocket(this.url);
this.ws = new WebSocket(url);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => this._handleOpen();
@@ -235,7 +242,7 @@ export class WebSocketClient {
console.log(`[WS-VIZ] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
this.reconnectTimer = setTimeout(() => {
this.connect();
void this.connect();
}, delay);
}
+77
View File
@@ -0,0 +1,77 @@
// Single-use WebSocket tickets (ADR-272).
//
// A browser's WebSocket constructor cannot set an `Authorization` header on the
// upgrade request. That used to mean the sensing WebSocket stayed reachable
// with no credential even when the server had auth switched on — the REST
// control plane was locked while the live sensing stream was open.
//
// The server now gates `/ws/*` and `/api/v1/stream/pose`. Browsers exchange
// their stored bearer token at `POST /api/v1/ws-ticket` — an ordinary request,
// where headers DO work — for a short-lived, single-use ticket, and pass that
// as `?ticket=` on the socket URL.
//
// Why a token in a URL is acceptable here when it normally is not: the ticket
// is consumed on first use, lives ~30 seconds, and authorizes one WebSocket
// and nothing else. It cannot be replayed against /api/v1/*. The long-lived
// bearer token is still never put in a URL.
import { API_TOKEN_STORAGE_KEY } from './api.service.js';
function storedToken() {
try {
return localStorage.getItem(API_TOKEN_STORAGE_KEY) || null;
} catch {
// Private browsing / storage disabled — treat as "no token configured".
return null;
}
}
/**
* Mint a ticket. Returns null when no token is configured (auth is off, so no
* ticket is needed) or when the server does not offer the endpoint.
*/
async function mintTicket() {
const token = storedToken();
if (!token) return null;
try {
const resp = await fetch('/api/v1/ws-ticket', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
// 404 means a server predating ADR-272: it still exempts WebSockets, so
// connecting without a ticket is correct there. Treated as "no ticket
// needed" rather than as an error, so the UI works against both.
if (resp.status === 404) return null;
if (!resp.ok) {
console.warn('[ws-ticket] mint failed:', resp.status);
return null;
}
const body = await resp.json();
return body.ticket || null;
} catch (err) {
// Offline, or the server is down. The socket attempt will fail on its own
// and the caller's reconnect logic handles it; failing loudly here would
// just duplicate that.
console.warn('[ws-ticket] mint error:', err.message);
return null;
}
}
/**
* Return `url` with a freshly minted ticket appended, or unchanged when no
* ticket is available or needed.
*
* Always call this immediately before opening the socket — a ticket expires in
* seconds and is valid exactly once, so one must never be cached or reused
* across reconnects.
*
* @param {string} url ws:// or wss:// URL
* @returns {Promise<string>}
*/
export async function withWsTicket(url) {
const ticket = await mintTicket();
if (!ticket) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}ticket=${encodeURIComponent(ticket)}`;
}
+113
View File
@@ -0,0 +1,113 @@
// Executed tests for the WebSocket ticket helper (ADR-272).
//
// Run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs
// (a directory argument does not work — Node resolves it as a module)
//
// WHAT THIS IS AND IS NOT.
// This EXECUTES the module in Node with stubbed `fetch` and `localStorage`. It
// is strictly more than the `node --check` syntax pass this file replaces, and
// strictly less than a browser: it does not exercise a real WebSocket upgrade,
// real cookie handling, or the page wiring. The claim "the UI JavaScript has
// never been run" is no longer true of this module; "browser-tested" still is
// not. Both statements matter and neither should be rounded up.
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
const STORAGE_KEY = 'ruview-api-token';
// --- stubs installed before the module under test is imported ---------------
let stored = {};
let fetchCalls = [];
let fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'T' }) });
globalThis.localStorage = {
getItem: (k) => (k in stored ? stored[k] : null),
setItem: (k, v) => { stored[k] = String(v); },
removeItem: (k) => { delete stored[k]; },
};
globalThis.fetch = async (...args) => { fetchCalls.push(args); return fetchImpl(...args); };
const { withWsTicket } = await import('./ws-ticket.js');
beforeEach(() => {
stored = {};
fetchCalls = [];
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'T' }) });
});
test('with no stored token the URL is returned unchanged and nothing is fetched', async () => {
// Auth is off, so no ticket is needed. Minting one would be a pointless
// round-trip on every reconnect.
const url = await withWsTicket('ws://host/ws/sensing');
assert.equal(url, 'ws://host/ws/sensing');
assert.equal(fetchCalls.length, 0);
});
test('a minted ticket is appended and the bearer is sent only in the header', async () => {
stored[STORAGE_KEY] = 'secret-bearer';
const url = await withWsTicket('ws://host/ws/sensing');
assert.equal(url, 'ws://host/ws/sensing?ticket=T');
// The long-lived bearer must never reach a URL — only the bounded ticket does.
assert.ok(!url.includes('secret-bearer'), `bearer leaked into URL: ${url}`);
const [path, init] = fetchCalls[0];
assert.equal(path, '/api/v1/ws-ticket');
assert.equal(init.method, 'POST');
assert.equal(init.headers.Authorization, 'Bearer secret-bearer');
});
test('an existing query string gets & rather than a second ?', async () => {
stored[STORAGE_KEY] = 'b';
const url = await withWsTicket('ws://host/ws/sensing?foo=1');
assert.equal(url, 'ws://host/ws/sensing?foo=1&ticket=T');
});
test('the ticket value is URL-encoded', async () => {
stored[STORAGE_KEY] = 'b';
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'a+b/c=d' }) });
const url = await withWsTicket('ws://host/ws/sensing');
assert.ok(url.endsWith('ticket=a%2Bb%2Fc%3Dd'), url);
});
test('404 means a server predating ADR-272, so connect without a ticket', async () => {
// That server still exempts WebSockets, so an unticketed connect is correct.
// This is what lets one UI work against both old and new servers, which is
// what makes the legacy escape hatch removable rather than permanent.
stored[STORAGE_KEY] = 'b';
fetchImpl = async () => ({ ok: false, status: 404, json: async () => ({}) });
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
});
test('a 503 does not append a ticket and does not throw', async () => {
// Ticket store exhausted. The socket attempt will fail on its own and the
// caller's reconnect logic handles it; throwing here would duplicate that.
stored[STORAGE_KEY] = 'b';
fetchImpl = async () => ({ ok: false, status: 503, json: async () => ({}) });
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
});
test('a network failure is swallowed rather than breaking the connect path', async () => {
stored[STORAGE_KEY] = 'b';
fetchImpl = async () => { throw new Error('offline'); };
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
});
test('a 200 with no ticket field yields no ticket', async () => {
stored[STORAGE_KEY] = 'b';
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({}) });
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
});
test('a fresh ticket is minted per call and never reused', async () => {
// Tickets are single-use and expire in ~30s, so caching one across reconnects
// fails on the second attempt.
stored[STORAGE_KEY] = 'b';
let n = 0;
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: `T${++n}` }) });
assert.equal(await withWsTicket('ws://h/ws/sensing'), 'ws://h/ws/sensing?ticket=T1');
assert.equal(await withWsTicket('ws://h/ws/sensing'), 'ws://h/ws/sensing?ticket=T2');
assert.equal(fetchCalls.length, 2, 'each connect attempt must mint its own');
});
+91 -13
View File
@@ -1,7 +1,27 @@
// RuView Service Worker - Offline caching for the dashboard shell
// Strategy: Network-first for API calls, Cache-first for static assets
const CACHE_NAME = 'ruview-v1';
// Bumped from v1: an older SW cached `/oauth/status` cache-first, so browsers
// that ran it hold a permanently signed-out answer. `activate` deletes every
// cache whose name is not CACHE_NAME, so bumping is what evicts it from clients
// already in the field. Bump again if a future change poisons the cache.
const CACHE_NAME = 'ruview-v2';
// Requests whose response depends on the caller's credentials. These must never
// be served from the Cache API.
//
// The Cache API is NOT the HTTP cache: it ignores `Cache-Control` completely, so
// the `no-store, no-cache, must-revalidate` the server already sends on
// `/oauth/status` has no effect here. A cached signed-out response was returned
// to the page forever, and only a hard reload — which bypasses the service
// worker entirely — showed the true state. (ADR-271.)
const NEVER_CACHE_PREFIXES = ['/oauth/'];
// What may be served cache-first. Previously cache-first was the *catch-all* for
// everything outside `/api/` and `/health/`, which meant any endpoint added at a
// new path was frozen on first response. An allowlist fails safe instead: an
// unrecognised path goes to the network untouched.
const STATIC_ASSET = /\.(?:js|mjs|css|html|json|png|jpe?g|gif|svg|ico|webp|woff2?|ttf|map)$/i;
const SHELL_ASSETS = [
'/',
'/index.html',
@@ -74,25 +94,49 @@ self.addEventListener('fetch', (event) => {
// Skip cross-origin requests
if (url.origin !== self.location.origin) return;
// Credentialed endpoints: hands off entirely. Not networkFirst — that still
// writes a copy into the cache, which would be replayed the moment the server
// is briefly unreachable, silently reinstating a stale sign-in state.
if (NEVER_CACHE_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) {
// Signing out is the one moment we know cached data belongs to a session
// that is ending. Observed, not intercepted — the request itself still goes
// straight to the network. `waitUntil` keeps the worker alive for the purge
// even though the navigation is what the browser is really waiting on.
if (url.pathname === '/oauth/logout') {
event.waitUntil(purgeNonShell());
}
return;
}
// API calls: network-first with cache fallback
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/health/')) {
event.respondWith(networkFirst(request));
return;
}
// Static assets: cache-first with network fallback
event.respondWith(cacheFirst(request));
// Static assets and the app shell: cache-first with network fallback.
if (request.mode === 'navigate' || STATIC_ASSET.test(url.pathname)) {
event.respondWith(cacheFirst(request));
}
// Anything else is left alone and goes to the network as normal.
});
async function cacheFirst(request) {
const cached = await caches.match(request);
// `ignoreSearch` so the shell is a single entry. Sign-in redirects back to
// `/ui/?signed_in=<ms>`, which would otherwise mint a fresh cache entry per
// sign-in and never hit any of them again.
const cached = await caches.match(request, { ignoreSearch: true });
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
// Store under the search-less URL to match how it is looked up above.
const key = new URL(request.url);
key.search = '';
cache.put(key.toString(), response.clone());
}
return response;
} catch {
@@ -105,20 +149,54 @@ async function cacheFirst(request) {
}
}
/**
* Network-only, with an explicit offline signal.
*
* This used to cache every successful `/api/` response and replay it whenever
* the network failed. Two things are wrong with that now:
*
* 1. **Authorization.** API responses are per-user once auth is on, but the
* cache is keyed by URL alone and nothing purges it at sign-out. Sign in as
* A, load sensing data, sign out, sign in as B, lose the network — B is
* served A's data with no authorization check at all. That is the same
* defect class as the cached `/oauth/status`: the Cache API happily outlives
* the session that produced its contents.
* 2. **Correctness.** This is a live sensing dashboard. Replaying a stale pose
* or presence reading as if it were current is its own defect — it can show
* a room as occupied after the person has left.
*
* The offline shell (HTML/CSS/JS) is still cached; only the data is not. If
* offline data replay is wanted back, it needs a per-session cache key and a
* purge on sign-out, not a URL-keyed shared cache.
*/
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
return await fetch(request);
} catch {
const cached = await caches.match(request);
if (cached) return cached;
return new Response(JSON.stringify({ error: 'offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
/**
* Drop everything except the static shell.
*
* Called when the user signs out. Belt-and-braces: nothing user-specific should
* be in the cache after the `networkFirst` change above, but a cache populated
* by an OLDER worker on this browser can still hold API responses, and that
* worker's entries survive into this one under the same name.
*/
async function purgeNonShell() {
const cache = await caches.open(CACHE_NAME);
const keys = await cache.keys();
await Promise.all(
keys
.filter((req) => {
const p = new URL(req.url).pathname;
return p.startsWith('/api/') || p.startsWith('/health/');
})
.map((req) => cache.delete(req))
);
}
+228
View File
@@ -0,0 +1,228 @@
// Executed tests for the service worker's request routing (ADR-271/272).
//
// WHY THIS EXISTS.
// Browser sign-in appeared to fail: after a successful OAuth round-trip the
// settings panel still offered "Sign in with Cognitum", and only a hard reload
// showed the truth. The server was correct throughout — `/oauth/status` fell
// through to the service worker's cache-first catch-all, so the first
// (signed-out) response was stored in the Cache API and replayed forever.
//
// The Cache API is not the HTTP cache. It ignores `Cache-Control` entirely, so
// the `no-store` the server already sent could not prevent this. Nothing in the
// Rust suite, the UI unit tests, or a curl probe could observe it: curl has no
// service worker, and a hard reload bypasses one. It was only visible by
// driving a real browser.
//
// These tests load the REAL `sw.js` with stubbed worker globals and assert on
// which strategy each path is routed to.
//
// Run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs
// (a directory argument does not work — Node resolves it as a module)
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
const SW_SOURCE = readFileSync(fileURLToPath(new URL('./sw.js', import.meta.url)), 'utf8');
const ORIGIN = 'http://127.0.0.1:8099';
/** Load sw.js in a fresh context and return its registered `fetch` listener. */
function loadServiceWorker() {
const listeners = {};
const cachePuts = [];
// A real in-memory cache, so purge and put behaviour can be observed rather
// than assumed.
const entries = new Map();
const cacheStub = {
addAll: async () => {},
put: async (req, res) => {
const url = String(req.url ?? req);
cachePuts.push(url);
entries.set(url, res);
},
keys: async () => Array.from(entries.keys()).map((url) => ({ url })),
delete: async (req) => entries.delete(String(req.url ?? req)),
match: async () => undefined,
};
const sandbox = {
self: {
addEventListener: (name, fn) => { listeners[name] = fn; },
location: { origin: ORIGIN },
skipWaiting: () => {},
clients: { claim: () => {} },
},
caches: {
open: async () => cacheStub,
keys: async () => [],
delete: async () => true,
match: async () => undefined,
},
// Never actually reached: every assertion below inspects the routing
// decision, not the network.
fetch: async () => ({ ok: true, clone: () => ({}) }),
URL,
Response: class { constructor(body, init) { this.body = body; Object.assign(this, init); } },
console,
};
vm.createContext(sandbox);
vm.runInContext(SW_SOURCE, sandbox);
return { listeners, cachePuts, sandbox, entries };
}
/**
* Route one request and report the decision.
* `handled: false` means the SW called neither respondWith nor cache — the
* request goes to the network untouched, which is the only safe outcome for a
* credentialed endpoint.
*/
function route(path, opts = {}) {
return dispatch(path, opts).handled;
}
/** Route one request and expose everything the worker did with it. */
function dispatch(path, { method = 'GET', mode = 'cors', headers = {}, sw = null } = {}) {
const worker = sw ?? loadServiceWorker();
let handled = false;
let responded = null;
const waited = [];
const request = {
url: `${ORIGIN}${path}`,
method,
mode,
headers: { get: (k) => headers[k] ?? headers[k.toLowerCase()] ?? null },
};
worker.listeners.fetch({
request,
respondWith: (p) => { handled = true; responded = p; },
waitUntil: (p) => { waited.push(p); },
});
return { handled, responded, waited, worker };
}
let sw;
beforeEach(() => { sw = loadServiceWorker(); });
// --- the defect this file exists for ----------------------------------------
test('/oauth/status is never handled by the service worker', () => {
// The exact request whose cached signed-out copy made sign-in look broken.
assert.equal(route('/oauth/status'), false);
});
test('every /oauth/ path bypasses the worker, not just status', () => {
// start/callback/logout all carry or clear credentials. A cached redirect or
// Set-Cookie replayed later is worse than a cached status.
for (const p of ['/oauth/start', '/oauth/callback?code=x&state=y', '/oauth/logout']) {
assert.equal(route(p), false, `${p} must go straight to the network`);
}
});
// --- the underlying defect: cache-first was the catch-all -------------------
test('an unrecognised path is left to the network rather than cached', () => {
// This is what made the OAuth bug possible in the first place: any endpoint
// added outside /api/ was silently frozen on its first response. An
// allowlist means the next such endpoint is safe by default.
assert.equal(route('/some/future/endpoint'), false);
});
test('static assets are still served cache-first', () => {
// The offline shell is the point of the worker; the fix must not disable it.
for (const p of ['/app.js', '/style.css', '/components/TabManager.js', '/icons/logo.svg']) {
assert.equal(route(p), true, `${p} should be cache-first`);
}
});
test('a navigation request is still served cache-first', () => {
assert.equal(route('/ui/', { mode: 'navigate' }), true);
});
test('API paths are still routed through the worker', () => {
// Handled, but network-only — see the "not written to the cache" test below.
assert.equal(route('/api/v1/models'), true);
assert.equal(route('/health/live'), true);
});
// --- pre-existing guards, pinned so the rewrite did not drop them -----------
test('non-GET requests are ignored', () => {
assert.equal(route('/api/v1/models', { method: 'POST' }), false);
});
test('websocket upgrades are ignored', () => {
assert.equal(route('/ws/sensing', { headers: { Upgrade: 'websocket' } }), false);
});
test('cross-origin requests are ignored', () => {
const { listeners } = loadServiceWorker();
let handled = false;
listeners.fetch({
request: {
url: 'https://auth.cognitum.one/oauth/authorize',
method: 'GET',
mode: 'cors',
headers: { get: () => null },
},
respondWith: () => { handled = true; },
});
assert.equal(handled, false);
});
// --- authenticated API responses must not be retained ------------------------
// Filed by the cross-vendor prosecutor in the qe-court round after the
// /oauth/status fix: closing the /oauth/ leg left the /api/ leg open.
test('a successful API response is NOT written to the cache', async () => {
// The leak: cache keys are URLs, nothing partitions them by session, and
// nothing purged them at sign-out. Sign in as A, fetch sensing data, sign
// out, sign in as B, lose the network -> B is served A's data.
const { responded, worker } = dispatch('/api/v1/sensing/latest');
await responded;
assert.deepEqual(worker.cachePuts, [], 'API responses must not be cached');
});
test('an API request with no network returns 503 rather than stale data', async () => {
// Also a correctness property, not only an authorization one: replaying a
// stale pose reading as current can show a room occupied after the person
// has left.
const worker = loadServiceWorker();
worker.sandbox.fetch = async () => { throw new Error('offline'); };
worker.entries.set(`${ORIGIN}/api/v1/sensing/latest`, { stale: true });
const { responded } = dispatch('/api/v1/sensing/latest', { sw: worker });
const res = await responded;
assert.equal(res.status, 503);
assert.match(String(res.body), /offline/);
});
test('signing out purges cached API data but keeps the offline shell', async () => {
const worker = loadServiceWorker();
worker.entries.set(`${ORIGIN}/api/v1/sensing/latest`, {});
worker.entries.set(`${ORIGIN}/health/live`, {});
worker.entries.set(`${ORIGIN}/app.js`, {});
const { handled, waited } = dispatch('/oauth/logout', { sw: worker });
// Observed, not intercepted — the logout request itself must still reach the
// server, or signing out would not actually sign anyone out.
assert.equal(handled, false, '/oauth/logout must still go to the network');
assert.equal(waited.length, 1, 'the purge must be kept alive via waitUntil');
await Promise.all(waited);
const left = Array.from(worker.entries.keys());
assert.deepEqual(left, [`${ORIGIN}/app.js`], 'only the static shell should survive');
});
// --- cache hygiene -----------------------------------------------------------
test('the cache name is bumped so clients holding the poisoned v1 evict it', () => {
// `activate` deletes every cache whose name !== CACHE_NAME. Browsers that
// already ran the old worker hold a signed-out /oauth/status in `ruview-v1`;
// only a name change removes it for them.
assert.ok(!/['"]ruview-v1['"]/.test(SW_SOURCE), 'CACHE_NAME must not still be ruview-v1');
assert.ok(/CACHE_NAME\s*=\s*['"]ruview-v[2-9]/.test(SW_SOURCE));
});
+95
View File
@@ -74,6 +74,16 @@ export class QuickSettings {
<span class="qs-switch"></span>
</label>
</div>
<div class="qs-section">
<div class="qs-section-title">Cognitum Account</div>
<div class="qs-row" style="flex-direction: column; align-items: stretch; gap: 6px;">
<span id="qs-signin-status" style="font-size: 0.9em; opacity: 0.85;">Checking...</span>
<div style="display: flex; gap: 8px;">
<button class="qs-btn" id="qs-signin" hidden>Sign in with Cognitum</button>
<button class="qs-btn-danger" id="qs-signout" hidden>Sign out</button>
</div>
</div>
</div>
<div class="qs-section">
<div class="qs-section-title">API Access</div>
<div class="qs-row" style="flex-direction: column; align-items: stretch; gap: 6px;">
@@ -103,6 +113,23 @@ export class QuickSettings {
// Bind events
this.panel.querySelector('.qs-close').addEventListener('click', () => this.close());
// Re-check sign-in state whenever the page could be showing a stale view:
// `pageshow` fires on a back/forward-cache restore (where no script re-runs
// and no fetch would otherwise happen), and `visibilitychange` covers
// signing in or out in another tab. Opening the panel alone is not enough —
// the panel may already be open, or the page may be restored wholesale.
window.addEventListener('pageshow', () => { void refreshSignInPanel(this.panel); });
document.addEventListener('visibilitychange', () => {
if (!document.hidden) void refreshSignInPanel(this.panel);
});
// ADR-271 sign-in. Bound here as well as in refreshSignInPanel so a click
// works even if the status fetch has not resolved yet.
this.panel.querySelector('#qs-signin')
.addEventListener('click', () => { window.location.href = '/oauth/start'; });
this.panel.querySelector('#qs-signout')
.addEventListener('click', () => { window.location.href = '/oauth/logout'; });
this.panel.querySelector('#qs-reduced-motion').addEventListener('change', (e) => {
document.body.classList.toggle('reduced-motion', e.target.checked);
this.saveSetting('reduced-motion', e.target.checked);
@@ -211,6 +238,11 @@ export class QuickSettings {
open() {
this.isOpen = true;
this.panel.classList.add('open');
// Refresh on every open, not once at construction: the session may have
// been established in another tab, or expired since the page loaded.
// Fire-and-forget — a failure renders as a message in the panel, and must
// not stop the panel opening.
void refreshSignInPanel(this.panel);
}
close() {
@@ -233,3 +265,66 @@ export class QuickSettings {
this.panel?.remove();
}
}
// ---- Cognitum browser sign-in (ADR-271) -------------------------------------
//
// `/oauth/status` is intentionally UNGATED: a signed-out browser cannot ask a
// gated endpoint whether sign-in is available. It returns capability flags and,
// when a session exists, who it belongs to — never a credential.
//
// Sign-in is a full-page navigation, not fetch(): the server replies 302 to
// auth.cognitum.one, and the browser must follow it and carry the transaction
// cookie. An XHR would follow the redirect invisibly and land nowhere useful.
export async function refreshSignInPanel(root = document) {
const status = root.querySelector('#qs-signin-status');
const signIn = root.querySelector('#qs-signin');
const signOut = root.querySelector('#qs-signout');
if (!status || !signIn || !signOut) return null;
let info;
try {
const resp = await fetch('/oauth/status', { credentials: 'same-origin' });
// 404 = a server predating ADR-271. Say so plainly rather than offering a
// button that will 404.
if (resp.status === 404) {
status.textContent = 'This server does not support Cognitum sign-in.';
signIn.hidden = true;
signOut.hidden = true;
return null;
}
if (!resp.ok) throw new Error(`status ${resp.status}`);
info = await resp.json();
} catch (err) {
status.textContent = `Could not reach the server (${err.message}).`;
signIn.hidden = true;
signOut.hidden = true;
return null;
}
if (info.signed_in) {
status.textContent = `Signed in${info.account ? ` as ${info.account}` : ''}${
info.scope ? ` - ${info.scope}` : ''
}`;
signIn.hidden = true;
signOut.hidden = false;
} else if (info.browser_signin) {
status.textContent = info.auth_required
? 'This server requires sign-in.'
: 'Optional: sign in to use your Cognitum account.';
signIn.hidden = false;
signOut.hidden = true;
} else if (info.auth_required) {
// Auth is on but OAuth is not — the static-token panel below is the path.
status.textContent = 'This server uses a shared API token (see API Access below).';
signIn.hidden = true;
signOut.hidden = true;
} else {
status.textContent = 'This server does not require sign-in.';
signIn.hidden = true;
signOut.hidden = true;
}
signIn.onclick = () => { window.location.href = '/oauth/start'; };
signOut.onclick = () => { window.location.href = '/oauth/logout'; };
return info;
}
Generated
+175
View File
@@ -392,6 +392,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
version = "0.21.7"
@@ -1533,6 +1539,18 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array 0.14.7",
"rand_core 0.6.4",
"subtle",
"zeroize",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -1969,6 +1987,20 @@ dependencies = [
"num-traits",
]
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
]
[[package]]
name = "ed25519"
version = "2.2.3"
@@ -2002,6 +2034,26 @@ dependencies = [
"serde",
]
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"ff",
"generic-array 0.14.7",
"group",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
"sec1",
"subtle",
"zeroize",
]
[[package]]
name = "embed-resource"
version = "3.0.6"
@@ -2160,6 +2212,16 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
@@ -2941,6 +3003,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@@ -3145,6 +3208,17 @@ dependencies = [
"system-deps",
]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "gtk"
version = "0.18.2"
@@ -4278,6 +4352,21 @@ dependencies = [
"serde_json",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64 0.22.1",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "katexit"
version = "0.1.5"
@@ -5594,6 +5683,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -6155,6 +6256,15 @@ dependencies = [
"num-integer",
]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]]
name = "proc-macro-crate"
version = "1.3.1"
@@ -6931,6 +7041,16 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac",
"subtle",
]
[[package]]
name = "rfd"
version = "0.16.0"
@@ -7511,6 +7631,26 @@ version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "753a07254fa68db183949ec6c7575d890da4d42404afabc11d610a720fcf570c"
[[package]]
name = "ruview-auth"
version = "0.1.0"
dependencies = [
"base64 0.21.7",
"jsonwebtoken",
"libc",
"p256",
"rand 0.8.5",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.18",
"tokio",
"tracing",
"ureq 2.12.1",
"url",
]
[[package]]
name = "ruview-swarm"
version = "0.1.0"
@@ -7666,6 +7806,20 @@ dependencies = [
"untrusted",
]
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array 0.14.7",
"pkcs8",
"subtle",
"zeroize",
]
[[package]]
name = "security-framework"
version = "2.11.1"
@@ -8131,6 +8285,18 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "simple_asn1"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-traits",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "simsimd"
version = "5.9.11"
@@ -10902,6 +11068,8 @@ dependencies = [
"ndarray 0.17.2",
"num-complex",
"predicates",
"reqwest 0.12.28",
"ruview-auth",
"serde",
"serde_json",
"tabled",
@@ -11135,18 +11303,25 @@ name = "wifi-densepose-sensing-server"
version = "0.3.4"
dependencies = [
"axum",
"base64 0.21.7",
"chrono",
"clap",
"criterion",
"futures-util",
"hmac",
"jsonwebtoken",
"midstreamer-attractor",
"midstreamer-temporal-compare",
"p256",
"proptest",
"rand 0.8.5",
"rumqttc",
"ruvector-mincut",
"ruview-auth",
"serde",
"serde_json",
"sha2",
"subtle",
"tempfile",
"thiserror 1.0.69",
"tokio",
+6
View File
@@ -29,6 +29,12 @@ members = [
# geo + worldgraph extracted to ruvnet/worldgraph submodule (see crates/worldgraph)
"crates/wifi-densepose-engine", # ADR-135..146 integration/composition layer
"crates/wifi-densepose-calibration", # ADR-151 — per-room calibration & specialist training
# ADR-271 — Cognitum OAuth access-token verification. RuView as an OAuth
# RESOURCE SERVER: offline ES256/JWKS verification of tokens issued by
# auth.cognitum.one, so a user signs in to their own sensing server with
# their Cognitum identity instead of a shared static bearer. No login flow
# and no outbound Cognitum API calls live here — verification only.
"crates/ruview-auth",
"crates/nvsim",
"crates/nvsim-server",
"crates/homecore", # ADR-127 — HOMECORE state machine
+63
View File
@@ -0,0 +1,63 @@
[package]
name = "ruview-auth"
version = "0.1.0"
edition = "2021"
description = "Cognitum OAuth access-token verification for RuView (ADR-271)"
publish = false
[dependencies]
# Same major as the service that ISSUES these tokens
# (cognitum-one/dashboard `services/identity`, workspace `jsonwebtoken = "9"`).
# Signature math is delegated to this crate; nothing here hand-rolls crypto.
jsonwebtoken = "9"
# `ureq`, not `reqwest`: `wifi-densepose-sensing-server` — the first consumer —
# deliberately chose ureq as "the smallest" HTTP client (see its Cargo.toml).
# Adding reqwest here would silently reverse that decision for the whole
# dependency graph. Optional so a caller can supply its own transport via
# `JwksFetcher` and take no HTTP dependency at all.
ureq = { version = "2", default-features = false, features = ["tls", "json"], optional = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
# --- `login` feature only (ADR-271 phase 2) -------------------------------
# The login flow is an interactive client concern: a browser, a loopback
# listener, a token exchange. The sensing server needs none of it and must not
# pay for it, so every dependency here is optional and off by default. A server
# built with default features gets the verifier and nothing more.
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
tokio = { workspace = true, optional = true }
rand = { version = "0.8", optional = true }
sha2 = { workspace = true, optional = true }
base64 = { version = "0.21", optional = true }
url = { version = "2", optional = true }
# Advisory cross-process file lock around the refresh critical section (Unix).
libc = { version = "0.2", optional = true }
[features]
default = ["ureq-transport"]
ureq-transport = ["dep:ureq"]
# PKCE generation only (RFC 7636). Light: rand + sha2 + base64, no HTTP stack.
# A resource server that runs its own browser sign-in redirect needs this
# WITHOUT the client-side login machinery.
pkce = ["dep:rand", "dep:sha2", "dep:base64"]
# Interactive OAuth login: PKCE, loopback callback, OOB paste fallback,
# credential storage, single-flight refresh. Opt in from a CLI or desktop app.
login = ["pkce", "dep:reqwest", "dep:tokio", "dep:url", "dep:libc"]
[dev-dependencies]
# Test-only: sign real ES256 tokens so the negative matrix exercises the same
# code path production does, rather than asserting against hand-built strings.
jsonwebtoken = "9"
serde_json = { workspace = true }
# Keypairs are GENERATED AT TEST RUNTIME, never committed. A checked-in
# `-----BEGIN PRIVATE KEY-----` is inert here but it trains scanners and readers
# to treat committed key material as normal, and this repo has no such
# precedent (zero tracked `.pem` files). Generating also makes the matrix
# self-contained: no fixture can drift out of sync with the JWKS it is served by.
p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] }
base64 = "0.21"
+567
View File
@@ -0,0 +1,567 @@
//! JWKS fetch + cache, keyed by `kid`.
//!
//! Ported from `cognitum-one/dashboard` `services/identity/src/jwks.rs` (the
//! same team that signs these tokens), with the unknown-`kid` forced refetch
//! from `meta-llm/src/auth/oauthBearer.ts`. Like both, this is
//! `jsonwebtoken` + `DecodingKey` only — nothing here hand-rolls signature math.
//!
//! ## Offline behaviour is a feature, not an oversight
//!
//! RuView runs on Raspberry-Pi-class hardware that loses WAN. On a refetch
//! failure we keep serving the keys we already have and log a warning, because
//! a signing key that verified a minute ago has not stopped being valid because
//! our network blipped — and failing closed there would log every user out of
//! their own sensing server whenever their internet wobbled.
//!
//! We fail closed in exactly one case: **we have never successfully fetched a
//! key set.** Then there is nothing to reason with, and admitting a request
//! would mean admitting an unverified token.
//!
//! ## The lock is never held across the network call
//!
//! [`JwksCache::decoding_key_for`] reads the cache under the lock, RELEASES it,
//! does any HTTP, then re-takes the lock only to install the result.
//!
//! This is not tidiness. An earlier revision held a `std::sync::Mutex` across a
//! blocking `ureq` call made from inside async middleware. `Mutex::lock()` in an
//! async fn is a real blocking syscall, not a yield point — so one slow or
//! unreachable JWKS fetch (up to the 3s timeout, longer if the link is dead)
//! blocked EVERY concurrent request on that mutex, including requests carrying
//! already-cached, perfectly valid tokens, and parked the tokio worker threads
//! they were running on. On Pi-class hardware with few workers that stalls the
//! whole server, and it fires on the routine 300s TTL rollover whenever the
//! network is degraded — precisely the offline-tolerance case this module
//! exists to handle.
//!
//! The cost of releasing the lock is that two callers can fetch concurrently
//! during a rollover. That is a harmless duplicated idempotent GET, and it is
//! strictly better than serialising every request behind one socket.
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use jsonwebtoken::DecodingKey;
use serde::Deserialize;
/// How long a fetched key set is trusted before a routine re-fetch.
/// Identity uses 300 s for the same job; matching it keeps staleness bounded
/// without putting an outbound request on every verify.
pub const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(300);
/// Floor between fetch ATTEMPTS — every attempt, not just the unknown-`kid`
/// forced refetch, and regardless of whether the attempt succeeded.
///
/// Without this, two things go wrong. A stream of tokens bearing a bogus `kid`
/// becomes an outbound request amplifier pointed at the identity service. And,
/// more damagingly, once the cache goes stale (`fetched_at` only advances on
/// success) *every* request performs its own fetch — so a lost WAN link turns
/// into a self-inflicted stall rather than the graceful degradation this module
/// promises.
pub const FETCH_MIN_INTERVAL: Duration = Duration::from_secs(30);
/// Wire timeout for a single JWKS fetch. meta-llm uses 3 s; a verify path must
/// never be able to hang on a slow upstream.
pub const DEFAULT_FETCH_TIMEOUT: Duration = Duration::from_secs(3);
#[derive(Debug, thiserror::Error)]
pub enum JwksError {
#[error("JWKS fetch failed: {0}")]
Fetch(String),
#[error("JWKS document malformed: {0}")]
Malformed(String),
#[error("JWKS document contained no usable EC keys")]
NoUsableKeys,
#[error("no key in JWKS matches kid {0:?}")]
UnknownKid(String),
#[error("token header has no kid")]
MissingKid,
/// Never fetched successfully — fail closed.
#[error("JWKS unavailable and no key set has ever been cached")]
NeverFetched,
}
/// How the key set is retrieved. Abstracted so tests run with no network and so
/// a host that already owns an HTTP client can supply it.
pub trait JwksFetcher: Send + Sync {
/// Return the raw JWKS document body.
fn fetch(&self, url: &str) -> Result<String, JwksError>;
}
/// One JWK. Only EC P-256 is accepted: identity signs with ES256 and nothing
/// else, so parsing RSA here would add a key type we would then have to be
/// careful never to verify with.
#[derive(Debug, Deserialize)]
struct Jwk {
kid: Option<String>,
kty: String,
crv: Option<String>,
x: Option<String>,
y: Option<String>,
}
#[derive(Debug, Deserialize)]
struct JwksDocument {
keys: Vec<Jwk>,
}
struct CacheState {
keys: HashMap<String, DecodingKey>,
fetched_at: Option<Instant>,
last_attempt_at: Option<Instant>,
last_forced_refetch: Option<Instant>,
}
/// `kid`-indexed JWKS cache.
pub struct JwksCache {
url: String,
ttl: Duration,
fetcher: Box<dyn JwksFetcher>,
state: Mutex<CacheState>,
}
impl JwksCache {
pub fn new(url: impl Into<String>, fetcher: Box<dyn JwksFetcher>) -> Self {
Self::with_ttl(url, fetcher, DEFAULT_CACHE_TTL)
}
pub fn with_ttl(url: impl Into<String>, fetcher: Box<dyn JwksFetcher>, ttl: Duration) -> Self {
Self {
url: url.into(),
ttl,
fetcher,
state: Mutex::new(CacheState {
keys: HashMap::new(),
fetched_at: None,
last_attempt_at: None,
last_forced_refetch: None,
}),
}
}
/// Fetch once up front so a misconfigured `jwks_uri` fails at startup rather
/// than on a user's first request. Call this from server boot: it is what
/// turns "OAuth is misconfigured" into a refusal to serve instead of a
/// confusing 401 much later.
pub fn warm(&self) -> Result<usize, JwksError> {
let fresh = self.fetch_and_parse()?;
let n = fresh.len();
let mut state = self.state.lock().expect("jwks cache poisoned");
state.keys = fresh;
state.fetched_at = Some(Instant::now());
Ok(n)
}
/// Resolve the verification key for a token header's `kid`.
pub fn decoding_key_for(&self, kid: &str) -> Result<DecodingKey, JwksError> {
// ---- Phase 1: answer from cache, holding the lock only to read. ----
let (fresh, have_any, may_force, may_attempt, stale_fallback) = {
let state = self.state.lock().expect("jwks cache poisoned");
let fresh = state
.fetched_at
.map_or(false, |at| at.elapsed() < self.ttl);
let cached = state.keys.get(kid).cloned();
// A fresh cache that HAS the key is the overwhelmingly common path
// and answers without touching anything else.
if fresh {
if let Some(key) = cached {
return Ok(key);
}
}
let may_force = state
.last_forced_refetch
.map_or(true, |at| at.elapsed() >= FETCH_MIN_INTERVAL);
let may_attempt = state
.last_attempt_at
.map_or(true, |at| at.elapsed() >= FETCH_MIN_INTERVAL);
// When the cache is fresh but lacks this kid there is nothing stale
// worth serving — identity may have rotated, and a refetch is the
// whole point. When it is stale, a previously-valid key beats an
// error if we are rate-limited.
let stale_fallback = if fresh { None } else { cached };
(
fresh,
state.fetched_at.is_some(),
may_force,
may_attempt,
stale_fallback,
)
};
// Lock released. Everything below may take milliseconds-to-seconds and
// MUST NOT hold it — see the module docs.
// TWO independent rate limiters, because they solve different problems.
// Merging them looks tidy and is wrong: a routine refetch would then
// suppress the unknown-`kid` path for 30s, delaying pickup of a key
// rotation that happened inside the TTL.
if fresh {
// Fresh cache, unknown kid: identity may have rotated. One forced
// refetch per floor, so a flood of junk-`kid` tokens cannot become
// an outbound request amplifier pointed at identity.
if !may_force {
return Err(JwksError::UnknownKid(kid.to_owned()));
}
self.state
.lock()
.expect("jwks cache poisoned")
.last_forced_refetch = Some(Instant::now());
} else if !may_attempt {
// Stale cache and we refetched recently. Serve what we have.
//
// This branch is the fix. `fetched_at` advances only on SUCCESS, so
// once the TTL elapsed after the last successful fetch, `fresh` was
// permanently false — and the ONLY limiter was gated behind
// `if fresh`. Every request therefore performed its own blocking
// fetch. On a Pi that loses WAN, the documented deployment, that
// turned into a self-inflicted stall 300s after the network went
// away, with no attacker involved.
//
// Serving the stale key is deliberate: one that verified a minute
// ago has not stopped being valid because our network blipped.
return match stale_fallback {
Some(key) => Ok(key),
None if have_any => Err(JwksError::UnknownKid(kid.to_owned())),
None => Err(JwksError::NeverFetched),
};
}
// Recorded BEFORE the fetch and regardless of its outcome. Recording it
// after, or only on success, is precisely the bug described above.
self.state
.lock()
.expect("jwks cache poisoned")
.last_attempt_at = Some(Instant::now());
// ---- Phase 2: network, WITHOUT the lock held. ----
let fetched = self.fetch_and_parse();
// ---- Phase 3: install, holding the lock only to write. ----
let mut state = self.state.lock().expect("jwks cache poisoned");
match fetched {
Ok(keys) => {
state.keys = keys;
state.fetched_at = Some(Instant::now());
}
Err(e) => {
// A key that verified a minute ago has not stopped being valid
// because the network blipped.
if !have_any {
return Err(JwksError::NeverFetched);
}
tracing::warn!(
url = %self.url,
error = %e,
"JWKS refresh failed; continuing with the previously cached key set"
);
}
}
state
.keys
.get(kid)
.cloned()
.ok_or_else(|| JwksError::UnknownKid(kid.to_owned()))
}
fn fetch_and_parse(&self) -> Result<HashMap<String, DecodingKey>, JwksError> {
let body = self.fetcher.fetch(&self.url)?;
parse_jwks(&body)
}
}
/// Parse a JWKS document into `kid` → `DecodingKey`, skipping entries we cannot
/// or should not use.
fn parse_jwks(body: &str) -> Result<HashMap<String, DecodingKey>, JwksError> {
let doc: JwksDocument =
serde_json::from_str(body).map_err(|e| JwksError::Malformed(e.to_string()))?;
let mut out = HashMap::new();
for jwk in doc.keys {
// EC P-256 only. Anything else is skipped rather than rejected, so a
// future key type appearing in the document does not break verification
// with the ES256 key sitting next to it.
if jwk.kty != "EC" {
tracing::debug!(kty = %jwk.kty, "skipping non-EC JWK");
continue;
}
if jwk.crv.as_deref() != Some("P-256") {
tracing::debug!(crv = ?jwk.crv, "skipping EC JWK that is not P-256");
continue;
}
let (Some(kid), Some(x), Some(y)) = (jwk.kid, jwk.x, jwk.y) else {
tracing::debug!("skipping EC JWK missing kid/x/y");
continue;
};
match DecodingKey::from_ec_components(&x, &y) {
Ok(key) => {
out.insert(kid, key);
}
Err(e) => tracing::debug!(kid = %kid, error = %e, "skipping unparseable EC JWK"),
}
}
if out.is_empty() {
return Err(JwksError::NoUsableKeys);
}
Ok(out)
}
/// Blocking `ureq` transport.
///
/// Blocking on purpose: the sensing server already runs its outbound registry
/// fetch inside `tokio::task::spawn_blocking` for the same reason, and an async
/// client here would pull in a second HTTP stack.
#[cfg(feature = "ureq-transport")]
pub struct UreqFetcher {
agent: ureq::Agent,
}
#[cfg(feature = "ureq-transport")]
impl UreqFetcher {
pub fn new() -> Self {
Self::with_timeout(DEFAULT_FETCH_TIMEOUT)
}
pub fn with_timeout(timeout: Duration) -> Self {
Self {
agent: ureq::AgentBuilder::new()
.timeout_connect(timeout)
.timeout_read(timeout)
.build(),
}
}
}
#[cfg(feature = "ureq-transport")]
impl Default for UreqFetcher {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "ureq-transport")]
impl JwksFetcher for UreqFetcher {
fn fetch(&self, url: &str) -> Result<String, JwksError> {
let resp = self
.agent
.get(url)
.call()
.map_err(|e| JwksError::Fetch(e.to_string()))?;
resp.into_string()
.map_err(|e| JwksError::Fetch(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
/// The live production key, captured 2026-07-22. Public key material — a
/// JWKS document is served anonymously to the internet by design.
const LIVE_KID: &str = "_jQ62WD8cCiIGkKNQB8Hg4El2TNU5rHIITV4h_ba4YM";
const LIVE_JWKS: &str = r#"{"keys":[{"alg":"ES256","crv":"P-256","kid":"_jQ62WD8cCiIGkKNQB8Hg4El2TNU5rHIITV4h_ba4YM","kty":"EC","use":"sig","x":"ixOcTyD66hYA52GE3NeLjMsUhPTVYl1_u6DimRKmxzU","y":"KQw2gxzKBk-FTGpioh0XKcIuaxh5No-Sn_qPbw3BH1M"}]}"#;
/// Shared handle so a test can swap the served document or take the
/// upstream offline *after* the fetcher has been moved into the cache.
#[derive(Clone)]
struct StubControl {
body: Arc<Mutex<String>>,
calls: Arc<AtomicUsize>,
offline: Arc<Mutex<bool>>,
}
impl StubControl {
fn new(body: &str) -> Self {
Self {
body: Arc::new(Mutex::new(body.to_owned())),
calls: Arc::new(AtomicUsize::new(0)),
offline: Arc::new(Mutex::new(false)),
}
}
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
fn serve(&self, body: &str) {
*self.body.lock().unwrap() = body.to_owned();
}
fn go_offline(&self) {
*self.offline.lock().unwrap() = true;
}
fn fetcher(&self) -> Box<StubFetcher> {
Box::new(StubFetcher(self.clone()))
}
}
struct StubFetcher(StubControl);
impl JwksFetcher for StubFetcher {
fn fetch(&self, _url: &str) -> Result<String, JwksError> {
self.0.calls.fetch_add(1, Ordering::SeqCst);
if *self.0.offline.lock().unwrap() {
return Err(JwksError::Fetch("stub offline".into()));
}
Ok(self.0.body.lock().unwrap().clone())
}
}
#[test]
fn parses_the_live_production_jwks() {
let keys = parse_jwks(LIVE_JWKS).expect("live JWKS parses");
assert_eq!(keys.len(), 1);
assert!(keys.contains_key(LIVE_KID));
}
#[test]
fn rejects_a_document_with_no_usable_keys() {
let rsa_only = r#"{"keys":[{"kty":"RSA","kid":"r1","n":"AQAB","e":"AQAB"}]}"#;
assert!(matches!(
parse_jwks(rsa_only),
Err(JwksError::NoUsableKeys)
));
}
#[test]
fn skips_a_non_p256_ec_key_rather_than_failing_the_whole_document() {
let mixed = r#"{"keys":[
{"kty":"EC","crv":"P-384","kid":"wrong-curve","x":"AA","y":"AA"},
{"alg":"ES256","crv":"P-256","kid":"_jQ62WD8cCiIGkKNQB8Hg4El2TNU5rHIITV4h_ba4YM","kty":"EC","use":"sig","x":"ixOcTyD66hYA52GE3NeLjMsUhPTVYl1_u6DimRKmxzU","y":"KQw2gxzKBk-FTGpioh0XKcIuaxh5No-Sn_qPbw3BH1M"}
]}"#;
let keys = parse_jwks(mixed).expect("parses");
assert_eq!(keys.len(), 1, "only the P-256 key is usable");
assert!(!keys.contains_key("wrong-curve"));
}
#[test]
fn malformed_json_is_an_error_not_a_panic() {
assert!(matches!(parse_jwks("{not json"), Err(JwksError::Malformed(_))));
}
#[test]
fn a_cached_key_is_served_without_refetching() {
let ctl = StubControl::new(LIVE_JWKS);
let cache = JwksCache::new("https://stub/jwks.json", ctl.fetcher());
cache.decoding_key_for(LIVE_KID).expect("first resolves");
cache.decoding_key_for(LIVE_KID).expect("second resolves");
assert_eq!(ctl.calls(), 1, "second call hit the cache");
}
#[test]
fn never_fetched_plus_unreachable_upstream_fails_closed() {
let ctl = StubControl::new(LIVE_JWKS);
ctl.go_offline();
let cache = JwksCache::new("https://stub/jwks.json", ctl.fetcher());
assert!(matches!(
cache.decoding_key_for(LIVE_KID),
Err(JwksError::NeverFetched)
));
}
#[test]
fn a_previously_cached_key_survives_an_upstream_outage() {
// The offline-tolerance property RuView's edge deployment depends on:
// a WAN blip must not log every user out of their own sensing server.
let ctl = StubControl::new(LIVE_JWKS);
let cache = JwksCache::with_ttl(
"https://stub/jwks.json",
ctl.fetcher(),
Duration::from_millis(0), // every lookup treats the cache as stale
);
cache.decoding_key_for(LIVE_KID).expect("warm the cache");
ctl.go_offline();
cache
.decoding_key_for(LIVE_KID)
.expect("known kid still resolves while upstream is unreachable");
}
#[test]
fn unknown_kid_triggers_exactly_one_forced_refetch_then_rate_limits() {
let ctl = StubControl::new(LIVE_JWKS);
let cache = JwksCache::new("https://stub/jwks.json", ctl.fetcher());
cache.decoding_key_for(LIVE_KID).expect("warm the cache");
assert_eq!(ctl.calls(), 1);
// First unknown kid: one forced refetch, since rotation may have
// happened inside the TTL.
assert!(matches!(
cache.decoding_key_for("bogus-kid"),
Err(JwksError::UnknownKid(_))
));
assert_eq!(ctl.calls(), 2, "one forced refetch");
// Subsequent unknown kids inside the floor must NOT amplify: otherwise
// a flood of junk-kid tokens becomes a DoS aimed at identity.
for _ in 0..20 {
let _ = cache.decoding_key_for("bogus-kid");
}
assert_eq!(
ctl.calls(),
2,
"rate limiter prevented an outbound request per token"
);
}
#[test]
fn a_stale_cache_with_a_dead_upstream_does_not_refetch_on_every_request() {
// THE BUG THIS GUARDS. `fetched_at` advances only on SUCCESS, and the
// only rate limiter used to sit behind `if fresh`. So once the TTL
// elapsed after the last successful fetch, `fresh` was permanently
// false, the limiter was never consulted, and EVERY request performed
// its own blocking 3s-timeout fetch. On a Pi that loses WAN that is a
// self-inflicted stall with no attacker present — and an attacker could
// force the same state by flooding tokens with an unknown `kid`.
//
// Before the fix the burst makes 25 further fetches (26 total). After
// it, zero: the warm-up's attempt timestamp still covers the burst,
// because the limiter now applies to the stale path too.
let ctl = StubControl::new(LIVE_JWKS);
let cache = JwksCache::with_ttl(
"https://stub/jwks.json",
ctl.fetcher(),
Duration::from_millis(1),
);
cache.decoding_key_for(LIVE_KID).expect("warm the cache");
assert_eq!(ctl.calls(), 1);
ctl.go_offline();
std::thread::sleep(Duration::from_millis(10)); // TTL elapses
for i in 0..25 {
// Still answered from the stale cache: a key that verified a moment
// ago has not stopped being valid because the network went away.
cache
.decoding_key_for(LIVE_KID)
.unwrap_or_else(|e| panic!("request {i} lost its cached key: {e}"));
}
assert_eq!(
ctl.calls(),
1,
"the burst must add NO outbound fetches; only the warm-up fetched"
);
}
#[test]
fn a_rotated_key_is_picked_up_inside_the_ttl() {
let ctl = StubControl::new(
r#"{"keys":[{"kty":"EC","crv":"P-256","kid":"old","x":"ixOcTyD66hYA52GE3NeLjMsUhPTVYl1_u6DimRKmxzU","y":"KQw2gxzKBk-FTGpioh0XKcIuaxh5No-Sn_qPbw3BH1M"}]}"#,
);
let cache = JwksCache::new("https://stub/jwks.json", ctl.fetcher());
cache.decoding_key_for("old").expect("old key resolves");
// Identity rotates. The TTL has NOT expired, so only the unknown-kid
// forced-refetch path can recover — which is exactly what it is for.
ctl.serve(LIVE_JWKS);
cache
.decoding_key_for(LIVE_KID)
.expect("rotation picked up without waiting out the TTL");
}
}
+89
View File
@@ -0,0 +1,89 @@
//! Cognitum OAuth access-token verification for RuView (ADR-271).
//!
//! RuView is an OAuth **resource server**, not a Cognitum API client: it makes
//! no authenticated calls to `cognitum.one`. A user signs in to their *own*
//! RuView instance with their Cognitum identity, and this crate verifies the
//! resulting access token **offline**, against identity's published JWKS.
//!
//! Offline is the requirement, not an optimisation — RuView runs on Pi-class
//! hardware that loses WAN, and there is no token-introspection endpoint to call
//! even when the network is up.
//!
//! The transport is injected, so this compiles with or without the
//! `ureq-transport` feature. With it enabled, pass
//! [`UreqFetcher::new()`][jwks::UreqFetcher] instead of writing your own.
//!
//! ```no_run
//! use ruview_auth::{
//! jwks::{JwksError, JwksFetcher},
//! scope, verify_access_token, JwksCache, VerifierConfig,
//! };
//!
//! struct MyFetcher;
//! impl JwksFetcher for MyFetcher {
//! fn fetch(&self, url: &str) -> Result<String, JwksError> {
//! # let _ = url;
//! // ... GET `url`, return the body ...
//! # unimplemented!()
//! }
//! }
//!
//! let jwks = JwksCache::new(
//! "https://auth.cognitum.one/.well-known/jwks.json",
//! Box::new(MyFetcher),
//! );
//! // Fail at boot, not on a user's first request.
//! jwks.warm().expect("JWKS reachable at startup");
//!
//! let config = VerifierConfig {
//! issuer: "https://auth.cognitum.one".to_string(),
//! required_scope: scope::SENSING_READ.to_string(),
//! // Audience: Cognitum has no `aud`, so `client_id` carries it.
//! allowed_client_ids: vec!["ruview".to_string()],
//! };
//!
//! let principal = verify_access_token("<jwt>", &jwks, &config)?;
//! println!("{} on account {}", principal.subject, principal.account_id);
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
//!
//! ## Scope is the capability boundary
//!
//! Cognitum access tokens carry no `aud`, and `client_id` is unreliable because
//! clients borrow each other's registrations. So the scope claim is the only
//! thing separating "may watch the sensing stream" from "may delete the trained
//! model". Callers pick [`VerifierConfig::required_scope`] per route:
//! [`scope::SENSING_READ`] for streams and inference,
//! [`scope::SENSING_ADMIN`] for training, model delete and recording delete.
//!
//! ## What this crate deliberately does not do
//!
//! - **No login flow by default.** Obtaining a token (PKCE, loopback, OOB
//! paste) lives behind the non-default `login` feature, so a server that only
//! verifies never compiles it. See [`login`] and ADR-271's 2026-07-22
//! amendment for why it lives here rather than in a second crate.
//! - **No revocation check.** There is no introspection endpoint. The 15-minute
//! token lifetime *is* the revocation window, which is precisely why
//! long-lived setup/workload credentials are refused outright.
//! - **No crypto.** Signature math is `jsonwebtoken`'s.
pub mod jwks;
pub mod principal;
pub mod verify;
/// PKCE generation (RFC 7636). Available without the full `login` stack so a
/// resource server can drive its own browser redirect.
#[cfg(feature = "pkce")]
pub mod pkce;
/// Interactive sign-in (PKCE, loopback, OOB paste, credential storage,
/// single-flight refresh). Off by default — a sensing server verifies tokens
/// and never obtains them, so it must not pay for the HTTP client this needs.
#[cfg(feature = "login")]
pub mod login;
pub use jwks::{JwksCache, JwksError, JwksFetcher};
#[cfg(feature = "ureq-transport")]
pub use jwks::UreqFetcher;
pub use principal::{scope, Principal};
pub use verify::{extract_bearer, verify_access_token, VerifierConfig, VerifyError};
+223
View File
@@ -0,0 +1,223 @@
//! The ephemeral loopback listener the browser redirects back to, plus opening
//! the system browser.
//!
//! A hand-rolled HTTP/1.1 responder rather than a second axum server: it serves
//! exactly one GET, then shuts down. Ported from `meta-proxy`
//! `src/oauth/{callback_server,browser}.rs`.
use std::net::SocketAddr;
use std::process::Stdio;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::time::{timeout, Duration};
pub struct CallbackServer {
listener: TcpListener,
/// The exact value to send as `redirect_uri`.
pub redirect_uri: String,
}
#[derive(Debug, Clone, Default)]
pub struct CallbackResult {
pub code: Option<String>,
pub state: Option<String>,
pub error: Option<String>,
}
const SUCCESS_PAGE: &str = r#"<html>
<body style="background:#0a0a0a; color:#f5f5f5; font-family:system-ui,sans-serif;
display:flex; align-items:center; justify-content:center; height:100vh; margin:0;">
<div style="text-align:center;">
<h1>&#10003; RuView sign-in complete</h1>
<p>You can close this tab and return to your terminal.</p>
</div>
</body>
</html>"#;
impl CallbackServer {
/// Bind `127.0.0.1:0` and derive the redirect URI.
///
/// The path must be **exactly** `/oauth/callback`: identity's
/// `client::validate_redirect_uri` accepts `http://127.0.0.1:<any-port>/oauth/callback`
/// and nothing else, so a different path fails the authorize request with a
/// redirect-URI mismatch rather than anything that names the real problem.
pub async fn bind() -> std::io::Result<Self> {
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
let addr: SocketAddr = listener.local_addr()?;
Ok(Self {
redirect_uri: format!("http://127.0.0.1:{}/oauth/callback", addr.port()),
listener,
})
}
pub fn port(&self) -> u16 {
self.listener.local_addr().map(|a| a.port()).unwrap_or(0)
}
/// Serve exactly one callback, reply with the success page, return the
/// parsed query. Times out so an abandoned browser tab does not hang the
/// CLI forever.
pub async fn await_callback(&self, wait_for: Duration) -> std::io::Result<CallbackResult> {
let (mut stream, _) = timeout(wait_for, self.listener.accept())
.await
.map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::TimedOut,
"timed out waiting for the OAuth callback — was the browser window closed?",
)
})??;
let mut buf = vec![0u8; 8192];
let n = stream.read(&mut buf).await?;
let text = String::from_utf8_lossy(&buf[..n]);
let target = text
.lines()
.next()
.unwrap_or("")
.split_whitespace()
.nth(1)
.unwrap_or("/oauth/callback")
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
SUCCESS_PAGE.len(),
SUCCESS_PAGE
);
stream.write_all(response.as_bytes()).await?;
stream.shutdown().await?;
Ok(parse_callback_query(&target))
}
}
fn parse_callback_query(path_and_query: &str) -> CallbackResult {
let query = path_and_query.split_once('?').map(|(_, q)| q).unwrap_or("");
let mut out = CallbackResult::default();
for (k, v) in url::form_urlencoded::parse(query.as_bytes()) {
match k.as_ref() {
"code" => out.code = Some(v.into_owned()),
"state" => out.state = Some(v.into_owned()),
"error" => out.error = Some(v.into_owned()),
_ => {}
}
}
out
}
/// Open `url` in the system browser.
///
/// Success means the launcher was spawned, not that a window appeared — which
/// cannot be determined in general. Callers must print the URL regardless.
pub fn open_browser(url: &str) -> std::io::Result<()> {
let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
("open", vec![url])
} else if cfg!(target_os = "windows") {
// The empty title argument stops `start` treating a quoted URL as the
// window title.
("cmd", vec!["/c", "start", "", url])
} else {
("xdg-open", vec![url])
};
std::process::Command::new(cmd)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|_| ())
}
/// Does this process look like it has no usable browser?
///
/// Mirrors meta-proxy's detection exactly (`login.rs:105-108`). It is a
/// heuristic, which is why `--no-browser` exists: a wrong guess costs the user
/// one flag, not a failed login.
pub fn looks_headless() -> bool {
std::env::var("SSH_CONNECTION").is_ok()
|| std::env::var("SSH_TTY").is_ok()
|| std::env::var("CONTAINER").is_ok()
|| std::path::Path::new("/.dockerenv").exists()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_code_and_state() {
let r = parse_callback_query("/oauth/callback?code=abc&state=xyz");
assert_eq!(r.code.as_deref(), Some("abc"));
assert_eq!(r.state.as_deref(), Some("xyz"));
assert!(r.error.is_none());
}
#[test]
fn parses_a_denial() {
let r = parse_callback_query("/oauth/callback?error=access_denied&state=xyz");
assert_eq!(r.error.as_deref(), Some("access_denied"));
assert!(r.code.is_none());
}
#[test]
fn percent_encoded_values_are_decoded() {
let r = parse_callback_query("/oauth/callback?code=a%2Bb%2Fc&state=s");
assert_eq!(r.code.as_deref(), Some("a+b/c"));
}
#[test]
fn a_query_less_callback_yields_nothing_rather_than_panicking() {
let r = parse_callback_query("/oauth/callback");
assert!(r.code.is_none() && r.state.is_none() && r.error.is_none());
}
#[tokio::test]
async fn the_redirect_uri_has_the_exact_shape_identity_requires() {
let s = CallbackServer::bind().await.unwrap();
assert!(s.redirect_uri.starts_with("http://127.0.0.1:"));
assert!(s.redirect_uri.ends_with("/oauth/callback"));
assert_ne!(s.port(), 0, "must bind a real ephemeral port");
}
#[tokio::test]
async fn a_real_tcp_callback_round_trips() {
let server = CallbackServer::bind().await.unwrap();
let port = server.port();
let client = tokio::spawn(async move {
let mut s = tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.unwrap();
s.write_all(b"GET /oauth/callback?code=real&state=st HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
.await
.unwrap();
let mut buf = vec![0u8; 4096];
let n = s.read(&mut buf).await.unwrap();
String::from_utf8_lossy(&buf[..n]).to_string()
});
let r = server.await_callback(Duration::from_secs(5)).await.unwrap();
assert_eq!(r.code.as_deref(), Some("real"));
assert_eq!(r.state.as_deref(), Some("st"));
let page = client.await.unwrap();
assert!(page.contains("200 OK"));
assert!(page.contains("RuView sign-in complete"));
}
#[tokio::test]
async fn an_abandoned_login_times_out_instead_of_hanging() {
let server = CallbackServer::bind().await.unwrap();
assert!(server
.await_callback(Duration::from_millis(50))
.await
.is_err());
}
#[test]
fn opening_a_browser_never_panics_even_with_no_launcher_present() {
// CI containers have no xdg-open; that is a handled condition, not a
// failure — the caller prints the URL either way.
let _ = open_browser("http://127.0.0.1:1/nope");
}
}
+251
View File
@@ -0,0 +1,251 @@
//! The `auth.cognitum.one` OAuth surface: authorize URL, `POST /oauth/token`
//! (`authorization_code` and `refresh_token` grants), and
//! `POST /v1/oauth/code-exchange` (the OOB fallback).
//!
//! Ported from `cognitum-one/meta-proxy` `src/oauth/client.rs`, with the
//! refresh grant kept — meta-proxy discards its access token after one use,
//! but a RuView session is long-lived and must refresh.
//!
//! **Target the identity origin, not the console.** metaharness ADR-119 found
//! `dashboard.cognitum.one` returns 405 for `POST /oauth/token` (the console
//! SPA swallows the route). `auth.cognitum.one` is the correct direct target.
use serde::{Deserialize, Serialize};
/// RuView's registered client (identity migration `0017`).
pub const CLIENT_ID: &str = "ruview";
/// RFC 8252 out-of-band sentinel. Must match
/// `services/identity/src/oauth/client.rs::FALLBACK_REDIRECT_URI` exactly.
pub const OOB_REDIRECT_URI: &str = "urn:ietf:wg:oauth:2.0:oob";
pub const DEFAULT_AUTH_BASE_URL: &str = "https://auth.cognitum.one";
/// Override the issuer origin (staging, a local identity, a mirror).
pub const AUTH_URL_ENV: &str = "RUVIEW_COGNITUM_AUTH_URL";
/// Override the client id.
///
/// Exists because Cognitum has no dynamic client registration, and products
/// have historically borrowed a registered id while their own was pending —
/// musica shipped as `meta-proxy` for exactly this reason. RuView has its own
/// row now, so this is an escape hatch, not the normal path.
pub const CLIENT_ID_ENV: &str = "RUVIEW_COGNITUM_CLIENT_ID";
pub fn auth_base_url() -> String {
std::env::var(AUTH_URL_ENV)
.ok()
.filter(|v| !v.trim().is_empty())
.map(|v| v.trim().trim_end_matches('/').to_string())
.unwrap_or_else(|| DEFAULT_AUTH_BASE_URL.to_string())
}
pub fn client_id() -> String {
std::env::var(CLIENT_ID_ENV)
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| CLIENT_ID.to_string())
}
#[derive(Debug, thiserror::Error)]
pub enum OAuthError {
#[error("network error talking to the authorization server: {0}")]
Network(#[from] reqwest::Error),
#[error("authorization server rejected the request: {error} — {description}")]
Protocol { error: String, description: String },
#[error("unexpected response shape from the authorization server")]
UnexpectedShape,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
#[serde(default)]
pub token_type: Option<String>,
#[serde(default)]
pub account_email: Option<String>,
/// The **rotating** refresh token. Identity revokes the presented one and
/// returns a replacement; see [`refresh`].
#[serde(default)]
pub refresh_token: Option<String>,
/// Access-token lifetime in seconds (identity issues 900). Absent ⇒ treat
/// the token as already needing refresh rather than assuming a default.
#[serde(default)]
pub expires_in: Option<i64>,
#[serde(default)]
pub scope: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ErrorBody {
#[serde(default)]
error: Option<String>,
#[serde(default)]
error_description: Option<String>,
}
async fn parse_token_response(resp: reqwest::Response) -> Result<TokenResponse, OAuthError> {
let status = resp.status();
let body = resp.text().await?;
if status.is_success() {
return serde_json::from_str::<TokenResponse>(&body)
.map_err(|_| OAuthError::UnexpectedShape);
}
// A non-JSON error body (an HTML error page, a proxy timeout) must not
// panic or masquerade as a protocol error we understand.
match serde_json::from_str::<ErrorBody>(&body) {
Ok(e) => Err(OAuthError::Protocol {
error: e.error.unwrap_or_else(|| status.to_string()),
description: e
.error_description
.unwrap_or_else(|| "no description supplied".into()),
}),
Err(_) => Err(OAuthError::UnexpectedShape),
}
}
/// Build the `/oauth/authorize` URL.
///
/// Uses a real URL encoder rather than `format!` so a scope containing a space
/// (`"sensing:read sensing:admin"`) is encoded correctly — hand-formatting this
/// is how a client ends up sending a truncated scope and getting a baffling
/// `Unknown scope`.
pub fn authorize_url(redirect_uri: &str, state: &str, code_challenge: &str, scope: &str) -> String {
let mut url = url::Url::parse(&format!("{}/oauth/authorize", auth_base_url()))
.expect("auth base URL is a valid URL");
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", &client_id())
.append_pair("redirect_uri", redirect_uri)
.append_pair("code_challenge", code_challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("state", state)
.append_pair("scope", scope);
url.to_string()
}
/// `POST /oauth/token`, `grant_type=authorization_code`.
pub async fn exchange_code(
http: &reqwest::Client,
code: &str,
code_verifier: &str,
redirect_uri: &str,
) -> Result<TokenResponse, OAuthError> {
let resp = http
.post(format!("{}/oauth/token", auth_base_url()))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("code_verifier", code_verifier),
("client_id", &client_id()),
("redirect_uri", redirect_uri),
])
.send()
.await?;
parse_token_response(resp).await
}
/// `POST /oauth/token`, `grant_type=refresh_token`.
///
/// **Identity rotates refresh tokens with reuse detection.** The response
/// carries a NEW refresh token and spends the old one; presenting a spent token
/// revokes the entire session family. Two consequences the caller must honour:
///
/// 1. Persist the returned `refresh_token` **before** using the new access
/// token — a crash in between otherwise strands the session.
/// 2. Never retry a failed refresh with the same token. A timeout is not proof
/// the server did not consume it.
///
/// [`super::store::Session::ensure_fresh`] does both; prefer it to calling this
/// directly.
pub async fn refresh(
http: &reqwest::Client,
refresh_token: &str,
) -> Result<TokenResponse, OAuthError> {
let resp = http
.post(format!("{}/oauth/token", auth_base_url()))
.form(&[
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("client_id", &client_id()),
])
.send()
.await?;
parse_token_response(resp).await
}
/// `POST /v1/oauth/code-exchange` — the OOB manual-paste fallback for hosts
/// with no browser and no reachable loopback (SSH into a Pi, a container).
pub async fn exchange_manual_code(
http: &reqwest::Client,
code: &str,
code_verifier: &str,
) -> Result<TokenResponse, OAuthError> {
#[derive(Serialize)]
struct Req<'a> {
code: &'a str,
code_verifier: &'a str,
client_id: &'a str,
}
let resp = http
.post(format!("{}/v1/oauth/code-exchange", auth_base_url()))
.json(&Req {
code,
code_verifier,
client_id: &client_id(),
})
.send()
.await?;
parse_token_response(resp).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn authorize_url_targets_the_identity_origin_not_the_console() {
// The console origin 405s POST /oauth/token (metaharness ADR-119).
let u = authorize_url("http://127.0.0.1:1/oauth/callback", "s", "c", "sensing:read");
assert!(u.starts_with("https://auth.cognitum.one/oauth/authorize"), "{u}");
assert!(!u.contains("dashboard.cognitum.one"));
}
#[test]
fn authorize_url_carries_every_required_parameter() {
let u = authorize_url("http://127.0.0.1:1/oauth/callback", "st8", "chal", "sensing:read");
for expected in [
"response_type=code",
"client_id=ruview",
"code_challenge=chal",
"code_challenge_method=S256",
"state=st8",
] {
assert!(u.contains(expected), "missing {expected} in {u}");
}
}
#[test]
fn a_multi_scope_request_is_url_encoded_not_truncated() {
// The space in "sensing:read sensing:admin" must survive as %20/+.
// Hand-formatting this is how a client silently requests one scope.
let u = authorize_url("http://127.0.0.1:1/oauth/callback", "s", "c", "sensing:read sensing:admin");
assert!(
u.contains("scope=sensing%3Aread+sensing%3Aadmin")
|| u.contains("scope=sensing%3Aread%20sensing%3Aadmin"),
"scope not encoded correctly: {u}"
);
}
#[test]
fn the_oob_sentinel_matches_the_servers_constant_exactly() {
// Any drift here fails the headless path with an opaque redirect_uri
// mismatch.
assert_eq!(OOB_REDIRECT_URI, "urn:ietf:wg:oauth:2.0:oob");
}
#[test]
fn the_default_client_id_is_ruviews_own_registration() {
assert_eq!(CLIENT_ID, "ruview");
}
}
+241
View File
@@ -0,0 +1,241 @@
//! Login orchestration: browser + loopback when possible, OOB paste when not.
use std::io::{BufRead, Write};
use std::path::PathBuf;
use std::time::Duration;
use super::callback::{looks_headless, open_browser, CallbackServer};
use super::client::{self, OAuthError};
use crate::pkce;
use super::store::{self, Session, StoreError};
use crate::scope;
/// How long to wait for the user to finish in the browser.
const CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
#[derive(Debug, thiserror::Error)]
pub enum LoginError {
#[error(transparent)]
OAuth(#[from] OAuthError),
#[error(transparent)]
Store(#[from] StoreError),
#[error("could not bind a loopback callback listener: {0}")]
Bind(#[source] std::io::Error),
#[error("waiting for the browser callback failed: {0}")]
Callback(#[source] std::io::Error),
#[error("the authorization server returned state {got:?}, expected {expected:?} — this login was not the one you started, so it was discarded")]
StateMismatch { expected: String, got: String },
#[error("the authorization server reported: {0}")]
Denied(String),
#[error("login cancelled")]
Cancelled,
#[error("could not read from the terminal: {0}")]
Io(#[from] std::io::Error),
}
pub struct LoginOptions {
/// Where to persist credentials.
pub credentials_path: PathBuf,
/// Scopes to request. Least privilege by default — `sensing:read` only.
pub scope: String,
/// Force the OOB paste flow even if a browser looks available.
pub no_browser: bool,
}
impl Default for LoginOptions {
fn default() -> Self {
Self {
credentials_path: store::default_credentials_path(),
// A client registration is a ceiling, not a default (ADR-060 §5).
// Routine use asks for read; admin is an explicit escalation.
scope: scope::SENSING_READ.to_string(),
no_browser: false,
}
}
}
/// Run the login flow and persist the resulting session.
///
/// `out` receives the human-facing prose (URLs, prompts) so a caller can
/// capture it in tests; `input` supplies the pasted code in the OOB path.
pub async fn login<W: Write, R: BufRead>(
opts: &LoginOptions,
out: &mut W,
input: &mut R,
) -> Result<Session, LoginError> {
let http = reqwest::Client::new();
let issuer = client::auth_base_url();
if opts.no_browser || looks_headless() {
return manual_login(opts, &http, issuer, out, input).await;
}
match browser_login(opts, &http, issuer.clone(), out).await {
Ok(s) => Ok(s),
// A loopback bind failure is environmental, not user error — fall back
// rather than dead-ending someone who is one paste away from success.
Err(LoginError::Bind(e)) => {
writeln!(
out,
"Could not open a local callback listener ({e}); falling back to paste-code sign-in.\n"
)?;
manual_login(opts, &http, issuer, out, input).await
}
Err(e) => Err(e),
}
}
async fn browser_login<W: Write>(
opts: &LoginOptions,
http: &reqwest::Client,
issuer: String,
out: &mut W,
) -> Result<Session, LoginError> {
let server = CallbackServer::bind().await.map_err(LoginError::Bind)?;
let req = pkce::generate();
let url = client::authorize_url(
&server.redirect_uri,
&req.state,
&req.code_challenge,
&opts.scope,
);
writeln!(out, "Opening your browser to sign in to Cognitum…")?;
writeln!(out, "If it doesn't open, visit:\n\n {url}\n")?;
// Best-effort: the URL is already printed, so a missing launcher is not fatal.
let _ = open_browser(&url);
let cb = server
.await_callback(CALLBACK_TIMEOUT)
.await
.map_err(LoginError::Callback)?;
if let Some(err) = cb.error {
return Err(LoginError::Denied(err));
}
// CSRF check before the code is spent: a code arriving with the wrong state
// did not come from the flow we started.
let got = cb.state.unwrap_or_default();
if got != req.state {
return Err(LoginError::StateMismatch {
expected: req.state,
got,
});
}
let code = cb.code.ok_or(LoginError::Cancelled)?;
let token = client::exchange_code(http, &code, &req.code_verifier, &server.redirect_uri).await?;
finish(opts, http, token, issuer, out)
}
async fn manual_login<W: Write, R: BufRead>(
opts: &LoginOptions,
http: &reqwest::Client,
issuer: String,
out: &mut W,
input: &mut R,
) -> Result<Session, LoginError> {
let req = pkce::generate();
let url = client::authorize_url(
client::OOB_REDIRECT_URI,
&req.state,
&req.code_challenge,
&opts.scope,
);
writeln!(
out,
"No local browser available (SSH/container detected, or --no-browser).\n"
)?;
writeln!(
out,
"Open this URL in a browser on any machine and authorize:\n\n {url}\n"
)?;
write!(out, "Paste the code shown after authorizing: ")?;
out.flush()?;
let mut line = String::new();
input.read_line(&mut line)?;
let code = line.trim();
if code.is_empty() {
return Err(LoginError::Cancelled);
}
let token = client::exchange_manual_code(http, code, &req.code_verifier).await?;
finish(opts, http, token, issuer, out)
}
fn finish<W: Write>(
opts: &LoginOptions,
http: &reqwest::Client,
token: client::TokenResponse,
issuer: String,
out: &mut W,
) -> Result<Session, LoginError> {
let granted = token.scope.clone();
let email = token.account_email.clone();
let session = Session::from_response(
opts.credentials_path.clone(),
http.clone(),
token,
issuer,
)?;
writeln!(out)?;
match email {
Some(e) => writeln!(out, "Signed in as {e}.")?,
None => writeln!(out, "Signed in.")?,
}
// Report what the server actually granted, not what we asked for. They can
// differ, and a user who thinks they hold `sensing:admin` when they don't
// will read the eventual 401 as a bug.
match granted {
Some(s) => writeln!(out, "Granted scope: {s}")?,
None => writeln!(out, "Granted scope: (not reported by the server)")?,
}
writeln!(
out,
"Credentials saved to {}",
opts.credentials_path.display()
)?;
Ok(session)
}
/// Forget the local session. Returns whether anything was removed.
///
/// Local-only by design: this makes the machine unable to act as you. Revoking
/// server-side is a separate, account-level action.
pub fn logout(credentials_path: &std::path::Path) -> Result<bool, StoreError> {
store::clear(credentials_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_default_scope_is_read_only() {
// ADR-060 §5: a registration is a ceiling, not a default. A session that
// streams poses must not casually hold delete capability.
assert_eq!(LoginOptions::default().scope, scope::SENSING_READ);
assert_ne!(LoginOptions::default().scope, scope::SENSING_ADMIN);
}
#[test]
fn logout_on_a_machine_that_never_logged_in_is_not_an_error() {
let p = std::env::temp_dir().join("ruview-flow-absent-credentials.json");
let _ = std::fs::remove_file(&p);
assert_eq!(logout(&p).unwrap(), false);
}
#[test]
fn a_state_mismatch_names_both_values_so_it_can_be_diagnosed() {
let e = LoginError::StateMismatch {
expected: "aaa".into(),
got: "bbb".into(),
};
let msg = e.to_string();
assert!(msg.contains("aaa") && msg.contains("bbb"), "{msg}");
assert!(msg.contains("discarded"), "must say the login was refused: {msg}");
}
}
+49
View File
@@ -0,0 +1,49 @@
//! Interactive Cognitum sign-in (ADR-271 phase 2). Feature `login`.
//!
//! The counterpart to this crate's verifier: the verifier checks tokens a
//! server receives, this obtains one for a user to present.
//!
//! Ported from `cognitum-one/meta-proxy` `src/oauth/`, cross-checked against
//! `musica`'s `cognitum_provider.rs` — the two independent implementations
//! against this same authorization server. Where they agree (exact
//! `/oauth/callback` redirect path, 60-second refresh skew, OOB fallback on
//! SSH/container) this follows both.
//!
//! ```no_run
//! # async fn demo() -> Result<(), Box<dyn std::error::Error>> {
//! use ruview_auth::login::{login, LoginOptions};
//!
//! let opts = LoginOptions::default(); // requests sensing:read only
//! let mut out = std::io::stdout();
//! let mut input = std::io::stdin().lock();
//! let session = login(&opts, &mut out, &mut input).await?;
//!
//! // Always go through ensure_fresh — never read access_token directly.
//! let bearer = session.ensure_fresh().await?;
//! # let _ = bearer;
//! # Ok(())
//! # }
//! ```
//!
//! # Two things that will bite if ignored
//!
//! 1. **Refresh tokens rotate with reuse detection.** Presenting a spent one
//! revokes the session family, so refresh is serialised and never retried.
//! Use [`store::Session::ensure_fresh`]; do not call [`client::refresh`]
//! directly unless you are reimplementing that guarantee.
//! 2. **Least scope by default.** [`LoginOptions::default`] asks for
//! `sensing:read`. Requesting `sensing:admin` should be a deliberate act for
//! an administrative operation, not the standing state of every session.
pub mod callback;
/// Re-exported from the crate root; PKCE is usable without this feature.
pub use crate::pkce;
pub mod client;
pub mod flow;
pub mod store;
pub use client::{OAuthError, TokenResponse, CLIENT_ID, CLIENT_ID_ENV, OOB_REDIRECT_URI};
pub use flow::{login, logout, LoginError, LoginOptions};
pub use store::{
default_credentials_path, Session, StoreError, StoredCredentials, CREDENTIALS_PATH_ENV,
};
+744
View File
@@ -0,0 +1,744 @@
//! Stored credentials and the refresh critical section.
//!
//! # Why refresh is the dangerous part
//!
//! Identity **rotates refresh tokens with reuse detection**: presenting one
//! returns a replacement and spends the original, and presenting a spent token
//! revokes the whole session family. So the two obvious implementations are
//! both wrong:
//!
//! * *Refresh concurrently* — two tasks present the same token, the second
//! looks like replay, and the user is logged out.
//! * *Retry a failed refresh with the same token* — a timeout is not evidence
//! the server didn't consume it. Retrying is precisely the replay the server
//! is watching for.
//!
//! [`Session::ensure_fresh`] therefore holds an async mutex **across the
//! await**, re-checks expiry after acquiring it (the task that waited may find
//! the work already done), persists the rotated token **before** returning, and
//! never retries.
//!
//! ## The in-process mutex is not enough
//!
//! Every CLI invocation is a NEW process with its own `Session` and its own
//! mutex, all sharing one credential file. Two `wifi-densepose` commands run
//! close together inside the refresh window would each load the same refresh
//! token and each present it — and the second is replay, so the user is logged
//! out for running two commands at once.
//!
//! So the critical section is also guarded by an advisory **file lock**, taken
//! NON-BLOCKING. If another process holds it, that process is already
//! refreshing: we wait briefly and re-read the file rather than queue up to do
//! the same work with a token that is about to be spent. Blocking on the lock
//! would also park the async executor — the same mistake this crate had to fix
//! in `jwks.rs`.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use super::client::{self, OAuthError, TokenResponse};
/// How many times to re-read the credential file while another process holds
/// the refresh lock, before giving up on it and refreshing ourselves.
const RELOAD_ATTEMPTS: usize = 20;
/// Gap between those re-reads. 20 x 150ms = 3s, comfortably longer than a
/// healthy token exchange and shorter than a user notices.
const RELOAD_INTERVAL: std::time::Duration = std::time::Duration::from_millis(150);
/// Refresh this many seconds before `exp`. Matches the figure meta-proxy and
/// musica independently arrived at against the same 15-minute token.
const REFRESH_SKEW_SECS: i64 = 60;
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("no stored credentials — run `wifi-densepose login` first")]
NotLoggedIn,
#[error("credential file {path} is unreadable: {source}")]
Unreadable {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("credential file {path} is malformed; run `wifi-densepose login` again")]
Malformed { path: PathBuf },
#[error("could not write credentials to {path}: {source}")]
Unwritable {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("session expired and could not be refreshed — run `wifi-densepose login` again: {0}")]
RefreshFailed(#[from] OAuthError),
#[error("the authorization server returned no refresh token; re-login is required")]
NoRefreshToken,
}
/// The persisted session. Deliberately small: this file holds live credentials.
///
/// `Debug` is hand-written and REDACTING — a derived impl prints both tokens in
/// full, and this type is the obvious thing to log when a session misbehaves.
#[derive(Clone, Serialize, Deserialize)]
pub struct StoredCredentials {
pub schema_version: u8,
pub access_token: String,
pub refresh_token: Option<String>,
/// Unix seconds. Absent ⇒ treated as already expired, never as "valid".
pub expires_at: Option<i64>,
pub scope: Option<String>,
pub account_email: Option<String>,
pub issuer: String,
}
impl std::fmt::Debug for StoredCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StoredCredentials")
.field("schema_version", &self.schema_version)
.field("access_token", &"<redacted>")
.field("refresh_token", &self.refresh_token.as_ref().map(|_| "<redacted>"))
.field("expires_at", &self.expires_at)
.field("scope", &self.scope)
.field("account_email", &self.account_email)
.field("issuer", &self.issuer)
.finish()
}
}
impl StoredCredentials {
pub const SCHEMA_VERSION: u8 = 1;
fn from_response(t: TokenResponse, issuer: String) -> Self {
let expires_at = t.expires_in.map(|s| now_unix() + s);
// Identity's /oauth/token response has no top-level `scope` field, but
// the access token itself carries a `scope` claim — and that claim is
// the authoritative one, since it is what a resource server actually
// gates on. Falling back to it turns "(not reported by the server)"
// into the real answer. Envelope first on the off chance a future
// response does carry one.
let scope = t
.scope
.clone()
.or_else(|| scope_from_access_token(&t.access_token));
Self {
schema_version: Self::SCHEMA_VERSION,
access_token: t.access_token,
refresh_token: t.refresh_token,
expires_at,
scope,
account_email: t.account_email,
issuer,
}
}
/// The granted scope, falling back to the access token's own claim.
///
/// Resolved at *read* time, not just at write time, so credential files
/// written before the fallback existed — or by any client that stores only
/// what the token response carried — still report correctly instead of
/// showing "(not reported)" forever. The token is the authoritative source
/// either way; the stored field is a convenience copy.
pub fn effective_scope(&self) -> Option<String> {
self.scope
.clone()
.filter(|s| !s.is_empty())
.or_else(|| scope_from_access_token(&self.access_token))
}
/// Does the access token need replacing?
///
/// A missing `expires_at` counts as expired. Guessing a lifetime here would
/// mean confidently sending a token the server may have expired minutes ago.
pub fn needs_refresh(&self) -> bool {
match self.expires_at {
None => true,
Some(exp) => now_unix() + REFRESH_SKEW_SECS >= exp,
}
}
}
/// Read the `scope` claim out of an access token **for display only**.
///
/// # This is NOT verification
///
/// It base64-decodes the JWT payload and does not check the signature, the
/// issuer, `exp`, `typ`, or anything else. Its only legitimate use is telling a
/// user what they just consented to, for a token this process received over TLS
/// directly from the issuer moments ago.
///
/// Never use it to make an authorization decision. Anything that gates access
/// must go through [`crate::verify::verify_access_token`], which checks the
/// signature against identity's published JWKS. A client reading its own freshly
/// issued token is a fundamentally different situation from a server reading a
/// token a stranger handed it.
///
/// Returns `None` rather than guessing if the token is not a well-formed JWT —
/// an unreadable scope must present as unknown, never as empty (which would
/// read as "you were granted nothing").
fn scope_from_access_token(jwt: &str) -> Option<String> {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let payload_b64 = jwt.split('.').nth(1)?;
let bytes = URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
claims
.get("scope")?
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_owned)
}
fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Default credential path: `~/.ruview/credentials.json`, overridable.
pub const CREDENTIALS_PATH_ENV: &str = "RUVIEW_CREDENTIALS_PATH";
pub fn default_credentials_path() -> PathBuf {
if let Ok(p) = std::env::var(CREDENTIALS_PATH_ENV) {
if !p.trim().is_empty() {
return PathBuf::from(p);
}
}
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".to_string());
Path::new(&home).join(".ruview").join("credentials.json")
}
/// Write credentials atomically and `0600`.
///
/// Same discipline the seed applies to its cloud key and meta-proxy to its
/// config: temp file in the destination directory, restrict the mode *before*
/// the rename, then rename. A partial credential file is worse than none, and a
/// world-readable one is a live session anyone on the box can steal.
pub fn save(path: &Path, creds: &StoredCredentials) -> Result<(), StoreError> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|source| StoreError::Unwritable {
path: path.to_path_buf(),
source,
})?;
}
let json = serde_json::to_vec_pretty(creds).expect("credentials serialize");
let tmp = path.with_extension("tmp");
// Create with 0600 ALREADY SET, rather than write-then-chmod.
//
// `fs::write` creates at `0666 & !umask` — 0644 on a default umask — so the
// refresh token was world-readable at a predictable path for the window
// between the write and the chmod. `save` runs on every silent refresh
// (REFRESH_SKEW_SECS against a 15-minute token), so that window recurred
// every few minutes, and the refresh token is the highest-value credential
// here: identity rotates with reuse detection, so a thief who presents it
// first takes the session family and logs the real user out.
write_private(&tmp, &json).map_err(|source| StoreError::Unwritable {
path: tmp.clone(),
source,
})?;
std::fs::rename(&tmp, path).map_err(|source| StoreError::Unwritable {
path: path.to_path_buf(),
source,
})?;
Ok(())
}
/// Write `bytes` to a file that is never readable by anyone else, at any point.
///
/// `create_new` also means a pre-existing `.tmp` — a symlink planted by a local
/// attacker, or a leftover from a crash — is an error rather than a target.
#[cfg(unix)]
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let _ = std::fs::remove_file(path); // clear our own leftover, not a race
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
f.write_all(bytes)?;
f.sync_all()
}
#[cfg(not(unix))]
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
std::fs::write(path, bytes)
}
#[cfg(unix)]
fn restrict_permissions(path: &Path) -> Result<(), StoreError> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
StoreError::Unwritable {
path: path.to_path_buf(),
source,
}
})
}
#[cfg(not(unix))]
fn restrict_permissions(path: &Path) -> Result<(), StoreError> {
// Windows: inherit the user profile directory's ACL. `icacls` would be the
// stricter equivalent; noted rather than silently pretended.
let _ = path;
Ok(())
}
pub fn load(path: &Path) -> Result<StoredCredentials, StoreError> {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(StoreError::NotLoggedIn),
Err(source) => {
return Err(StoreError::Unreadable {
path: path.to_path_buf(),
source,
})
}
};
serde_json::from_slice(&bytes).map_err(|_| StoreError::Malformed {
path: path.to_path_buf(),
})
}
/// Remove stored credentials. Idempotent.
///
/// This forgets the local copy; it does not revoke server-side. That is a
/// deliberate split (meta-proxy makes the same one): "this machine can no
/// longer act as me" is the fail-secure local action, and revocation is a
/// separate, account-level decision.
pub fn clear(path: &Path) -> Result<bool, StoreError> {
match std::fs::remove_file(path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(source) => Err(StoreError::Unwritable {
path: path.to_path_buf(),
source,
}),
}
}
/// An advisory, cross-process exclusive lock on the credential file.
///
/// Unix only. On other platforms this is a no-op and the cross-process race
/// remains — stated rather than silently pretended, since a lock that does
/// nothing while claiming to protect is worse than none.
struct FileLock {
#[cfg(unix)]
file: std::fs::File,
}
impl FileLock {
/// `None` if another process holds it. Never blocks.
#[cfg(unix)]
fn try_acquire(credentials_path: &Path) -> Option<Self> {
use std::os::unix::io::AsRawFd;
let path = credentials_path.with_extension("lock");
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&path)
.ok()?;
// LOCK_EX | LOCK_NB
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc == 0 {
Some(Self { file })
} else {
None
}
}
#[cfg(not(unix))]
fn try_acquire(_credentials_path: &Path) -> Option<Self> {
Some(Self {})
}
}
#[cfg(unix)]
impl Drop for FileLock {
fn drop(&mut self) {
use std::os::unix::io::AsRawFd;
// Released on close anyway; explicit so the intent is legible.
unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) };
}
}
/// A live session that refreshes itself, safely, at most once at a time.
#[derive(Clone)]
pub struct Session {
path: PathBuf,
http: reqwest::Client,
inner: Arc<Mutex<StoredCredentials>>,
}
impl Session {
pub fn load_from(path: PathBuf, http: reqwest::Client) -> Result<Self, StoreError> {
let creds = load(&path)?;
Ok(Self {
path,
http,
inner: Arc::new(Mutex::new(creds)),
})
}
pub fn from_response(
path: PathBuf,
http: reqwest::Client,
token: TokenResponse,
issuer: String,
) -> Result<Self, StoreError> {
let creds = StoredCredentials::from_response(token, issuer);
save(&path, &creds)?;
Ok(Self {
path,
http,
inner: Arc::new(Mutex::new(creds)),
})
}
pub async fn snapshot(&self) -> StoredCredentials {
self.inner.lock().await.clone()
}
/// Return a non-expired access token, refreshing if needed.
///
/// The mutex is held **across the network call** on purpose. That
/// serialises refreshes, which is the entire point: identity's reuse
/// detection turns a concurrent second refresh into a session revocation.
/// The re-check after acquiring means a task that queued behind another's
/// refresh returns the fresh token instead of spending the rotated one.
pub async fn ensure_fresh(&self) -> Result<String, StoreError> {
let mut guard = self.inner.lock().await;
if !guard.needs_refresh() {
return Ok(guard.access_token.clone());
}
// Cross-process guard. Non-blocking on purpose: a busy lock means
// another process is mid-refresh, so the useful move is to wait for its
// result rather than race it with a token it is about to spend.
let _file_lock: Option<FileLock> = match FileLock::try_acquire(&self.path) {
Some(lock) => Some(lock),
None => {
for _ in 0..RELOAD_ATTEMPTS {
tokio::time::sleep(RELOAD_INTERVAL).await;
if let Ok(fresh) = load(&self.path) {
if !fresh.needs_refresh() {
let token = fresh.access_token.clone();
*guard = fresh;
return Ok(token);
}
}
}
// The other process died or is wedged. Fall through and refresh
// ourselves — the lock is advisory, not a correctness barrier.
tracing::warn!(
"another process held the credential lock without completing a refresh; \
proceeding"
);
None
}
};
let Some(refresh_token) = guard.refresh_token.clone() else {
return Err(StoreError::NoRefreshToken);
};
// Deliberately not retried. A timeout is not evidence the server did
// not consume the token, and re-presenting it is exactly the replay
// that revokes the session.
let refreshed = client::refresh(&self.http, &refresh_token).await?;
let issuer = guard.issuer.clone();
let mut next = StoredCredentials::from_response(refreshed, issuer);
// Identity always returns a replacement, but if it ever omitted one,
// dropping the old token would strand the session with no way back.
if next.refresh_token.is_none() {
next.refresh_token = Some(refresh_token);
}
// Persist BEFORE handing the new access token out: a crash between the
// two otherwise leaves a rotated-away token on disk and a live one only
// in memory.
save(&self.path, &next)?;
let token = next.access_token.clone();
*guard = next;
Ok(token)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn creds(expires_at: Option<i64>) -> StoredCredentials {
StoredCredentials {
schema_version: StoredCredentials::SCHEMA_VERSION,
access_token: "at".into(),
refresh_token: Some("rt".into()),
expires_at,
scope: Some("sensing:read".into()),
account_email: Some("a@b.c".into()),
issuer: "https://auth.test".into(),
}
}
#[test]
fn a_token_with_no_expiry_is_treated_as_expired() {
// Guessing a lifetime would mean confidently sending a token the
// server may have expired minutes ago.
assert!(creds(None).needs_refresh());
}
#[test]
fn a_freshly_issued_token_does_not_need_refreshing() {
assert!(!creds(Some(now_unix() + 900)).needs_refresh());
}
#[test]
fn refresh_is_triggered_inside_the_skew_window() {
// 30s left, 60s skew — refresh now rather than racing expiry mid-request.
assert!(creds(Some(now_unix() + 30)).needs_refresh());
}
#[test]
fn an_already_expired_token_needs_refreshing() {
assert!(creds(Some(now_unix() - 1)).needs_refresh());
}
#[cfg(unix)]
#[test]
fn the_credential_lock_is_exclusive_and_non_blocking() {
// Guards the cross-process race: two CLI invocations inside the refresh
// window used to be able to present the same rotating refresh token,
// which identity treats as replay and answers by revoking the session.
let dir = std::env::temp_dir().join(format!("ruview-auth-lock-{}", std::process::id()));
let path = dir.join("credentials.json");
std::fs::create_dir_all(&dir).unwrap();
let first = FileLock::try_acquire(&path).expect("first acquire succeeds");
assert!(
FileLock::try_acquire(&path).is_none(),
"a second holder must be refused, and refused WITHOUT blocking"
);
drop(first);
assert!(
FileLock::try_acquire(&path).is_some(),
"the lock must be released on drop"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn redacted_debug_never_prints_token_material() {
// A derived Debug prints both tokens in full, and this is the obvious
// type to log when a session misbehaves.
// Distinctive values — an earlier version of this test used "at"/"rt",
// which collide with `expires_at` and produce a false failure.
let mut c = creds(Some(1));
c.access_token = "SECRET-ACCESS-VALUE".into();
c.refresh_token = Some("SECRET-REFRESH-VALUE".into());
let rendered = format!("{c:?}");
assert!(
!rendered.contains("SECRET-ACCESS-VALUE"),
"access token leaked: {rendered}"
);
assert!(
!rendered.contains("SECRET-REFRESH-VALUE"),
"refresh token leaked: {rendered}"
);
assert!(rendered.contains("<redacted>"));
// Non-secret fields stay visible or the type is useless for debugging.
assert!(rendered.contains("https://auth.test"));
}
#[test]
fn save_then_load_round_trips() {
let dir = std::env::temp_dir().join(format!("ruview-auth-test-{}", std::process::id()));
let path = dir.join("credentials.json");
let _ = std::fs::remove_dir_all(&dir);
save(&path, &creds(Some(123))).unwrap();
let back = load(&path).unwrap();
assert_eq!(back.access_token, "at");
assert_eq!(back.expires_at, Some(123));
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn a_saved_credential_file_is_not_readable_by_anyone_else() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("ruview-auth-perm-{}", std::process::id()));
let path = dir.join("credentials.json");
let _ = std::fs::remove_dir_all(&dir);
save(&path, &creds(Some(1))).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "credentials must be 0600, got {mode:o}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_temp_file_is_never_world_readable_even_for_an_instant() {
// The test above checks the FINAL file. It passed while `save` wrote via
// `fs::write` (0644 under a default umask) and chmodded afterwards — so
// the refresh token sat world-readable at a predictable path in between,
// on every silent refresh. Asserting on the destination could never see
// that; this asserts on the temp file `save` actually creates.
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("ruview-auth-tmpperm-{}", std::process::id()));
let path = dir.join("credentials.json");
let tmp = path.with_extension("tmp");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
write_private(&tmp, b"secret").unwrap();
let mode = std::fs::metadata(&tmp).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "temp file must be created 0600, got {mode:o}");
assert_eq!(mode & 0o077, 0, "group/other must have no access at all");
// A leftover from a crashed run is cleared and replaced, and the
// replacement is 0600 too — the mode must not be inherited from
// whatever was there before.
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o666));
write_private(&tmp, b"replacement").unwrap();
let mode = std::fs::metadata(&tmp).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "a replaced temp file must also be 0600, got {mode:o}");
assert_eq!(std::fs::read(&tmp).unwrap(), b"replacement", "must replace, not append");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn loading_a_missing_file_says_not_logged_in_rather_than_erroring_obscurely() {
let path = std::env::temp_dir().join("ruview-auth-definitely-absent.json");
let _ = std::fs::remove_file(&path);
assert!(matches!(load(&path), Err(StoreError::NotLoggedIn)));
}
#[test]
fn a_corrupt_credential_file_is_reported_as_malformed_not_as_absent() {
let dir = std::env::temp_dir().join(format!("ruview-auth-bad-{}", std::process::id()));
let path = dir.join("credentials.json");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(&path, b"{not json").unwrap();
assert!(matches!(load(&path), Err(StoreError::Malformed { .. })));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn clearing_is_idempotent() {
let dir = std::env::temp_dir().join(format!("ruview-auth-clear-{}", std::process::id()));
let path = dir.join("credentials.json");
let _ = std::fs::remove_dir_all(&dir);
save(&path, &creds(Some(1))).unwrap();
assert!(clear(&path).unwrap(), "first clear removes the file");
assert!(!clear(&path).unwrap(), "second clear is a no-op, not an error");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_leaves_no_temp_file_behind() {
let dir = std::env::temp_dir().join(format!("ruview-auth-tmp-{}", std::process::id()));
let path = dir.join("credentials.json");
let _ = std::fs::remove_dir_all(&dir);
save(&path, &creds(Some(1))).unwrap();
assert!(!path.with_extension("tmp").exists(), "temp file must be renamed away");
let _ = std::fs::remove_dir_all(&dir);
}
}
/// The scope-from-token fallback. Split out so its "display only, never an
/// authorization input" contract is pinned by name.
#[cfg(test)]
mod scope_display_tests {
use super::*;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
fn jwt_with_payload(payload: serde_json::Value) -> String {
// Header and signature are irrelevant here — that is the whole point:
// this path never inspects them, so the test must not imply it does.
format!(
"eyJhbGciOiJFUzI1NiJ9.{}.not-a-real-signature",
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap())
)
}
#[test]
fn reads_the_scope_claim_when_the_envelope_omits_it() {
// The live behaviour that motivated this: identity's /oauth/token
// response carries no top-level `scope`, but the token does.
let t = jwt_with_payload(serde_json::json!({"scope": "sensing:read"}));
assert_eq!(scope_from_access_token(&t).as_deref(), Some("sensing:read"));
}
#[test]
fn reads_a_multi_scope_claim_intact() {
let t = jwt_with_payload(serde_json::json!({"scope": "sensing:read sensing:admin"}));
assert_eq!(
scope_from_access_token(&t).as_deref(),
Some("sensing:read sensing:admin")
);
}
#[test]
fn an_unparseable_token_reads_as_unknown_not_as_empty() {
// "" would render as "you were granted nothing", which is a different
// and wrong claim.
assert_eq!(scope_from_access_token("not-a-jwt"), None);
assert_eq!(scope_from_access_token(""), None);
assert_eq!(scope_from_access_token("a.!!!not-base64!!!.c"), None);
}
#[test]
fn an_empty_scope_claim_reads_as_unknown() {
let t = jwt_with_payload(serde_json::json!({"scope": ""}));
assert_eq!(scope_from_access_token(&t), None);
}
#[test]
fn a_token_with_no_scope_claim_reads_as_unknown() {
let t = jwt_with_payload(serde_json::json!({"sub": "u1"}));
assert_eq!(scope_from_access_token(&t), None);
}
#[test]
fn the_response_envelope_wins_when_it_does_carry_a_scope() {
let token = TokenResponse {
access_token: jwt_with_payload(serde_json::json!({"scope": "from:token"})),
token_type: None,
account_email: None,
refresh_token: None,
expires_in: Some(900),
scope: Some("from:envelope".into()),
};
let c = StoredCredentials::from_response(token, "https://auth.test".into());
assert_eq!(c.scope.as_deref(), Some("from:envelope"));
}
#[test]
fn the_token_claim_is_used_when_the_envelope_is_silent() {
let token = TokenResponse {
access_token: jwt_with_payload(serde_json::json!({"scope": "sensing:read"})),
token_type: None,
account_email: None,
refresh_token: None,
expires_in: Some(900),
scope: None,
};
let c = StoredCredentials::from_response(token, "https://auth.test".into());
assert_eq!(c.scope.as_deref(), Some("sensing:read"));
}
}
+83
View File
@@ -0,0 +1,83 @@
//! OAuth 2.0 PKCE (RFC 7636) generation.
//!
//! Ported from `cognitum-one/meta-proxy` `src/oauth/pkce.rs`, itself ported from
//! `dashboard/apps/cli`. Kept byte-compatible on purpose: a verifier generated
//! here has to validate against the same `services/identity` code every other
//! Cognitum client already talks to.
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use rand::RngCore;
use sha2::{Digest, Sha256};
/// One login attempt's PKCE pair plus its CSRF `state`.
#[derive(Debug, Clone)]
pub struct PkceRequest {
pub state: String,
pub code_verifier: String,
pub code_challenge: String,
}
fn random_url_safe_token(byte_len: usize) -> String {
let mut bytes = vec![0u8; byte_len];
rand::rngs::OsRng.fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
pub fn challenge_from_verifier(verifier: &str) -> String {
URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
}
/// Fresh `state` + verifier/challenge for one login attempt.
///
/// 32 random bytes each: base64url-encodes to 43 characters, comfortably inside
/// RFC 7636 §4.1's 43128 range without padding.
pub fn generate() -> PkceRequest {
let state = random_url_safe_token(32);
let code_verifier = random_url_safe_token(32);
let code_challenge = challenge_from_verifier(&code_verifier);
PkceRequest {
state,
code_verifier,
code_challenge,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_the_rfc7636_appendix_b_worked_example() {
// The spec's own vector. If this drifts, our S256 is not S256 and the
// server will reject every exchange — worth pinning to the standard
// rather than to our own output.
assert_eq!(
challenge_from_verifier("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
);
}
#[test]
fn verifier_length_is_within_rfc7636_bounds() {
let r = generate();
assert!(
r.code_verifier.len() >= 43 && r.code_verifier.len() <= 128,
"len {}",
r.code_verifier.len()
);
}
#[test]
fn challenge_is_derived_from_the_verifier_it_ships_with() {
let r = generate();
assert_eq!(challenge_from_verifier(&r.code_verifier), r.code_challenge);
}
#[test]
fn separate_attempts_share_nothing() {
let (a, b) = (generate(), generate());
assert_ne!(a.state, b.state);
assert_ne!(a.code_verifier, b.code_verifier);
}
}
+142
View File
@@ -0,0 +1,142 @@
//! The authenticated caller, and the scopes it consented to.
use std::collections::BTreeSet;
/// RuView's own scopes, registered on the `ruview` OAuth client
/// (identity migration `0016`, ADR-060).
///
/// Split by **blast radius**, not by endpoint count: the question is whether a
/// leaked token can destroy something, not how many routes it covers.
pub mod scope {
/// Observe: sensing/pose streams, one-shot inference, reading metadata.
///
/// Not "harmless" — for a presence and vital-signs sensor, read access tells
/// the holder who is home. It is *non-destructive*, which is a weaker claim.
pub const SENSING_READ: &str = "sensing:read";
/// Mutate or destroy: training, model delete, recording delete.
///
/// Irreversible: a deleted model or labelled capture may represent days of
/// collection, and a training run burns hours of CPU on a Pi.
pub const SENSING_ADMIN: &str = "sensing:admin";
}
/// A verified caller. Constructed only by
/// [`crate::verify::verify_access_token`] — there is deliberately no public
/// constructor, so a `Principal` in hand always means a signature was checked.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Principal {
/// `sub` — the identity user id.
pub subject: String,
/// `account_id` — the billing tenant (the user's Firebase UID; ADR-045).
/// Required and non-empty; see the verifier for why.
pub account_id: String,
pub org_id: String,
pub workspace_id: String,
/// `client_id` — which OAuth client obtained this token.
///
/// **Attribution and logging only — never an authorization input.** Clients
/// borrow each other's registrations when their own has not been deployed
/// yet (musica ships `DEFAULT_CLIENT_ID = "meta-proxy"`), so this claim does
/// not reliably identify the product holding the token.
pub client_id: String,
/// `jti` — unique per token; use for request-log correlation.
pub token_id: String,
/// The consented scopes, split on whitespace.
scopes: BTreeSet<String>,
/// `exp`, unix seconds.
pub expires_at: i64,
}
impl Principal {
pub(crate) fn new(
subject: String,
account_id: String,
org_id: String,
workspace_id: String,
client_id: String,
token_id: String,
scope_claim: &str,
expires_at: i64,
) -> Self {
Self {
subject,
account_id,
org_id,
workspace_id,
client_id,
token_id,
scopes: scope_claim.split_whitespace().map(str::to_owned).collect(),
expires_at,
}
}
/// Does this principal hold `scope`?
///
/// Exact match only. There is **no prefix or hierarchy rule** — holding
/// `sensing:admin` does not imply `sensing:read`, and a token that needs
/// both must have consented to both. Implying one scope from another is how
/// a consent screen ends up meaning less than it said.
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.contains(scope)
}
/// Scopes, sorted — for logging.
pub fn scopes(&self) -> impl Iterator<Item = &str> {
self.scopes.iter().map(String::as_str)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn principal_with(scope: &str) -> Principal {
Principal::new(
"sub".into(),
"acct".into(),
"org".into(),
"ws".into(),
"ruview".into(),
"jti".into(),
scope,
0,
)
}
#[test]
fn has_scope_matches_a_single_consented_scope() {
assert!(principal_with("sensing:read").has_scope(scope::SENSING_READ));
}
#[test]
fn has_scope_matches_within_a_whitespace_separated_list() {
let p = principal_with("sensing:read sensing:admin");
assert!(p.has_scope(scope::SENSING_READ));
assert!(p.has_scope(scope::SENSING_ADMIN));
}
#[test]
fn admin_does_not_imply_read() {
// Guards the "no hierarchy" rule above. If someone later adds prefix
// matching to be helpful, this fails and they have to read the comment.
assert!(!principal_with("sensing:admin").has_scope(scope::SENSING_READ));
}
#[test]
fn unrelated_scope_grants_nothing() {
let p = principal_with("inference");
assert!(!p.has_scope(scope::SENSING_READ));
assert!(!p.has_scope(scope::SENSING_ADMIN));
}
#[test]
fn empty_scope_claim_grants_nothing() {
assert!(!principal_with("").has_scope(scope::SENSING_READ));
}
#[test]
fn scope_prefix_of_a_real_scope_does_not_match() {
assert!(!principal_with("sensing").has_scope(scope::SENSING_READ));
}
}
+331
View File
@@ -0,0 +1,331 @@
//! Cognitum OAuth access-token verification (ADR-271).
//!
//! The accept-rule is ported from `meta-llm/src/auth/oauthBearer.ts` (ADR-045),
//! the only other resource-server-side verifier of these tokens in the org.
//! Divergence from it would be a bug, not a preference — a token meta-llm
//! rejects must not be one RuView accepts.
//!
//! ## The trust chain, narrowly
//!
//! 1. Only identity's ES256 key — fetched from the published JWKS by `kid` —
//! can sign an accepted token. No shared secret, no static PEM to leak.
//! 2. **The algorithm is fixed to ES256 by this code.** The token header's `alg`
//! is only ever *compared against* that allowlist, never used to *select* an
//! algorithm. That is what makes `alg: none` and RSA-substitution
//! non-starters rather than things we defend against case by case.
//! 3. Signature math is `jsonwebtoken`'s. This module owns claim policy only.
//!
//! ## Why `setup` and `workload` tokens are refused outright
//!
//! Identity also issues long-lived *setup* (365-day) and *workload* credentials.
//! Their revocation lives in identity's `oauth_setup_tokens` table, and RuView —
//! like meta-llm — has **no database and no way to check it**. A 15-minute
//! access token needs no revocation round-trip because it expires faster than
//! any realistic revocation propagates; a 365-day one does. Accepting one would
//! mean honouring a credential that may already have been revoked, so we don't.
//!
//! ## There is no `aud` claim, and no `iss` claim either
//!
//! Verified against real production tokens: the claim set is exactly
//! `typ, sub, account_id, org_id, workspace_id, client_id, scope, family_id,
//! jti, iat, exp, setup, workload`. No audience. No issuer.
//!
//! **What binds a token to its issuer, then?** The JWKS. We accept only
//! signatures made by a key served from the configured `jwks_uri`, so
//! possession of a valid signature *is* proof of issuer. Adding an `iss` claim
//! check on top would not strengthen that — and requiring a claim identity does
//! not emit rejects every genuine token, which is exactly what an earlier
//! revision of this module did.
//!
//! **`client_id` is Cognitum's stand-in for `aud`.** `cognitum-one/freetokens`
//! (live) documents the contract — *"Cognitum access tokens intentionally use
//! custom `client_id` rather than a registered JWT `aud` claim"* — and rejects
//! any token whose `client_id` is not its own. This verifier does the same via
//! [`VerifierConfig::allowed_client_ids`].
//!
//! An earlier revision treated `client_id` as unusable because clients borrow
//! each other's registrations (musica shipped as `meta-proxy` while its own was
//! pending) and relied on scope alone. That was reasoning from a transitional
//! state: RuView has its own registered client, and accepting a token minted for
//! any Cognitum product is a weaker position than the platform intends.
//!
//! So there are now TWO boundaries, not one: audience (`client_id`) and
//! capability (`scope`). Neither is optional garnish.
use jsonwebtoken::{decode, decode_header, Algorithm, Validation};
use serde::Deserialize;
use crate::jwks::{JwksCache, JwksError};
use crate::principal::Principal;
/// The `typ` identity stamps on ordinary interactive access tokens
/// (`jwt.rs`'s `TOKEN_TYP_ACCESS`).
const TYP_ACCESS: &str = "access";
/// Clock leeway for `exp`/`iat`.
///
/// Deliberately small. Against a 15-minute token a generous window is a real
/// extension of a revoked credential's life, so this absorbs ordinary NTP jitter
/// and nothing more. Hosts without a battery-backed clock (Pi-class) need real
/// time sync — see [`VerifyError::ExpiredOrNotYetValid`], which is reported
/// distinctly so "your clock is wrong" is diagnosable rather than presenting as
/// a generic 401.
const CLOCK_LEEWAY_SECS: u64 = 30;
#[derive(Debug, thiserror::Error)]
pub enum VerifyError {
#[error("authorization header is missing or not a Bearer token")]
MissingBearer,
#[error("token is not a well-formed JWT: {0}")]
Malformed(String),
#[error("token algorithm is not ES256")]
WrongAlgorithm,
#[error("could not resolve a verification key: {0}")]
Jwks(#[from] JwksError),
#[error("token signature is not valid for identity's published key")]
BadSignature,
/// `exp`/`iat` outside the accepted window. Distinct from `BadSignature` on
/// purpose: on an RTC-less host this is usually a clock-sync problem, not an
/// attack, and an operator needs to be able to tell those apart.
#[error("token is expired or not yet valid (check host clock sync)")]
ExpiredOrNotYetValid,
#[error("token type {found:?} is not an interactive access token")]
WrongTokenType { found: Option<String> },
#[error("long-lived setup/workload credentials are not accepted (unverifiable revocation)")]
LongLivedCredential,
#[error("token carries no account_id and cannot be attributed")]
MissingAccountId,
#[error("token does not carry the required scope {required:?}")]
MissingScope { required: String },
/// Minted for a different Cognitum product. `client_id` is the platform's
/// audience mechanism in the absence of `aud`.
#[error("token was issued to client {found:?}, which this server does not accept")]
WrongAudience { found: String },
}
/// Identity's access-token claims. Mirrors `AccessTokenClaims` in
/// `dashboard/services/identity/src/jwt.rs`.
///
/// `typ` is `Option` because identity types it that way — absence must be
/// treated as "not an access token", never as a default.
#[derive(Debug, Deserialize)]
struct AccessTokenClaims {
#[serde(default)]
typ: Option<String>,
sub: String,
#[serde(default)]
account_id: Option<String>,
#[serde(default)]
org_id: String,
#[serde(default)]
workspace_id: String,
#[serde(default)]
client_id: String,
#[serde(default)]
scope: String,
#[serde(default)]
jti: String,
exp: i64,
/// Long-lived, non-rotating setup credential. Absent on older tokens.
#[serde(default)]
setup: bool,
/// Machine workload credential. Absent on older tokens.
#[serde(default)]
workload: bool,
}
/// Verifier configuration.
pub struct VerifierConfig {
/// The authorization server's origin, e.g. `https://auth.cognitum.one`.
///
/// **Not validated against a claim** — Cognitum access tokens carry no
/// `iss` (see module docs); the JWKS provides the issuer binding. This is
/// here for logging and for deriving the default `jwks_uri`, so that the
/// configured issuer and the keys we trust cannot drift apart silently.
pub issuer: String,
/// The scope a caller must hold for the route being served.
pub required_scope: String,
/// `client_id` values whose tokens this server accepts — the AUDIENCE check.
///
/// Cognitum tokens carry no `aud`; the platform uses `client_id` for this
/// instead. `freetokens` (cognitum-one/freetokens, live) states the contract
/// plainly — *"Cognitum access tokens intentionally use custom `client_id`
/// rather than a registered JWT `aud` claim"* — and enforces
/// `payload.client_id !== OAUTH_CLIENT_ID` on every request.
///
/// Empty means accept any client, which is what an earlier revision did on
/// the reasoning that clients borrow each other's registrations (musica
/// shipped as `meta-proxy` while its own was pending). That was a
/// transitional state, not the model: RuView has its own registered client,
/// so leaving this empty means accepting a token minted for ANY Cognitum
/// product. Configure it.
pub allowed_client_ids: Vec<String>,
}
/// Verify a raw JWT and produce a [`Principal`].
///
/// Every rejection path returns a typed error; none of them return a partially
/// trusted principal.
pub fn verify_access_token(
token: &str,
jwks: &JwksCache,
config: &VerifierConfig,
) -> Result<Principal, VerifyError> {
let header = decode_header(token).map_err(|e| VerifyError::Malformed(e.to_string()))?;
// Compared, never selected. `decode` below independently enforces the same
// allowlist; this early check exists so the failure is legible.
if header.alg != Algorithm::ES256 {
return Err(VerifyError::WrongAlgorithm);
}
let kid = header.kid.ok_or(JwksError::MissingKid)?;
let key = jwks.decoding_key_for(&kid)?;
let mut validation = Validation::new(Algorithm::ES256);
validation.leeway = CLOCK_LEEWAY_SECS;
validation.validate_exp = true;
// No `aud` and no `iss` validation — Cognitum access tokens carry neither.
// See "What binds a token to its issuer" in the module docs: the JWKS is
// the binding, and requiring a claim identity does not emit rejects every
// real token. meta-llm's verifier makes the same two omissions.
validation.validate_aud = false;
validation.set_required_spec_claims(&["exp"]);
let data = decode::<AccessTokenClaims>(token, &key, &validation).map_err(map_jwt_error)?;
let claims = data.claims;
// ---- Claim policy. Mirrors meta-llm's oauthBearer.ts accept-rule. ----
if claims.typ.as_deref() != Some(TYP_ACCESS) {
return Err(VerifyError::WrongTokenType { found: claims.typ });
}
// AUDIENCE. Cognitum's stand-in for `aud` (see VerifierConfig docs).
if !config.allowed_client_ids.is_empty()
&& !config
.allowed_client_ids
.iter()
.any(|c| c == &claims.client_id)
{
return Err(VerifyError::WrongAudience {
found: claims.client_id,
});
}
if claims.setup || claims.workload {
// Belt and braces alongside the `typ` check: identity stamps these as
// booleans as well, and a credential that sets either must never be
// honoured here regardless of how it types itself.
return Err(VerifyError::LongLivedCredential);
}
let account_id = claims.account_id.filter(|a| !a.is_empty());
let Some(account_id) = account_id else {
// meta-llm requires this so a token cannot bill an account it doesn't
// belong to. RuView's reason is attribution: an unattributable principal
// cannot appear in an audit trail, which is most of the point of moving
// off a shared static bearer.
return Err(VerifyError::MissingAccountId);
};
let principal = Principal::new(
claims.sub,
account_id,
claims.org_id,
claims.workspace_id,
claims.client_id,
claims.jti,
&claims.scope,
claims.exp,
);
if !principal.has_scope(&config.required_scope) {
return Err(VerifyError::MissingScope {
required: config.required_scope.clone(),
});
}
Ok(principal)
}
/// Extract a bearer token from an `Authorization` header value.
///
/// The scheme is matched **case-insensitively** per RFC 7235 §2.1, and leading
/// whitespace before the token is tolerated. This mirrors what
/// `wifi-densepose-sensing-server`'s existing `bearer_auth` already does
/// deliberately, so a client sending `bearer`/`BEARER` is not rejected by one
/// layer and accepted by the other. The token itself is never normalised.
pub fn extract_bearer(header_value: &str) -> Result<&str, VerifyError> {
let (scheme, token) = header_value
.split_once(' ')
.ok_or(VerifyError::MissingBearer)?;
if !scheme.eq_ignore_ascii_case("Bearer") {
return Err(VerifyError::MissingBearer);
}
let token = token.trim();
if token.is_empty() {
return Err(VerifyError::MissingBearer);
}
Ok(token)
}
fn map_jwt_error(e: jsonwebtoken::errors::Error) -> VerifyError {
use jsonwebtoken::errors::ErrorKind;
match e.kind() {
ErrorKind::InvalidSignature => VerifyError::BadSignature,
ErrorKind::ExpiredSignature | ErrorKind::ImmatureSignature => {
VerifyError::ExpiredOrNotYetValid
}
ErrorKind::InvalidAlgorithm | ErrorKind::InvalidAlgorithmName => {
VerifyError::WrongAlgorithm
}
_ => VerifyError::Malformed(e.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_bearer_accepts_a_well_formed_header() {
assert_eq!(extract_bearer("Bearer abc.def.ghi").unwrap(), "abc.def.ghi");
}
#[test]
fn extract_bearer_rejects_a_missing_prefix() {
assert!(matches!(
extract_bearer("abc.def.ghi"),
Err(VerifyError::MissingBearer)
));
}
#[test]
fn extract_bearer_accepts_any_scheme_casing() {
// RFC 7235 §2.1: the auth-scheme is case-insensitive. The sensing
// server's own middleware already matches it that way on purpose, and
// the two layers must not disagree about what a valid header looks like.
for header in ["Bearer t.o.k", "bearer t.o.k", "BEARER t.o.k"] {
assert_eq!(extract_bearer(header).unwrap(), "t.o.k", "for {header:?}");
}
}
#[test]
fn extract_bearer_tolerates_extra_space_before_the_token() {
assert_eq!(extract_bearer("Bearer t.o.k").unwrap(), "t.o.k");
}
#[test]
fn extract_bearer_rejects_a_different_scheme() {
assert!(matches!(
extract_bearer("Basic dXNlcjpwYXNz"),
Err(VerifyError::MissingBearer)
));
}
#[test]
fn extract_bearer_rejects_an_empty_token() {
assert!(matches!(
extract_bearer("Bearer "),
Err(VerifyError::MissingBearer)
));
}
}
@@ -0,0 +1,472 @@
//! The verifier accept/reject matrix — gates G-1 and G-2 of the ADR-271 plan.
//!
//! Every token here is a **real ES256 JWT signed at test time** with a
//! freshly generated key, so these exercise the same code path production does
//! rather than asserting against hand-built strings. No network: the JWKS is
//! served from a stub.
//!
//! Keypairs are **generated at test runtime, never committed**. A checked-in
//! `-----BEGIN PRIVATE KEY-----` would be inert here, but it trains scanners and
//! readers to treat committed key material as normal, and this repo has no such
//! precedent. Generating also means no fixture can drift out of sync with the
//! JWKS document it is served by — the two are derived from the same key.
use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use jsonwebtoken::{encode, EncodingKey, Header};
use p256::ecdsa::SigningKey;
use p256::pkcs8::{EncodePrivateKey, LineEnding};
use ruview_auth::{
jwks::{JwksError, JwksFetcher},
scope, verify_access_token, JwksCache, VerifierConfig, VerifyError,
};
use serde_json::json;
const TEST_KID: &str = "test-key-1";
const TEST_ISSUER: &str = "https://auth.test.local";
/// A generated P-256 keypair: the PKCS#8 PEM to sign with, and the JWK
/// coordinates to serve in the stub JWKS.
struct TestKey {
pkcs8_pem: String,
x: String,
y: String,
}
fn generate_key() -> TestKey {
let signing = SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
let pem = signing
.to_pkcs8_pem(LineEnding::LF)
.expect("PKCS#8 encode")
.to_string();
let point = signing.verifying_key().to_encoded_point(false);
TestKey {
pkcs8_pem: pem,
x: URL_SAFE_NO_PAD.encode(point.x().expect("P-256 x")),
y: URL_SAFE_NO_PAD.encode(point.y().expect("P-256 y")),
}
}
/// The key the stub JWKS publishes — i.e. "identity's signing key".
fn primary_key() -> &'static TestKey {
static K: OnceLock<TestKey> = OnceLock::new();
K.get_or_init(generate_key)
}
/// A *different* valid P-256 key, published nowhere — for the forged-signature
/// case. Distinct from a malformed token: this is a real, well-formed ES256
/// signature that simply is not identity's.
fn other_key() -> &'static TestKey {
static K: OnceLock<TestKey> = OnceLock::new();
K.get_or_init(generate_key)
}
/// `alg: none`, precomputed (jsonwebtoken will not encode one, which is itself
/// reassuring). Claims are otherwise entirely valid.
const ALG_NONE_TOKEN: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIiwia2lkIjoidGVzdC1rZXktMSJ9.eyJ0eXAiOiJhY2Nlc3MiLCJzdWIiOiJzIiwiYWNjb3VudF9pZCI6ImEiLCJvcmdfaWQiOiJvIiwid29ya3NwYWNlX2lkIjoidyIsImNsaWVudF9pZCI6InJ1dmlldyIsInNjb3BlIjoic2Vuc2luZzpyZWFkIiwianRpIjoiaiIsImlhdCI6NDEwMjQ0NDgwMCwiZXhwIjo0MTAyNDQ4NDAwLCJzZXR1cCI6ZmFsc2UsIndvcmtsb2FkIjpmYWxzZSwiaXNzIjoiaHR0cHM6Ly9hdXRoLnRlc3QubG9jYWwifQ.";
struct StaticJwks(String);
impl JwksFetcher for StaticJwks {
fn fetch(&self, _url: &str) -> Result<String, JwksError> {
Ok(self.0.clone())
}
}
fn jwks_serving_test_key() -> JwksCache {
let key = primary_key();
let doc = json!({
"keys": [{
"alg": "ES256", "crv": "P-256", "kty": "EC", "use": "sig",
"kid": TEST_KID, "x": key.x, "y": key.y
}]
})
.to_string();
JwksCache::new("https://stub/jwks.json", Box::new(StaticJwks(doc)))
}
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
/// A claim set matching identity's real `AccessTokenClaims`, valid unless a
/// test overrides a field.
fn valid_claims() -> serde_json::Value {
json!({
"typ": "access",
"sub": "0f8fad5b-d9cb-469f-a165-70867728950e",
"account_id": "firebase-uid-abc123",
"org_id": "org-1",
"workspace_id": "ws-1",
"client_id": "ruview",
"scope": "sensing:read",
"family_id": "fam-1",
"jti": "jti-1",
"iat": now() - 10,
"exp": now() + 900, // identity's real 15-minute TTL
"setup": false,
"workload": false,
// NOTE: no `iss`. Real Cognitum access tokens carry none — verified
// against production. An earlier fixture added one, the verifier was
// built to require it, and the whole suite passed while rejecting every
// genuine token. Fixtures mirror production or they prove nothing.
})
}
fn sign(claims: &serde_json::Value) -> String {
sign_with(claims, primary_key())
}
fn sign_with(claims: &serde_json::Value, key: &TestKey) -> String {
let mut header = Header::new(jsonwebtoken::Algorithm::ES256);
header.kid = Some(TEST_KID.to_string());
let enc = EncodingKey::from_ec_pem(key.pkcs8_pem.as_bytes()).expect("generated key parses");
encode(&header, claims, &enc).expect("signs")
}
fn config_for(required_scope: &str) -> VerifierConfig {
VerifierConfig {
issuer: TEST_ISSUER.to_string(),
required_scope: required_scope.to_string(),
// Mirrors production: RuView accepts only tokens minted for itself.
allowed_client_ids: vec!["ruview".to_string()],
}
}
fn verify(token: &str, required_scope: &str) -> Result<ruview_auth::Principal, VerifyError> {
verify_access_token(token, &jwks_serving_test_key(), &config_for(required_scope))
}
// ─────────────────────────── accept ───────────────────────────
#[test]
fn a_valid_access_token_is_accepted_and_fully_attributed() {
let principal = verify(&sign(&valid_claims()), scope::SENSING_READ).expect("accepted");
assert_eq!(principal.subject, "0f8fad5b-d9cb-469f-a165-70867728950e");
assert_eq!(principal.account_id, "firebase-uid-abc123");
assert_eq!(principal.org_id, "org-1");
assert_eq!(principal.client_id, "ruview");
assert_eq!(principal.token_id, "jti-1");
assert!(principal.has_scope(scope::SENSING_READ));
}
#[test]
fn a_token_holding_both_scopes_satisfies_either_requirement() {
let mut c = valid_claims();
c["scope"] = json!("sensing:read sensing:admin");
let token = sign(&c);
assert!(verify(&token, scope::SENSING_READ).is_ok());
assert!(verify(&token, scope::SENSING_ADMIN).is_ok());
}
// ─────────────────── signature / algorithm ────────────────────
#[test]
fn a_token_signed_by_a_different_key_is_rejected() {
let token = sign_with(&valid_claims(), other_key());
assert!(matches!(
verify(&token, scope::SENSING_READ),
Err(VerifyError::BadSignature)
));
}
#[test]
fn alg_none_is_rejected() {
// The classic downgrade. It is rejected two layers deep:
//
// 1. `jsonwebtoken`'s `Algorithm` enum has **no `none` variant**, so the
// header fails to deserialize at all — `none` is unrepresentable, not
// merely disallowed. That is why the variant here is `Malformed` rather
// than `WrongAlgorithm`: we never get far enough to compare algorithms.
// 2. Even if it parsed, `Validation::new(ES256)` would reject it, since
// `alg` is only ever compared against an allowlist, never used to
// select an algorithm.
//
// The assertion below pins layer 1. If a future `jsonwebtoken` ever adds a
// `none` variant this flips to `WrongAlgorithm` and the failure is a prompt
// to re-verify layer 2 still holds — which is exactly when we'd want to look.
let result = verify(ALG_NONE_TOKEN, scope::SENSING_READ);
assert!(result.is_err(), "alg:none must never authenticate");
assert!(
matches!(result, Err(VerifyError::Malformed(_))),
"expected rejection at header parse; got {result:?}"
);
}
#[test]
fn a_tampered_payload_invalidates_the_signature() {
let token = sign(&valid_claims());
let mut parts: Vec<&str> = token.split('.').collect();
// Swap the payload for one claiming admin scope, keeping the signature.
let mut c = valid_claims();
c["scope"] = json!("sensing:admin");
let forged = sign(&c);
let forged_payload = forged.split('.').nth(1).unwrap().to_string();
parts[1] = &forged_payload;
let spliced = parts.join(".");
assert!(matches!(
verify(&spliced, scope::SENSING_READ),
Err(VerifyError::BadSignature)
));
}
#[test]
fn an_unknown_kid_is_rejected() {
let mut header = Header::new(jsonwebtoken::Algorithm::ES256);
header.kid = Some("a-kid-we-have-never-seen".to_string());
let key = EncodingKey::from_ec_pem(primary_key().pkcs8_pem.as_bytes()).unwrap();
let token = encode(&header, &valid_claims(), &key).unwrap();
assert!(matches!(
verify(&token, scope::SENSING_READ),
Err(VerifyError::Jwks(JwksError::UnknownKid(_)))
));
}
// ───────────────────────── time ──────────────────────────────
#[test]
fn an_expired_token_is_rejected_distinguishably() {
let mut c = valid_claims();
c["iat"] = json!(now() - 2000);
c["exp"] = json!(now() - 1000);
// Distinct from BadSignature on purpose: on an RTC-less Pi this is usually
// a clock-sync fault, and an operator must be able to tell them apart.
assert!(matches!(
verify(&sign(&c), scope::SENSING_READ),
Err(VerifyError::ExpiredOrNotYetValid)
));
}
#[test]
fn a_token_expiring_just_inside_the_leeway_is_still_accepted() {
let mut c = valid_claims();
c["exp"] = json!(now() - 5); // within the 30s leeway
assert!(verify(&sign(&c), scope::SENSING_READ).is_ok());
}
#[test]
fn a_token_expired_beyond_the_leeway_is_rejected() {
let mut c = valid_claims();
c["exp"] = json!(now() - 120);
assert!(matches!(
verify(&sign(&c), scope::SENSING_READ),
Err(VerifyError::ExpiredOrNotYetValid)
));
}
// ────────────────────── issuer / type ────────────────────────
#[test]
fn a_token_with_no_iss_claim_is_accepted_because_cognitum_issues_none() {
// THE regression test for this module's worst bug to date.
//
// An earlier revision required and validated `iss`. Cognitum access tokens
// have no `iss` claim, so that rejected every real token — while the suite
// stayed green, because the fixtures had an `iss` the real thing lacks.
// `valid_claims()` now mirrors production, so this passing means the
// verifier accepts the shape that actually exists.
let claims = valid_claims();
assert!(
claims.get("iss").is_none(),
"fixture must mirror production, which emits no iss"
);
verify(&sign(&claims), scope::SENSING_READ).expect("a real-shaped token must verify");
}
#[test]
fn an_unrelated_iss_claim_does_not_change_the_outcome() {
// If identity ever starts emitting `iss`, we neither require nor reject on
// it — the JWKS is the issuer binding. This pins that adding the claim
// cannot silently start failing tokens.
let mut c = valid_claims();
c["iss"] = json!("https://something.else.example");
verify(&sign(&c), scope::SENSING_READ)
.expect("issuer binding is the JWKS, not a claim");
}
#[test]
fn the_jwks_is_what_actually_binds_a_token_to_its_issuer() {
// The complement of the two above: with no `iss` check, the signature is
// the ONLY thing standing between us and a forged token. A different key
// must therefore be refused — otherwise removing the issuer check would
// have removed the boundary entirely.
let token = sign_with(&valid_claims(), other_key());
assert!(matches!(
verify(&token, scope::SENSING_READ),
Err(VerifyError::BadSignature)
));
}
#[test]
fn an_inference_typed_token_is_not_an_access_token() {
let mut c = valid_claims();
c["typ"] = json!("inference");
assert!(matches!(
verify(&sign(&c), scope::SENSING_READ),
Err(VerifyError::WrongTokenType { .. })
));
}
#[test]
fn a_token_with_no_typ_claim_is_rejected() {
let mut c = valid_claims();
c.as_object_mut().unwrap().remove("typ");
// Absence must never be read as a default.
assert!(matches!(
verify(&sign(&c), scope::SENSING_READ),
Err(VerifyError::WrongTokenType { found: None })
));
}
// ──────────── long-lived credentials (unverifiable revocation) ────────────
#[test]
fn a_setup_token_is_refused_even_when_typed_as_access() {
// A 365-day credential whose revocation lives in a table RuView cannot read.
let mut c = valid_claims();
c["setup"] = json!(true);
assert!(matches!(
verify(&sign(&c), scope::SENSING_READ),
Err(VerifyError::LongLivedCredential)
));
}
#[test]
fn a_workload_token_is_refused_even_when_typed_as_access() {
let mut c = valid_claims();
c["workload"] = json!(true);
assert!(matches!(
verify(&sign(&c), scope::SENSING_READ),
Err(VerifyError::LongLivedCredential)
));
}
// ───────────────────── attribution ───────────────────────────
#[test]
fn a_token_without_account_id_cannot_be_attributed() {
let mut c = valid_claims();
c.as_object_mut().unwrap().remove("account_id");
assert!(matches!(
verify(&sign(&c), scope::SENSING_READ),
Err(VerifyError::MissingAccountId)
));
}
#[test]
fn an_empty_account_id_is_treated_as_absent() {
let mut c = valid_claims();
c["account_id"] = json!("");
assert!(matches!(
verify(&sign(&c), scope::SENSING_READ),
Err(VerifyError::MissingAccountId)
));
}
// ───────────── G-2: scope is the capability boundary ─────────────
#[test]
fn g2_a_genuinely_valid_token_from_another_cognitum_product_cannot_reach_the_sensing_surface() {
// THE highest-value test in this suite.
//
// Correctly signed, unexpired, right issuer, right `typ` — a real token a
// user legitimately holds for meta-proxy/completions. Cognitum access tokens
// carry no `aud`, and cross-product identity is intended, so NOTHING about
// the signature or the identity claims distinguishes it. Only scope does.
//
// A naive verifier accepts this. If this test ever passes-by-accepting,
// an `inference` token has become a key to someone's home sensor.
let mut c = valid_claims();
c["client_id"] = json!("meta-proxy");
c["scope"] = json!("inference");
// Rejected on AUDIENCE now (client_id), which is the stronger of the two
// reasons — it fires before scope is even considered.
assert!(matches!(
verify(&sign(&c), scope::SENSING_READ),
Err(VerifyError::WrongAudience { .. })
));
}
#[test]
fn a_token_minted_for_another_cognitum_product_is_refused_even_with_the_right_scope() {
// The audience check standing alone. Same user, same signature, correct
// sensing:read scope — but minted for freetokens, so not for this server.
// `cognitum-one/freetokens` enforces the mirror image of this.
let mut c = valid_claims();
c["client_id"] = json!("freetokens");
assert!(matches!(
verify(&sign(&c), scope::SENSING_READ),
Err(VerifyError::WrongAudience { .. })
));
}
#[test]
fn an_empty_audience_list_accepts_any_client() {
// The documented opt-out (RUVIEW_OAUTH_CLIENT_IDS=*). Pinned so the
// behaviour is deliberate rather than accidental.
let mut c = valid_claims();
c["client_id"] = json!("some-other-product");
let cfg = VerifierConfig {
issuer: TEST_ISSUER.to_string(),
required_scope: scope::SENSING_READ.to_string(),
allowed_client_ids: vec![],
};
verify_access_token(&sign(&c), &jwks_serving_test_key(), &cfg)
.expect("an empty allowlist means accept any client");
}
#[test]
fn multiple_allowed_clients_are_honoured() {
// Migration case: accepting a borrowed registration alongside our own.
let mut c = valid_claims();
c["client_id"] = json!("meta-proxy");
let cfg = VerifierConfig {
issuer: TEST_ISSUER.to_string(),
required_scope: scope::SENSING_READ.to_string(),
allowed_client_ids: vec!["ruview".into(), "meta-proxy".into()],
};
verify_access_token(&sign(&c), &jwks_serving_test_key(), &cfg).expect("both accepted");
}
#[test]
fn g2_a_read_scoped_session_cannot_reach_the_admin_surface() {
// The routine case the least-scope rule exists for: a dashboard streaming
// poses must not be able to delete the model it streams through.
let token = sign(&valid_claims()); // scope: sensing:read
assert!(matches!(
verify(&token, scope::SENSING_ADMIN),
Err(VerifyError::MissingScope { .. })
));
}
#[test]
fn g2_an_admin_scoped_token_does_not_implicitly_grant_read() {
// No hierarchy: consent means exactly what it said.
let mut c = valid_claims();
c["scope"] = json!("sensing:admin");
assert!(matches!(
verify(&sign(&c), scope::SENSING_READ),
Err(VerifyError::MissingScope { .. })
));
}
#[test]
fn a_token_with_no_scope_at_all_grants_nothing() {
let mut c = valid_claims();
c["scope"] = json!("");
assert!(matches!(
verify(&sign(&c), scope::SENSING_READ),
Err(VerifyError::MissingScope { .. })
));
}
+8
View File
@@ -56,6 +56,14 @@ csv = "1.3"
# Error handling
anyhow = "1.0"
# ADR-271 phase 2 — `login`/`logout`/`whoami`. The `login` feature carries the
# interactive half (PKCE, loopback, OOB paste, credential store, refresh);
# the sensing server depends on this same crate with default features and
# gets only the verifier.
ruview-auth = { path = "../ruview-auth", features = ["login"] }
# Only for constructing the HTTP client hands to Session.
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
thiserror = "2.0"
# Time
+184
View File
@@ -0,0 +1,184 @@
//! `wifi-densepose login` / `logout` / `whoami` — Cognitum sign-in (ADR-271).
//!
//! Signing in yields a Cognitum access token that a RuView sensing server
//! verifies offline against `auth.cognitum.one`'s published JWKS. It replaces
//! sharing one `RUVIEW_API_TOKEN` string between everyone who needs access:
//! requests become attributable to a person, and destructive routes can be
//! separated from read-only ones by scope.
use std::path::PathBuf;
use clap::Args;
use ruview_auth::login::{self, LoginOptions};
use ruview_auth::scope;
#[derive(Debug, Args)]
pub struct LoginArgs {
/// Also request `sensing:admin` — the capability to train models and delete
/// models and recordings.
///
/// Off by default on purpose. A session that only streams poses has no
/// business holding delete capability, and a token that carries it is a
/// bigger loss if it leaks. Ask for it when you are about to do
/// administrative work, not as a matter of habit.
#[arg(long)]
pub admin: bool,
/// Skip the browser and use the paste-a-code flow.
///
/// Detected automatically over SSH and inside containers; this forces it.
#[arg(long)]
pub no_browser: bool,
/// Where to store credentials. Defaults to `~/.ruview/credentials.json`.
#[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)]
pub credentials_path: Option<PathBuf>,
}
#[derive(Debug, Args)]
pub struct LogoutArgs {
#[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)]
pub credentials_path: Option<PathBuf>,
}
#[derive(Debug, Args)]
pub struct WhoamiArgs {
#[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)]
pub credentials_path: Option<PathBuf>,
/// Refresh the access token now if it has expired, instead of only
/// reporting that it will be refreshed on next use.
///
/// Refreshing rotates the stored refresh token — identity spends the old
/// one — so this is a real state change, not a read. That is why it is a
/// flag rather than something `whoami` does silently.
#[arg(long)]
pub refresh: bool,
}
fn path_or_default(p: Option<PathBuf>) -> PathBuf {
p.unwrap_or_else(login::default_credentials_path)
}
/// What `login` asks the authorization server for.
///
/// Extracted so it is testable on its own. `LoginOptions::default()` has its own
/// least-privilege test in the library, but this command does NOT go through
/// that default — it builds the scope string itself, so the library test says
/// nothing about what the CLI actually requests.
fn requested_scope(admin: bool) -> String {
if admin {
// Admin implies read: there is no scope hierarchy server-side, so a
// session that needs both must consent to both explicitly.
format!("{} {}", scope::SENSING_READ, scope::SENSING_ADMIN)
} else {
scope::SENSING_READ.to_string()
}
}
pub async fn login_cmd(args: LoginArgs) -> anyhow::Result<()> {
let scope = requested_scope(args.admin);
let opts = LoginOptions {
credentials_path: path_or_default(args.credentials_path),
scope,
no_browser: args.no_browser,
};
let mut out = std::io::stdout();
let stdin = std::io::stdin();
let mut input = stdin.lock();
login::login(&opts, &mut out, &mut input).await?;
Ok(())
}
pub async fn logout_cmd(args: LogoutArgs) -> anyhow::Result<()> {
let path = path_or_default(args.credentials_path);
if login::logout(&path)? {
println!("Signed out — {} removed.", path.display());
} else {
println!("Not signed in; nothing to remove.");
}
// Deliberately local-only. This makes the machine unable to act as you;
// revoking the session for every device is an account-level action.
println!("Note: this forgets the local credential only. It does not revoke the session server-side.");
Ok(())
}
pub async fn whoami_cmd(args: WhoamiArgs) -> anyhow::Result<()> {
let path = path_or_default(args.credentials_path);
let mut creds = ruview_auth::login::store::load(&path)?;
if args.refresh && creds.needs_refresh() {
println!("Access token expired — refreshing…");
let session = ruview_auth::login::Session::load_from(path.clone(), reqwest::Client::new())?;
// Goes through ensure_fresh, so it inherits the single-flight guarantee
// and the persist-before-return ordering rather than reimplementing a
// second, subtly different refresh path.
session.ensure_fresh().await?;
creds = session.snapshot().await;
println!("Refreshed.\n");
}
println!("Credentials: {}", path.display());
println!("Issuer: {}", creds.issuer);
match &creds.account_email {
Some(e) => println!("Account: {e}"),
None => println!("Account: (not reported)"),
}
// Falls back to the token's own claim, so a file written before that
// fallback existed still reports its real scope.
match creds.effective_scope() {
Some(s) => println!("Scope: {s}"),
None => println!("Scope: (not reported)"),
}
// State, not just contents: an expired-looking session is the single most
// common reason a command starts 401ing, so say it plainly here rather than
// letting the user infer it from a failure elsewhere.
if creds.needs_refresh() {
println!("Status: access token expired or expiring — pass --refresh to renew it now");
} else {
println!("Status: access token valid");
}
if creds.refresh_token.is_none() {
println!("Warning: no refresh token stored; you will need to sign in again when this expires");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plain_login_asks_for_read_only() {
// The whole point of splitting the scopes (ADR-060) is that streaming
// poses must not carry the capability to delete recordings. If this
// ever returns admin by default, every session silently becomes
// destructive-capable and nothing else in the suite would notice.
let s = requested_scope(false);
assert_eq!(s, scope::SENSING_READ);
assert!(!s.contains(scope::SENSING_ADMIN), "read-only login leaked admin: {s}");
}
#[test]
fn admin_login_asks_for_both_because_there_is_no_hierarchy() {
// The authorization server grants exactly what is requested; admin does
// not imply read. Asking for admin alone would produce a session that
// cannot stream.
let s = requested_scope(true);
assert!(s.split_whitespace().any(|x| x == scope::SENSING_READ), "{s}");
assert!(s.split_whitespace().any(|x| x == scope::SENSING_ADMIN), "{s}");
}
#[test]
fn an_explicit_credentials_path_is_honoured_over_the_default() {
// `--credentials-path` also carries the RUVIEW_CREDENTIALS_PATH env
// binding; silently ignoring it would write credentials somewhere the
// operator did not choose.
let p = PathBuf::from("/tmp/ruview-cli-explicit-credentials.json");
assert_eq!(path_or_default(Some(p.clone())), p);
assert_eq!(path_or_default(None), login::default_credentials_path());
}
}
+11
View File
@@ -26,6 +26,7 @@
use clap::{Parser, Subcommand};
pub mod auth;
pub mod calibrate;
pub mod calibrate_api;
pub mod room;
@@ -50,6 +51,16 @@ pub struct Cli {
/// Top-level commands
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Sign in to Cognitum (ADR-271). Stores a token this machine can present
/// to a RuView sensing server instead of sharing one static API token.
Login(auth::LoginArgs),
/// Forget the locally stored Cognitum credentials.
Logout(auth::LogoutArgs),
/// Show the stored Cognitum session: account, scope, and whether it is live.
Whoami(auth::WhoamiArgs),
/// Empty-room baseline calibration (ADR-135).
/// Captures CSI frames via UDP and saves a per-subcarrier statistical
/// baseline used for real-time motion z-scoring and CIR reference.
+9
View File
@@ -18,6 +18,15 @@ async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Login(args) => {
wifi_densepose_cli::auth::login_cmd(args).await?;
}
Commands::Logout(args) => {
wifi_densepose_cli::auth::logout_cmd(args).await?;
}
Commands::Whoami(args) => {
wifi_densepose_cli::auth::whoami_cmd(args).await?;
}
Commands::Calibrate(args) => {
wifi_densepose_cli::calibrate::execute(args).await?;
}
@@ -91,6 +91,17 @@ ureq = { version = "2", default-features = false, features = ["tls", "json"
sha2 = "0.10"
thiserror = "1"
# ADR-271 — Cognitum OAuth access-token verification. Reuses the `ureq`
# transport above rather than pulling a second HTTP stack: `ruview-auth`'s
# JWKS fetch sits behind a trait, and its default feature is the ureq one.
ruview-auth = { path = "../ruview-auth", features = ["pkce"] }
# ADR-271 browser sign-in: signed transaction + session cookies.
hmac = "0.12"
subtle = "2"
base64 = "0.21"
# ADR-272 — unpredictable single-use WebSocket tickets.
rand = "0.8"
# ADR-115 §3.8 — MQTT publisher (HA-DISCO).
# Gated behind the `mqtt` feature so the default binary stays small for users
# who don't need Home Assistant integration. `rumqttc` is the chosen Rust MQTT
@@ -128,6 +139,12 @@ criterion = { version = "0.5", features = ["html_reports"] }
# (random Unicode, control chars, etc.). Pinned to a small version that
# doesn't pull in proptest-derive (we don't need it).
proptest = { version = "1.5", default-features = false, features = ["std"] }
# ADR-271 — sign real ES256 tokens so the middleware's OAuth path is exercised
# end to end (router → middleware → verifier), not just mocked at the seam.
# Keys are generated at test runtime; none are committed.
jsonwebtoken = "9"
p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] }
base64 = "0.21"
[[bench]]
name = "mqtt_throughput"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,8 @@
//! - Real-time CSI introspection / low-latency tap (`introspection`, ADR-099)
pub mod bearer_auth;
pub mod browser_session;
pub mod ws_ticket;
pub mod cli;
pub mod dataset;
pub mod edge_registry;
@@ -7720,6 +7720,10 @@ async fn main() {
// ADR-044 §5.3: load persisted runtime config from the data directory.
let data_dir = std::path::PathBuf::from("data");
let runtime_config = load_runtime_config(&data_dir);
// ADR-271: resolve (or generate + persist) the browser-session signing key
// before any request can arrive. Zero-config for a single appliance; the
// env var still wins for a multi-instance deployment that must share one.
wifi_densepose_sensing_server::browser_session::init_secret(&data_dir);
info!(
"Loaded runtime config: dedup_factor={:.2}",
runtime_config.dedup_factor
@@ -7967,9 +7971,33 @@ async fn main() {
// #443: optional bearer-token auth on `/api/v1/*`. `RUVIEW_API_TOKEN`
// unset/empty ⇒ middleware is a no-op (LAN-mode default preserved); set ⇒
// every `/api/v1/*` request must carry `Authorization: Bearer <token>`.
let bearer_auth_state = wifi_densepose_sensing_server::bearer_auth::AuthState::from_env();
//
// ADR-271: additionally, `RUVIEW_OAUTH_ISSUER` enables Cognitum OAuth
// verification alongside (not instead of) the static token.
//
// FAIL CLOSED. If OAuth was requested but cannot work — empty issuer, or a
// JWKS we cannot fetch at boot — we exit rather than serve. Starting anyway
// would silently downgrade an operator who asked for OAuth to either an
// open API or a single-shared-secret one, and they would have no signal
// that it happened. A loud death at boot is the kind thing here.
let bearer_auth_state =
match wifi_densepose_sensing_server::bearer_auth::AuthState::from_env() {
Ok(s) => s,
Err(e) => {
error!(
"API auth: OAuth was requested but cannot be initialised: {e}. \
Refusing to start unset RUVIEW_OAUTH_ISSUER to run without it."
);
std::process::exit(1);
}
};
if bearer_auth_state.is_enabled() {
info!("API auth: bearer-token enforcement ON for /api/v1/* (RUVIEW_API_TOKEN set)");
if bearer_auth_state.oauth_enabled() {
info!("API auth: ON for /api/v1/* — Cognitum OAuth (ADR-271){}",
if bearer_auth_state.static_token_enabled() { " + static RUVIEW_API_TOKEN" } else { "" });
} else {
info!("API auth: bearer-token enforcement ON for /api/v1/* (RUVIEW_API_TOKEN set)");
}
if bind_ip.is_unspecified() {
warn!(
"API auth ON but bind-addr is {} — consider --bind-addr 127.0.0.1 for LAN-only deployments",
@@ -7978,7 +8006,7 @@ async fn main() {
}
} else {
info!(
"API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN=<token> to enforce bearer auth."
"API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN=<token> or RUVIEW_OAUTH_ISSUER=<issuer> to enforce auth."
);
}
@@ -8016,6 +8044,18 @@ async fn main() {
// so a client on :8765 can stream signed RuField FieldEvents alongside
// `/ws/sensing`. Merged with its own FieldState (different state type).
.merge(rufield_surface::router(field_surface.clone()))
// ADR-272 FIX: this router had NO auth layer at all. `/ws/sensing` and
// `/ws/field` on the dedicated WS port accepted unauthenticated
// upgrades even with auth ON — and this is the port the UI actually
// uses (ui/services/sensing.service.js maps HTTP 8080 -> WS 8765), so
// gating only the HTTP port protected a path the browser never takes.
// Applied AFTER the merge so it covers the RuField routes too.
// AuthState shares its TicketStore via Arc, so a ticket minted at
// POST /api/v1/ws-ticket on the HTTP port is redeemable here.
.layer(axum::middleware::from_fn_with_state(
bearer_auth_state.clone(),
wifi_densepose_sensing_server::bearer_auth::require_bearer,
))
.layer(axum::middleware::from_fn_with_state(
host_allowlist.clone(),
wifi_densepose_sensing_server::host_validation::require_allowed_host,
@@ -8090,6 +8130,18 @@ async fn main() {
)
// Stream endpoints
.route("/api/v1/stream/status", get(stream_status))
// ADR-272 — browsers cannot set Authorization on a WebSocket upgrade,
// so they exchange their credential here for a 30s single-use ticket.
.route("/api/v1/ws-ticket", axum::routing::post(ws_ticket_handler))
// ADR-271 browser sign-in. Deliberately NOT under /api/v1/*: these are
// how a browser obtains a credential, so gating them would deadlock.
.route("/oauth/start", get(oauth_start))
.route("/oauth/callback", get(oauth_callback))
.route("/oauth/logout", get(oauth_logout))
// Ungated on purpose: a signed-OUT browser needs to discover whether
// sign-in is available, and it cannot ask a gated endpoint that.
// Returns only capability + who-you-are, never a credential.
.route("/oauth/status", get(oauth_status))
.route("/api/v1/stream/pose", get(ws_pose_handler))
// Sensing WebSocket on the HTTP port so the UI can reach it without a second port
.route("/ws/sensing", get(ws_sensing_handler))
@@ -8145,19 +8197,27 @@ async fn main() {
// is unset/empty the middleware is a no-op — the default stays
// LAN-mode-friendly. `/health*`, `/ws/sensing`, and `/ui/*` are never
// gated (orchestrator probes + local browsers).
// ADR-272: the ws-ticket handler needs the store the middleware owns.
.layer(axum::Extension(bearer_auth_state.clone()))
.with_state(state.clone())
// ADR-262 P3: additive RuField surface (`/api/field` + `/ws/field`).
// Merged AFTER `.with_state` (so http_app is already `Router<()>` and
// can absorb the field router's own `FieldState`).
.merge(rufield_surface::router(field_surface.clone()))
// Opt-in bearer auth (#443) + ADR-272 WebSocket gating.
//
// Applied AFTER the merge, and that ordering is load-bearing: axum
// `.layer()` wraps only what is already registered, so while this sat
// above the merge, `/ws/field` bypassed authentication entirely —
// measured 101 on an unauthenticated upgrade with auth ON. Adding
// routes after an auth layer silently exempts them, which is exactly
// the failure mode ADR-272 exists to prevent.
//
// Unset RUVIEW_API_TOKEN/RUVIEW_OAUTH_ISSUER still makes this a no-op.
.layer(axum::middleware::from_fn_with_state(
bearer_auth_state.clone(),
wifi_densepose_sensing_server::bearer_auth::require_bearer,
))
.with_state(state.clone())
// ADR-262 P3: additive RuField surface (`/api/field` + `/ws/field`).
// Merged AFTER `.with_state` (so http_app is already `Router<()>` and
// can absorb the field router's own `FieldState`). These routes sit
// OUTSIDE `/api/v1/*` so they are not bearer-gated, but the
// host-validation layer below still applies (it is added last, so it
// runs first, over the whole merged router). The surface's own §10
// egress gate is what keeps above-policy classes off the wire.
.merge(rufield_surface::router(field_surface.clone()))
// DNS-rebinding defense: applied last so it runs first on the request
// path (axum layers run outermost-in). Rejects requests whose `Host`
// header is not in the allowlist before any handler — including
@@ -9113,6 +9173,251 @@ mod observatory_persons_field_position_tests {
}
}
/// `POST /api/v1/ws-ticket` — mint a single-use WebSocket ticket (ADR-272).
///
/// Reached only through the auth middleware, so an unauthenticated caller
/// cannot mint one. The ticket inherits the caller's scopes, so a
/// `sensing:read` session cannot produce a ticket that outranks itself.
///
/// Exists because a browser's `WebSocket` constructor cannot set an
/// `Authorization` header. Native clients do not need this — they send a bearer
/// on the upgrade directly.
async fn ws_ticket_handler(
axum::Extension(auth): axum::Extension<wifi_densepose_sensing_server::bearer_auth::AuthState>,
request: axum::extract::Request,
) -> axum::response::Response {
use axum::response::IntoResponse;
use wifi_densepose_sensing_server::ws_ticket::TicketGrant;
// Present when the caller authenticated with OAuth; absent when they used
// the legacy static token, which predates scopes and carries full authority.
let principal = request.extensions().get::<ruview_auth::Principal>();
let grant = TicketGrant {
scopes: principal.map(|p| p.scopes().collect::<Vec<_>>().join(" ")),
subject: principal.map(|p| p.subject.clone()),
};
match auth.tickets().issue(grant) {
Some(ticket) => (
axum::http::StatusCode::OK,
axum::Json(serde_json::json!({
"ticket": ticket,
"expires_in_secs": wifi_densepose_sensing_server::ws_ticket::TICKET_TTL.as_secs(),
"usage": "append as ?ticket=<value> to the WebSocket URL; valid once",
})),
)
.into_response(),
// Refusing beats growing the store without bound.
None => (
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"too many outstanding WebSocket tickets; retry shortly\n",
)
.into_response(),
}
}
// ---- ADR-271 browser sign-in ------------------------------------------------
//
// Ported from cognitum-one/freetokens (`src/auth/oauth.ts`, live). The browser
// never holds an OAuth token: this server does the exchange and issues its own
// signed session cookie. Closes the gap where `wifi-densepose login` wrote a
// file no browser could read.
fn request_is_tls(headers: &axum::http::HeaderMap) -> bool {
// Behind a reverse proxy the TLS terminates upstream, so trust the standard
// forwarding header when present. Conservative default: not TLS, which only
// ever omits `Secure` — it never adds a cookie where it shouldn't be.
headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.map(|p| p.eq_ignore_ascii_case("https"))
.unwrap_or(false)
|| wifi_densepose_sensing_server::browser_session::public_base_url().starts_with("https://")
}
async fn oauth_start(
axum::Extension(auth): axum::Extension<wifi_densepose_sensing_server::bearer_auth::AuthState>,
headers: axum::http::HeaderMap,
) -> axum::response::Response {
use axum::response::IntoResponse;
use wifi_densepose_sensing_server::browser_session as bs;
let Some(issuer) = auth.oauth_issuer() else {
return (
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"OAuth is not enabled on this server (set RUVIEW_OAUTH_ISSUER)\n",
)
.into_response();
};
let secure = request_is_tls(&headers);
// Least privilege: a browser session asks for read. Admin work goes through
// the CLI, which requires an explicit --admin. See BROWSER_SIGNIN_SCOPE for
// what widening this would cost.
match bs::begin(&issuer, &auth.primary_client_id(), bs::BROWSER_SIGNIN_SCOPE, secure) {
Ok((location, cookie)) => (
axum::http::StatusCode::FOUND,
[
(axum::http::header::LOCATION, location),
(axum::http::header::SET_COOKIE, cookie),
],
)
.into_response(),
Err(e) => (axum::http::StatusCode::SERVICE_UNAVAILABLE, format!("{e}\n")).into_response(),
}
}
#[derive(serde::Deserialize)]
struct OAuthCallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
async fn oauth_callback(
axum::Extension(auth): axum::Extension<wifi_densepose_sensing_server::bearer_auth::AuthState>,
headers: axum::http::HeaderMap,
axum::extract::Query(q): axum::extract::Query<OAuthCallbackQuery>,
) -> axum::response::Response {
use axum::response::IntoResponse;
use wifi_densepose_sensing_server::browser_session as bs;
let secure = request_is_tls(&headers);
let bad = |code: axum::http::StatusCode, msg: String| {
(code, [(axum::http::header::SET_COOKIE, bs::clear_transaction(secure))], msg)
.into_response()
};
if let Some(err) = q.error {
return bad(axum::http::StatusCode::BAD_REQUEST, format!("Cognitum declined the sign-in: {err}\n"));
}
let (Some(code), Some(state)) = (q.code, q.state) else {
return bad(axum::http::StatusCode::BAD_REQUEST, "Incomplete sign-in response\n".into());
};
let cookie_header = headers
.get(axum::http::header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
// CSRF check BEFORE the single-use code is spent.
let verifier = match bs::verifier_for_callback(&cookie_header, &state) {
Ok(v) => v,
Err(e) => return bad(axum::http::StatusCode::BAD_REQUEST, format!("{e}\n")),
};
let Some(issuer) = auth.oauth_issuer() else {
return bad(axum::http::StatusCode::SERVICE_UNAVAILABLE, "OAuth is not enabled\n".into());
};
let client_id = auth.primary_client_id();
// `ureq` is blocking; spawn_blocking so a slow token endpoint cannot park an
// async worker (the same mistake this codebase had to fix in jwks.rs).
let exchange = tokio::task::spawn_blocking(move || {
ureq::post(&format!("{issuer}/oauth/token"))
.send_form(&[
("grant_type", "authorization_code"),
("code", &code),
("code_verifier", &verifier),
("client_id", &client_id),
("redirect_uri", &bs::redirect_uri()),
])
.map_err(|e| e.to_string())
.and_then(|r| r.into_string().map_err(|e| e.to_string()))
})
.await;
let body = match exchange {
Ok(Ok(b)) => b,
Ok(Err(e)) => return bad(axum::http::StatusCode::BAD_GATEWAY, format!("token exchange failed: {e}\n")),
Err(e) => return bad(axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("token exchange task failed: {e}\n")),
};
let access_token = match serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("access_token")?.as_str().map(str::to_owned))
{
Some(t) => t,
None => return bad(axum::http::StatusCode::BAD_GATEWAY, "token endpoint returned no access_token\n".into()),
};
// Verify with the SAME verifier that gates every other request — signature,
// audience, typ, expiry, scope. A browser sign-in must not be a softer path.
let principal = match auth.verify_for_browser(&access_token) {
Ok(p) => p,
Err(e) => return bad(axum::http::StatusCode::UNAUTHORIZED, format!("{e}\n")),
};
let session_cookie = match bs::issue(&principal, secure) {
Ok(c) => c,
Err(e) => return bad(axum::http::StatusCode::SERVICE_UNAVAILABLE, format!("{e}\n")),
};
tracing::info!(sub = %principal.subject, "browser sign-in complete");
// Clear the spent transaction as well as issuing the session. A consumed
// OAuth transaction has no further use, and leaving it to age out for ten
// minutes means every subsequent request carries a dead cookie.
(
axum::http::StatusCode::FOUND,
// AppendHeaders, NOT an array: the array form REPLACES same-name
// headers, so a second Set-Cookie silently overwrites the first — which
// would drop the session cookie and make sign-in a no-op.
axum::response::AppendHeaders([
(axum::http::header::LOCATION, format!("/ui/?signed_in={}", now_millis())),
(axum::http::header::SET_COOKIE, session_cookie),
(axum::http::header::SET_COOKIE, bs::clear_transaction(secure)),
]),
)
.into_response()
}
async fn oauth_logout(headers: axum::http::HeaderMap) -> axum::response::Response {
use axum::response::IntoResponse;
// Local only: forgets this browser's session. Revoking the Cognitum session
// for every device is an account-level action at auth.cognitum.one.
let secure = request_is_tls(&headers);
use wifi_densepose_sensing_server::browser_session as bs;
(
axum::http::StatusCode::FOUND,
axum::response::AppendHeaders([
// Cache-busting query so the landing page is re-fetched rather than
// restored from the back/forward cache with a stale panel.
(axum::http::header::LOCATION, format!("/ui/?signed_out={}", now_millis())),
(axum::http::header::SET_COOKIE, bs::clear_session(secure)),
(axum::http::header::SET_COOKIE, bs::clear_transaction(secure)),
]),
)
.into_response()
}
fn now_millis() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
/// `GET /oauth/status` — what a signed-out browser needs to render the right UI.
///
/// Deliberately ungated and deliberately thin: capability flags and, if a live
/// session exists, who it belongs to. No token, no scope escalation hints, no
/// server configuration beyond "is sign-in possible here".
async fn oauth_status(
axum::Extension(auth): axum::Extension<wifi_densepose_sensing_server::bearer_auth::AuthState>,
headers: axum::http::HeaderMap,
) -> axum::Json<serde_json::Value> {
use wifi_densepose_sensing_server::browser_session as bs;
let raw = headers
.get(axum::http::header::COOKIE)
.and_then(|v| v.to_str().ok());
let session = raw.and_then(bs::from_cookie_header);
axum::Json(serde_json::json!({
"auth_required": auth.is_enabled(),
"oauth_enabled": auth.oauth_enabled(),
"browser_signin": auth.oauth_enabled() && bs::is_configured(),
"signed_in": session.is_some(),
"account": session.as_ref().map(|s| s.account_id.clone()),
"scope": session.as_ref().map(|s| s.scope.clone()),
}))
}
#[cfg(test)]
mod adr186_http_tests {
//! ADR-186 P6: HTTP-level tests that build the real `training_api` router
@@ -0,0 +1,382 @@
//! Short-lived, single-use WebSocket tickets (ADR-272).
//!
//! # Why this exists
//!
//! A browser's `WebSocket` constructor cannot set an `Authorization` header on
//! the upgrade request. That limitation is why `/ws/sensing`,
//! `/ws/introspection` and `/api/v1/stream/pose` have been exempt from
//! [`crate::bearer_auth`] — which means that on a server with auth switched
//! ON, an unauthenticated caller can still complete a WebSocket handshake to
//! the **live sensing stream**. The REST control plane is locked; the data
//! plane is open.
//!
//! A ticket closes that without pretending browsers can do something they
//! cannot: the page makes an ordinary authenticated `POST /api/v1/ws-ticket`
//! (a normal request, where it *can* set headers), gets an opaque string, and
//! passes it as `?ticket=…` on the upgrade.
//!
//! # Why a query parameter is acceptable here, when it usually is not
//!
//! Putting a credential in a URL is normally a mistake: URLs land in access
//! logs, `Referer` headers and browser history. Three properties keep this one
//! bounded, and all three are load-bearing:
//!
//! 1. **Single use.** Consumed on the first upgrade attempt. A ticket in a log
//! is already spent.
//! 2. **Seconds, not hours.** [`TICKET_TTL`] is 30s — long enough for a page to
//! open a socket, far too short to be worth harvesting.
//! 3. **It is not the credential.** It authorizes one WebSocket connection.
//! It cannot be replayed against `/api/v1/*`, cannot be refreshed, and
//! carries no user identity a thief could reuse elsewhere.
//!
//! Native clients — the Python client, the Rust CLI, the TS MCP client — are
//! **not** browsers and must send a normal `Authorization` header on the
//! upgrade instead. Routing them through tickets would add a round-trip and a
//! second credential path for no benefit.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use rand::RngCore;
/// How long a ticket is valid. Deliberately tiny — a page opens its socket
/// immediately after fetching one, so anything longer is only useful to
/// someone who found the URL later.
pub const TICKET_TTL: Duration = Duration::from_secs(30);
/// Global cap on outstanding tickets.
const MAX_OUTSTANDING: usize = 512;
/// Per-principal cap.
///
/// The global cap alone is not enough: one authenticated `sensing:read` caller
/// looping on `POST /api/v1/ws-ticket` could occupy all 512 slots for 30
/// seconds and 503 every other user — a denial of service by an ordinary,
/// lowest-privilege account. A page needs a handful of concurrent sockets, so
/// this is generous while making one caller unable to starve the rest.
///
/// Tickets issued to the legacy static token share the `None` bucket, since
/// that credential carries no subject to attribute them to.
const MAX_PER_PRINCIPAL: usize = 16;
/// What a redeemed ticket authorizes.
///
/// The scopes are captured at issue time from the authenticated request, so a
/// WebSocket inherits exactly the authority of the credential that asked for
/// it — a `sensing:read` session cannot obtain a ticket that outranks itself.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketGrant {
/// Space-separated scopes held by the issuing principal, or `None` when the
/// issuer was the legacy static token (which predates scopes and carries
/// full authority).
pub scopes: Option<String>,
/// `sub` of the issuing principal, for logging. `None` for the static token.
pub subject: Option<String>,
}
struct Entry {
grant: TicketGrant,
expires_at: Instant,
}
/// In-memory ticket store.
///
/// `Debug` deliberately reports only a count, never ticket values — a ticket in
/// a debug log is a live credential for as long as it is unspent.
///
/// In-memory is correct rather than merely convenient: tickets live for
/// seconds, and a ticket surviving a restart would be a ticket outliving the
/// server that vouched for it.
#[derive(Clone, Default)]
pub struct TicketStore {
inner: Arc<Mutex<HashMap<String, Entry>>>,
}
impl std::fmt::Debug for TicketStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let n = self.inner.lock().map(|m| m.len()).unwrap_or(0);
f.debug_struct("TicketStore").field("outstanding", &n).finish()
}
}
impl TicketStore {
pub fn new() -> Self {
Self::default()
}
/// Mint a ticket for an authenticated caller.
///
/// Returns `None` if too many tickets are outstanding — refusing to issue
/// is the correct failure here; the alternative is unbounded growth driven
/// by a caller who is authenticated but misbehaving.
pub fn issue(&self, grant: TicketGrant) -> Option<String> {
let mut map = self.inner.lock().expect("ticket store poisoned");
prune(&mut map);
if map.len() >= MAX_OUTSTANDING {
tracing::warn!(
outstanding = map.len(),
"refusing to issue a WebSocket ticket: global cap reached"
);
return None;
}
// Per-principal cap, so one caller cannot starve every other user.
let held_by_this_principal = map
.values()
.filter(|e| e.grant.subject == grant.subject)
.count();
if held_by_this_principal >= MAX_PER_PRINCIPAL {
tracing::warn!(
subject = ?grant.subject,
held = held_by_this_principal,
"refusing to issue a WebSocket ticket: per-principal cap reached"
);
return None;
}
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
let ticket = hex(&bytes);
map.insert(
ticket.clone(),
Entry {
grant,
expires_at: Instant::now() + TICKET_TTL,
},
);
Some(ticket)
}
/// Redeem a ticket. **Removes it** — a ticket is valid exactly once, so a
/// replay of the same URL fails even within the TTL.
pub fn consume(&self, ticket: &str) -> Option<TicketGrant> {
let mut map = self.inner.lock().expect("ticket store poisoned");
prune(&mut map);
let entry = map.remove(ticket)?;
// Belt and braces: prune already dropped expired entries, but an entry
// expiring between the two would otherwise slip through.
if entry.expires_at <= Instant::now() {
return None;
}
Some(entry.grant)
}
#[cfg(test)]
fn outstanding(&self) -> usize {
self.inner.lock().unwrap().len()
}
}
fn prune(map: &mut HashMap<String, Entry>) {
let now = Instant::now();
map.retain(|_, e| e.expires_at > now);
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write;
bytes.iter().fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}
/// Extract `ticket` from a raw query string.
fn ticket_from_query(query: &str) -> Option<String> {
for pair in query.split('&') {
if let Some(v) = pair.strip_prefix("ticket=") {
if !v.is_empty() {
return Some(v.to_string());
}
}
}
None
}
/// Extract `ticket` from a request URI's query, if present.
pub fn ticket_from_uri(uri: &axum::http::Uri) -> Option<String> {
ticket_from_query(uri.query()?)
}
#[cfg(test)]
mod tests {
use super::*;
fn grant() -> TicketGrant {
TicketGrant {
scopes: Some("sensing:read".into()),
subject: Some("user-1".into()),
}
}
#[test]
fn a_ticket_round_trips_once() {
let store = TicketStore::new();
let t = store.issue(grant()).expect("issued");
assert_eq!(store.consume(&t), Some(grant()));
}
#[test]
fn a_ticket_cannot_be_used_twice() {
// The property that makes a credential-in-a-URL tolerable: by the time
// it reaches a log, it is spent.
let store = TicketStore::new();
let t = store.issue(grant()).unwrap();
assert!(store.consume(&t).is_some(), "first use succeeds");
assert!(store.consume(&t).is_none(), "replay must fail");
}
#[test]
fn an_unknown_ticket_is_refused() {
let store = TicketStore::new();
assert!(store.consume("deadbeef").is_none());
}
#[test]
fn consuming_removes_the_entry_rather_than_marking_it() {
let store = TicketStore::new();
let t = store.issue(grant()).unwrap();
assert_eq!(store.outstanding(), 1);
store.consume(&t);
assert_eq!(store.outstanding(), 0, "spent tickets must not accumulate");
}
#[test]
fn an_expired_ticket_is_refused_and_pruned() {
let store = TicketStore::new();
let t = store.issue(grant()).unwrap();
// Force expiry without sleeping.
{
let mut map = store.inner.lock().unwrap();
map.get_mut(&t).unwrap().expires_at = Instant::now() - Duration::from_secs(1);
}
assert!(store.consume(&t).is_none(), "expired ticket must be refused");
assert_eq!(store.outstanding(), 0, "and must not linger");
}
#[test]
fn tickets_are_unpredictable_and_distinct() {
let store = TicketStore::new();
let a = store.issue(grant()).unwrap();
let b = store.issue(grant()).unwrap();
assert_ne!(a, b);
// 32 bytes hex — guessing is not a strategy.
assert_eq!(a.len(), 64, "expected 256 bits of ticket");
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn the_grant_records_the_issuing_principals_scopes() {
// A sensing:read session must not be able to mint a ticket that
// outranks it — the WebSocket inherits the issuer's authority.
let store = TicketStore::new();
let g = TicketGrant {
scopes: Some("sensing:read".into()),
subject: Some("u".into()),
};
let t = store.issue(g.clone()).unwrap();
assert_eq!(store.consume(&t).unwrap().scopes.as_deref(), Some("sensing:read"));
}
#[test]
fn one_principal_cannot_starve_the_global_pool() {
// The reported DoS: an ordinary sensing:read caller looping on
// /api/v1/ws-ticket used to be able to occupy every slot and 503
// everyone else.
let store = TicketStore::new();
let noisy = TicketGrant {
scopes: Some("sensing:read".into()),
subject: Some("noisy-user".into()),
};
for _ in 0..MAX_PER_PRINCIPAL {
assert!(store.issue(noisy.clone()).is_some());
}
assert!(
store.issue(noisy).is_none(),
"one principal must hit its own cap"
);
// ...and a different user is entirely unaffected.
let other = TicketGrant {
scopes: Some("sensing:read".into()),
subject: Some("quiet-user".into()),
};
assert!(
store.issue(other).is_some(),
"another principal must still be served"
);
assert!(
store.outstanding() < MAX_OUTSTANDING,
"the global pool was never exhausted"
);
}
#[test]
fn issuing_is_refused_once_too_many_are_outstanding() {
let store = TicketStore::new();
for i in 0..MAX_OUTSTANDING {
// Distinct subjects, so this exercises the GLOBAL cap and not the
// per-principal one.
let g = TicketGrant {
scopes: Some("sensing:read".into()),
subject: Some(format!("user-{i}")),
};
assert!(store.issue(g).is_some());
}
assert!(
store
.issue(TicketGrant {
scopes: Some("sensing:read".into()),
subject: Some("one-more".into())
})
.is_none(),
"the global cap must still hold"
);
}
#[test]
fn expired_tickets_free_capacity_again() {
let store = TicketStore::new();
for i in 0..MAX_OUTSTANDING {
store.issue(TicketGrant {
scopes: Some("sensing:read".into()),
subject: Some(format!("user-{i}")),
});
}
assert!(store
.issue(TicketGrant {
scopes: Some("sensing:read".into()),
subject: Some("blocked".into())
})
.is_none());
{
let mut map = store.inner.lock().unwrap();
for e in map.values_mut() {
e.expires_at = Instant::now() - Duration::from_secs(1);
}
}
assert!(
store.issue(grant()).is_some(),
"the cap must be self-healing, not a permanent wedge"
);
}
#[test]
fn parses_a_ticket_from_a_query_string() {
assert_eq!(ticket_from_query("ticket=abc123").as_deref(), Some("abc123"));
assert_eq!(
ticket_from_query("foo=1&ticket=abc123&bar=2").as_deref(),
Some("abc123")
);
}
#[test]
fn an_absent_or_empty_ticket_parameter_yields_none() {
assert!(ticket_from_query("foo=1").is_none());
assert!(ticket_from_query("ticket=").is_none());
assert!(ticket_from_query("").is_none());
}
#[test]
fn a_parameter_merely_ending_in_ticket_is_not_a_ticket() {
// `?myticket=x` must not be read as `?ticket=x`.
assert!(ticket_from_query("myticket=abc").is_none());
}
}
@@ -0,0 +1,317 @@
//! Boots the REAL `sensing-server` binary and probes BOTH listeners.
//!
//! # Why this test exists
//!
//! Two authentication bypasses shipped in the ADR-272 work, and 526 green unit
//! tests could not see either, because every auth test in this crate builds its
//! OWN `Router` with a hand-picked subset of routes. A synthetic router can
//! never observe how the real one is assembled — and both defects were assembly:
//!
//! 1. The dedicated WebSocket listener (`--ws-port`) was constructed with only
//! host validation. `require_bearer` was never applied to it at all. That is
//! the port the shipped UI actually connects to
//! (`ui/services/sensing.service.js` maps HTTP 8080 -> WS 8765), so the
//! earlier fix protected a path the browser never takes.
//! 2. `/ws/field` was `.merge()`d AFTER the auth layer on the HTTP router. In
//! axum a layer wraps only what is already registered, so merging afterwards
//! silently exempts those routes.
//!
//! Both were found by adversarial review, not by the suite. This test closes
//! that gap: it runs the actual binary, so it sees the actual wiring.
//!
//! It deliberately asserts on **ports and transports**, not on handler logic —
//! handler behaviour is covered by the unit suites. What is unique here is that
//! nothing is synthetic: real process, real listeners, real TCP.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
const TOKEN: &str = "integration-test-secret";
/// Reserve a port by binding and immediately releasing it.
///
/// Mildly racy, which is why each test reserves its own set and the server is
/// given several seconds to come up: a collision surfaces as a boot failure,
/// not as a false pass.
fn free_port() -> u16 {
let l = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral");
let p = l.local_addr().unwrap().port();
drop(l);
p
}
struct Server {
child: Child,
http: u16,
ws: u16,
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Server {
/// Spawn the real binary. `env` lets a test choose the auth configuration.
///
/// PANICS rather than returning `None` on failure. This used to return an
/// `Option` that every test turned into `return`, which meant all five
/// assertions were skipped precisely when the server was broken — including
/// broken BY an auth change. A boot failure printed one line that `cargo
/// test` swallows without `--nocapture` and reported `5 passed`. The only
/// test in the suite that observes real wiring disarmed itself exactly when
/// it mattered; a boot-time panic in the auth path would have shipped green.
fn start(env: &[(&str, &str)]) -> Self {
let (http, ws, udp) = (free_port(), free_port(), free_port());
let mut cmd = Command::new(env!("CARGO_BIN_EXE_sensing-server"));
cmd.args([
"--http-port", &http.to_string(),
"--ws-port", &ws.to_string(),
"--udp-port", &udp.to_string(),
"--bind-addr", "127.0.0.1",
"--no-edge-registry",
"--source", "simulate",
])
// Inherit nothing auth-related from the developer's shell, or a local
// RUVIEW_* export would silently change what this test proves.
.env_remove("RUVIEW_API_TOKEN")
.env_remove("RUVIEW_OAUTH_ISSUER")
.env_remove("RUVIEW_WS_LEGACY_UNAUTHENTICATED")
.stdout(Stdio::null())
// Captured, not discarded: if the server dies at boot, its stderr is the
// only thing that says why, and the panic below reproduces it.
.stderr(Stdio::piped());
for (k, v) in env {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("spawn sensing-server");
let http_port = http;
let ws_port = ws;
if !await_ready(http_port, ws_port) {
let mut err = String::new();
if let Some(mut s) = child.stderr.take() {
let _ = s.read_to_string(&mut err);
}
let _ = child.kill();
let _ = child.wait();
panic!(
"sensing-server did not become ready on :{http_port} (http) and :{ws_port} (ws) \
within 30s. This is a FAILURE, not a skip the wiring assertions below cannot \
run, and a boot-time break in the auth path is exactly what they exist to catch.\n\
--- server stderr ---\n{err}"
);
}
Server { child, http: http_port, ws: ws_port }
}
}
fn await_ready(http: u16, ws: u16) -> bool {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if TcpStream::connect(("127.0.0.1", http)).is_ok()
&& TcpStream::connect(("127.0.0.1", ws)).is_ok()
{
return true;
}
std::thread::sleep(Duration::from_millis(200));
}
false
}
/// One raw HTTP/1.1 request; returns the status code.
fn status(port: u16, method: &str, path: &str, headers: &[(&str, &str)]) -> u16 {
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
let mut s = TcpStream::connect(addr).expect("connect");
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let mut req = format!("{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n");
for (k, v) in headers {
req.push_str(&format!("{k}: {v}\r\n"));
}
req.push_str("Connection: close\r\n\r\n");
s.write_all(req.as_bytes()).expect("write");
let mut line = String::new();
BufReader::new(&mut s).read_line(&mut line).expect("status line");
line.split_whitespace()
.nth(1)
.and_then(|c| c.parse().ok())
.unwrap_or_else(|| panic!("unparseable status line: {line:?}"))
}
/// A genuine WebSocket upgrade. 101 means the connection was ACCEPTED.
fn ws_upgrade(port: u16, path: &str, bearer: Option<&str>) -> u16 {
let mut headers: Vec<(&str, &str)> = vec![
("Upgrade", "websocket"),
("Connection", "Upgrade"),
("Sec-WebSocket-Version", "13"),
("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="),
];
let auth;
if let Some(b) = bearer {
auth = format!("Bearer {b}");
headers.push(("Authorization", &auth));
}
// Not `Connection: close` — that would contradict the upgrade.
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
let mut s = TcpStream::connect(addr).expect("connect");
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let mut req = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n");
for (k, v) in &headers {
req.push_str(&format!("{k}: {v}\r\n"));
}
req.push_str("\r\n");
s.write_all(req.as_bytes()).expect("write");
let mut buf = [0u8; 256];
let n = s.read(&mut buf).expect("read");
let head = String::from_utf8_lossy(&buf[..n]);
let line = head.lines().next().unwrap_or_default();
line.split_whitespace()
.nth(1)
.and_then(|c| c.parse().ok())
.unwrap_or_else(|| panic!("unparseable status line: {line:?}"))
}
/// Every WebSocket path, on every listener. This list is the point of the test.
const WS_PATHS: &[&str] = &["/ws/sensing", "/ws/introspection", "/api/v1/stream/pose", "/ws/field"];
/// Non-WebSocket routes that carry sensing data and must be gated.
///
/// `/api/field` is here because it was NOT gated: it serves the same signed
/// `FieldEvent` stream as `/ws/field`, but sits outside `/api/v1/`, and the gate
/// protected `/api/v1/*` by prefix. `/ws/field` was gated in this PR and its
/// REST twin, one path segment over, returned 200 to an anonymous caller on
/// both listeners. That is why the gate is now deny-by-default.
const PROTECTED_REST_PATHS: &[&str] = &["/api/field", "/api/v1/models"];
#[test]
fn with_auth_on_no_listener_serves_sensing_data_anonymously() {
let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]);
for (label, port) in [("http", server.http), ("ws", server.ws)] {
for path in PROTECTED_REST_PATHS {
assert_eq!(
status(port, "GET", path, &[]),
401,
"{label} port served {path} to an anonymous caller"
);
// And the credential must actually work, or the assertion above
// could pass because the route simply does not exist.
let ok = status(port, "GET", path, &[("Authorization", &format!("Bearer {TOKEN}"))]);
assert_ne!(ok, 401, "{label} port {path} rejected a VALID bearer");
}
}
}
#[test]
fn the_dashboard_shell_and_sign_in_stay_reachable_when_auth_is_on() {
// Deny-by-default must not lock the user out of the page that renders the
// sign-in button, or of sign-in itself. This is the other half of the
// allowlist: too tight is as broken as too loose, just louder.
let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]);
for path in ["/health", "/oauth/status"] {
assert_ne!(
status(server.http, "GET", path, &[]),
401,
"{path} must stay anonymous — sign-in depends on it"
);
}
}
#[test]
fn with_auth_on_no_listener_accepts_an_unauthenticated_websocket() {
let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]);
// Control first: if REST is not gated, the server is misconfigured and the
// WebSocket assertions below would pass for the wrong reason.
assert_eq!(
status(server.http, "GET", "/api/v1/models", &[]),
401,
"REST must be gated, or this test proves nothing"
);
for &port_label in &["http", "ws"] {
let port = if port_label == "http" { server.http } else { server.ws };
for path in WS_PATHS {
let code = ws_upgrade(port, path, None);
assert_ne!(
code, 101,
"{port_label} port ACCEPTED an unauthenticated upgrade to {path} — \
this is the bypass that shipped twice"
);
assert_eq!(
code, 401,
"{port_label} port {path} should refuse with 401, got {code}"
);
}
}
}
#[test]
fn a_bearer_on_the_upgrade_is_accepted_on_both_listeners() {
let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]);
// Native clients (Python, CLI, MCP) are not browser-constrained and must be
// able to authenticate a WebSocket without the ticket round-trip.
for (label, port) in [("http", server.http), ("ws", server.ws)] {
assert_eq!(
ws_upgrade(port, "/ws/sensing", Some(TOKEN)),
101,
"{label} port must accept a valid bearer on the upgrade"
);
}
}
#[test]
fn with_auth_off_both_listeners_stay_open() {
// The compatibility promise: an unconfigured deployment sees no change.
let server = Server::start(&[]);
assert_eq!(status(server.http, "GET", "/api/v1/models", &[]), 200);
for (label, port) in [("http", server.http), ("ws", server.ws)] {
assert_eq!(
ws_upgrade(port, "/ws/sensing", None),
101,
"{label} port must stay open when no credential is configured"
);
}
}
#[test]
fn the_legacy_escape_hatch_opens_websockets_without_weakening_rest() {
let server = Server::start(&[
("RUVIEW_API_TOKEN", TOKEN),
("RUVIEW_WS_LEGACY_UNAUTHENTICATED", "1"),
]);
// The hatch is scoped to WebSockets on purpose. If it ever widened to REST
// it would be a bypass wearing a migration label.
assert_eq!(
status(server.http, "GET", "/api/v1/models", &[]),
401,
"the escape hatch must not weaken REST"
);
for (label, port) in [("http", server.http), ("ws", server.ws)] {
assert_eq!(
ws_upgrade(port, "/ws/sensing", None),
101,
"{label} port should be open while the hatch is set"
);
}
}
#[test]
fn health_stays_anonymous_on_both_listeners() {
// Documented exemption (ADR-272): orchestrator probes are anonymous by
// design. Pinned so it is a decision, not an accident nobody re-checks.
let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]);
for (label, port) in [("http", server.http), ("ws", server.ws)] {
assert_eq!(
status(port, "GET", "/health", &[]),
200,
"{label} port /health must remain anonymous"
);
}
}