Merge #1397: Cognitum OAuth resource server, sign-in, and WebSocket auth (ADR-271/272)

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