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> = std::sync::OnceLock::new(); + +/// Resolve the signing secret: env first, then a persisted file, then generate. +/// +/// Requiring an operator to invent a secret before browser sign-in works is a +/// footgun — they set `RUVIEW_OAUTH_ISSUER`, expect sign-in, and get a 503 that +/// names an env var they have never heard of. A single-host appliance has no +/// reason to need that step, so we generate one and persist it `0600` next to +/// the server's other state. +/// +/// Persisted rather than in-memory so a restart does not silently sign everyone +/// out. The env var still wins, which is what a multi-instance deployment needs +/// — several servers must share a secret or a session issued by one is +/// rejected by the next. +pub fn init_secret(data_dir: &Path) { + let resolved = std::env::var(SESSION_SECRET_ENV) + .ok() + .filter(|s| !s.trim().is_empty()) + .map(|s| { + tracing::info!("browser session secret: from {SESSION_SECRET_ENV}"); + s + }) + .or_else(|| load_or_create_secret(data_dir)); + let _ = SECRET.set(resolved); +} + +fn load_or_create_secret(data_dir: &Path) -> Option { + let path = data_dir.join("session-secret"); + if let Ok(existing) = std::fs::read_to_string(&path) { + let trimmed = existing.trim().to_string(); + if !trimmed.is_empty() { + tracing::info!(path = %path.display(), "browser session secret: loaded"); + return Some(trimmed); + } + } + let mut bytes = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut bytes); + let generated = b64(&bytes); + if let Err(e) = write_secret(&path, &generated) { + tracing::warn!( + path = %path.display(), + error = %e, + "could not persist a browser session secret; sessions will not survive a restart. \ + Set {SESSION_SECRET_ENV} to fix this permanently." + ); + // Still usable this run — better than refusing sign-in outright. + return Some(generated); + } + tracing::info!(path = %path.display(), "browser session secret: generated"); + Some(generated) +} + +fn write_secret(path: &Path, value: &str) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let tmp = path.with_extension("tmp"); + // Created 0600, not written-then-chmodded. `fs::write` creates at + // `0666 & !umask`, so this file — the HMAC key for EVERY browser session — + // was world-readable for the window before the chmod. Anyone who read it + // could forge a session cookie for any account with any scope, including + // `sensing:admin`, which is strictly worse than stealing one session. + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let _ = std::fs::remove_file(&tmp); + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&tmp)?; + f.write_all(value.as_bytes())?; + f.sync_all()?; + } + #[cfg(not(unix))] + std::fs::write(&tmp, value)?; + std::fs::rename(&tmp, path) +} + +fn secret() -> Result { + SECRET + .get() + .and_then(|s| s.clone()) + .ok_or(SessionError::NotConfigured) +} + +/// Is browser sign-in usable on this server? +pub fn is_configured() -> bool { + secret().is_ok() +} + +/// Begin sign-in: where to redirect, and the cookie to set. +pub fn begin(issuer: &str, client_id: &str, scope: &str, secure: bool) -> Result<(String, String), SessionError> { + let secret = secret()?; + let req = ruview_auth::pkce::generate(); + let txn = Transaction { + state: req.state.clone(), + verifier: req.code_verifier, + exp: now() + TXN_TTL_SECS, + }; + let payload = serde_json::to_vec(&txn).expect("transaction serializes"); + + let mut url = url_encode_authorize(issuer, client_id, scope, &req.state, &req.code_challenge); + url.push_str(""); // no-op; keeps the builder readable + + Ok(( + url, + cookie(TXN_COOKIE, &sign(&payload, &secret), TXN_TTL_SECS, secure), + )) +} + +fn url_encode_authorize( + issuer: &str, + client_id: &str, + scope: &str, + state: &str, + challenge: &str, +) -> String { + // Percent-encode every value: `scope` legitimately contains a space + // ("sensing:read sensing:admin") and hand-formatting silently truncates it. + fn enc(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + (b as char).to_string() + } + _ => format!("%{b:02X}"), + }) + .collect() + } + format!( + "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256", + issuer.trim_end_matches('/'), + enc(client_id), + enc(&redirect_uri()), + enc(scope), + enc(state), + enc(challenge), + ) +} + +pub fn public_base_url() -> String { + std::env::var(PUBLIC_BASE_URL_ENV) + .ok() + .filter(|s| !s.trim().is_empty()) + .map(|s| s.trim().trim_end_matches('/').to_string()) + .unwrap_or_else(|| "http://127.0.0.1:8080".to_string()) +} + +pub fn redirect_uri() -> String { + format!("{}/oauth/callback", public_base_url()) +} + +/// Cookie that clears the transaction. +pub fn clear_transaction(secure: bool) -> String { + cookie(TXN_COOKIE, "", 0, secure) +} + +/// Cookie that ends the session. +pub fn clear_session(secure: bool) -> String { + cookie(SESSION_COOKIE, "", 0, secure) +} + +/// Validate the callback's `state` against the transaction cookie and return +/// the PKCE verifier needed for the exchange. +pub fn verifier_for_callback(cookie_header: &str, state: &str) -> Result { + let secret = secret()?; + // Unambiguous: a second validly-signed txn cookie would let an attacker + // substitute their own PKCE verifier, defeating the binding entirely. + let bytes = unsign_unambiguous(cookie_header, TXN_COOKIE, &secret) + .ok_or(SessionError::InvalidTransaction)?; + let txn: Transaction = + serde_json::from_slice(&bytes).map_err(|_| SessionError::InvalidTransaction)?; + if txn.exp < now() { + return Err(SessionError::InvalidTransaction); + } + // CSRF: constant-time, and BEFORE the code is spent. + let ok: bool = txn.state.as_bytes().ct_eq(state.as_bytes()).into(); + if !ok { + return Err(SessionError::StateMismatch); + } + Ok(txn.verifier) +} + +/// Issue the session cookie for a verified principal. +pub fn issue(principal: &ruview_auth::Principal, secure: bool) -> Result { + let secret = secret()?; + let session = BrowserSession { + subject: principal.subject.clone(), + account_id: principal.account_id.clone(), + scope: principal.scopes().collect::>().join(" "), + // Never outlive our own ceiling, and never inherit the access token's + // 15 minutes either — this is a browser session, not the token. + exp: now() + SESSION_TTL_SECS, + // Stamped at issue, which is the moment a Cognitum token was actually + // verified. Never refreshed by activity: it answers "when did you last + // prove who you are", not "when were you last here". + auth_time: now(), + }; + let payload = serde_json::to_vec(&session).expect("session serializes"); + Ok(cookie( + SESSION_COOKIE, + &sign(&payload, &secret), + SESSION_TTL_SECS, + secure, + )) +} + +/// Recover a live session from a request's `Cookie:` header. +pub fn from_cookie_header(cookie_header: &str) -> Option { + let secret = secret().ok()?; + let bytes = unsign_unambiguous(cookie_header, SESSION_COOKIE, &secret)?; + let session: BrowserSession = serde_json::from_slice(&bytes).ok()?; + session.is_live().then_some(session) +} + +/// Install a usable signing secret for tests in this crate. +/// +/// Idempotent: `SECRET` is a `OnceLock`, so whichever test gets there first +/// wins and the rest reuse it. Nothing here depends on the secret's VALUE, only +/// on the process having one, so the race is benign. +#[cfg(test)] +pub(crate) fn init_secret_for_tests() { + let _ = SECRET.set(Some("crate-test-session-secret".to_string())); +} + +/// Mint a session cookie VALUE (not a `Set-Cookie` header) for tests elsewhere +/// in this crate — `bearer_auth`, which needs to present one. +/// +/// Deliberately goes through the same `sign` path as [`issue`], so a test that +/// presents this is exercising the real verification path rather than a +/// test-only bypass. `ttl` may be negative to forge an already-expired session. +#[cfg(test)] +pub(crate) fn test_cookie_value(subject: &str, account_id: &str, scope: &str, ttl: i64) -> String { + // Freshly authenticated, so step-up does not interfere with tests about + // scope or expiry. Use `test_cookie_value_aged` to exercise step-up itself. + test_cookie_value_aged(subject, account_id, scope, ttl, 0) +} + +/// Sign an arbitrary payload with the test secret, so a test elsewhere in the +/// crate can construct a cookie whose SHAPE differs from the current struct — +/// e.g. one issued before a field existed. +#[cfg(test)] +pub(crate) fn test_sign_for_tests(payload: &[u8]) -> String { + init_secret_for_tests(); + sign(payload, &secret().expect("secret installed above")) +} + +/// As [`test_cookie_value`], but `age` seconds since the user authenticated. +#[cfg(test)] +pub(crate) fn test_cookie_value_aged( + subject: &str, + account_id: &str, + scope: &str, + ttl: i64, + age: i64, +) -> String { + init_secret_for_tests(); + let secret = secret().expect("secret installed above"); + let session = BrowserSession { + subject: subject.to_string(), + account_id: account_id.to_string(), + scope: scope.to_string(), + exp: now() + ttl, + auth_time: now() - age, + }; + sign(&serde_json::to_vec(&session).expect("serializes"), &secret) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET: &str = "test-secret-value"; + + /// Pull a cookie's value out of a `Set-Cookie` header, as a browser would + /// when later sending it back in a `Cookie:` header. + fn value_of(set_cookie: &str) -> String { + set_cookie + .split(';') + .next() + .and_then(|kv| kv.split_once('=')) + .map(|(_, v)| v.to_string()) + .expect("Set-Cookie has name=value") + } + + fn query_param(url: &str, key: &str) -> String { + url.split(['?', '&']) + .find_map(|p| p.strip_prefix(&format!("{key}="))) + .unwrap_or_else(|| panic!("{key} missing from {url}")) + .to_string() + } + + // ── public API: the surface that had no tests at all ────────────── + // + // Every test below this line covers a PUBLIC function. The tests that + // already existed all targeted private helpers (sign, unsign, cookie, + // read_cookie, is_live, has_scope), so `issue`, `from_cookie_header`, + // `begin`, `verifier_for_callback` and `is_configured` — the entire + // browser sign-in flow — had no executable evidence behind them. + + #[test] + fn a_server_with_a_secret_reports_browser_sign_in_as_available() { + init_secret_for_tests(); + assert!(is_configured(), "sign-in must be offered once a secret exists"); + } + + #[test] + fn begin_produces_an_authorize_url_carrying_every_required_parameter() { + init_secret_for_tests(); + let (url, set_cookie) = + begin("https://auth.cognitum.one/", "ruview", "sensing:read", false).unwrap(); + + assert!(url.starts_with("https://auth.cognitum.one/oauth/authorize?"), "{url}"); + assert!(url.contains("response_type=code"), "{url}"); + assert!(url.contains("client_id=ruview"), "{url}"); + // S256 only — the AS rejects `plain`, so getting this wrong is a + // sign-in that always fails. + assert!(url.contains("code_challenge_method=S256"), "{url}"); + assert!(!query_param(&url, "code_challenge").is_empty(), "{url}"); + assert!(!query_param(&url, "state").is_empty(), "{url}"); + // The scope's space must survive encoding or the AS sees one scope. + let (u2, _) = begin("https://a.example", "ruview", "sensing:read sensing:admin", false).unwrap(); + assert!(u2.contains("sensing%3Aread%20sensing%3Aadmin"), "{u2}"); + + // The verifier must never be in the URL — only its S256 hash. + assert!(set_cookie.starts_with(TXN_COOKIE), "{set_cookie}"); + assert!(set_cookie.contains("HttpOnly"), "the verifier must not be script-readable"); + } + + #[test] + fn the_callback_returns_the_verifier_when_the_state_matches() { + init_secret_for_tests(); + let (url, set_cookie) = begin("https://a.example", "ruview", "sensing:read", false).unwrap(); + let state = query_param(&url, "state"); + let header = format!("{TXN_COOKIE}={}", value_of(&set_cookie)); + + let verifier = verifier_for_callback(&header, &state).expect("matching state"); + assert!(verifier.len() >= 43, "PKCE verifier looks too short: {}", verifier.len()); + } + + #[test] + fn a_callback_whose_state_does_not_match_is_refused() { + // MUTANT THIS KILLS: deleting the `state` comparison in + // `verifier_for_callback`. Without it the callback accepts a code from + // a flow the user never started — login CSRF: an attacker completes + // their own authorization, feeds the victim the resulting callback URL, + // and the victim's browser silently ends up in the ATTACKER's session. + init_secret_for_tests(); + let (_url, set_cookie) = begin("https://a.example", "ruview", "sensing:read", false).unwrap(); + let header = format!("{TXN_COOKIE}={}", value_of(&set_cookie)); + + assert!(matches!( + verifier_for_callback(&header, "state-from-a-different-flow"), + Err(SessionError::StateMismatch) + )); + // Empty is the degenerate case a naive comparison lets through. + assert!(matches!( + verifier_for_callback(&header, ""), + Err(SessionError::StateMismatch) + )); + } + + #[test] + fn a_callback_with_no_transaction_or_a_forged_one_is_refused() { + init_secret_for_tests(); + // No cookie at all. + assert!(matches!( + verifier_for_callback("other=1", "any"), + Err(SessionError::InvalidTransaction) + )); + // Present but not signed by us: an attacker choosing their own verifier + // would defeat PKCE entirely. + assert!(matches!( + verifier_for_callback(&format!("{TXN_COOKIE}=bm90LXNpZ25lZA.deadbeef"), "any"), + Err(SessionError::InvalidTransaction) + )); + } + + #[test] + fn an_expired_transaction_is_refused_even_with_the_right_state() { + init_secret_for_tests(); + let secret = secret().unwrap(); + let txn = Transaction { + state: "s".into(), + verifier: "v".into(), + exp: now() - 1, + }; + let header = format!( + "{TXN_COOKIE}={}", + sign(&serde_json::to_vec(&txn).unwrap(), &secret) + ); + assert!(matches!( + verifier_for_callback(&header, "s"), + Err(SessionError::InvalidTransaction) + )); + } + + #[test] + fn an_issued_session_round_trips_with_its_subject_account_and_scope() { + init_secret_for_tests(); + let raw = test_cookie_value("sub-1", "acct-1", "sensing:read", 3600); + let session = from_cookie_header(&format!("{SESSION_COOKIE}={raw}")) + .expect("a freshly issued session must be recoverable"); + + assert_eq!(session.subject, "sub-1"); + assert_eq!(session.account_id, "acct-1"); + assert!(session.has_scope("sensing:read")); + assert!(!session.has_scope("sensing:admin"), "scope must not be widened in transit"); + } + + #[test] + fn an_expired_session_cookie_does_not_authenticate() { + // MUTANT THIS KILLS: `session.is_live().then_some(session)` -> + // `Some(session)` in `from_cookie_header`. `is_live` IS unit-tested, + // but nothing asserted that the caller consults it — the recurring + // "tested in isolation, call site untested" shape. Without this, a + // signed cookie authenticates forever and the session TTL is decorative. + init_secret_for_tests(); + let raw = test_cookie_value("sub-1", "acct-1", "sensing:read", -1); + assert!(from_cookie_header(&format!("{SESSION_COOKIE}={raw}")).is_none()); + } + + #[test] + fn a_session_signed_with_another_secret_does_not_authenticate() { + init_secret_for_tests(); + // Forged with a different key: the payload is well-formed and unexpired, + // so only the MAC stands between it and a valid session. + let forged = sign( + &serde_json::to_vec(&BrowserSession { + subject: "attacker".into(), + account_id: "acct-attacker".into(), + scope: "sensing:admin".into(), + exp: now() + 3600, + auth_time: now(), + }) + .unwrap(), + "a-different-secret", + ); + assert!(from_cookie_header(&format!("{SESSION_COOKIE}={forged}")).is_none()); + } + + // ── cookie shadowing (P3) ───────────────────────────────────────── + + #[test] + fn every_value_sent_under_a_name_is_visible_not_just_the_first() { + let h = "ruview_session=attacker; other=x; ruview_session=victim"; + assert_eq!( + read_all_cookies(h, "ruview_session"), + vec!["attacker".to_string(), "victim".to_string()] + ); + } + + #[test] + fn a_shadowing_cookie_cannot_silently_take_over_the_session() { + // THE ATTACK. Cookies are keyed by (name, domain, path) and RFC 6265 + // §5.4 sends longer-`Path` matches FIRST. They are not isolated by port + // or scheme, so any other service on this host — or a plain-HTTP MITM + // injecting Set-Cookie — can plant one. + // + // The attacker signs in legitimately, captures their OWN validly-signed + // cookie, and gets it set with `Path=/ui` on the victim's browser. Under + // first-match the victim's browser sends the attacker's cookie first, it + // verifies (it IS genuinely signed), and the victim silently operates + // inside the attacker's session. + // + // The signature was never the problem, which is why "it's signed" does + // not answer this. + init_secret_for_tests(); + let attacker = test_cookie_value("attacker", "acct-attacker", "sensing:read", 3600); + let victim = test_cookie_value("victim", "acct-victim", "sensing:read", 3600); + + let header = format!("ruview_session={attacker}; ruview_session={victim}"); + assert!( + from_cookie_header(&header).is_none(), + "two validly-signed sessions must be refused, not resolved by order" + ); + + // Order must not matter — the victim's cookie arriving first is the same + // ambiguity, not a pass. + let reversed = format!("ruview_session={victim}; ruview_session={attacker}"); + assert!(from_cookie_header(&reversed).is_none()); + } + + #[test] + fn a_junk_shadow_cookie_does_not_lock_the_real_user_out() { + // Only ONE candidate verifies, so there is no ambiguity to refuse. This + // matters: if any duplicate name caused a refusal, planting garbage + // would be a trivial denial of service against every user. + init_secret_for_tests(); + let real = test_cookie_value("victim", "acct-victim", "sensing:read", 3600); + for header in [ + format!("ruview_session=not-even-signed; ruview_session={real}"), + format!("ruview_session={real}; ruview_session=bm9wZQ.deadbeef"), + ] { + let s = from_cookie_header(&header).expect("the genuine cookie must still work"); + assert_eq!(s.subject, "victim"); + } + } + + #[test] + fn a_shadowing_transaction_cookie_cannot_substitute_a_pkce_verifier() { + // Same attack against the sign-in transaction: a second validly-signed + // txn cookie would let an attacker supply their own verifier and state, + // which defeats the PKCE binding rather than merely confusing it. + init_secret_for_tests(); + let (url_a, cookie_a) = begin("https://a.example", "ruview", "sensing:read", false).unwrap(); + let (_url_b, cookie_b) = begin("https://a.example", "ruview", "sensing:read", false).unwrap(); + let state_a = query_param(&url_a, "state"); + + let header = format!( + "{TXN_COOKIE}={}; {TXN_COOKIE}={}", + value_of(&cookie_a), + value_of(&cookie_b) + ); + assert!(matches!( + verifier_for_callback(&header, &state_a), + Err(SessionError::InvalidTransaction) + )); + } + + #[test] + fn the_cookie_max_age_matches_the_session_expiry() { + // Two independent expressions of the same lifetime: the cookie's + // Max-Age (when the browser stops sending it) and the payload's `exp` + // (when we stop accepting it). If they drift, one silently wins — + // a longer Max-Age means the browser keeps presenting a session we + // reject, a shorter one means we hold authority the browser discards. + init_secret_for_tests(); + let raw = test_cookie_value("s", "a", "sensing:read", SESSION_TTL_SECS); + let session = from_cookie_header(&format!("{SESSION_COOKIE}={raw}")).unwrap(); + + let set_cookie = cookie(SESSION_COOKIE, &raw, SESSION_TTL_SECS, false); + assert!( + set_cookie.contains(&format!("Max-Age={SESSION_TTL_SECS}")), + "{set_cookie}" + ); + // Same lifetime, allowing a second for the clock ticking between them. + assert!( + (session.exp - now() - SESSION_TTL_SECS).abs() <= 1, + "cookie Max-Age and session exp disagree: exp-now={}, Max-Age={SESSION_TTL_SECS}", + session.exp - now() + ); + } + + #[test] + fn browser_sign_in_stays_read_only_until_someone_decides_otherwise() { + // Pins the decision documented on BROWSER_SIGNIN_SCOPE. Widening it is + // legitimate, but it must be a choice: it makes every browser sign-in + // consent to delete capability, and it activates the ADMIN_REVERIFY_SECS + // branch that is currently unreachable in production. + assert_eq!(BROWSER_SIGNIN_SCOPE, ruview_auth::scope::SENSING_READ); + assert!( + !BROWSER_SIGNIN_SCOPE.split_whitespace().any(|s| s == ruview_auth::scope::SENSING_ADMIN), + "browser sign-in must not silently request admin: {BROWSER_SIGNIN_SCOPE}" + ); + } + + #[test] + fn the_authorize_url_actually_carries_that_scope() { + // The constant is only worth pinning if it reaches the wire. Asserting + // on the constant alone would pass even if `begin` were called with + // something else — the same "tested in isolation, call site untested" + // shape that produced several defects in this branch. + init_secret_for_tests(); + let (url, _) = begin("https://a.example", "ruview", BROWSER_SIGNIN_SCOPE, false).unwrap(); + assert!(url.contains("scope=sensing%3Aread"), "{url}"); + assert!(!url.contains("sensing%3Aadmin"), "{url}"); + } + + #[test] + fn clearing_cookies_expires_them_immediately() { + for c in [clear_session(false), clear_transaction(false)] { + assert!(c.contains("Max-Age=0"), "{c}"); + } + } + + fn session(exp: i64) -> BrowserSession { + BrowserSession { + subject: "user-1".into(), + account_id: "acct-1".into(), + scope: "sensing:read".into(), + exp, + auth_time: now(), + } + } + + #[test] + fn a_signed_value_round_trips() { + let signed = sign(b"hello", SECRET); + assert_eq!(unsign(&signed, SECRET).as_deref(), Some(&b"hello"[..])); + } + + #[test] + fn a_tampered_payload_is_rejected() { + // The whole point of signing: the browser holds this value and can edit + // it. Flipping a byte must invalidate the tag. + let signed = sign(b"hello", SECRET); + let (body, tag) = signed.split_once('.').unwrap(); + let mut bad = body.to_string(); + bad.push('x'); + assert!(unsign(&format!("{bad}.{tag}"), SECRET).is_none()); + } + + #[test] + fn a_value_signed_with_another_secret_is_rejected() { + let signed = sign(b"hello", "a-different-secret"); + assert!(unsign(&signed, SECRET).is_none()); + } + + #[test] + fn a_malformed_cookie_value_is_rejected_rather_than_panicking() { + for bad in ["", ".", "no-separator", "!!!.!!!", "a.b.c"] { + assert!(unsign(bad, SECRET).is_none(), "{bad:?} must not verify"); + } + } + + #[test] + fn cookies_are_httponly_and_samesite_lax() { + // HttpOnly is what keeps page JavaScript — and therefore an XSS — away + // from the session. + let c = cookie("n", "v", 600, false); + assert!(c.contains("HttpOnly"), "{c}"); + assert!(c.contains("SameSite=Lax"), "{c}"); + assert!(c.contains("Path=/"), "{c}"); + assert!(!c.contains("Secure"), "plain HTTP must not set Secure: {c}"); + } + + #[test] + fn secure_is_set_only_over_tls() { + assert!(cookie("n", "v", 600, true).contains("; Secure")); + } + + #[test] + fn a_session_cookie_never_contains_the_access_token() { + // The core property of this design: the browser holds an assertion, + // not a credential it could replay against Cognitum or another service. + let payload = serde_json::to_vec(&session(now() + 3600)).unwrap(); + let rendered = sign(&payload, SECRET); + let decoded = String::from_utf8(unsign(&rendered, SECRET).unwrap()).unwrap(); + assert!(!decoded.contains("eyJ"), "looks like a JWT: {decoded}"); + assert!(decoded.contains("user-1") && decoded.contains("sensing:read")); + } + + #[test] + fn an_expired_session_is_not_live() { + assert!(!session(now() - 1).is_live()); + assert!(session(now() + 60).is_live()); + } + + #[test] + fn session_scope_matching_is_exact() { + let s = session(now() + 60); + assert!(s.has_scope("sensing:read")); + assert!(!s.has_scope("sensing:admin"), "no implied escalation"); + assert!(!s.has_scope("sensing"), "prefixes must not match"); + } + + #[test] + fn reads_a_named_cookie_out_of_a_header() { + let h = "foo=1; ruview_session=abc.def; bar=2"; + assert_eq!(read_cookie(h, "ruview_session").as_deref(), Some("abc.def")); + assert_eq!(read_cookie(h, "absent"), None); + } + + #[test] + fn a_cookie_name_that_merely_ends_with_the_target_is_not_matched() { + // `xruview_session=` must not be read as `ruview_session=`. + assert_eq!(read_cookie("xruview_session=v", "ruview_session"), None); + } + + #[test] + fn the_authorize_url_encodes_a_multi_scope_request() { + let u = url_encode_authorize( + "https://auth.cognitum.one", + "ruview", + "sensing:read sensing:admin", + "st", + "ch", + ); + assert!(u.starts_with("https://auth.cognitum.one/oauth/authorize")); + assert!(u.contains("client_id=ruview")); + assert!(u.contains("code_challenge_method=S256")); + assert!( + u.contains("scope=sensing%3Aread%20sensing%3Aadmin"), + "space must be encoded, not truncated: {u}" + ); + } + + #[test] + fn a_trailing_slash_on_the_issuer_does_not_double_up() { + let u = url_encode_authorize("https://auth.cognitum.one/", "ruview", "s", "st", "ch"); + assert!(!u.contains(".one//oauth"), "{u}"); + } +} + +/// Regression guard for a response-shape mistake that silently broke sign-in. +#[cfg(test)] +mod response_shape_tests { + use axum::response::IntoResponse; + + /// Axum's array-of-tuples form REPLACES same-name headers. Two `Set-Cookie` + /// entries collapse to one — which, on the sign-in callback, dropped the + /// session cookie and made a successful OAuth round-trip a no-op. Only the + /// last cookie survived. + #[test] + fn an_array_of_headers_silently_drops_a_duplicate_set_cookie() { + let resp = ( + axum::http::StatusCode::FOUND, + [ + (axum::http::header::SET_COOKIE, "a=1".to_string()), + (axum::http::header::SET_COOKIE, "b=2".to_string()), + ], + ) + .into_response(); + assert_eq!( + resp.headers() + .get_all(axum::http::header::SET_COOKIE) + .iter() + .count(), + 1, + "documenting the footgun: the array form replaces, it does not append" + ); + } + + /// `AppendHeaders` is what actually emits both. + #[test] + fn append_headers_emits_every_set_cookie() { + let resp = ( + axum::http::StatusCode::FOUND, + axum::response::AppendHeaders([ + (axum::http::header::LOCATION, "/ui/".to_string()), + (axum::http::header::SET_COOKIE, "a=1".to_string()), + (axum::http::header::SET_COOKIE, "b=2".to_string()), + ]), + ) + .into_response(); + let cookies: Vec<_> = resp + .headers() + .get_all(axum::http::header::SET_COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .collect(); + assert_eq!(cookies.len(), 2, "both cookies must reach the browser"); + assert!(cookies.contains(&"a=1") && cookies.contains(&"b=2")); + assert!(resp.headers().get(axum::http::header::LOCATION).is_some()); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/lib.rs b/v2/crates/wifi-densepose-sensing-server/src/lib.rs index 75ca5020..7779f79a 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/lib.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/lib.rs @@ -9,6 +9,8 @@ //! - Real-time CSI introspection / low-latency tap (`introspection`, ADR-099) pub mod bearer_auth; +pub mod browser_session; +pub mod ws_ticket; pub mod cli; pub mod dataset; pub mod edge_registry; diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index bf176069..e986b090 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -7720,6 +7720,10 @@ async fn main() { // ADR-044 §5.3: load persisted runtime config from the data directory. let data_dir = std::path::PathBuf::from("data"); let runtime_config = load_runtime_config(&data_dir); + // ADR-271: resolve (or generate + persist) the browser-session signing key + // before any request can arrive. Zero-config for a single appliance; the + // env var still wins for a multi-instance deployment that must share one. + wifi_densepose_sensing_server::browser_session::init_secret(&data_dir); info!( "Loaded runtime config: dedup_factor={:.2}", runtime_config.dedup_factor @@ -7967,9 +7971,33 @@ async fn main() { // #443: optional bearer-token auth on `/api/v1/*`. `RUVIEW_API_TOKEN` // unset/empty ⇒ middleware is a no-op (LAN-mode default preserved); set ⇒ // every `/api/v1/*` request must carry `Authorization: Bearer `. - let bearer_auth_state = wifi_densepose_sensing_server::bearer_auth::AuthState::from_env(); + // + // ADR-271: additionally, `RUVIEW_OAUTH_ISSUER` enables Cognitum OAuth + // verification alongside (not instead of) the static token. + // + // FAIL CLOSED. If OAuth was requested but cannot work — empty issuer, or a + // JWKS we cannot fetch at boot — we exit rather than serve. Starting anyway + // would silently downgrade an operator who asked for OAuth to either an + // open API or a single-shared-secret one, and they would have no signal + // that it happened. A loud death at boot is the kind thing here. + let bearer_auth_state = + match wifi_densepose_sensing_server::bearer_auth::AuthState::from_env() { + Ok(s) => s, + Err(e) => { + error!( + "API auth: OAuth was requested but cannot be initialised: {e}. \ + Refusing to start — unset RUVIEW_OAUTH_ISSUER to run without it." + ); + std::process::exit(1); + } + }; if bearer_auth_state.is_enabled() { - info!("API auth: bearer-token enforcement ON for /api/v1/* (RUVIEW_API_TOKEN set)"); + if bearer_auth_state.oauth_enabled() { + info!("API auth: ON for /api/v1/* — Cognitum OAuth (ADR-271){}", + if bearer_auth_state.static_token_enabled() { " + static RUVIEW_API_TOKEN" } else { "" }); + } else { + info!("API auth: bearer-token enforcement ON for /api/v1/* (RUVIEW_API_TOKEN set)"); + } if bind_ip.is_unspecified() { warn!( "API auth ON but bind-addr is {} — consider --bind-addr 127.0.0.1 for LAN-only deployments", @@ -7978,7 +8006,7 @@ async fn main() { } } else { info!( - "API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN= to enforce bearer auth." + "API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN= or RUVIEW_OAUTH_ISSUER= to enforce auth." ); } @@ -8016,6 +8044,18 @@ async fn main() { // so a client on :8765 can stream signed RuField FieldEvents alongside // `/ws/sensing`. Merged with its own FieldState (different state type). .merge(rufield_surface::router(field_surface.clone())) + // ADR-272 FIX: this router had NO auth layer at all. `/ws/sensing` and + // `/ws/field` on the dedicated WS port accepted unauthenticated + // upgrades even with auth ON — and this is the port the UI actually + // uses (ui/services/sensing.service.js maps HTTP 8080 -> WS 8765), so + // gating only the HTTP port protected a path the browser never takes. + // Applied AFTER the merge so it covers the RuField routes too. + // AuthState shares its TicketStore via Arc, so a ticket minted at + // POST /api/v1/ws-ticket on the HTTP port is redeemable here. + .layer(axum::middleware::from_fn_with_state( + bearer_auth_state.clone(), + wifi_densepose_sensing_server::bearer_auth::require_bearer, + )) .layer(axum::middleware::from_fn_with_state( host_allowlist.clone(), wifi_densepose_sensing_server::host_validation::require_allowed_host, @@ -8090,6 +8130,18 @@ async fn main() { ) // Stream endpoints .route("/api/v1/stream/status", get(stream_status)) + // ADR-272 — browsers cannot set Authorization on a WebSocket upgrade, + // so they exchange their credential here for a 30s single-use ticket. + .route("/api/v1/ws-ticket", axum::routing::post(ws_ticket_handler)) + // ADR-271 browser sign-in. Deliberately NOT under /api/v1/*: these are + // how a browser obtains a credential, so gating them would deadlock. + .route("/oauth/start", get(oauth_start)) + .route("/oauth/callback", get(oauth_callback)) + .route("/oauth/logout", get(oauth_logout)) + // Ungated on purpose: a signed-OUT browser needs to discover whether + // sign-in is available, and it cannot ask a gated endpoint that. + // Returns only capability + who-you-are, never a credential. + .route("/oauth/status", get(oauth_status)) .route("/api/v1/stream/pose", get(ws_pose_handler)) // Sensing WebSocket on the HTTP port so the UI can reach it without a second port .route("/ws/sensing", get(ws_sensing_handler)) @@ -8145,19 +8197,27 @@ async fn main() { // is unset/empty the middleware is a no-op — the default stays // LAN-mode-friendly. `/health*`, `/ws/sensing`, and `/ui/*` are never // gated (orchestrator probes + local browsers). + // ADR-272: the ws-ticket handler needs the store the middleware owns. + .layer(axum::Extension(bearer_auth_state.clone())) + .with_state(state.clone()) + // ADR-262 P3: additive RuField surface (`/api/field` + `/ws/field`). + // Merged AFTER `.with_state` (so http_app is already `Router<()>` and + // can absorb the field router's own `FieldState`). + .merge(rufield_surface::router(field_surface.clone())) + // Opt-in bearer auth (#443) + ADR-272 WebSocket gating. + // + // Applied AFTER the merge, and that ordering is load-bearing: axum + // `.layer()` wraps only what is already registered, so while this sat + // above the merge, `/ws/field` bypassed authentication entirely — + // measured 101 on an unauthenticated upgrade with auth ON. Adding + // routes after an auth layer silently exempts them, which is exactly + // the failure mode ADR-272 exists to prevent. + // + // Unset RUVIEW_API_TOKEN/RUVIEW_OAUTH_ISSUER still makes this a no-op. .layer(axum::middleware::from_fn_with_state( bearer_auth_state.clone(), wifi_densepose_sensing_server::bearer_auth::require_bearer, )) - .with_state(state.clone()) - // ADR-262 P3: additive RuField surface (`/api/field` + `/ws/field`). - // Merged AFTER `.with_state` (so http_app is already `Router<()>` and - // can absorb the field router's own `FieldState`). These routes sit - // OUTSIDE `/api/v1/*` so they are not bearer-gated, but the - // host-validation layer below still applies (it is added last, so it - // runs first, over the whole merged router). The surface's own §10 - // egress gate is what keeps above-policy classes off the wire. - .merge(rufield_surface::router(field_surface.clone())) // DNS-rebinding defense: applied last so it runs first on the request // path (axum layers run outermost-in). Rejects requests whose `Host` // header is not in the allowlist before any handler — including @@ -9113,6 +9173,251 @@ mod observatory_persons_field_position_tests { } } +/// `POST /api/v1/ws-ticket` — mint a single-use WebSocket ticket (ADR-272). +/// +/// Reached only through the auth middleware, so an unauthenticated caller +/// cannot mint one. The ticket inherits the caller's scopes, so a +/// `sensing:read` session cannot produce a ticket that outranks itself. +/// +/// Exists because a browser's `WebSocket` constructor cannot set an +/// `Authorization` header. Native clients do not need this — they send a bearer +/// on the upgrade directly. +async fn ws_ticket_handler( + axum::Extension(auth): axum::Extension, + request: axum::extract::Request, +) -> axum::response::Response { + use axum::response::IntoResponse; + use wifi_densepose_sensing_server::ws_ticket::TicketGrant; + + // Present when the caller authenticated with OAuth; absent when they used + // the legacy static token, which predates scopes and carries full authority. + let principal = request.extensions().get::(); + let grant = TicketGrant { + scopes: principal.map(|p| p.scopes().collect::>().join(" ")), + subject: principal.map(|p| p.subject.clone()), + }; + + match auth.tickets().issue(grant) { + Some(ticket) => ( + axum::http::StatusCode::OK, + axum::Json(serde_json::json!({ + "ticket": ticket, + "expires_in_secs": wifi_densepose_sensing_server::ws_ticket::TICKET_TTL.as_secs(), + "usage": "append as ?ticket= to the WebSocket URL; valid once", + })), + ) + .into_response(), + // Refusing beats growing the store without bound. + None => ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "too many outstanding WebSocket tickets; retry shortly\n", + ) + .into_response(), + } +} + +// ---- ADR-271 browser sign-in ------------------------------------------------ +// +// Ported from cognitum-one/freetokens (`src/auth/oauth.ts`, live). The browser +// never holds an OAuth token: this server does the exchange and issues its own +// signed session cookie. Closes the gap where `wifi-densepose login` wrote a +// file no browser could read. + +fn request_is_tls(headers: &axum::http::HeaderMap) -> bool { + // Behind a reverse proxy the TLS terminates upstream, so trust the standard + // forwarding header when present. Conservative default: not TLS, which only + // ever omits `Secure` — it never adds a cookie where it shouldn't be. + headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .map(|p| p.eq_ignore_ascii_case("https")) + .unwrap_or(false) + || wifi_densepose_sensing_server::browser_session::public_base_url().starts_with("https://") +} + +async fn oauth_start( + axum::Extension(auth): axum::Extension, + headers: axum::http::HeaderMap, +) -> axum::response::Response { + use axum::response::IntoResponse; + use wifi_densepose_sensing_server::browser_session as bs; + + let Some(issuer) = auth.oauth_issuer() else { + return ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "OAuth is not enabled on this server (set RUVIEW_OAUTH_ISSUER)\n", + ) + .into_response(); + }; + let secure = request_is_tls(&headers); + // Least privilege: a browser session asks for read. Admin work goes through + // the CLI, which requires an explicit --admin. See BROWSER_SIGNIN_SCOPE for + // what widening this would cost. + match bs::begin(&issuer, &auth.primary_client_id(), bs::BROWSER_SIGNIN_SCOPE, secure) { + Ok((location, cookie)) => ( + axum::http::StatusCode::FOUND, + [ + (axum::http::header::LOCATION, location), + (axum::http::header::SET_COOKIE, cookie), + ], + ) + .into_response(), + Err(e) => (axum::http::StatusCode::SERVICE_UNAVAILABLE, format!("{e}\n")).into_response(), + } +} + +#[derive(serde::Deserialize)] +struct OAuthCallbackQuery { + code: Option, + state: Option, + error: Option, +} + +async fn oauth_callback( + axum::Extension(auth): axum::Extension, + headers: axum::http::HeaderMap, + axum::extract::Query(q): axum::extract::Query, +) -> axum::response::Response { + use axum::response::IntoResponse; + use wifi_densepose_sensing_server::browser_session as bs; + + let secure = request_is_tls(&headers); + let bad = |code: axum::http::StatusCode, msg: String| { + (code, [(axum::http::header::SET_COOKIE, bs::clear_transaction(secure))], msg) + .into_response() + }; + + if let Some(err) = q.error { + return bad(axum::http::StatusCode::BAD_REQUEST, format!("Cognitum declined the sign-in: {err}\n")); + } + let (Some(code), Some(state)) = (q.code, q.state) else { + return bad(axum::http::StatusCode::BAD_REQUEST, "Incomplete sign-in response\n".into()); + }; + let cookie_header = headers + .get(axum::http::header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + + // CSRF check BEFORE the single-use code is spent. + let verifier = match bs::verifier_for_callback(&cookie_header, &state) { + Ok(v) => v, + Err(e) => return bad(axum::http::StatusCode::BAD_REQUEST, format!("{e}\n")), + }; + + let Some(issuer) = auth.oauth_issuer() else { + return bad(axum::http::StatusCode::SERVICE_UNAVAILABLE, "OAuth is not enabled\n".into()); + }; + let client_id = auth.primary_client_id(); + + // `ureq` is blocking; spawn_blocking so a slow token endpoint cannot park an + // async worker (the same mistake this codebase had to fix in jwks.rs). + let exchange = tokio::task::spawn_blocking(move || { + ureq::post(&format!("{issuer}/oauth/token")) + .send_form(&[ + ("grant_type", "authorization_code"), + ("code", &code), + ("code_verifier", &verifier), + ("client_id", &client_id), + ("redirect_uri", &bs::redirect_uri()), + ]) + .map_err(|e| e.to_string()) + .and_then(|r| r.into_string().map_err(|e| e.to_string())) + }) + .await; + + let body = match exchange { + Ok(Ok(b)) => b, + Ok(Err(e)) => return bad(axum::http::StatusCode::BAD_GATEWAY, format!("token exchange failed: {e}\n")), + Err(e) => return bad(axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("token exchange task failed: {e}\n")), + }; + let access_token = match serde_json::from_str::(&body) + .ok() + .and_then(|v| v.get("access_token")?.as_str().map(str::to_owned)) + { + Some(t) => t, + None => return bad(axum::http::StatusCode::BAD_GATEWAY, "token endpoint returned no access_token\n".into()), + }; + + // Verify with the SAME verifier that gates every other request — signature, + // audience, typ, expiry, scope. A browser sign-in must not be a softer path. + let principal = match auth.verify_for_browser(&access_token) { + Ok(p) => p, + Err(e) => return bad(axum::http::StatusCode::UNAUTHORIZED, format!("{e}\n")), + }; + + let session_cookie = match bs::issue(&principal, secure) { + Ok(c) => c, + Err(e) => return bad(axum::http::StatusCode::SERVICE_UNAVAILABLE, format!("{e}\n")), + }; + tracing::info!(sub = %principal.subject, "browser sign-in complete"); + + // Clear the spent transaction as well as issuing the session. A consumed + // OAuth transaction has no further use, and leaving it to age out for ten + // minutes means every subsequent request carries a dead cookie. + ( + axum::http::StatusCode::FOUND, + // AppendHeaders, NOT an array: the array form REPLACES same-name + // headers, so a second Set-Cookie silently overwrites the first — which + // would drop the session cookie and make sign-in a no-op. + axum::response::AppendHeaders([ + (axum::http::header::LOCATION, format!("/ui/?signed_in={}", now_millis())), + (axum::http::header::SET_COOKIE, session_cookie), + (axum::http::header::SET_COOKIE, bs::clear_transaction(secure)), + ]), + ) + .into_response() +} + +async fn oauth_logout(headers: axum::http::HeaderMap) -> axum::response::Response { + use axum::response::IntoResponse; + // Local only: forgets this browser's session. Revoking the Cognitum session + // for every device is an account-level action at auth.cognitum.one. + let secure = request_is_tls(&headers); + use wifi_densepose_sensing_server::browser_session as bs; + ( + axum::http::StatusCode::FOUND, + axum::response::AppendHeaders([ + // Cache-busting query so the landing page is re-fetched rather than + // restored from the back/forward cache with a stale panel. + (axum::http::header::LOCATION, format!("/ui/?signed_out={}", now_millis())), + (axum::http::header::SET_COOKIE, bs::clear_session(secure)), + (axum::http::header::SET_COOKIE, bs::clear_transaction(secure)), + ]), + ) + .into_response() +} + +fn now_millis() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) +} + +/// `GET /oauth/status` — what a signed-out browser needs to render the right UI. +/// +/// Deliberately ungated and deliberately thin: capability flags and, if a live +/// session exists, who it belongs to. No token, no scope escalation hints, no +/// server configuration beyond "is sign-in possible here". +async fn oauth_status( + axum::Extension(auth): axum::Extension, + headers: axum::http::HeaderMap, +) -> axum::Json { + use wifi_densepose_sensing_server::browser_session as bs; + let raw = headers + .get(axum::http::header::COOKIE) + .and_then(|v| v.to_str().ok()); + let session = raw.and_then(bs::from_cookie_header); + axum::Json(serde_json::json!({ + "auth_required": auth.is_enabled(), + "oauth_enabled": auth.oauth_enabled(), + "browser_signin": auth.oauth_enabled() && bs::is_configured(), + "signed_in": session.is_some(), + "account": session.as_ref().map(|s| s.account_id.clone()), + "scope": session.as_ref().map(|s| s.scope.clone()), + })) +} #[cfg(test)] mod adr186_http_tests { //! ADR-186 P6: HTTP-level tests that build the real `training_api` router diff --git a/v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs b/v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs new file mode 100644 index 00000000..8dc00c7a --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs @@ -0,0 +1,382 @@ +//! Short-lived, single-use WebSocket tickets (ADR-272). +//! +//! # Why this exists +//! +//! A browser's `WebSocket` constructor cannot set an `Authorization` header on +//! the upgrade request. That limitation is why `/ws/sensing`, +//! `/ws/introspection` and `/api/v1/stream/pose` have been exempt from +//! [`crate::bearer_auth`] — which means that on a server with auth switched +//! ON, an unauthenticated caller can still complete a WebSocket handshake to +//! the **live sensing stream**. The REST control plane is locked; the data +//! plane is open. +//! +//! A ticket closes that without pretending browsers can do something they +//! cannot: the page makes an ordinary authenticated `POST /api/v1/ws-ticket` +//! (a normal request, where it *can* set headers), gets an opaque string, and +//! passes it as `?ticket=…` on the upgrade. +//! +//! # Why a query parameter is acceptable here, when it usually is not +//! +//! Putting a credential in a URL is normally a mistake: URLs land in access +//! logs, `Referer` headers and browser history. Three properties keep this one +//! bounded, and all three are load-bearing: +//! +//! 1. **Single use.** Consumed on the first upgrade attempt. A ticket in a log +//! is already spent. +//! 2. **Seconds, not hours.** [`TICKET_TTL`] is 30s — long enough for a page to +//! open a socket, far too short to be worth harvesting. +//! 3. **It is not the credential.** It authorizes one WebSocket connection. +//! It cannot be replayed against `/api/v1/*`, cannot be refreshed, and +//! carries no user identity a thief could reuse elsewhere. +//! +//! Native clients — the Python client, the Rust CLI, the TS MCP client — are +//! **not** browsers and must send a normal `Authorization` header on the +//! upgrade instead. Routing them through tickets would add a round-trip and a +//! second credential path for no benefit. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use rand::RngCore; + +/// How long a ticket is valid. Deliberately tiny — a page opens its socket +/// immediately after fetching one, so anything longer is only useful to +/// someone who found the URL later. +pub const TICKET_TTL: Duration = Duration::from_secs(30); + +/// Global cap on outstanding tickets. +const MAX_OUTSTANDING: usize = 512; + +/// Per-principal cap. +/// +/// The global cap alone is not enough: one authenticated `sensing:read` caller +/// looping on `POST /api/v1/ws-ticket` could occupy all 512 slots for 30 +/// seconds and 503 every other user — a denial of service by an ordinary, +/// lowest-privilege account. A page needs a handful of concurrent sockets, so +/// this is generous while making one caller unable to starve the rest. +/// +/// Tickets issued to the legacy static token share the `None` bucket, since +/// that credential carries no subject to attribute them to. +const MAX_PER_PRINCIPAL: usize = 16; + +/// What a redeemed ticket authorizes. +/// +/// The scopes are captured at issue time from the authenticated request, so a +/// WebSocket inherits exactly the authority of the credential that asked for +/// it — a `sensing:read` session cannot obtain a ticket that outranks itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketGrant { + /// Space-separated scopes held by the issuing principal, or `None` when the + /// issuer was the legacy static token (which predates scopes and carries + /// full authority). + pub scopes: Option, + /// `sub` of the issuing principal, for logging. `None` for the static token. + pub subject: Option, +} + +struct Entry { + grant: TicketGrant, + expires_at: Instant, +} + +/// In-memory ticket store. +/// +/// `Debug` deliberately reports only a count, never ticket values — a ticket in +/// a debug log is a live credential for as long as it is unspent. +/// +/// In-memory is correct rather than merely convenient: tickets live for +/// seconds, and a ticket surviving a restart would be a ticket outliving the +/// server that vouched for it. +#[derive(Clone, Default)] +pub struct TicketStore { + inner: Arc>>, +} + +impl std::fmt::Debug for TicketStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let n = self.inner.lock().map(|m| m.len()).unwrap_or(0); + f.debug_struct("TicketStore").field("outstanding", &n).finish() + } +} + +impl TicketStore { + pub fn new() -> Self { + Self::default() + } + + /// Mint a ticket for an authenticated caller. + /// + /// Returns `None` if too many tickets are outstanding — refusing to issue + /// is the correct failure here; the alternative is unbounded growth driven + /// by a caller who is authenticated but misbehaving. + pub fn issue(&self, grant: TicketGrant) -> Option { + let mut map = self.inner.lock().expect("ticket store poisoned"); + prune(&mut map); + if map.len() >= MAX_OUTSTANDING { + tracing::warn!( + outstanding = map.len(), + "refusing to issue a WebSocket ticket: global cap reached" + ); + return None; + } + // Per-principal cap, so one caller cannot starve every other user. + let held_by_this_principal = map + .values() + .filter(|e| e.grant.subject == grant.subject) + .count(); + if held_by_this_principal >= MAX_PER_PRINCIPAL { + tracing::warn!( + subject = ?grant.subject, + held = held_by_this_principal, + "refusing to issue a WebSocket ticket: per-principal cap reached" + ); + return None; + } + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + let ticket = hex(&bytes); + map.insert( + ticket.clone(), + Entry { + grant, + expires_at: Instant::now() + TICKET_TTL, + }, + ); + Some(ticket) + } + + /// Redeem a ticket. **Removes it** — a ticket is valid exactly once, so a + /// replay of the same URL fails even within the TTL. + pub fn consume(&self, ticket: &str) -> Option { + let mut map = self.inner.lock().expect("ticket store poisoned"); + prune(&mut map); + let entry = map.remove(ticket)?; + // Belt and braces: prune already dropped expired entries, but an entry + // expiring between the two would otherwise slip through. + if entry.expires_at <= Instant::now() { + return None; + } + Some(entry.grant) + } + + #[cfg(test)] + fn outstanding(&self) -> usize { + self.inner.lock().unwrap().len() + } +} + +fn prune(map: &mut HashMap) { + let now = Instant::now(); + map.retain(|_, e| e.expires_at > now); +} + +fn hex(bytes: &[u8]) -> String { + use std::fmt::Write; + bytes.iter().fold(String::with_capacity(bytes.len() * 2), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) +} + +/// Extract `ticket` from a raw query string. +fn ticket_from_query(query: &str) -> Option { + for pair in query.split('&') { + if let Some(v) = pair.strip_prefix("ticket=") { + if !v.is_empty() { + return Some(v.to_string()); + } + } + } + None +} + +/// Extract `ticket` from a request URI's query, if present. +pub fn ticket_from_uri(uri: &axum::http::Uri) -> Option { + ticket_from_query(uri.query()?) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn grant() -> TicketGrant { + TicketGrant { + scopes: Some("sensing:read".into()), + subject: Some("user-1".into()), + } + } + + #[test] + fn a_ticket_round_trips_once() { + let store = TicketStore::new(); + let t = store.issue(grant()).expect("issued"); + assert_eq!(store.consume(&t), Some(grant())); + } + + #[test] + fn a_ticket_cannot_be_used_twice() { + // The property that makes a credential-in-a-URL tolerable: by the time + // it reaches a log, it is spent. + let store = TicketStore::new(); + let t = store.issue(grant()).unwrap(); + assert!(store.consume(&t).is_some(), "first use succeeds"); + assert!(store.consume(&t).is_none(), "replay must fail"); + } + + #[test] + fn an_unknown_ticket_is_refused() { + let store = TicketStore::new(); + assert!(store.consume("deadbeef").is_none()); + } + + #[test] + fn consuming_removes_the_entry_rather_than_marking_it() { + let store = TicketStore::new(); + let t = store.issue(grant()).unwrap(); + assert_eq!(store.outstanding(), 1); + store.consume(&t); + assert_eq!(store.outstanding(), 0, "spent tickets must not accumulate"); + } + + #[test] + fn an_expired_ticket_is_refused_and_pruned() { + let store = TicketStore::new(); + let t = store.issue(grant()).unwrap(); + // Force expiry without sleeping. + { + let mut map = store.inner.lock().unwrap(); + map.get_mut(&t).unwrap().expires_at = Instant::now() - Duration::from_secs(1); + } + assert!(store.consume(&t).is_none(), "expired ticket must be refused"); + assert_eq!(store.outstanding(), 0, "and must not linger"); + } + + #[test] + fn tickets_are_unpredictable_and_distinct() { + let store = TicketStore::new(); + let a = store.issue(grant()).unwrap(); + let b = store.issue(grant()).unwrap(); + assert_ne!(a, b); + // 32 bytes hex — guessing is not a strategy. + assert_eq!(a.len(), 64, "expected 256 bits of ticket"); + assert!(a.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn the_grant_records_the_issuing_principals_scopes() { + // A sensing:read session must not be able to mint a ticket that + // outranks it — the WebSocket inherits the issuer's authority. + let store = TicketStore::new(); + let g = TicketGrant { + scopes: Some("sensing:read".into()), + subject: Some("u".into()), + }; + let t = store.issue(g.clone()).unwrap(); + assert_eq!(store.consume(&t).unwrap().scopes.as_deref(), Some("sensing:read")); + } + + #[test] + fn one_principal_cannot_starve_the_global_pool() { + // The reported DoS: an ordinary sensing:read caller looping on + // /api/v1/ws-ticket used to be able to occupy every slot and 503 + // everyone else. + let store = TicketStore::new(); + let noisy = TicketGrant { + scopes: Some("sensing:read".into()), + subject: Some("noisy-user".into()), + }; + for _ in 0..MAX_PER_PRINCIPAL { + assert!(store.issue(noisy.clone()).is_some()); + } + assert!( + store.issue(noisy).is_none(), + "one principal must hit its own cap" + ); + // ...and a different user is entirely unaffected. + let other = TicketGrant { + scopes: Some("sensing:read".into()), + subject: Some("quiet-user".into()), + }; + assert!( + store.issue(other).is_some(), + "another principal must still be served" + ); + assert!( + store.outstanding() < MAX_OUTSTANDING, + "the global pool was never exhausted" + ); + } + + #[test] + fn issuing_is_refused_once_too_many_are_outstanding() { + let store = TicketStore::new(); + for i in 0..MAX_OUTSTANDING { + // Distinct subjects, so this exercises the GLOBAL cap and not the + // per-principal one. + let g = TicketGrant { + scopes: Some("sensing:read".into()), + subject: Some(format!("user-{i}")), + }; + assert!(store.issue(g).is_some()); + } + assert!( + store + .issue(TicketGrant { + scopes: Some("sensing:read".into()), + subject: Some("one-more".into()) + }) + .is_none(), + "the global cap must still hold" + ); + } + + #[test] + fn expired_tickets_free_capacity_again() { + let store = TicketStore::new(); + for i in 0..MAX_OUTSTANDING { + store.issue(TicketGrant { + scopes: Some("sensing:read".into()), + subject: Some(format!("user-{i}")), + }); + } + assert!(store + .issue(TicketGrant { + scopes: Some("sensing:read".into()), + subject: Some("blocked".into()) + }) + .is_none()); + { + let mut map = store.inner.lock().unwrap(); + for e in map.values_mut() { + e.expires_at = Instant::now() - Duration::from_secs(1); + } + } + assert!( + store.issue(grant()).is_some(), + "the cap must be self-healing, not a permanent wedge" + ); + } + + #[test] + fn parses_a_ticket_from_a_query_string() { + assert_eq!(ticket_from_query("ticket=abc123").as_deref(), Some("abc123")); + assert_eq!( + ticket_from_query("foo=1&ticket=abc123&bar=2").as_deref(), + Some("abc123") + ); + } + + #[test] + fn an_absent_or_empty_ticket_parameter_yields_none() { + assert!(ticket_from_query("foo=1").is_none()); + assert!(ticket_from_query("ticket=").is_none()); + assert!(ticket_from_query("").is_none()); + } + + #[test] + fn a_parameter_merely_ending_in_ticket_is_not_a_ticket() { + // `?myticket=x` must not be read as `?ticket=x`. + assert!(ticket_from_query("myticket=abc").is_none()); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/tests/auth_wiring.rs b/v2/crates/wifi-densepose-sensing-server/tests/auth_wiring.rs new file mode 100644 index 00000000..6dbf9633 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/tests/auth_wiring.rs @@ -0,0 +1,317 @@ +//! Boots the REAL `sensing-server` binary and probes BOTH listeners. +//! +//! # Why this test exists +//! +//! Two authentication bypasses shipped in the ADR-272 work, and 526 green unit +//! tests could not see either, because every auth test in this crate builds its +//! OWN `Router` with a hand-picked subset of routes. A synthetic router can +//! never observe how the real one is assembled — and both defects were assembly: +//! +//! 1. The dedicated WebSocket listener (`--ws-port`) was constructed with only +//! host validation. `require_bearer` was never applied to it at all. That is +//! the port the shipped UI actually connects to +//! (`ui/services/sensing.service.js` maps HTTP 8080 -> WS 8765), so the +//! earlier fix protected a path the browser never takes. +//! 2. `/ws/field` was `.merge()`d AFTER the auth layer on the HTTP router. In +//! axum a layer wraps only what is already registered, so merging afterwards +//! silently exempts those routes. +//! +//! Both were found by adversarial review, not by the suite. This test closes +//! that gap: it runs the actual binary, so it sees the actual wiring. +//! +//! It deliberately asserts on **ports and transports**, not on handler logic — +//! handler behaviour is covered by the unit suites. What is unique here is that +//! nothing is synthetic: real process, real listeners, real TCP. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +const TOKEN: &str = "integration-test-secret"; + +/// Reserve a port by binding and immediately releasing it. +/// +/// Mildly racy, which is why each test reserves its own set and the server is +/// given several seconds to come up: a collision surfaces as a boot failure, +/// not as a false pass. +fn free_port() -> u16 { + let l = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral"); + let p = l.local_addr().unwrap().port(); + drop(l); + p +} + +struct Server { + child: Child, + http: u16, + ws: u16, +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Server { + /// Spawn the real binary. `env` lets a test choose the auth configuration. + /// + /// PANICS rather than returning `None` on failure. This used to return an + /// `Option` that every test turned into `return`, which meant all five + /// assertions were skipped precisely when the server was broken — including + /// broken BY an auth change. A boot failure printed one line that `cargo + /// test` swallows without `--nocapture` and reported `5 passed`. The only + /// test in the suite that observes real wiring disarmed itself exactly when + /// it mattered; a boot-time panic in the auth path would have shipped green. + fn start(env: &[(&str, &str)]) -> Self { + let (http, ws, udp) = (free_port(), free_port(), free_port()); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_sensing-server")); + cmd.args([ + "--http-port", &http.to_string(), + "--ws-port", &ws.to_string(), + "--udp-port", &udp.to_string(), + "--bind-addr", "127.0.0.1", + "--no-edge-registry", + "--source", "simulate", + ]) + // Inherit nothing auth-related from the developer's shell, or a local + // RUVIEW_* export would silently change what this test proves. + .env_remove("RUVIEW_API_TOKEN") + .env_remove("RUVIEW_OAUTH_ISSUER") + .env_remove("RUVIEW_WS_LEGACY_UNAUTHENTICATED") + .stdout(Stdio::null()) + // Captured, not discarded: if the server dies at boot, its stderr is the + // only thing that says why, and the panic below reproduces it. + .stderr(Stdio::piped()); + for (k, v) in env { + cmd.env(k, v); + } + let mut child = cmd.spawn().expect("spawn sensing-server"); + let http_port = http; + let ws_port = ws; + if !await_ready(http_port, ws_port) { + let mut err = String::new(); + if let Some(mut s) = child.stderr.take() { + let _ = s.read_to_string(&mut err); + } + let _ = child.kill(); + let _ = child.wait(); + panic!( + "sensing-server did not become ready on :{http_port} (http) and :{ws_port} (ws) \ + within 30s. This is a FAILURE, not a skip — the wiring assertions below cannot \ + run, and a boot-time break in the auth path is exactly what they exist to catch.\n\ + --- server stderr ---\n{err}" + ); + } + Server { child, http: http_port, ws: ws_port } + } + +} + +fn await_ready(http: u16, ws: u16) -> bool { + let deadline = Instant::now() + Duration::from_secs(30); + while Instant::now() < deadline { + if TcpStream::connect(("127.0.0.1", http)).is_ok() + && TcpStream::connect(("127.0.0.1", ws)).is_ok() + { + return true; + } + std::thread::sleep(Duration::from_millis(200)); + } + false +} + +/// One raw HTTP/1.1 request; returns the status code. +fn status(port: u16, method: &str, path: &str, headers: &[(&str, &str)]) -> u16 { + let addr: SocketAddr = ([127, 0, 0, 1], port).into(); + let mut s = TcpStream::connect(addr).expect("connect"); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + let mut req = format!("{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n"); + for (k, v) in headers { + req.push_str(&format!("{k}: {v}\r\n")); + } + req.push_str("Connection: close\r\n\r\n"); + s.write_all(req.as_bytes()).expect("write"); + + let mut line = String::new(); + BufReader::new(&mut s).read_line(&mut line).expect("status line"); + line.split_whitespace() + .nth(1) + .and_then(|c| c.parse().ok()) + .unwrap_or_else(|| panic!("unparseable status line: {line:?}")) +} + +/// A genuine WebSocket upgrade. 101 means the connection was ACCEPTED. +fn ws_upgrade(port: u16, path: &str, bearer: Option<&str>) -> u16 { + let mut headers: Vec<(&str, &str)> = vec![ + ("Upgrade", "websocket"), + ("Connection", "Upgrade"), + ("Sec-WebSocket-Version", "13"), + ("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="), + ]; + let auth; + if let Some(b) = bearer { + auth = format!("Bearer {b}"); + headers.push(("Authorization", &auth)); + } + // Not `Connection: close` — that would contradict the upgrade. + let addr: SocketAddr = ([127, 0, 0, 1], port).into(); + let mut s = TcpStream::connect(addr).expect("connect"); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + let mut req = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n"); + for (k, v) in &headers { + req.push_str(&format!("{k}: {v}\r\n")); + } + req.push_str("\r\n"); + s.write_all(req.as_bytes()).expect("write"); + + let mut buf = [0u8; 256]; + let n = s.read(&mut buf).expect("read"); + let head = String::from_utf8_lossy(&buf[..n]); + let line = head.lines().next().unwrap_or_default(); + line.split_whitespace() + .nth(1) + .and_then(|c| c.parse().ok()) + .unwrap_or_else(|| panic!("unparseable status line: {line:?}")) +} + +/// Every WebSocket path, on every listener. This list is the point of the test. +const WS_PATHS: &[&str] = &["/ws/sensing", "/ws/introspection", "/api/v1/stream/pose", "/ws/field"]; + +/// Non-WebSocket routes that carry sensing data and must be gated. +/// +/// `/api/field` is here because it was NOT gated: it serves the same signed +/// `FieldEvent` stream as `/ws/field`, but sits outside `/api/v1/`, and the gate +/// protected `/api/v1/*` by prefix. `/ws/field` was gated in this PR and its +/// REST twin, one path segment over, returned 200 to an anonymous caller on +/// both listeners. That is why the gate is now deny-by-default. +const PROTECTED_REST_PATHS: &[&str] = &["/api/field", "/api/v1/models"]; + +#[test] +fn with_auth_on_no_listener_serves_sensing_data_anonymously() { + let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]); + + for (label, port) in [("http", server.http), ("ws", server.ws)] { + for path in PROTECTED_REST_PATHS { + assert_eq!( + status(port, "GET", path, &[]), + 401, + "{label} port served {path} to an anonymous caller" + ); + // And the credential must actually work, or the assertion above + // could pass because the route simply does not exist. + let ok = status(port, "GET", path, &[("Authorization", &format!("Bearer {TOKEN}"))]); + assert_ne!(ok, 401, "{label} port {path} rejected a VALID bearer"); + } + } +} + +#[test] +fn the_dashboard_shell_and_sign_in_stay_reachable_when_auth_is_on() { + // Deny-by-default must not lock the user out of the page that renders the + // sign-in button, or of sign-in itself. This is the other half of the + // allowlist: too tight is as broken as too loose, just louder. + let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]); + for path in ["/health", "/oauth/status"] { + assert_ne!( + status(server.http, "GET", path, &[]), + 401, + "{path} must stay anonymous — sign-in depends on it" + ); + } +} + +#[test] +fn with_auth_on_no_listener_accepts_an_unauthenticated_websocket() { + let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]); + + // Control first: if REST is not gated, the server is misconfigured and the + // WebSocket assertions below would pass for the wrong reason. + assert_eq!( + status(server.http, "GET", "/api/v1/models", &[]), + 401, + "REST must be gated, or this test proves nothing" + ); + + for &port_label in &["http", "ws"] { + let port = if port_label == "http" { server.http } else { server.ws }; + for path in WS_PATHS { + let code = ws_upgrade(port, path, None); + assert_ne!( + code, 101, + "{port_label} port ACCEPTED an unauthenticated upgrade to {path} — \ + this is the bypass that shipped twice" + ); + assert_eq!( + code, 401, + "{port_label} port {path} should refuse with 401, got {code}" + ); + } + } +} + +#[test] +fn a_bearer_on_the_upgrade_is_accepted_on_both_listeners() { + let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]); + // Native clients (Python, CLI, MCP) are not browser-constrained and must be + // able to authenticate a WebSocket without the ticket round-trip. + for (label, port) in [("http", server.http), ("ws", server.ws)] { + assert_eq!( + ws_upgrade(port, "/ws/sensing", Some(TOKEN)), + 101, + "{label} port must accept a valid bearer on the upgrade" + ); + } +} + +#[test] +fn with_auth_off_both_listeners_stay_open() { + // The compatibility promise: an unconfigured deployment sees no change. + let server = Server::start(&[]); + assert_eq!(status(server.http, "GET", "/api/v1/models", &[]), 200); + for (label, port) in [("http", server.http), ("ws", server.ws)] { + assert_eq!( + ws_upgrade(port, "/ws/sensing", None), + 101, + "{label} port must stay open when no credential is configured" + ); + } +} + +#[test] +fn the_legacy_escape_hatch_opens_websockets_without_weakening_rest() { + let server = Server::start(&[ + ("RUVIEW_API_TOKEN", TOKEN), + ("RUVIEW_WS_LEGACY_UNAUTHENTICATED", "1"), + ]); + // The hatch is scoped to WebSockets on purpose. If it ever widened to REST + // it would be a bypass wearing a migration label. + assert_eq!( + status(server.http, "GET", "/api/v1/models", &[]), + 401, + "the escape hatch must not weaken REST" + ); + for (label, port) in [("http", server.http), ("ws", server.ws)] { + assert_eq!( + ws_upgrade(port, "/ws/sensing", None), + 101, + "{label} port should be open while the hatch is set" + ); + } +} + +#[test] +fn health_stays_anonymous_on_both_listeners() { + // Documented exemption (ADR-272): orchestrator probes are anonymous by + // design. Pinned so it is a decision, not an accident nobody re-checks. + let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]); + for (label, port) in [("http", server.http), ("ws", server.ws)] { + assert_eq!( + status(port, "GET", "/health", &[]), + 200, + "{label} port /health must remain anonymous" + ); + } +}