diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0090af50..2bbdc260 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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:
diff --git a/docs/adr/ADR-271-cognitum-oauth-resource-server.md b/docs/adr/ADR-271-cognitum-oauth-resource-server.md
new file mode 100644
index 00000000..e02a8d8a
--- /dev/null
+++ b/docs/adr/ADR-271-cognitum-oauth-resource-server.md
@@ -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.
+
+
+Original text (no longer accurate)
+
+`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.
+
+
+
+## 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=; 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.
diff --git a/docs/adr/ADR-272-websocket-authentication-tickets.md b/docs/adr/ADR-272-websocket-authentication-tickets.md
new file mode 100644
index 00000000..8b5a5024
--- /dev/null
+++ b/docs/adr/ADR-272-websocket-authentication-tickets.md
@@ -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=` 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.
diff --git a/ui/observatory/js/main.js b/ui/observatory/js/main.js
index 26abbe2c..b0149368 100644
--- a/ui/observatory/js/main.js
+++ b/ui/observatory/js/main.js
@@ -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);
diff --git a/ui/services/api.service.js b/ui/services/api.service.js
index 4e5daee7..49695af9 100644
--- a/ui/services/api.service.js
+++ b/ui/services/api.service.js
@@ -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(() => ({
diff --git a/ui/services/sensing.service.js b/ui/services/sensing.service.js
index 2d5635e4..e07a09cd 100644
--- a/ui/services/sensing.service.js
+++ b/ui/services/sensing.service.js
@@ -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
diff --git a/ui/services/websocket-client.js b/ui/services/websocket-client.js
index 208e0780..64f1ca86 100644
--- a/ui/services/websocket-client.js
+++ b/ui/services/websocket-client.js
@@ -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);
}
diff --git a/ui/services/ws-ticket.js b/ui/services/ws-ticket.js
new file mode 100644
index 00000000..9870562a
--- /dev/null
+++ b/ui/services/ws-ticket.js
@@ -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}
+ */
+export async function withWsTicket(url) {
+ const ticket = await mintTicket();
+ if (!ticket) return url;
+ const sep = url.includes('?') ? '&' : '?';
+ return `${url}${sep}ticket=${encodeURIComponent(ticket)}`;
+}
diff --git a/ui/services/ws-ticket.test.mjs b/ui/services/ws-ticket.test.mjs
new file mode 100644
index 00000000..e96ea4df
--- /dev/null
+++ b/ui/services/ws-ticket.test.mjs
@@ -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');
+});
diff --git a/ui/sw.js b/ui/sw.js
index eb84e2b4..f47d00cc 100644
--- a/ui/sw.js
+++ b/ui/sw.js
@@ -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=`, 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))
+ );
+}
diff --git a/ui/sw.test.mjs b/ui/sw.test.mjs
new file mode 100644
index 00000000..7a2f59ff
--- /dev/null
+++ b/ui/sw.test.mjs
@@ -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));
+});
diff --git a/ui/utils/quick-settings.js b/ui/utils/quick-settings.js
index afae95eb..480ad1cb 100644
--- a/ui/utils/quick-settings.js
+++ b/ui/utils/quick-settings.js
@@ -74,6 +74,16 @@ export class QuickSettings {
+
+
Cognitum Account
+
+ Checking...
+
+
+
+
+
+
API Access
@@ -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;
+}
diff --git a/v2/Cargo.lock b/v2/Cargo.lock
index 3819a75a..e4d23aa5 100644
--- a/v2/Cargo.lock
+++ b/v2/Cargo.lock
@@ -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",
diff --git a/v2/Cargo.toml b/v2/Cargo.toml
index 88a243c3..22b701d6 100644
--- a/v2/Cargo.toml
+++ b/v2/Cargo.toml
@@ -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
diff --git a/v2/crates/ruview-auth/Cargo.toml b/v2/crates/ruview-auth/Cargo.toml
new file mode 100644
index 00000000..96df45b3
--- /dev/null
+++ b/v2/crates/ruview-auth/Cargo.toml
@@ -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"
diff --git a/v2/crates/ruview-auth/src/jwks.rs b/v2/crates/ruview-auth/src/jwks.rs
new file mode 100644
index 00000000..56c026fd
--- /dev/null
+++ b/v2/crates/ruview-auth/src/jwks.rs
@@ -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;
+}
+
+/// 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,
+ kty: String,
+ crv: Option,
+ x: Option,
+ y: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct JwksDocument {
+ keys: Vec,
+}
+
+struct CacheState {
+ keys: HashMap,
+ fetched_at: Option,
+ last_attempt_at: Option,
+ last_forced_refetch: Option,
+}
+
+/// `kid`-indexed JWKS cache.
+pub struct JwksCache {
+ url: String,
+ ttl: Duration,
+ fetcher: Box,
+ state: Mutex,
+}
+
+impl JwksCache {
+ pub fn new(url: impl Into, fetcher: Box) -> Self {
+ Self::with_ttl(url, fetcher, DEFAULT_CACHE_TTL)
+ }
+
+ pub fn with_ttl(url: impl Into, fetcher: Box, 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 {
+ 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 {
+ // ---- 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, 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, 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 {
+ 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>,
+ calls: Arc,
+ offline: Arc>,
+ }
+
+ 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 {
+ Box::new(StubFetcher(self.clone()))
+ }
+ }
+
+ struct StubFetcher(StubControl);
+
+ impl JwksFetcher for StubFetcher {
+ fn fetch(&self, _url: &str) -> Result {
+ 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");
+ }
+}
diff --git a/v2/crates/ruview-auth/src/lib.rs b/v2/crates/ruview-auth/src/lib.rs
new file mode 100644
index 00000000..821abed8
--- /dev/null
+++ b/v2/crates/ruview-auth/src/lib.rs
@@ -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 {
+//! # 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("", &jwks, &config)?;
+//! println!("{} on account {}", principal.subject, principal.account_id);
+//! # Ok::<(), Box>(())
+//! ```
+//!
+//! ## 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};
diff --git a/v2/crates/ruview-auth/src/login/callback.rs b/v2/crates/ruview-auth/src/login/callback.rs
new file mode 100644
index 00000000..4b489140
--- /dev/null
+++ b/v2/crates/ruview-auth/src/login/callback.rs
@@ -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,
+ pub state: Option,
+ pub error: Option,
+}
+
+const SUCCESS_PAGE: &str = r#"
+
+
+
✓ RuView sign-in complete
+
You can close this tab and return to your terminal.
+
+
+"#;
+
+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:/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 {
+ 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 {
+ 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");
+ }
+}
diff --git a/v2/crates/ruview-auth/src/login/client.rs b/v2/crates/ruview-auth/src/login/client.rs
new file mode 100644
index 00000000..e1af1669
--- /dev/null
+++ b/v2/crates/ruview-auth/src/login/client.rs
@@ -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,
+ #[serde(default)]
+ pub account_email: Option,
+ /// The **rotating** refresh token. Identity revokes the presented one and
+ /// returns a replacement; see [`refresh`].
+ #[serde(default)]
+ pub refresh_token: Option,
+ /// 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,
+ #[serde(default)]
+ pub scope: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct ErrorBody {
+ #[serde(default)]
+ error: Option,
+ #[serde(default)]
+ error_description: Option,
+}
+
+async fn parse_token_response(resp: reqwest::Response) -> Result {
+ let status = resp.status();
+ let body = resp.text().await?;
+ if status.is_success() {
+ return serde_json::from_str::(&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::(&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 {
+ 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 {
+ 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 {
+ #[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");
+ }
+}
diff --git a/v2/crates/ruview-auth/src/login/flow.rs b/v2/crates/ruview-auth/src/login/flow.rs
new file mode 100644
index 00000000..ad46fb97
--- /dev/null
+++ b/v2/crates/ruview-auth/src/login/flow.rs
@@ -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(
+ opts: &LoginOptions,
+ out: &mut W,
+ input: &mut R,
+) -> Result {
+ 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(
+ opts: &LoginOptions,
+ http: &reqwest::Client,
+ issuer: String,
+ out: &mut W,
+) -> Result {
+ 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(
+ opts: &LoginOptions,
+ http: &reqwest::Client,
+ issuer: String,
+ out: &mut W,
+ input: &mut R,
+) -> Result {
+ 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(
+ opts: &LoginOptions,
+ http: &reqwest::Client,
+ token: client::TokenResponse,
+ issuer: String,
+ out: &mut W,
+) -> Result {
+ 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 {
+ 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}");
+ }
+}
diff --git a/v2/crates/ruview-auth/src/login/mod.rs b/v2/crates/ruview-auth/src/login/mod.rs
new file mode 100644
index 00000000..942f684e
--- /dev/null
+++ b/v2/crates/ruview-auth/src/login/mod.rs
@@ -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> {
+//! 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,
+};
diff --git a/v2/crates/ruview-auth/src/login/store.rs b/v2/crates/ruview-auth/src/login/store.rs
new file mode 100644
index 00000000..80e360e3
--- /dev/null
+++ b/v2/crates/ruview-auth/src/login/store.rs
@@ -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,
+ /// Unix seconds. Absent ⇒ treated as already expired, never as "valid".
+ pub expires_at: Option,
+ pub scope: Option,
+ pub account_email: Option,
+ 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", &"")
+ .field("refresh_token", &self.refresh_token.as_ref().map(|_| ""))
+ .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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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>,
+}
+
+impl Session {
+ pub fn load_from(path: PathBuf, http: reqwest::Client) -> Result {
+ 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 {
+ 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 {
+ 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 = 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) -> 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(""));
+ // 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"));
+ }
+}
diff --git a/v2/crates/ruview-auth/src/pkce.rs b/v2/crates/ruview-auth/src/pkce.rs
new file mode 100644
index 00000000..a023b68c
--- /dev/null
+++ b/v2/crates/ruview-auth/src/pkce.rs
@@ -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 43–128 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);
+ }
+}
diff --git a/v2/crates/ruview-auth/src/principal.rs b/v2/crates/ruview-auth/src/principal.rs
new file mode 100644
index 00000000..32d99bad
--- /dev/null
+++ b/v2/crates/ruview-auth/src/principal.rs
@@ -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,
+ /// `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 {
+ 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));
+ }
+}
diff --git a/v2/crates/ruview-auth/src/verify.rs b/v2/crates/ruview-auth/src/verify.rs
new file mode 100644
index 00000000..e5bd5c7b
--- /dev/null
+++ b/v2/crates/ruview-auth/src/verify.rs
@@ -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 },
+ #[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,
+ sub: String,
+ #[serde(default)]
+ account_id: Option,
+ #[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,
+}
+
+/// 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 {
+ 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::(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)
+ ));
+ }
+}
diff --git a/v2/crates/ruview-auth/tests/verifier_matrix.rs b/v2/crates/ruview-auth/tests/verifier_matrix.rs
new file mode 100644
index 00000000..e376d837
--- /dev/null
+++ b/v2/crates/ruview-auth/tests/verifier_matrix.rs
@@ -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 = 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 = 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 {
+ 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 {
+ 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 { .. })
+ ));
+}
diff --git a/v2/crates/wifi-densepose-cli/Cargo.toml b/v2/crates/wifi-densepose-cli/Cargo.toml
index bc4b58d1..f5082939 100644
--- a/v2/crates/wifi-densepose-cli/Cargo.toml
+++ b/v2/crates/wifi-densepose-cli/Cargo.toml
@@ -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
diff --git a/v2/crates/wifi-densepose-cli/src/auth.rs b/v2/crates/wifi-densepose-cli/src/auth.rs
new file mode 100644
index 00000000..eb97b4b8
--- /dev/null
+++ b/v2/crates/wifi-densepose-cli/src/auth.rs
@@ -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,
+}
+
+#[derive(Debug, Args)]
+pub struct LogoutArgs {
+ #[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)]
+ pub credentials_path: Option,
+}
+
+#[derive(Debug, Args)]
+pub struct WhoamiArgs {
+ #[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)]
+ pub credentials_path: Option,
+
+ /// 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 {
+ 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());
+ }
+}
diff --git a/v2/crates/wifi-densepose-cli/src/lib.rs b/v2/crates/wifi-densepose-cli/src/lib.rs
index d2b73bd9..79366f04 100644
--- a/v2/crates/wifi-densepose-cli/src/lib.rs
+++ b/v2/crates/wifi-densepose-cli/src/lib.rs
@@ -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.
diff --git a/v2/crates/wifi-densepose-cli/src/main.rs b/v2/crates/wifi-densepose-cli/src/main.rs
index e4d48f8b..ad468586 100644
--- a/v2/crates/wifi-densepose-cli/src/main.rs
+++ b/v2/crates/wifi-densepose-cli/src/main.rs
@@ -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?;
}
diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml
index 72924feb..62eb6fad 100644
--- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml
+++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml
@@ -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"
diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs
index 011343ac..783ed7c1 100644
--- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs
+++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs
@@ -18,36 +18,266 @@
//!
//! The header check uses a length-then-byte constant-time compare to avoid
//! leaking the token through timing.
+//!
+//! # Cognitum OAuth (ADR-271)
+//!
+//! A second, **additive** credential is supported: a Cognitum OAuth access
+//! token, verified offline against `auth.cognitum.one`'s published JWKS. It is
+//! enabled by setting [`OAUTH_ISSUER_ENV`], and the two schemes layer:
+//!
+//! 1. If `RUVIEW_API_TOKEN` is set and the presented bearer matches it exactly,
+//! the request is allowed — byte-for-byte today's behaviour.
+//! 2. Otherwise, if OAuth is configured, the bearer is verified as a JWT and
+//! must carry the scope the route requires.
+//! 3. Otherwise `401`.
+//!
+//! Order matters for compatibility, not for security: a static token that
+//! matches is not a JWT, and a JWT never matches the static token. Trying the
+//! static compare first means an existing deployment's behaviour is unchanged
+//! even with OAuth switched on.
+//!
+//! **Nothing here weakens the unset case.** With neither variable set the
+//! middleware is the same no-op it has always been.
+//!
+//! ## Scope gating
+//!
+//! Not every route carries the same blast radius, so a single "authenticated"
+//! bit is too coarse once we have scopes. [`required_scope_for`] maps a request
+//! to `sensing:read` or `sensing:admin` — see its docs for the split and why it
+//! is drawn where it is.
use std::sync::Arc;
use axum::{
extract::{Request, State},
- http::{header::AUTHORIZATION, StatusCode},
+ http::{header::AUTHORIZATION, Method, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
+use ruview_auth::{scope, verify_access_token, JwksCache, UreqFetcher, VerifierConfig};
/// Environment variable that gates the middleware. Unset / empty ⇒ auth off.
pub const API_TOKEN_ENV: &str = "RUVIEW_API_TOKEN";
+/// Issuer origin of the Cognitum authorization server. Setting this enables
+/// OAuth verification; unset ⇒ OAuth off and behaviour is unchanged.
+pub const OAUTH_ISSUER_ENV: &str = "RUVIEW_OAUTH_ISSUER";
+
+/// Optional JWKS override. Defaults to `/.well-known/jwks.json`, which
+/// is where RFC 8414 metadata points for `auth.cognitum.one`. Overridable so a
+/// staging issuer or an air-gapped mirror can be pointed at without a rebuild.
+pub const OAUTH_JWKS_URL_ENV: &str = "RUVIEW_OAUTH_JWKS_URL";
+
+/// The production Cognitum issuer, for operators who just want it on.
+pub const COGNITUM_ISSUER: &str = "https://auth.cognitum.one";
+
+/// Comma-separated `client_id` values whose tokens this server accepts — the
+/// AUDIENCE control. Defaults to [`DEFAULT_CLIENT_ID`].
+///
+/// Cognitum tokens carry no `aud`; `client_id` is the platform's stand-in, and
+/// `cognitum-one/freetokens` enforces exactly this. Set to `*` to accept any
+/// Cognitum client — only sensible while borrowing another product's
+/// registration, and it means any Cognitum token opens this server.
+pub const OAUTH_CLIENT_IDS_ENV: &str = "RUVIEW_OAUTH_CLIENT_IDS";
+
+/// RuView's own registered OAuth client (identity migration `0017`).
+pub const DEFAULT_CLIENT_ID: &str = "ruview";
+
+fn allowed_client_ids() -> Vec {
+ match std::env::var(OAUTH_CLIENT_IDS_ENV) {
+ Ok(v) if v.trim() == "*" => Vec::new(), // explicit opt-out
+ Ok(v) if !v.trim().is_empty() => {
+ let parsed: Vec = v
+ .split(',')
+ .map(|c| c.trim().to_string())
+ .filter(|c| !c.is_empty())
+ .collect();
+ // An empty Vec is the OPT-OUT sentinel downstream: `verify.rs` skips
+ // the audience check entirely when the allowlist is empty. So a value
+ // that is non-empty but parses to nothing — `","`, `" , "`, a stray
+ // trailing comma — would silently disable the audience boundary and
+ // admit a token minted for any other Cognitum product. Only the
+ // literal `*` may turn that check off.
+ if parsed.is_empty() {
+ tracing::warn!(
+ value = %v,
+ "{OAUTH_CLIENT_IDS_ENV} is set but lists no client id; falling back to \
+ {DEFAULT_CLIENT_ID}. Use `*` if you really mean to accept any client."
+ );
+ return vec![DEFAULT_CLIENT_ID.to_string()];
+ }
+ parsed
+ }
+ _ => vec![DEFAULT_CLIENT_ID.to_string()],
+ }
+}
+
/// Path prefix the middleware protects when auth is enabled.
+///
+/// Retained because it names the bulk of the protected surface and is asserted
+/// in tests, but it is NO LONGER the rule. The gate is [`is_anonymous`] —
+/// deny-by-default. See that function for why.
pub const PROTECTED_PREFIX: &str = "/api/v1/";
-/// `/api/v1/stream/pose` is a WebSocket upgrade endpoint reachable from
-/// browser code. Unlike a plain fetch(), the browser `WebSocket` constructor
-/// cannot attach an `Authorization` header to the handshake request, so this
-/// path can never carry a bearer token from a stock browser client — the
-/// same reasoning that already exempts `/ws/sensing` (see module docs).
-/// Exempted here rather than moved out of `/api/v1/*` to avoid an API
-/// surface change for existing clients.
-const EXEMPT_PATHS: &[&str] = &["/api/v1/stream/pose"];
+/// Paths that stay reachable with no credential when auth is enabled.
+///
+/// # Why an allowlist
+///
+/// The gate used to be the inverse: protect `/api/v1/*`, let everything else
+/// through. That is exposure-by-default, and it leaked. `/api/field` — the REST
+/// sibling of `/ws/field`, serving the same signed `FieldEvent` stream of live
+/// presence, pose and vitals — sits at `/api/field`, not `/api/v1/`, so it
+/// returned `200` with no credential on BOTH listeners while `/api/v1/models`
+/// correctly returned `401`. `/ws/field` was gated in this same PR; its REST
+/// twin one path segment over was not.
+///
+/// Measured, with `RUVIEW_API_TOKEN` set and no credential supplied:
+/// `/api/v1/models` -> 401, `/ws/field` -> 401, `/api/field` -> 200 on :8080
+/// and :8765.
+///
+/// With an allowlist, a route added at a new path is gated because nobody
+/// remembered to expose it, rather than exposed because nobody remembered to
+/// protect it. That is the same inversion already applied to the scope gate in
+/// [`required_scope_for`].
+const ANONYMOUS_PREFIXES: &[&str] = &[
+ // Orchestrator and load-balancer probes. Documented exemption (ADR-272),
+ // pinned by `health_stays_anonymous_on_both_listeners`.
+ "/health",
+ // Sign-in cannot require being signed in.
+ "/oauth/",
+];
-/// Cheap, cloneable handle to the configured token (or `None`).
-#[derive(Debug, Clone, Default)]
+/// Is this path reachable without a credential? See [`ANONYMOUS_PREFIXES`].
+pub fn is_anonymous(path: &str) -> bool {
+ // The dashboard shell itself, mounted with `nest_service("/ui", …)`. It has
+ // to load for the user to reach the sign-in button at all; the data it then
+ // fetches is what is protected.
+ if path == "/" || path == "/ui" || path.starts_with("/ui/") {
+ return true;
+ }
+ ANONYMOUS_PREFIXES.iter().any(|p| path.starts_with(p))
+}
+
+/// WebSocket upgrade endpoints. Previously ungated — `/ws/*` sat outside
+/// [`PROTECTED_PREFIX`] and `/api/v1/stream/pose` was an explicit exemption —
+/// because a browser's `WebSocket` constructor cannot attach an
+/// `Authorization` header to the handshake.
+///
+/// Measured consequence, with `RUVIEW_API_TOKEN` set and a real handshake
+/// carrying no credential: all three returned `101 Switching Protocols` while
+/// `/api/v1/models` returned `401`. The control plane was locked and the data
+/// plane — live presence, pose, vitals — was open.
+///
+/// They are now gated, and accept **either** a bearer (native clients, which
+/// are not browser-constrained) **or** a single-use ticket (ADR-272).
+///
+/// This list is the set that exists today, used for the boot warning and tests.
+/// The runtime rule is [`is_ws_path`], which matches by prefix so routes added
+/// later are gated without an edit here.
+pub const WS_PATHS: &[&str] = &[
+ "/ws/sensing",
+ "/ws/introspection",
+ "/api/v1/stream/pose",
+];
+
+/// Restore the pre-ADR-272 behaviour: WebSocket upgrades accepted with no
+/// credential even when auth is on.
+///
+/// A migration aid, not a supported configuration. It exists because gating
+/// these paths breaks a browser UI that has not yet been updated to fetch a
+/// ticket, and some deployments cannot update server and UI in lockstep. It
+/// logs a warning naming the exposure on every boot, deliberately hard to
+/// ignore in a log.
+pub const LEGACY_WS_ENV: &str = "RUVIEW_WS_LEGACY_UNAUTHENTICATED";
+
+fn legacy_ws_unauthenticated() -> bool {
+ matches!(
+ std::env::var(LEGACY_WS_ENV).as_deref(),
+ Ok("1") | Ok("true") | Ok("TRUE")
+ )
+}
+
+/// WebSocket upgrade paths that do NOT live under [`WS_PREFIX`] and so must be
+/// named explicitly.
+pub const WS_PATHS_OUTSIDE_PREFIX: &[&str] = &["/api/v1/stream/pose"];
+
+/// Everything under here is treated as a WebSocket upgrade.
+pub const WS_PREFIX: &str = "/ws/";
+
+/// Is this a WebSocket upgrade path?
+///
+/// Matched by **prefix**, not by an allowlist, and that choice is the whole
+/// point. An allowlist means every WebSocket route added later is ungated until
+/// someone remembers to add it here — which is exactly the bug this module just
+/// fixed, reintroduced on a delay. `/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.
+///
+/// `/api/v1/stream/pose` is the one upgrade endpoint outside the prefix, so it
+/// is named. New WebSocket routes should go under `/ws/` and inherit gating for
+/// free.
+fn is_ws_path(path: &str) -> bool {
+ path.starts_with(WS_PREFIX) || WS_PATHS_OUTSIDE_PREFIX.contains(&path)
+}
+
+/// Cognitum OAuth verification state. Built once at boot and shared.
+pub struct OAuthState {
+ jwks: JwksCache,
+ issuer: String,
+ allowed_client_ids: Vec,
+}
+
+impl std::fmt::Debug for OAuthState {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("OAuthState")
+ .field("issuer", &self.issuer)
+ .finish_non_exhaustive()
+ }
+}
+
+/// Why OAuth could not be configured. Every variant is fatal at boot — see
+/// [`AuthState::from_env`]'s contract about failing closed.
+#[derive(Debug, thiserror::Error)]
+pub enum OAuthConfigError {
+ #[error("{OAUTH_ISSUER_ENV} is set but empty")]
+ EmptyIssuer,
+ #[error("JWKS at {url} is unreachable, so no token could ever be verified: {source}")]
+ JwksUnreachable {
+ url: String,
+ #[source]
+ source: ruview_auth::JwksError,
+ },
+}
+
+/// Cheap, cloneable handle to the configured credentials.
+///
+/// `Debug` is hand-written and REDACTING: this holds the raw
+/// `RUVIEW_API_TOKEN`. A derived impl would print it in full the first time
+/// anyone writes `tracing::debug!(?auth, ...)`. `OAuthState` and `TicketStore`
+/// already redact for the same reason; the type actually holding the secret
+/// should not be the one that does not.
+#[derive(Clone, Default)]
pub struct AuthState {
- /// The expected bearer token, if any. `None` ⇒ middleware is a no-op.
+ /// The expected static bearer token, if any.
token: Option>,
+ /// Cognitum OAuth verification, if enabled.
+ oauth: Option>,
+ /// Single-use WebSocket tickets (ADR-272).
+ tickets: crate::ws_ticket::TicketStore,
+ /// Cached at construction so a mid-flight env change cannot silently open
+ /// the WebSocket paths on a running server.
+ legacy_ws: bool,
+}
+
+impl std::fmt::Debug for AuthState {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("AuthState")
+ .field("static_token", &self.token.as_ref().map(|_| ""))
+ .field("oauth", &self.oauth)
+ .field("tickets", &self.tickets)
+ .field("legacy_ws", &self.legacy_ws)
+ .finish()
+ }
}
impl AuthState {
@@ -55,29 +285,217 @@ impl AuthState {
pub fn from_token(t: impl Into) -> Self {
let s = t.into();
if s.is_empty() {
- AuthState { token: None }
+ AuthState::default()
} else {
AuthState {
token: Some(Arc::new(s)),
+ oauth: None,
+ tickets: crate::ws_ticket::TicketStore::new(),
+ legacy_ws: legacy_ws_unauthenticated(),
}
}
}
- /// Read [`API_TOKEN_ENV`] from the process environment. Returns
- /// `AuthState { token: None }` when the variable is unset or empty.
- pub fn from_env() -> Self {
- match std::env::var(API_TOKEN_ENV) {
- Ok(s) if !s.is_empty() => AuthState::from_token(s),
- _ => AuthState::default(),
+ /// Read the auth configuration from the process environment.
+ ///
+ /// **Fails closed.** If OAuth is requested but cannot be made to work — the
+ /// issuer is empty, or the JWKS cannot be fetched at boot — this returns
+ /// `Err` and the caller must refuse to serve `/api/v1/*`. Starting anyway
+ /// would mean an operator who asked for OAuth silently gets either an open
+ /// API or a single-shared-secret one, which is precisely the failure mode
+ /// that makes people distrust an auth switch.
+ ///
+ /// The JWKS is fetched eagerly for the same reason: a misconfigured
+ /// `jwks_uri` should fail at boot with a legible message, not as a puzzling
+ /// 401 on some user's first request an hour later.
+ pub fn from_env() -> Result {
+ let token = match std::env::var(API_TOKEN_ENV) {
+ Ok(s) if !s.is_empty() => Some(Arc::new(s)),
+ _ => None,
+ };
+
+ let oauth = match std::env::var(OAUTH_ISSUER_ENV) {
+ Ok(issuer) if !issuer.trim().is_empty() => {
+ let issuer = issuer.trim().trim_end_matches('/').to_string();
+ let jwks_url = std::env::var(OAUTH_JWKS_URL_ENV)
+ .ok()
+ .filter(|u| !u.trim().is_empty())
+ .unwrap_or_else(|| format!("{issuer}/.well-known/jwks.json"));
+
+ let jwks = JwksCache::new(jwks_url.clone(), Box::new(UreqFetcher::new()));
+ let key_count =
+ jwks.warm()
+ .map_err(|source| OAuthConfigError::JwksUnreachable {
+ url: jwks_url.clone(),
+ source,
+ })?;
+ tracing::info!(
+ issuer = %issuer,
+ jwks_url = %jwks_url,
+ key_count,
+ "Cognitum OAuth enabled for /api/v1/*"
+ );
+ let allowed_client_ids = allowed_client_ids();
+ if allowed_client_ids.is_empty() {
+ tracing::warn!(
+ "{OAUTH_CLIENT_IDS_ENV}=* — this server accepts a Cognitum token minted \
+ for ANY product, not just RuView. `client_id` is the platform's stand-in \
+ for `aud`; disabling it leaves scope as the only boundary."
+ );
+ } else {
+ tracing::info!(accepted_clients = ?allowed_client_ids, "OAuth audience restricted");
+ }
+ Some(Arc::new(OAuthState { jwks, issuer, allowed_client_ids }))
+ }
+ Ok(_) => return Err(OAuthConfigError::EmptyIssuer),
+ Err(_) => None,
+ };
+
+ let legacy_ws = legacy_ws_unauthenticated();
+ if legacy_ws && (token.is_some() || oauth.is_some()) {
+ tracing::warn!(
+ "{LEGACY_WS_ENV} is set: WebSocket upgrades ({}) accept connections with NO \
+ credential even though API auth is ON. The live sensing stream — presence, \
+ pose and vital signs — is readable by anyone who can reach this port. This is \
+ a migration aid for UIs not yet updated to fetch a ticket; unset it as soon \
+ as the UI is updated.",
+ WS_PATHS.join(", ")
+ );
}
+ Ok(AuthState {
+ token,
+ oauth,
+ tickets: crate::ws_ticket::TicketStore::new(),
+ legacy_ws,
+ })
+ }
+
+ /// The ticket store, for the `POST /api/v1/ws-ticket` handler.
+ pub fn tickets(&self) -> &crate::ws_ticket::TicketStore {
+ &self.tickets
+ }
+
+ /// Issuer origin, when OAuth is enabled.
+ pub fn oauth_issuer(&self) -> Option {
+ self.oauth.as_ref().map(|o| o.issuer.clone())
+ }
+
+ /// The client id to present when starting a browser sign-in.
+ pub fn primary_client_id(&self) -> String {
+ self.oauth
+ .as_ref()
+ .and_then(|o| o.allowed_client_ids.first().cloned())
+ .unwrap_or_else(|| DEFAULT_CLIENT_ID.to_string())
+ }
+
+ /// Verify a token obtained through the browser flow, using the SAME rules
+ /// as every other request — a sign-in path must not be a softer one.
+ pub fn verify_for_browser(
+ &self,
+ token: &str,
+ ) -> Result {
+ let oauth = self
+ .oauth
+ .as_ref()
+ .ok_or(ruview_auth::VerifyError::MissingBearer)?;
+ verify_access_token(
+ token,
+ &oauth.jwks,
+ &VerifierConfig {
+ issuer: oauth.issuer.clone(),
+ required_scope: scope::SENSING_READ.to_string(),
+ allowed_client_ids: oauth.allowed_client_ids.clone(),
+ },
+ )
+ }
+
+ /// Whether the legacy unauthenticated-WebSocket escape hatch is active.
+ pub fn legacy_ws_enabled(&self) -> bool {
+ self.legacy_ws
}
/// Whether the middleware will enforce auth on `/api/v1/*` requests.
pub fn is_enabled(&self) -> bool {
+ self.token.is_some() || self.oauth.is_some()
+ }
+
+ /// Whether Cognitum OAuth verification is active.
+ pub fn oauth_enabled(&self) -> bool {
+ self.oauth.is_some()
+ }
+
+ /// Whether the legacy static `RUVIEW_API_TOKEN` is configured.
+ pub fn static_token_enabled(&self) -> bool {
self.token.is_some()
}
}
+/// The scope a request must carry, split by **blast radius** (ADR-060): can
+/// this call destroy something, or only observe?
+///
+/// **Reads are open; writes are closed unless explicitly allowlisted.**
+///
+/// An earlier revision enumerated the admin routes by prefix and let everything
+/// else fall through to `sensing:read`. That is the wrong polarity for a
+/// security gate and it shipped a real hole: `POST /api/v1/adaptive/train`
+/// trains a classifier, overwrites the on-disk model and swaps the live one —
+/// but it does not start with `/api/v1/train/`, so it landed on `sensing:read`,
+/// the scope `wifi-densepose login` requests BY DEFAULT. A denylist for a scope
+/// gate will keep missing routes as routes keep being added.
+///
+/// So: `GET`/`HEAD`/`OPTIONS` need `sensing:read`. Any other method needs
+/// `sensing:admin` unless its exact path is in [`READ_SAFE_MUTATIONS`] — routes
+/// that change runtime state but destroy nothing. A new mutating route added
+/// without thought is therefore admin-gated by default, which is the safe way
+/// to be wrong.
+///
+/// `sensing:read` is 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 fn required_scope_for(method: &Method, path: &str) -> &'static str {
+ // Reads are open to `sensing:read`.
+ if !is_mutating(method) {
+ return scope::SENSING_READ;
+ }
+ // A small, explicit allowlist of mutating routes that change runtime state
+ // but destroy nothing — a dashboard doing its ordinary job.
+ if READ_SAFE_MUTATIONS.contains(&path)
+ || (path.starts_with("/api/v1/rf/vendors/") && path.ends_with("/events"))
+ {
+ return scope::SENSING_READ;
+ }
+ // Everything else that mutates requires admin. FAIL CLOSED — see the docs
+ // above for why this is a default rather than a list.
+ scope::SENSING_ADMIN
+}
+
+fn is_mutating(method: &Method) -> bool {
+ !matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS)
+}
+
+/// Mutating routes that need only `sensing:read`.
+///
+/// Deliberately an ALLOWLIST. Everything absent from it that mutates requires
+/// `sensing:admin`, so adding a route without thinking about scope fails safe
+/// instead of silently landing on read.
+const READ_SAFE_MUTATIONS: &[&str] = &[
+ // Browsers must be able to obtain a WebSocket ticket with a read token,
+ // or the read scope cannot open a stream at all.
+ "/api/v1/ws-ticket",
+ // Load/unload/activate: reversible, destroy nothing.
+ "/api/v1/models/load",
+ "/api/v1/models/unload",
+ "/api/v1/models/lora/activate",
+ "/api/v1/model/sona/activate",
+ "/api/v1/adaptive/unload",
+ // Capture and calibration: create data, never destroy it.
+ "/api/v1/calibration/start",
+ "/api/v1/calibration/stop",
+ "/api/v1/pose/calibrate",
+ "/api/v1/recording/start",
+ "/api/v1/recording/stop",
+];
+
/// Constant-time byte slice equality. Returns `false` immediately on length
/// mismatch (lengths are not secret here — both sides are fixed tokens).
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
@@ -96,17 +514,46 @@ fn ct_eq(a: &[u8], b: &[u8]) -> bool {
/// [`axum::middleware::from_fn_with_state`].
pub async fn require_bearer(
State(auth): State,
- request: Request,
+ mut request: Request,
next: Next,
) -> Response {
- let Some(expected) = auth.token.clone() else {
- return next.run(request).await;
- };
- let path = request.uri().path();
- if !path.starts_with(PROTECTED_PREFIX) || EXEMPT_PATHS.contains(&path) {
+ if !auth.is_enabled() {
return next.run(request).await;
}
- let supplied = request
+ let path = request.uri().path().to_string();
+
+ // WebSocket upgrades: bearer OR single-use ticket (ADR-272). Checked before
+ // the prefix test because `/api/v1/stream/pose` is both a WS path and under
+ // the protected prefix.
+ if is_ws_path(&path) {
+ if auth.legacy_ws {
+ return next.run(request).await;
+ }
+ if let Some(ticket) = crate::ws_ticket::ticket_from_uri(request.uri()) {
+ // Consumed here — one attempt per ticket, valid or not, so a
+ // guessed value cannot be retried and a real one cannot be replayed.
+ if let Some(grant) = auth.tickets.consume(&ticket) {
+ let holds_read = grant
+ .scopes
+ .as_deref()
+ // `None` = issued by the legacy static token, which predates
+ // scopes and carries full authority.
+ .map_or(true, |s| s.split_whitespace().any(|x| x == scope::SENSING_READ));
+ if holds_read {
+ tracing::debug!(path = %path, subject = ?grant.subject, "WebSocket authorized by ticket");
+ return next.run(request).await;
+ }
+ tracing::debug!(path = %path, "ticket lacked the scope this stream requires");
+ }
+ return unauthorized(&auth);
+ }
+ // No ticket: fall through to the bearer path below, which is how a
+ // native (non-browser) client authenticates a WebSocket.
+ } else if is_anonymous(&path) {
+ return next.run(request).await;
+ }
+
+ let Some(supplied) = request
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
@@ -120,21 +567,235 @@ pub async fn require_bearer(
scheme
.eq_ignore_ascii_case("Bearer")
.then(|| token.trim_start())
- });
- let ok = supplied
- .map(|s| ct_eq(s.as_bytes(), expected.as_bytes()))
- .unwrap_or(false);
- if ok {
- next.run(request).await
- } else {
- (
- StatusCode::UNAUTHORIZED,
- "missing or invalid bearer token (set Authorization: Bearer )\n",
- )
- .into_response()
+ })
+ else {
+ // No bearer header at all — a browser session may still authorize this.
+ return session_or_unauthorized(&auth, request, next).await;
+ };
+
+ // 1. Legacy static token. Unchanged, and tried first so an existing
+ // deployment behaves identically even with OAuth switched on.
+ if let Some(expected) = auth.token.as_ref() {
+ if ct_eq(supplied.as_bytes(), expected.as_bytes()) {
+ return next.run(request).await;
+ }
}
+
+ // 2. Cognitum OAuth (ADR-271).
+ if let Some(oauth) = auth.oauth.as_ref() {
+ let required = if is_ws_path(&path) {
+ // A stream is a read, regardless of the HTTP verb on the upgrade.
+ scope::SENSING_READ
+ } else {
+ required_scope_for(request.method(), &path)
+ };
+ // Verification can hit the network: a `kid` miss or an expired cache
+ // makes `JwksCache` perform a BLOCKING `ureq` fetch (3s connect + 3s
+ // read). Doing that inline parks the tokio worker running this request,
+ // and on Pi-class hardware with few workers a handful of concurrent
+ // misses stalls the whole server — including `/health`.
+ //
+ // `main.rs` already does exactly this for the token exchange, noting it
+ // as "the same mistake this codebase had to fix in jwks.rs". The hot
+ // verification path had not been given the same treatment.
+ let token = supplied.to_string();
+ let oauth = Arc::clone(oauth);
+ let required_owned = required.to_string();
+ let verified = tokio::task::spawn_blocking(move || {
+ let config = VerifierConfig {
+ issuer: oauth.issuer.clone(),
+ required_scope: required_owned,
+ allowed_client_ids: oauth.allowed_client_ids.clone(),
+ };
+ verify_access_token(&token, &oauth.jwks, &config)
+ })
+ .await;
+
+ let verified = match verified {
+ Ok(v) => v,
+ Err(join) => {
+ // The blocking task panicked or was cancelled. Fail closed:
+ // "we could not verify" is never "the request is authorized".
+ tracing::error!(error = %join, path = %path.as_str(), "JWKS verification task failed");
+ return unauthorized(&auth);
+ }
+ };
+
+ match verified {
+ Ok(principal) => {
+ tracing::debug!(
+ sub = %principal.subject,
+ account_id = %principal.account_id,
+ client_id = %principal.client_id,
+ jti = %principal.token_id,
+ scope = %required,
+ path = %path.as_str(),
+ "OAuth request authorized"
+ );
+ // Downstream handlers can attribute the request without
+ // re-parsing the token.
+ request.extensions_mut().insert(principal);
+ return next.run(request).await;
+ }
+ Err(e) => {
+ // Logged, never returned: the reason a token failed is useful
+ // to an operator and useful to an attacker probing for which
+ // claim to forge next. The response stays a flat 401.
+ tracing::debug!(error = %e, path = %path.as_str(), required_scope = %required, "OAuth verification failed");
+ return unauthorized(&auth);
+ }
+ }
+ }
+
+ // 3. Browser session cookie (ADR-271 browser half). Checked last: it is the
+ // weakest-bound credential (host-only, no proof-of-possession), so a
+ // presented bearer or ticket should win.
+ if auth.oauth.is_some() {
+ if let Some(cookie_header) = request
+ .headers()
+ .get(axum::http::header::COOKIE)
+ .and_then(|v| v.to_str().ok())
+ {
+ if let Some(session) = crate::browser_session::from_cookie_header(cookie_header) {
+ let required = if is_ws_path(&path) {
+ scope::SENSING_READ
+ } else {
+ required_scope_for(request.method(), &path)
+ };
+ // Step-up: a privileged action needs a RECENT authentication,
+ // not merely a live session. The session outlives the access
+ // token that created it and Cognitum offers no introspection,
+ // so a stale session is authority we cannot revoke — bounded
+ // here to the routes where that authority actually does damage.
+ //
+ // Ordered AFTER the scope check on purpose. Asking someone who
+ // does not hold `sensing:admin` to re-authenticate sends them
+ // through a redirect that cannot possibly help: they come back
+ // with the same scopes and are refused again. Only a caller who
+ // actually holds the capability is asked to prove it is fresh.
+ if session.has_scope(required)
+ && required == scope::SENSING_ADMIN
+ && !session.recently_authenticated()
+ {
+ tracing::debug!(
+ sub = %session.subject,
+ path = %path.as_str(),
+ "browser session is too old for a privileged action; re-authentication required"
+ );
+ return reauthentication_required(&auth);
+ }
+ if session.has_scope(required) {
+ tracing::debug!(
+ sub = %session.subject,
+ scope = %required,
+ path = %path.as_str(),
+ "request authorized by browser session"
+ );
+ return next.run(request).await;
+ }
+ tracing::debug!(
+ path = %path.as_str(),
+ required_scope = %required,
+ "browser session lacks the scope this route requires"
+ );
+ }
+ }
+ }
+
+ unauthorized(&auth)
}
+/// The no-bearer path: try a browser session cookie, else 401.
+async fn session_or_unauthorized(auth: &AuthState, request: Request, next: Next) -> Response {
+ let path = request.uri().path().to_string();
+ if auth.oauth.is_some() {
+ if let Some(h) = request
+ .headers()
+ .get(axum::http::header::COOKIE)
+ .and_then(|v| v.to_str().ok())
+ {
+ if let Some(session) = crate::browser_session::from_cookie_header(h) {
+ let required = if is_ws_path(&path) {
+ scope::SENSING_READ
+ } else {
+ required_scope_for(request.method(), &path)
+ };
+ // Step-up: a privileged action needs a RECENT authentication,
+ // not merely a live session. The session outlives the access
+ // token that created it and Cognitum offers no introspection,
+ // so a stale session is authority we cannot revoke — bounded
+ // here to the routes where that authority actually does damage.
+ //
+ // Ordered AFTER the scope check on purpose. Asking someone who
+ // does not hold `sensing:admin` to re-authenticate sends them
+ // through a redirect that cannot possibly help: they come back
+ // with the same scopes and are refused again. Only a caller who
+ // actually holds the capability is asked to prove it is fresh.
+ if session.has_scope(required)
+ && required == scope::SENSING_ADMIN
+ && !session.recently_authenticated()
+ {
+ tracing::debug!(
+ sub = %session.subject,
+ path = %path.as_str(),
+ "browser session is too old for a privileged action; re-authentication required"
+ );
+ return reauthentication_required(&auth);
+ }
+ if session.has_scope(required) {
+ tracing::debug!(sub = %session.subject, path = %path.as_str(), "browser session authorized");
+ return next.run(request).await;
+ }
+ }
+ }
+ }
+ unauthorized(auth)
+}
+
+/// A uniform 401. The hint names whichever credentials are actually accepted,
+/// so an operator is not told to set a variable this server ignores — but it
+/// never says *why* a presented token failed.
+fn unauthorized(auth: &AuthState) -> Response {
+ let body = match (auth.token.is_some(), auth.oauth.is_some()) {
+ (true, true) => concat!(
+ "missing or invalid bearer token\n",
+ "accepted: Authorization: Bearer , ",
+ "or a Cognitum OAuth access token with the scope this route requires\n"
+ ),
+ (false, true) => concat!(
+ "missing or invalid bearer token\n",
+ "accepted: a Cognitum OAuth access token with the scope this route requires\n"
+ ),
+ _ => "missing or invalid bearer token (set Authorization: Bearer )\n",
+ };
+ (StatusCode::UNAUTHORIZED, body).into_response()
+}
+
+/// 401 for a browser session that is valid but too old for a privileged action.
+///
+/// Distinguished from a plain 401 by an RFC 6750 §3 `WWW-Authenticate` error
+/// code, because the client's correct response is different: not "sign in",
+/// which it already has, but "prove it again". Without a distinguishable signal
+/// the UI would surface a stale-session delete as a generic failure, and the
+/// user would have no idea that re-signing-in fixes it.
+///
+/// This leaks nothing: the caller already knows it was refused, and the code
+/// says only that the *session age* was the reason.
+fn reauthentication_required(auth: &AuthState) -> Response {
+ let mut resp = unauthorized(auth);
+ resp.headers_mut().insert(
+ axum::http::header::WWW_AUTHENTICATE,
+ axum::http::HeaderValue::from_static(
+ r#"Bearer error="invalid_token", error_description="reauthentication required for a privileged action""#,
+ ),
+ );
+ resp
+}
+
+/// Convenience re-export so handlers can name the type they pull out of
+/// request extensions without depending on `ruview-auth` directly.
+pub use ruview_auth::Principal as AuthenticatedPrincipal;
+
#[cfg(test)]
mod tests {
use super::*;
@@ -146,6 +807,43 @@ mod tests {
};
use tower::ServiceExt;
+ /// ONE test, not three, because `allowed_client_ids` reads process-global
+ /// env and cargo runs tests on parallel threads — three tests mutating
+ /// `RUVIEW_OAUTH_CLIENT_IDS` race and fail intermittently.
+ #[test]
+ fn client_id_allowlist_parsing_never_silently_opts_out() {
+ let read = |v: &str| {
+ std::env::set_var(OAUTH_CLIENT_IDS_ENV, v);
+ let ids = allowed_client_ids();
+ std::env::remove_var(OAUTH_CLIENT_IDS_ENV);
+ ids
+ };
+
+ // An empty allowlist is the OPT-OUT sentinel in `verify.rs` — it skips
+ // the audience check entirely. So a value that is non-empty but parses
+ // to nothing (a stray trailing comma, `","`) would silently turn the
+ // boundary off and admit a token minted for any other Cognitum product.
+ // Same fail-open shape as the scope denylist this PR already inverted.
+ for bad in [",", " , ", ",,,", " "] {
+ let ids = read(bad);
+ assert!(
+ !ids.is_empty(),
+ "{bad:?} produced an empty allowlist, which disables the audience check"
+ );
+ assert_eq!(ids, vec![DEFAULT_CLIENT_ID.to_string()]);
+ }
+
+ // The deliberate escape hatch must keep working, or the fix above would
+ // be a behaviour change wearing a security label.
+ assert!(read("*").is_empty(), "`*` must remain the way to accept any client");
+
+ // And a real list must still parse, trailing comma and all.
+ assert_eq!(
+ read(" ruview , musica ,"),
+ vec!["ruview".to_string(), "musica".to_string()]
+ );
+ }
+
fn ok_handler() -> Router {
Router::new()
.route("/health", get(|| async { "ok" }))
@@ -372,20 +1070,28 @@ mod tests {
);
}
- /// `/api/v1/stream/pose` is a WebSocket upgrade the browser `WebSocket`
- /// constructor drives directly — it cannot attach an `Authorization`
- /// header, so this path must stay reachable even with auth ON (mirrors
- /// the existing `/ws/sensing` exemption, just inside the `/api/v1/*`
- /// prefix this time).
+ /// SUPERSEDED by ADR-272. This was `enabled_exempts_pose_stream_websocket`,
+ /// which asserted `/api/v1/stream/pose` stayed reachable with no bearer
+ /// because a browser cannot set `Authorization` on an upgrade (PR #1313).
+ ///
+ /// That reasoning about browsers is still true — the conclusion was not.
+ /// Measured on a server with auth ON: a credential-less handshake to
+ /// `/api/v1/stream/pose`, `/ws/sensing` and `/ws/introspection` all
+ /// returned `101`, so the REST control plane was locked while the live
+ /// sensing stream was open. The browser limitation is now answered by a
+ /// single-use ticket rather than by an exemption.
+ ///
+ /// The half of the original test that still matters is kept: whatever the
+ /// WebSocket rule is, it must not leak to other `/api/v1/*` paths.
#[tokio::test]
- async fn enabled_exempts_pose_stream_websocket() {
+ async fn the_pose_stream_websocket_is_no_longer_exempt() {
let r = wrap(AuthState::from_token("s3cr3t"));
assert_eq!(
status(r.clone(), "GET", "/api/v1/stream/pose", None).await,
- StatusCode::OK,
- "pose stream WS must stay reachable without a bearer token"
+ StatusCode::UNAUTHORIZED,
+ "the pose stream must no longer accept a credential-less upgrade"
);
- // The exemption is narrow: it must not leak to other /api/v1/* paths.
+ // Preserved from the original: the WebSocket rule stays narrow.
assert_eq!(
status(r, "GET", "/api/v1/info", None).await,
StatusCode::UNAUTHORIZED
@@ -416,3 +1122,935 @@ mod tests {
assert_eq!(PROTECTED_PREFIX, "/api/v1/");
}
}
+
+/// ADR-271 — the OAuth path and the scope gate, exercised end to end through a
+/// real Router: request → middleware → `ruview-auth` verifier → handler.
+///
+/// Tokens are real ES256 JWTs signed with a key generated at test runtime; no
+/// key material is committed. The verifier's own accept/reject matrix lives in
+/// `ruview-auth`; what is tested here is the wiring — layering with the legacy
+/// static token, which scope each route demands, and that a rejected token
+/// never reaches a handler.
+#[cfg(test)]
+mod oauth_tests {
+ use super::*;
+ use axum::{
+ body::Body,
+ http::{Request, StatusCode},
+ routing::{delete, get, post},
+ Router,
+ };
+ 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};
+ use std::sync::OnceLock;
+ use tower::ServiceExt;
+
+ const KID: &str = "test-kid";
+ const ISSUER: &str = "https://auth.test.local";
+
+ struct TestKey {
+ pem: String,
+ x: String,
+ y: String,
+ }
+
+ fn key() -> &'static TestKey {
+ static K: OnceLock = OnceLock::new();
+ K.get_or_init(|| {
+ let sk = SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
+ let point = sk.verifying_key().to_encoded_point(false);
+ TestKey {
+ pem: sk.to_pkcs8_pem(LineEnding::LF).unwrap().to_string(),
+ x: URL_SAFE_NO_PAD.encode(point.x().unwrap()),
+ y: URL_SAFE_NO_PAD.encode(point.y().unwrap()),
+ }
+ })
+ }
+
+ struct StaticJwks(String);
+ impl JwksFetcher for StaticJwks {
+ fn fetch(&self, _url: &str) -> Result {
+ Ok(self.0.clone())
+ }
+ }
+
+ fn oauth_state() -> Arc {
+ let k = key();
+ let doc = format!(
+ r#"{{"keys":[{{"kty":"EC","crv":"P-256","alg":"ES256","use":"sig","kid":"{KID}","x":"{}","y":"{}"}}]}}"#,
+ k.x, k.y
+ );
+ Arc::new(OAuthState {
+ jwks: JwksCache::new("https://stub/jwks.json", Box::new(StaticJwks(doc))),
+ issuer: ISSUER.to_string(),
+ allowed_client_ids: vec!["ruview".to_string()],
+ })
+ }
+
+ fn token_with_scope(scope_claim: &str) -> String {
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs() as i64;
+ let claims = serde_json::json!({
+ "typ": "access",
+ "sub": "user-1",
+ "account_id": "acct-1",
+ "org_id": "org-1",
+ "workspace_id": "ws-1",
+ "client_id": "ruview",
+ "scope": scope_claim,
+ "jti": "jti-1",
+ "iat": now - 10,
+ "exp": now + 900,
+ "setup": false,
+ "workload": false,
+ // NO `iss`. Real Cognitum tokens carry none — mirroring production
+ // here matters even though the verifier ignores the claim either
+ // way: a fixture that invents a claim reality lacks is exactly what
+ // hid the original `iss` bug for a day. See ruview-auth's
+ // `a_token_with_no_iss_claim_is_accepted_because_cognitum_issues_none`.
+ });
+ let mut header = Header::new(jsonwebtoken::Algorithm::ES256);
+ header.kid = Some(KID.to_string());
+ encode(
+ &header,
+ &claims,
+ &EncodingKey::from_ec_pem(key().pem.as_bytes()).unwrap(),
+ )
+ .unwrap()
+ }
+
+ /// Mirrors the real route shapes the scope gate keys off.
+ fn app(auth: AuthState) -> Router {
+ Router::new()
+ .route("/api/v1/info", get(|| async { "ok" }))
+ .route("/api/v1/models", get(|| async { "ok" }))
+ .route("/api/v1/models/m1", delete(|| async { "deleted" }))
+ .route("/api/v1/recording/r1", delete(|| async { "deleted" }))
+ .route("/api/v1/train/start", post(|| async { "training" }))
+ .layer(axum::middleware::from_fn_with_state(auth, require_bearer))
+ }
+
+ async fn call(auth: AuthState, method: &str, path: &str, bearer: Option<&str>) -> StatusCode {
+ let mut req = Request::builder().method(method).uri(path);
+ if let Some(b) = bearer {
+ req = req.header(AUTHORIZATION, format!("Bearer {b}"));
+ }
+ app(auth)
+ .oneshot(req.body(Body::empty()).unwrap())
+ .await
+ .unwrap()
+ .status()
+ }
+
+ /// Same as [`call`] but presents a browser session cookie instead of a
+ /// bearer. The cookie is minted through `browser_session`'s real signing
+ /// path, so this exercises genuine verification.
+ async fn call_with_session(auth: AuthState, method: &str, path: &str, cookie: &str) -> StatusCode {
+ let req = Request::builder()
+ .method(method)
+ .uri(path)
+ .header(axum::http::header::COOKIE, cookie);
+ app(auth)
+ .oneshot(req.body(Body::empty()).unwrap())
+ .await
+ .unwrap()
+ .status()
+ }
+
+ fn session_cookie(scope_claim: &str, ttl: i64) -> String {
+ format!(
+ "ruview_session={}",
+ crate::browser_session::test_cookie_value("sub-b", "acct-b", scope_claim, ttl)
+ )
+ }
+
+ // ── browser session cookie as a credential ────────────────────────
+ //
+ // The session cookie authorizes /api/v1/* and WebSocket upgrades, and had
+ // no test presenting one at any level — the newest credential in the system
+ // was the one with no executable evidence behind it.
+
+ #[tokio::test]
+ async fn a_read_scoped_browser_session_can_read() {
+ let c = session_cookie(scope::SENSING_READ, 3600);
+ assert_eq!(
+ call_with_session(oauth_only(), "GET", "/api/v1/models", &c).await,
+ StatusCode::OK
+ );
+ }
+
+ #[tokio::test]
+ async fn a_read_scoped_browser_session_cannot_delete_or_train() {
+ // MUTANT THIS KILLS: replacing `session.has_scope(required)` with
+ // `true` in the cookie branch. Without this, anyone signed in through
+ // the browser — the least-bound credential in the system, host-only
+ // with no proof-of-possession — could delete models and recordings and
+ // start training, regardless of what they consented to.
+ let c = session_cookie(scope::SENSING_READ, 3600);
+ for (method, path) in [
+ ("DELETE", "/api/v1/models/m1"),
+ ("DELETE", "/api/v1/recording/r1"),
+ ("POST", "/api/v1/train/start"),
+ ] {
+ assert_eq!(
+ call_with_session(oauth_only(), method, path, &c).await,
+ StatusCode::UNAUTHORIZED,
+ "{method} {path} accepted a read-only browser session"
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn an_admin_scoped_browser_session_can_delete() {
+ // The negative above must not pass merely because cookies never work.
+ let c = session_cookie(
+ &format!("{} {}", scope::SENSING_READ, scope::SENSING_ADMIN),
+ 3600,
+ );
+ assert_eq!(
+ call_with_session(oauth_only(), "DELETE", "/api/v1/models/m1", &c).await,
+ StatusCode::OK
+ );
+ }
+
+ // ── step-up: privileged actions need a RECENT authentication ──────
+
+ fn aged_admin_cookie(age: i64) -> String {
+ format!(
+ "ruview_session={}",
+ crate::browser_session::test_cookie_value_aged(
+ "sub-b",
+ "acct-b",
+ &format!("{} {}", scope::SENSING_READ, scope::SENSING_ADMIN),
+ 3600,
+ age,
+ )
+ )
+ }
+
+ #[tokio::test]
+ async fn a_stale_but_live_session_can_still_read() {
+ // Step-up must not degrade into "re-authenticate every 5 minutes". The
+ // dashboard's primary use is watching a live stream; reads ride the
+ // full session lifetime.
+ let c = aged_admin_cookie(crate::browser_session::ADMIN_REVERIFY_SECS + 60);
+ assert_eq!(
+ call_with_session(oauth_only(), "GET", "/api/v1/models", &c).await,
+ StatusCode::OK
+ );
+ }
+
+ #[tokio::test]
+ async fn a_stale_session_cannot_delete_even_holding_the_admin_scope() {
+ // The session outlives the ~15-minute access token that created it, and
+ // Cognitum publishes no introspection endpoint — so a stale session is
+ // authority nobody can revoke. Bounded to the routes where it does
+ // damage: holding `sensing:admin` is necessary but no longer sufficient.
+ let c = aged_admin_cookie(crate::browser_session::ADMIN_REVERIFY_SECS + 60);
+ for (method, path) in [
+ ("DELETE", "/api/v1/models/m1"),
+ ("DELETE", "/api/v1/recording/r1"),
+ ("POST", "/api/v1/train/start"),
+ ] {
+ assert_eq!(
+ call_with_session(oauth_only(), method, path, &c).await,
+ StatusCode::UNAUTHORIZED,
+ "{method} {path} accepted a stale session for a privileged action"
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn a_read_only_user_is_not_sent_to_reauthenticate_pointlessly() {
+ // The scope check must come FIRST. A caller without `sensing:admin`
+ // cannot be helped by re-authenticating — they return with the same
+ // scopes and are refused again — so sending the step-up challenge would
+ // cost them a redirect and tell them something untrue about why they
+ // were refused. Only a caller who actually HOLDS the capability is
+ // asked to prove it is fresh.
+ let stale_read_only = format!(
+ "ruview_session={}",
+ crate::browser_session::test_cookie_value_aged(
+ "sub-b",
+ "acct-b",
+ scope::SENSING_READ,
+ 3600,
+ crate::browser_session::ADMIN_REVERIFY_SECS + 60,
+ )
+ );
+ let req = Request::builder()
+ .method("DELETE")
+ .uri("/api/v1/models/m1")
+ .header(axum::http::header::COOKIE, &stale_read_only);
+ let resp = app(oauth_only())
+ .oneshot(req.body(Body::empty()).unwrap())
+ .await
+ .unwrap();
+
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+ let challenge = resp
+ .headers()
+ .get(axum::http::header::WWW_AUTHENTICATE)
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or_default();
+ assert!(
+ !challenge.contains("reauthentication required"),
+ "a read-only caller must not be told to re-authenticate: {challenge:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn an_admin_holder_with_a_stale_session_does_get_the_challenge() {
+ // The counterpart: the signal must actually fire for the caller it can
+ // help, or the UI never learns to send them back through /oauth/start.
+ let c = aged_admin_cookie(crate::browser_session::ADMIN_REVERIFY_SECS + 60);
+ let req = Request::builder()
+ .method("DELETE")
+ .uri("/api/v1/models/m1")
+ .header(axum::http::header::COOKIE, &c);
+ let resp = app(oauth_only())
+ .oneshot(req.body(Body::empty()).unwrap())
+ .await
+ .unwrap();
+
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+ let challenge = resp
+ .headers()
+ .get(axum::http::header::WWW_AUTHENTICATE)
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or_default();
+ assert!(
+ challenge.contains("reauthentication required"),
+ "an admin holder with a stale session must be told to re-authenticate: {challenge:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn a_freshly_authenticated_session_can_delete() {
+ // The negative above must not pass merely because admin never works.
+ let c = aged_admin_cookie(0);
+ assert_eq!(
+ call_with_session(oauth_only(), "DELETE", "/api/v1/models/m1", &c).await,
+ StatusCode::OK
+ );
+ }
+
+ #[tokio::test]
+ async fn a_session_cookie_predating_auth_time_cannot_perform_admin_actions() {
+ // Cookies issued before `auth_time` existed deserialize with 0, which is
+ // infinitely stale. They must degrade to read-only rather than being
+ // treated as freshly authenticated — fail closed, and self-healing the
+ // next time the user signs in.
+ let legacy = serde_json::json!({
+ "subject": "sub-old",
+ "account_id": "acct-old",
+ "scope": "sensing:read sensing:admin",
+ "exp": chrono::Utc::now().timestamp() + 3600,
+ });
+ let raw = crate::browser_session::test_sign_for_tests(&serde_json::to_vec(&legacy).unwrap());
+ let c = format!("ruview_session={raw}");
+
+ assert_eq!(
+ call_with_session(oauth_only(), "GET", "/api/v1/models", &c).await,
+ StatusCode::OK,
+ "an old cookie must keep working for reads"
+ );
+ assert_eq!(
+ call_with_session(oauth_only(), "DELETE", "/api/v1/models/m1", &c).await,
+ StatusCode::UNAUTHORIZED,
+ "an old cookie must not carry privileged authority"
+ );
+ }
+
+ #[tokio::test]
+ async fn an_expired_browser_session_is_refused() {
+ let c = session_cookie(scope::SENSING_READ, -1);
+ assert_eq!(
+ call_with_session(oauth_only(), "GET", "/api/v1/models", &c).await,
+ StatusCode::UNAUTHORIZED
+ );
+ }
+
+ #[tokio::test]
+ async fn a_bad_bearer_beats_a_good_cookie_rather_than_falling_back() {
+ // Pins REAL precedence, which is not what the ordering comment in
+ // `require_bearer` implies. When an Authorization header is present the
+ // OAuth step returns on BOTH arms, so the cookie branch that follows it
+ // is unreachable — a browser holding a valid session that also sends a
+ // stale bearer gets 401 rather than falling back to its cookie.
+ //
+ // Verified empirically: mutating that branch's `has_scope` to `true`
+ // changes no test outcome, while mutating the one in
+ // `session_or_unauthorized` fails `a_read_scoped_browser_session_
+ // cannot_delete_or_train`.
+ //
+ // This fails CLOSED, so it is not a hole — but it was undocumented and
+ // untested, and "try the next credential" is what the code reads like.
+ // Pinned here so changing it is a decision rather than an accident.
+ let c = session_cookie(scope::SENSING_READ, 3600);
+ let req = Request::builder()
+ .method("GET")
+ .uri("/api/v1/models")
+ .header(AUTHORIZATION, "Bearer not-a-valid-token")
+ .header(axum::http::header::COOKIE, &c);
+ let status = app(oauth_only())
+ .oneshot(req.body(Body::empty()).unwrap())
+ .await
+ .unwrap()
+ .status();
+ assert_eq!(
+ status,
+ StatusCode::UNAUTHORIZED,
+ "a presented bearer is authoritative; the cookie is not consulted after it fails"
+ );
+ }
+
+ #[tokio::test]
+ async fn a_forged_browser_session_is_refused() {
+ let c = "ruview_session=Zm9yZ2Vk.bm90LWEtdmFsaWQtbWFj";
+ assert_eq!(
+ call_with_session(oauth_only(), "GET", "/api/v1/models", c).await,
+ StatusCode::UNAUTHORIZED
+ );
+ }
+
+ fn oauth_only() -> AuthState {
+ AuthState {
+ token: None,
+ oauth: Some(oauth_state()),
+ tickets: crate::ws_ticket::TicketStore::new(),
+ legacy_ws: false,
+ }
+ }
+
+ // ── scope policy (pure) ───────────────────────────────────────────
+
+ #[test]
+ fn training_requires_the_admin_scope() {
+ assert_eq!(
+ required_scope_for(&Method::POST, "/api/v1/train/start"),
+ scope::SENSING_ADMIN
+ );
+ }
+
+ #[test]
+ fn deleting_a_model_or_recording_requires_the_admin_scope() {
+ assert_eq!(
+ required_scope_for(&Method::DELETE, "/api/v1/models/m1"),
+ scope::SENSING_ADMIN
+ );
+ assert_eq!(
+ required_scope_for(&Method::DELETE, "/api/v1/recording/r1"),
+ scope::SENSING_ADMIN
+ );
+ }
+
+ #[test]
+ fn reading_models_is_not_admin_merely_because_the_path_matches() {
+ // The gate is (method, path), not path alone — GET on the same prefix
+ // must stay a read.
+ assert_eq!(
+ required_scope_for(&Method::GET, "/api/v1/models/m1"),
+ scope::SENSING_READ
+ );
+ }
+
+ #[test]
+ fn non_destructive_mutations_stay_read_scoped() {
+ // Loading a model changes server state but destroys nothing. Putting it
+ // behind the destructive scope would push routine dashboard use into
+ // asking for delete capability — the opposite of least privilege.
+ assert_eq!(
+ required_scope_for(&Method::POST, "/api/v1/models/load"),
+ scope::SENSING_READ
+ );
+ assert_eq!(
+ required_scope_for(&Method::POST, "/api/v1/recording/start"),
+ scope::SENSING_READ
+ );
+ }
+
+ // ── wiring ────────────────────────────────────────────────────────
+
+ #[tokio::test]
+ async fn a_read_scoped_token_reaches_a_read_route() {
+ let t = token_with_scope(scope::SENSING_READ);
+ assert_eq!(
+ call(oauth_only(), "GET", "/api/v1/info", Some(&t)).await,
+ StatusCode::OK
+ );
+ }
+
+ #[tokio::test]
+ async fn a_read_scoped_token_cannot_delete_a_model() {
+ // The whole point of the split: a dashboard session streaming poses
+ // must not be able to destroy the model it streams through.
+ let t = token_with_scope(scope::SENSING_READ);
+ assert_eq!(
+ call(oauth_only(), "DELETE", "/api/v1/models/m1", Some(&t)).await,
+ StatusCode::UNAUTHORIZED
+ );
+ }
+
+ #[tokio::test]
+ async fn a_read_scoped_token_cannot_start_training() {
+ let t = token_with_scope(scope::SENSING_READ);
+ assert_eq!(
+ call(oauth_only(), "POST", "/api/v1/train/start", Some(&t)).await,
+ StatusCode::UNAUTHORIZED
+ );
+ }
+
+ #[tokio::test]
+ async fn an_admin_scoped_token_may_delete_and_train() {
+ let t = token_with_scope("sensing:read sensing:admin");
+ assert_eq!(
+ call(oauth_only(), "DELETE", "/api/v1/models/m1", Some(&t)).await,
+ StatusCode::OK
+ );
+ assert_eq!(
+ call(oauth_only(), "POST", "/api/v1/train/start", Some(&t)).await,
+ StatusCode::OK
+ );
+ }
+
+ #[tokio::test]
+ async fn an_inference_token_from_another_cognitum_product_is_refused_everywhere() {
+ // The cross-product case. Correctly signed, unexpired, right issuer —
+ // only the scope stops it. Asserted here at the middleware layer too,
+ // because this is where a wiring mistake would actually let it through.
+ let t = token_with_scope("inference");
+ for (m, p) in [
+ ("GET", "/api/v1/info"),
+ ("DELETE", "/api/v1/models/m1"),
+ ("POST", "/api/v1/train/start"),
+ ] {
+ assert_eq!(
+ call(oauth_only(), m, p, Some(&t)).await,
+ StatusCode::UNAUTHORIZED,
+ "{m} {p} must reject an inference-only token"
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn a_garbage_bearer_is_refused_when_only_oauth_is_configured() {
+ assert_eq!(
+ call(oauth_only(), "GET", "/api/v1/info", Some("not-a-jwt")).await,
+ StatusCode::UNAUTHORIZED
+ );
+ }
+
+ #[tokio::test]
+ async fn no_credential_is_refused_when_only_oauth_is_configured() {
+ assert_eq!(
+ call(oauth_only(), "GET", "/api/v1/info", None).await,
+ StatusCode::UNAUTHORIZED
+ );
+ }
+
+ // ── layering with the legacy static token ─────────────────────────
+
+ fn both() -> AuthState {
+ AuthState {
+ token: Some(Arc::new("legacy-secret".to_string())),
+ oauth: Some(oauth_state()),
+ tickets: crate::ws_ticket::TicketStore::new(),
+ legacy_ws: false,
+ }
+ }
+
+ #[tokio::test]
+ async fn the_legacy_static_token_still_works_with_oauth_enabled() {
+ // Backward compatibility: turning OAuth on must not break a deployment
+ // that has been using RUVIEW_API_TOKEN.
+ assert_eq!(
+ call(both(), "GET", "/api/v1/info", Some("legacy-secret")).await,
+ StatusCode::OK
+ );
+ }
+
+ #[tokio::test]
+ async fn the_legacy_static_token_is_not_scope_gated() {
+ // It predates scopes and carries no claims, so it keeps the full
+ // access it has always had. Narrowing it here would be a silent
+ // breaking change to existing deployments; migrating to OAuth is how
+ // an operator opts into the finer split.
+ assert_eq!(
+ call(both(), "POST", "/api/v1/train/start", Some("legacy-secret")).await,
+ StatusCode::OK
+ );
+ }
+
+ #[tokio::test]
+ async fn an_oauth_token_works_alongside_a_configured_static_token() {
+ let t = token_with_scope(scope::SENSING_READ);
+ assert_eq!(
+ call(both(), "GET", "/api/v1/info", Some(&t)).await,
+ StatusCode::OK
+ );
+ }
+
+ #[tokio::test]
+ async fn a_wrong_static_token_falls_through_to_oauth_and_is_refused() {
+ assert_eq!(
+ call(both(), "GET", "/api/v1/info", Some("wrong-secret")).await,
+ StatusCode::UNAUTHORIZED
+ );
+ }
+
+ // ── attribution ───────────────────────────────────────────────────
+
+ #[tokio::test]
+ async fn the_verified_principal_is_available_to_handlers() {
+ // The reason for moving off a shared secret: requests become
+ // attributable. If the principal is not in extensions, no handler and
+ // no audit log can name who called.
+ async fn echo(req: Request) -> String {
+ match req.extensions().get::() {
+ Some(p) => format!("{}|{}|{}", p.subject, p.account_id, p.client_id),
+ None => "none".to_string(),
+ }
+ }
+ let router = Router::new()
+ .route("/api/v1/whoami", get(echo))
+ .layer(axum::middleware::from_fn_with_state(
+ oauth_only(),
+ require_bearer,
+ ));
+ let t = token_with_scope(scope::SENSING_READ);
+ let resp = router
+ .oneshot(
+ Request::builder()
+ .uri("/api/v1/whoami")
+ .header(AUTHORIZATION, format!("Bearer {t}"))
+ .body(Body::empty())
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
+ assert_eq!(String::from_utf8_lossy(&body), "user-1|acct-1|ruview");
+ }
+
+ // ── the unset case must stay untouched ────────────────────────────
+
+ #[tokio::test]
+ async fn with_neither_credential_configured_the_middleware_is_still_a_no_op() {
+ assert_eq!(
+ call(AuthState::default(), "POST", "/api/v1/train/start", None).await,
+ StatusCode::OK
+ );
+ }
+}
+
+/// ADR-272 — WebSocket gating. These pin the hole that was measured open:
+/// with auth ON, a credential-less upgrade to `/ws/sensing` returned 101.
+#[cfg(test)]
+mod ws_gate_tests {
+ use super::*;
+ use crate::ws_ticket::TicketGrant;
+ use axum::{
+ body::Body,
+ http::{Request, StatusCode},
+ routing::get,
+ Router,
+ };
+ use tower::ServiceExt;
+
+ fn app(auth: AuthState) -> Router {
+ Router::new()
+ .route("/ws/sensing", get(|| async { "stream" }))
+ .route("/ws/introspection", get(|| async { "introspect" }))
+ .route("/api/v1/stream/pose", get(|| async { "pose" }))
+ .route("/api/v1/models", get(|| async { "models" }))
+ .layer(axum::middleware::from_fn_with_state(auth, require_bearer))
+ }
+
+ async fn get_status(auth: AuthState, uri: &str, bearer: Option<&str>) -> StatusCode {
+ let mut req = Request::builder().method("GET").uri(uri);
+ if let Some(b) = bearer {
+ req = req.header(AUTHORIZATION, format!("Bearer {b}"));
+ }
+ app(auth)
+ .oneshot(req.body(Body::empty()).unwrap())
+ .await
+ .unwrap()
+ .status()
+ }
+
+ fn static_auth() -> AuthState {
+ AuthState {
+ token: Some(Arc::new("secret".into())),
+ oauth: None,
+ tickets: crate::ws_ticket::TicketStore::new(),
+ legacy_ws: false,
+ }
+ }
+
+ #[tokio::test]
+ async fn every_websocket_path_refuses_an_unauthenticated_upgrade() {
+ // The measured regression: all three answered 101 before this change.
+ for p in WS_PATHS {
+ assert_eq!(
+ get_status(static_auth(), p, None).await,
+ StatusCode::UNAUTHORIZED,
+ "{p} must not accept a credential-less upgrade"
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn a_native_client_may_authenticate_a_websocket_with_a_bearer() {
+ // Python / CLI / MCP are not browser-constrained and must not be forced
+ // through the ticket round-trip.
+ for p in WS_PATHS {
+ assert_eq!(
+ get_status(static_auth(), p, Some("secret")).await,
+ StatusCode::OK,
+ "{p} must accept a bearer on the upgrade"
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn a_valid_ticket_authorizes_exactly_one_upgrade() {
+ let auth = static_auth();
+ let ticket = auth
+ .tickets()
+ .issue(TicketGrant { scopes: None, subject: None })
+ .unwrap();
+ let uri = format!("/ws/sensing?ticket={ticket}");
+
+ assert_eq!(get_status(auth.clone(), &uri, None).await, StatusCode::OK);
+ assert_eq!(
+ get_status(auth, &uri, None).await,
+ StatusCode::UNAUTHORIZED,
+ "a replayed ticket must fail — this is what makes a URL credential tolerable"
+ );
+ }
+
+ #[tokio::test]
+ async fn an_unknown_ticket_is_refused() {
+ assert_eq!(
+ get_status(static_auth(), "/ws/sensing?ticket=deadbeef", None).await,
+ StatusCode::UNAUTHORIZED
+ );
+ }
+
+ #[tokio::test]
+ async fn a_ticket_without_the_streams_scope_is_refused() {
+ // A ticket inherits its issuer's authority and cannot exceed it.
+ let auth = static_auth();
+ let ticket = auth
+ .tickets()
+ .issue(TicketGrant {
+ scopes: Some("inference".into()),
+ subject: Some("u".into()),
+ })
+ .unwrap();
+ assert_eq!(
+ get_status(auth, &format!("/ws/sensing?ticket={ticket}"), None).await,
+ StatusCode::UNAUTHORIZED
+ );
+ }
+
+ #[tokio::test]
+ async fn a_ticket_is_not_a_credential_for_the_rest_api() {
+ // Containment: a ticket buys one WebSocket, never REST access.
+ let auth = static_auth();
+ let ticket = auth
+ .tickets()
+ .issue(TicketGrant { scopes: None, subject: None })
+ .unwrap();
+ assert_eq!(
+ get_status(auth, &format!("/api/v1/models?ticket={ticket}"), None).await,
+ StatusCode::UNAUTHORIZED
+ );
+ }
+
+ #[tokio::test]
+ async fn the_legacy_escape_hatch_restores_unauthenticated_websockets() {
+ let mut auth = static_auth();
+ auth.legacy_ws = true;
+ assert_eq!(get_status(auth, "/ws/sensing", None).await, StatusCode::OK);
+ }
+
+ #[tokio::test]
+ async fn the_legacy_escape_hatch_does_not_weaken_the_rest_api() {
+ // The blast radius of the hatch must be exactly the WebSocket paths.
+ let mut auth = static_auth();
+ auth.legacy_ws = true;
+ assert_eq!(
+ get_status(auth, "/api/v1/models", None).await,
+ StatusCode::UNAUTHORIZED
+ );
+ }
+
+ #[tokio::test]
+ async fn with_auth_off_websockets_stay_open_as_before() {
+ // Unconfigured deployments must see no behaviour change at all.
+ assert_eq!(
+ get_status(AuthState::default(), "/ws/sensing", None).await,
+ StatusCode::OK
+ );
+ }
+}
+
+#[cfg(test)]
+mod ws_path_matching_tests {
+ use super::*;
+
+ #[test]
+ fn every_currently_known_websocket_path_matches() {
+ for p in WS_PATHS {
+ assert!(is_ws_path(p), "{p} must be recognised as a WebSocket path");
+ }
+ }
+
+ #[test]
+ fn a_websocket_route_that_does_not_exist_yet_is_already_gated() {
+ // `/ws/train/progress` arrives with ADR-186 (PR #1387) and is already
+ // referenced by the UI. Under an exact-match allowlist it would ship
+ // unauthenticated. Prefix matching means it is gated on arrival.
+ assert!(is_ws_path("/ws/train/progress"));
+ assert!(is_ws_path("/ws/anything-added-in-future"));
+ }
+
+ #[test]
+ fn ordinary_rest_paths_are_not_treated_as_websockets() {
+ for p in [
+ "/api/v1/models",
+ "/api/v1/stream/status", // a plain GET, not an upgrade
+ "/health",
+ "/ui/index.html",
+ "/",
+ ] {
+ assert!(!is_ws_path(p), "{p} must not be treated as a WebSocket path");
+ }
+ }
+
+ #[test]
+ fn a_path_merely_starting_with_ws_is_not_the_ws_prefix() {
+ // `/wsx/...` must not match `/ws/`.
+ assert!(!is_ws_path("/wsx/sensing"));
+ assert!(!is_ws_path("/ws"));
+ }
+}
+
+/// The scope classifier, after inverting it to fail-closed. The charge that
+/// forced this: `POST /api/v1/adaptive/train` trains and overwrites the live
+/// model, but did not match the `/api/v1/train/` prefix, so it was reachable
+/// with `sensing:read` — the scope `login` requests by default.
+#[cfg(test)]
+mod scope_gate_polarity_tests {
+ use super::*;
+
+ #[test]
+ fn adaptive_train_requires_admin() {
+ // The reported bypass. Handler calls train_from_recordings(), writes
+ // the model to disk and swaps the live one.
+ assert_eq!(
+ required_scope_for(&Method::POST, "/api/v1/adaptive/train"),
+ scope::SENSING_ADMIN
+ );
+ }
+
+ #[test]
+ fn every_known_destructive_route_requires_admin() {
+ for (m, p) in [
+ (Method::POST, "/api/v1/train/start"),
+ (Method::POST, "/api/v1/train/stop"),
+ (Method::POST, "/api/v1/adaptive/train"),
+ (Method::DELETE, "/api/v1/models/m1"),
+ (Method::DELETE, "/api/v1/recording/r1"),
+ (Method::POST, "/api/v1/config/ground-truth"),
+ ] {
+ assert_eq!(
+ required_scope_for(&m, p),
+ scope::SENSING_ADMIN,
+ "{m} {p} must require admin"
+ );
+ }
+ }
+
+ #[test]
+ fn an_unknown_mutating_route_defaults_to_admin() {
+ // THE property the old denylist lacked. A route added tomorrow is
+ // admin-gated until someone consciously classifies it as read-safe.
+ for p in [
+ "/api/v1/some/route/invented/later",
+ "/api/v1/adaptive/retrain-everything",
+ "/api/v1/models/nuke",
+ ] {
+ assert_eq!(
+ required_scope_for(&Method::POST, p),
+ scope::SENSING_ADMIN,
+ "unknown mutating route {p} must fail closed to admin"
+ );
+ assert_eq!(
+ required_scope_for(&Method::DELETE, p),
+ scope::SENSING_ADMIN
+ );
+ }
+ }
+
+ #[test]
+ fn reads_stay_open_to_the_read_scope() {
+ for p in [
+ "/api/v1/models",
+ "/api/v1/models/m1",
+ "/api/v1/recording/list",
+ "/api/v1/adaptive/status",
+ "/api/v1/anything/at/all",
+ ] {
+ assert_eq!(
+ required_scope_for(&Method::GET, p),
+ scope::SENSING_READ,
+ "GET {p} must stay open to read"
+ );
+ }
+ }
+
+ #[test]
+ fn allowlisted_mutations_stay_read_scoped() {
+ // Non-destructive state changes a dashboard makes routinely. Pushing
+ // these to admin would force ordinary use to hold delete capability.
+ for p in READ_SAFE_MUTATIONS {
+ assert_eq!(
+ required_scope_for(&Method::POST, p),
+ scope::SENSING_READ,
+ "{p} is allowlisted and must stay read-scoped"
+ );
+ }
+ }
+
+ #[test]
+ fn a_read_token_can_still_mint_a_websocket_ticket() {
+ // Load-bearing: if this needed admin, the read scope could never open
+ // a stream from a browser at all.
+ assert_eq!(
+ required_scope_for(&Method::POST, "/api/v1/ws-ticket"),
+ scope::SENSING_READ
+ );
+ }
+
+ #[test]
+ fn vendor_event_ingest_is_read_scoped_by_prefix() {
+ // Path carries a `:vendor` segment, so it cannot be an exact match.
+ assert_eq!(
+ required_scope_for(&Method::POST, "/api/v1/rf/vendors/netgear/events"),
+ scope::SENSING_READ
+ );
+ // ...but the prefix must not become a wildcard for anything under it.
+ assert_eq!(
+ required_scope_for(&Method::POST, "/api/v1/rf/vendors/netgear/delete-all"),
+ scope::SENSING_ADMIN
+ );
+ }
+}
diff --git a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs
new file mode 100644
index 00000000..4a008041
--- /dev/null
+++ b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs
@@ -0,0 +1,1040 @@
+//! Browser sign-in: `GET /oauth/start` → Cognitum → `GET /oauth/callback`
+//! → a signed session cookie (ADR-271, browser half).
+//!
+//! # Why this exists
+//!
+//! `wifi-densepose login` writes `~/.ruview/credentials.json`. **A browser
+//! cannot read that file.** So until now the UI had no way to obtain a Cognitum
+//! token at all — the WebSocket ticket mechanism ADR-272 built "for browsers"
+//! was only exercisable with the legacy static shared secret that OAuth was
+//! meant to replace. An adversarial review found the gap; this closes it.
+//!
+//! # The pattern, ported from `cognitum-one/freetokens`
+//!
+//! freetokens (`src/auth/oauth.ts`, live at `freetokens.cognitum.one`) solves
+//! exactly this, and the shape is worth stating because it is not the obvious
+//! one:
+//!
+//! **The browser never holds an OAuth token.** The server generates the PKCE
+//! verifier and state, keeps them in a signed cookie, performs the code
+//! exchange itself, verifies the token, and then issues *its own* session
+//! cookie. The access token never reaches page JavaScript, so it cannot be
+//! read by an XSS, stored in `localStorage`, or leaked through a URL.
+//!
+//! # Deviation from freetokens, and why
+//!
+//! freetokens uses the `__Host-` cookie prefix, which **requires** the `Secure`
+//! attribute. It is served only over HTTPS, so that is free. RuView is
+//! routinely reached over plain HTTP on a LAN or at `http://localhost`, where a
+//! `__Host-`/`Secure` cookie is simply never sent and sign-in would silently
+//! fail. So the names carry no prefix and `Secure` is set only when the request
+//! arrived over TLS. Every other attribute — `HttpOnly`, `SameSite=Lax`,
+//! `Path=/` — matches, and the signature is what actually protects the value.
+
+use std::path::Path;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use hmac::{Hmac, Mac};
+use sha2::Sha256;
+use subtle::ConstantTimeEq;
+
+type HmacSha256 = Hmac;
+
+/// Signing key for both cookies. Absent ⇒ browser sign-in is unavailable and
+/// `/oauth/start` answers 503, rather than issuing cookies nobody can verify.
+pub const SESSION_SECRET_ENV: &str = "RUVIEW_SESSION_SECRET";
+
+/// Public origin this server is reached at, used to build `redirect_uri`.
+/// Must match the value registered for the `ruview` OAuth client.
+pub const PUBLIC_BASE_URL_ENV: &str = "RUVIEW_PUBLIC_BASE_URL";
+
+const TXN_COOKIE: &str = "ruview_oauth_txn";
+const SESSION_COOKIE: &str = "ruview_session";
+
+/// The OAuth round-trip is a page load or two. Ten minutes is generous.
+const TXN_TTL_SECS: i64 = 600;
+/// How long a browser stays signed in before repeating the redirect.
+///
+/// One hour, down from twelve. The session cookie is an assertion that this
+/// server verified a Cognitum access token; that token lives ~15 minutes, and
+/// Cognitum publishes no introspection endpoint, so there is no way to ask
+/// whether the grant behind a session still stands. Every second of this TTL is
+/// time a revoked or disabled account keeps working. Twelve hours made that
+/// window a working day.
+///
+/// An hour is short enough to bound the damage and long enough that the
+/// re-auth redirect is rare; because the user's Cognitum session is normally
+/// still alive, that redirect is usually silent.
+pub const SESSION_TTL_SECS: i64 = 3600;
+
+/// How recently the user must have actually authenticated for this server to
+/// honour a **privileged** (`sensing:admin`) request from a browser session.
+///
+/// Reads ride the full [`SESSION_TTL_SECS`]; deleting models and recordings, and
+/// starting training, do not. This is step-up-by-recency: the blast radius of a
+/// stale session is the mutating routes, so those are what get re-verified,
+/// rather than making every user re-authenticate hourly for a dashboard whose
+/// primary use is watching a live stream.
+///
+/// **This is a backstop, not an active control.** Browser sign-in requests
+/// `sensing:read` only and always will ([`BROWSER_SIGNIN_SCOPE`]), so no browser
+/// session holds `sensing:admin` and this branch is never reached in production.
+/// It is kept because it is cheap and fail-closed: if the requested scope is
+/// ever widened, the freshness requirement is already in place rather than
+/// something someone has to remember to add. Its tests exercise it through a
+/// crate-internal seam that mints an admin cookie the real flow does not
+/// produce — do not read them as evidence the control is exercised.
+pub const ADMIN_REVERIFY_SECS: i64 = 300;
+
+/// The scope `/oauth/start` requests. Read-only, deliberately.
+///
+/// Named rather than inlined because it is a decision, not a detail, and it has
+/// two consequences that are easy to widen by accident:
+///
+/// 1. **The UI's admin controls do not work from a browser session.**
+/// `model.service.js` issues `DELETE /api/v1/models/{id}`; from a
+/// Cognitum-signed-in browser that is a 401. Admin work goes through the CLI
+/// (`wifi-densepose login --admin`) or a pasted admin bearer.
+/// 2. **[`ADMIN_REVERIFY_SECS`] therefore guards a case that cannot yet arise.**
+/// No browser session holds `sensing:admin`, so the freshness branch never
+/// fires in production today. It becomes load-bearing the instant this
+/// constant grows, which is the right ordering — but do not mistake its
+/// passing tests for evidence that the control is exercised.
+///
+/// **Decided 2026-07-23: browser-side admin is not wanted.** This stays
+/// read-only. Widening it would make every browser sign-in consent to delete
+/// capability just to watch a stream, and the destructive operations have a
+/// deliberate home — the CLI, where `--admin` is explicit and typed.
+pub const BROWSER_SIGNIN_SCOPE: &str = ruview_auth::scope::SENSING_READ;
+
+fn now() -> i64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs() as i64)
+ .unwrap_or(0)
+}
+
+fn b64(bytes: &[u8]) -> String {
+ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
+ use base64::Engine;
+ URL_SAFE_NO_PAD.encode(bytes)
+}
+
+fn unb64(s: &str) -> Option> {
+ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
+ use base64::Engine;
+ URL_SAFE_NO_PAD.decode(s).ok()
+}
+
+/// `.`.
+fn sign(payload: &[u8], secret: &str) -> String {
+ let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("hmac accepts any key size");
+ let body = b64(payload);
+ mac.update(body.as_bytes());
+ format!("{body}.{}", b64(&mac.finalize().into_bytes()))
+}
+
+/// Verify and unwrap. Constant-time tag comparison — a byte-at-a-time compare
+/// on a MAC is a forgery oracle.
+fn unsign(value: &str, secret: &str) -> Option> {
+ let sep = value.rfind('.')?;
+ let (body, tag) = (&value[..sep], &value[sep + 1..]);
+ let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
+ mac.update(body.as_bytes());
+ let expected = b64(&mac.finalize().into_bytes());
+ if expected.as_bytes().ct_eq(tag.as_bytes()).into() {
+ unb64(body)
+ } else {
+ None
+ }
+}
+
+/// What the transaction cookie carries between `/oauth/start` and the callback.
+#[derive(serde::Serialize, serde::Deserialize)]
+struct Transaction {
+ state: String,
+ verifier: String,
+ exp: i64,
+}
+
+/// What the session cookie carries after a successful sign-in.
+///
+/// Deliberately NOT the access token. The browser gets an assertion that this
+/// server already verified one — nothing replayable elsewhere.
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub struct BrowserSession {
+ pub subject: String,
+ pub account_id: String,
+ pub scope: String,
+ pub exp: i64,
+ /// When the user last actually authenticated against Cognitum, unix
+ /// seconds — the OIDC `auth_time` idea, used here for step-up.
+ ///
+ /// `#[serde(default)]` so a cookie issued before this field existed still
+ /// deserializes. It then reads as `0`, which is infinitely stale, so such a
+ /// session can still read but cannot perform a privileged action until the
+ /// user signs in again. Fail-closed, and self-healing on next sign-in.
+ #[serde(default)]
+ pub auth_time: i64,
+}
+
+impl BrowserSession {
+ pub fn is_live(&self) -> bool {
+ self.exp > now()
+ }
+ pub fn has_scope(&self, want: &str) -> bool {
+ self.scope.split_whitespace().any(|s| s == want)
+ }
+ /// Has the user authenticated recently enough for a privileged action?
+ ///
+ /// See [`ADMIN_REVERIFY_SECS`]. Note this is deliberately NOT "is the
+ /// session live" — a session can be perfectly valid for reads and still too
+ /// old to delete a model.
+ pub fn recently_authenticated(&self) -> bool {
+ now() - self.auth_time < ADMIN_REVERIFY_SECS
+ }
+}
+
+fn cookie(name: &str, value: &str, max_age: i64, secure: bool) -> String {
+ format!(
+ "{name}={value}; Path=/; Max-Age={max_age}; HttpOnly; SameSite=Lax{}",
+ if secure { "; Secure" } else { "" }
+ )
+}
+
+/// Read one cookie from a raw `Cookie:` header.
+///
+/// Returns the FIRST match, which is only safe when the caller has already
+/// established there is exactly one — see [`read_all_cookies`] and the
+/// shadowing attack it exists to stop. Kept for callers that genuinely want
+/// first-match semantics; the credential paths do not.
+pub fn read_cookie(header: &str, name: &str) -> Option {
+ read_all_cookies(header, name).into_iter().next()
+}
+
+/// Every value sent under `name`, in header order.
+///
+/// # Why this is not `read_cookie`
+///
+/// A `Cookie:` header can legitimately carry the same name more than once —
+/// cookies are keyed by (name, domain, path), and RFC 6265 §5.4 orders
+/// longer-`Path` matches FIRST. Cookies are also not isolated by port or by
+/// scheme, so *any* other service on the same host, or a plain-HTTP MITM
+/// injecting a `Set-Cookie`, can add one.
+///
+/// Taking the first match therefore let an attacker **shadow** a victim's
+/// session: sign in normally, capture your own validly-signed
+/// `ruview_session`, then get it set with `Path=/ui` on the victim's browser.
+/// The victim then sends both, the attacker's first, and it verifies —
+/// because it is genuinely signed. The victim silently operates inside the
+/// attacker's session; `/oauth/status` reports the attacker's account and the
+/// victim's recordings are attributed to them.
+///
+/// Note the shape of this: the signature was doing its job the whole time.
+/// Forgery is not the threat here, and "the signature protects the value" does
+/// not answer it. The `__Host-` prefix would — it forbids `Domain` and pins
+/// `Path=/` — but it also requires `Secure`, and RuView is routinely reached
+/// over plain HTTP on a LAN, where such a cookie is never sent at all.
+///
+/// So the callers resolve ambiguity themselves: accept only when exactly one
+/// candidate verifies. An attacker can still cause a *refusal* by planting a
+/// second valid cookie, which is a nuisance; they can no longer cause a
+/// silent takeover, which is a compromise.
+pub fn read_all_cookies(header: &str, name: &str) -> Vec {
+ header
+ .split(';')
+ .filter_map(|part| {
+ let (k, v) = part.split_once('=')?;
+ (k.trim() == name).then(|| v.trim().to_string())
+ })
+ .collect()
+}
+
+/// Unwrap the one candidate that verifies, or `None` if zero or several do.
+///
+/// Several verifying means the browser sent two genuinely-signed cookies of the
+/// same name — which a legitimate client never does, and which is exactly the
+/// shadowing attack described on [`read_all_cookies`]. Refusing is correct: we
+/// cannot tell which one the user meant, and guessing is how the takeover works.
+fn unsign_unambiguous(header: &str, name: &str, secret: &str) -> Option> {
+ let mut verified = read_all_cookies(header, name)
+ .into_iter()
+ .filter_map(|raw| unsign(&raw, secret));
+ let first = verified.next()?;
+ match verified.next() {
+ None => Some(first),
+ Some(_) => {
+ tracing::warn!(
+ cookie = name,
+ "request carried more than one validly-signed {name}; refusing rather than \
+ guessing which is the user's — see read_all_cookies"
+ );
+ None
+ }
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum SessionError {
+ #[error("browser sign-in is not configured on this server")]
+ NotConfigured,
+ #[error("the sign-in request is missing or has expired")]
+ InvalidTransaction,
+ #[error("the sign-in state did not match — this response did not come from the flow that started")]
+ StateMismatch,
+ #[error("Cognitum sign-in could not be completed: {0}")]
+ ExchangeFailed(String),
+ #[error("Cognitum returned a token this server will not accept: {0}")]
+ InvalidToken(String),
+}
+
+/// Process-wide secret, resolved once.
+static SECRET: std::sync::OnceLock