mirror of
https://github.com/ruvnet/RuView
synced 2026-07-24 17:43:20 +00:00
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:
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
@@ -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));
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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>✓ 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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! OAuth 2.0 PKCE (RFC 7636) generation.
|
||||
//!
|
||||
//! Ported from `cognitum-one/meta-proxy` `src/oauth/pkce.rs`, itself ported from
|
||||
//! `dashboard/apps/cli`. Kept byte-compatible on purpose: a verifier generated
|
||||
//! here has to validate against the same `services/identity` code every other
|
||||
//! Cognitum client already talks to.
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// One login attempt's PKCE pair plus its CSRF `state`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PkceRequest {
|
||||
pub state: String,
|
||||
pub code_verifier: String,
|
||||
pub code_challenge: String,
|
||||
}
|
||||
|
||||
fn random_url_safe_token(byte_len: usize) -> String {
|
||||
let mut bytes = vec![0u8; byte_len];
|
||||
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
pub fn challenge_from_verifier(verifier: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
|
||||
}
|
||||
|
||||
/// Fresh `state` + verifier/challenge for one login attempt.
|
||||
///
|
||||
/// 32 random bytes each: base64url-encodes to 43 characters, comfortably inside
|
||||
/// RFC 7636 §4.1's 43–128 range without padding.
|
||||
pub fn generate() -> PkceRequest {
|
||||
let state = random_url_safe_token(32);
|
||||
let code_verifier = random_url_safe_token(32);
|
||||
let code_challenge = challenge_from_verifier(&code_verifier);
|
||||
PkceRequest {
|
||||
state,
|
||||
code_verifier,
|
||||
code_challenge,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn matches_the_rfc7636_appendix_b_worked_example() {
|
||||
// The spec's own vector. If this drifts, our S256 is not S256 and the
|
||||
// server will reject every exchange — worth pinning to the standard
|
||||
// rather than to our own output.
|
||||
assert_eq!(
|
||||
challenge_from_verifier("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
|
||||
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verifier_length_is_within_rfc7636_bounds() {
|
||||
let r = generate();
|
||||
assert!(
|
||||
r.code_verifier.len() >= 43 && r.code_verifier.len() <= 128,
|
||||
"len {}",
|
||||
r.code_verifier.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_is_derived_from_the_verifier_it_ships_with() {
|
||||
let r = generate();
|
||||
assert_eq!(challenge_from_verifier(&r.code_verifier), r.code_challenge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_attempts_share_nothing() {
|
||||
let (a, b) = (generate(), generate());
|
||||
assert_ne!(a.state, b.state);
|
||||
assert_ne!(a.code_verifier, b.code_verifier);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 { .. })
|
||||
));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user