mirror of
https://github.com/ruvnet/RuView
synced 2026-07-24 17:43:20 +00:00
main
12 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
89cceaf835 |
fix(auth): close P1/P2/P3 — JWKS stall, 12h session, cookie shadowing
All three deferred findings from the qe-court round. Each fix is guarded by a test confirmed to FAIL against the old behaviour. P1 — JWKS: self-inflicted stall, and a blocking fetch on a tokio worker. `fetched_at` advances only on SUCCESS, and the only rate limiter sat behind `if fresh`. So once the TTL elapsed after the last successful fetch, `fresh` was permanently false, the limiter was never consulted, and EVERY request performed its own blocking 3s-timeout fetch. A Pi that loses WAN stalled itself 300s later with no attacker present; an attacker could force the same state by flooding tokens with an unknown `kid`. Now: `last_attempt_at` is recorded BEFORE every fetch regardless of outcome, and gates the stale path too; a stale-but-present key is served rather than erroring, which is the offline tolerance this module always claimed. Measured by the new test: 26 outbound fetches before, 1 after. Kept as TWO independent limiters. Merging them looks tidy and is wrong — a routine refetch would then suppress the unknown-`kid` path for 30s and delay pickup of a key rotation inside the TTL. I made that mistake first; two existing tests caught it. The blocking call also now runs in `spawn_blocking` at the verify boundary, matching what `main.rs` already does for the token exchange, where the comment reads "the same mistake this codebase had to fix in jwks.rs". The hot verification path had never been given the same treatment. A panicked task fails closed. P2 — session lifetime, per decision: 1 hour, plus step-up. SESSION_TTL_SECS 12h -> 1h, and privileged (`sensing:admin`) actions now require the user to have authenticated within ADMIN_REVERIFY_SECS (5 min), tracked by a new `auth_time` claim. Reads ride the full session; only the routes where a stale session does damage are re-verified, so a dashboard whose main use is watching a live stream does not re-auth hourly. `auth_time` is `#[serde(default)]`, so a cookie issued before the field existed reads as 0 — infinitely stale. Such a session keeps working for reads and cannot perform privileged actions. Fail-closed and self-healing on next sign-in. The refusal carries an RFC 6750 `WWW-Authenticate` error code, because the client's correct response differs from a plain 401: the user IS signed in and needs to prove it again. `api.service.js` acts on that and redirects through `/oauth/start` — otherwise a stale-session delete surfaces as a generic "Request failed" with no hint that signing in again fixes it. P3 — cookie shadowing. `read_cookie` returned the FIRST match, and RFC 6265 §5.4 sends longer-`Path` cookies first. Cookies are not isolated by port or scheme, so any other service on the host — or a plain-HTTP MITM injecting Set-Cookie — could plant `ruview_session=<their own validly signed session>; Path=/ui`. The victim sent both, the attacker's first, and it verified because it genuinely was signed: silent session takeover, with `/oauth/status` reporting the attacker's account. The signature was doing its job throughout, which is why "it's signed" never answered this. `__Host-` would, but requires `Secure`, and RuView is routinely reached over plain HTTP on a LAN. So both credential paths now accept only when EXACTLY ONE candidate verifies. An attacker can still cause a refusal by planting a second valid cookie — a nuisance — but no longer a takeover. Planting junk changes nothing, so this does not become a trivial DoS. Tests: +1 jwks (26-vs-1 fetch amplification), +4 step-up, +4 shadowing, +1 duplicate-name reader. Mutation-verified: reverting the stale-path guard gives 26 fetches; reverting to first-match cookie reads fails `a_shadowing_cookie_cannot_silently_take_over_the_session`. Verified: workspace 176 suites clean under CI flags, ruview-auth 62+25+2 with --all-features, UI 22. ADR-271: P1/P2/P3 marked RESOLVED with the analysis retained, since it explains why each fix has the shape it does. Co-Authored-By: Ruflo & AQE |
||
|
|
c72bbc15dd |
fix(auth): close /api/field bypass, two fail-opens, and a self-disarming test
Findings from a qe-court adversarial round (4 prosecutors across 2 vendors).
Each was verified against the code before being accepted; the ones below
reproduced, the rest are reported in the PR thread rather than acted on.
FATAL — `/api/field` was reachable with no credential, on both listeners.
The gate protected `/api/v1/*` by prefix. `/api/field` is the REST sibling of
`/ws/field` and serves the same signed FieldEvent stream — live presence, pose,
vitals. `/ws/field` was gated in this PR; its twin one path segment over was
not. Measured with RUVIEW_API_TOKEN set and no credential supplied:
/api/v1/models 401 (control)
/ws/field 401 (gated by this PR)
/api/field 200 on :8080 AND :8765
Fixed by inverting the gate to deny-by-default with an explicit anonymous
allowlist (`/`, `/ui`, `/health`, `/oauth/`). A route added at a new path is
now gated because nobody exposed it, rather than exposed because nobody
protected it — the same inversion already applied to the scope gate.
FATAL — the wiring test disarmed itself exactly when it mattered.
`Server::start` returned an Option that all five tests turned into `return`,
so a server that failed to boot produced "5 passed" with zero assertions run,
and cargo swallows the skip line without --nocapture. The one test that
observes real wiring — the guard against both shipped bypasses — was silent
for any change that breaks startup, including a boot panic in the auth path.
It now panics with the child's stderr.
MAJOR — a malformed client-id list silently disabled the audience check.
An empty allowlist is the opt-out sentinel in verify.rs. `RUVIEW_OAUTH_CLIENT_IDS=","`
is non-empty, passes the guard, then filters to an empty Vec — turning the
audience boundary off with no log and admitting a token minted for any other
Cognitum product. Only a literal `*` may opt out now; anything else that parses
to nothing warns and falls back to the default. Same fail-open shape as the
scope denylist this PR already had to invert.
MAJOR — credentials were world-readable for a window on every refresh.
`fs::write` creates at 0666 & !umask (0644 by default), and both writers
chmodded afterwards. The existing permissions test asserted on the FINAL file
and passed throughout. Affected the CLI refresh token (rotated with reuse
detection — a thief who presents it first takes the session family) and the
browser session secret (the HMAC key for every session; stealing it forges any
account at any scope). Both now create with mode 0600 via OpenOptions.
MAJOR — ui/sw.js cached authenticated API responses.
Closing the /oauth/ leg left the /api/ leg open. `networkFirst` cached every
successful response, keyed by URL alone, purged by nothing at sign-out: sign in
as A, load sensing data, sign out, sign in as B, lose the network, and B is
served A's data with no authorization check. API responses are now network-only
— which is also the correct behaviour for a live sensing dashboard, where
replaying a stale reading can show a room occupied after the person left — plus
a cache purge on sign-out.
CI — 40 of ruview-auth's 87 tests never ran.
The workspace runs --no-default-features, which switches off the `login` and
`pkce` features. Measured: 47 tests vs 87. The whole interactive sign-in path —
credential storage, single-flight refresh, the file lock, the loopback callback
— was green locally and never executed in CI. Added an --all-features step.
(Checked the sensing-server for the same problem and did NOT find it:
bearer_auth's 49 and browser_session's 15 do run under CI flags.)
Tests: +2 wiring tests (one fails against the old gate with
"http port served /api/field to an anonymous caller", passes after), +3 UI
service-worker tests, +3 CLI scope tests, +1 temp-file permission test, +1
client-id parsing test. wifi-densepose-cli/src/auth.rs had zero tests and
builds its own scope string, so the library's least-privilege test said nothing
about what the CLI requests.
Verified: ruview-auth 61+25+2 pass (--all-features), sensing-server bearer_auth
50, browser_session 15, auth_wiring 7, workspace 25 suites clean, UI 22.
Co-Authored-By: Ruflo & AQE
|
||
|
|
347698b67c |
feat(auth): browser sign-in — /oauth/start, /oauth/callback, session cookie
Closes the gap adversarial review found: `wifi-densepose login` writes ~/.ruview/credentials.json, which a BROWSER CANNOT READ. The UI therefore had no way to obtain a Cognitum token at all, and the WebSocket ticket mechanism ADR-272 built "for browsers" was only exercisable with the legacy static shared secret OAuth was meant to replace. The ADRs described a browser story that did not exist. Ported from cognitum-one/freetokens (src/auth/oauth.ts, live at freetokens.cognitum.one), whose shape is not the obvious one and is the whole point: THE BROWSER NEVER HOLDS AN OAUTH TOKEN. The server generates the PKCE verifier and state, keeps them in an HMAC-signed cookie, performs the code exchange itself, verifies the token, and issues its OWN session cookie carrying an assertion — subject, account, scope, expiry — not a credential. So the access token cannot be read by an XSS, cannot sit in localStorage, and cannot leak through a URL. A stolen session cookie is useless against Cognitum or any sibling service. GET /oauth/start -> 302 to auth.cognitum.one + signed transaction cookie GET /oauth/callback -> constant-time state check, exchange, verify, session GET /oauth/logout -> clears the local session (not the Cognitum session) Verified against the running binary: /oauth/start returns the same 302 + HttpOnly/SameSite=Lax/Max-Age=600 shape freetokens does live; a forged `state` is refused 400; a forged session cookie is refused 401. DELIBERATE DEVIATION from freetokens: no `__Host-` cookie prefix. That prefix REQUIRES `Secure`, and RuView is routinely reached at http://localhost or over plain HTTP on a LAN, where such a cookie is never sent and sign-in would fail silently. `Secure` is set only when the request actually arrived over TLS (direct or via x-forwarded-proto). Every other attribute matches; the HMAC is what protects the value. Other decisions worth stating: - The callback verifies through the SAME `verify_access_token` every other request uses — signature, audience (client_id), typ, expiry, scope. A sign-in path must not be a softer path. - The session cookie is checked LAST in the middleware, after bearer and ticket: it is the weakest-bound credential, so a presented bearer should win. - A browser session requests `sensing:read` only. Admin work goes through the CLI's explicit `--admin`. - The token exchange runs in `spawn_blocking` — `ureq` is blocking, and parking an async worker is the mistake this codebase just had to fix in jwks.rs. - `/oauth/*` sits outside `/api/v1/*` on purpose: gating the routes you use to obtain a credential would deadlock. PKCE moved out from behind the `login` feature into its own light `pkce` feature (rand + sha2 + base64, no HTTP stack), so the server can build an authorize URL without pulling in the client-side login machinery. `login` now implies `pkce`. Tests: 13 new browser_session unit tests — signature round-trip, tampered payload, wrong secret, malformed cookie values, HttpOnly/SameSite/Secure attributes, exact scope matching with no implied escalation, a cookie name that merely ends with the target not matching, multi-scope URL encoding, and the core property that a session cookie never contains the access token. Totals: 547 sensing-server lib + 5 wiring integration, 87 ruview-auth. Co-Authored-By: Ruflo & AQE |
||
|
|
b09625ece7 |
fix(auth): close the four residual findings from the adversarial review
(a) Test fixture still invented an `iss` claim.
`bearer_auth.rs`'s `token_with_scope` added `"iss": ISSUER` to its tokens.
Harmless today because the verifier ignores `iss` — but it is the same
fixture-invents-reality pattern that hid the original `iss` bug for a day,
sitting in the second-largest auth test suite. Removed, with a pointer to
ruview-auth's regression test.
(b) Cross-process refresh race -> session revocation.
The single-flight guarantee was per-PROCESS. Every CLI invocation is a new
process with its own Session and mutex over one shared credential file, so
two commands run close together inside the 60s refresh window would each
present the same rotating refresh token — and the second is replay, which
identity answers by revoking the whole session family. The user gets logged
out for running two commands at once.
Now guarded by an advisory file lock, taken NON-BLOCKING. A busy lock means
another process is already refreshing, so we wait and re-read its result
rather than race it (20 x 150ms, then proceed anyway — the lock is advisory,
not a correctness barrier, and a dead holder must not wedge us). Blocking on
the lock would have parked the async executor, which is the exact mistake
just fixed in jwks.rs.
Unix only. On other platforms it is a documented no-op — a lock that does
nothing while claiming to protect is worse than none.
(c) One principal could exhaust the global ticket pool.
The 512 cap was global with no per-caller quota, so a single authenticated
`sensing:read` client looping on POST /api/v1/ws-ticket could hold every
slot for 30s and 503 everyone else — denial of service by the
lowest-privilege account the product issues. Added a 16-ticket
per-principal cap; a page needs a handful. Test asserts a noisy user hits
its own cap while a second user is still served and the global pool is
never exhausted.
(d) Debug impls printed live credentials.
`AuthState` derived Debug over the raw RUVIEW_API_TOKEN and
`StoredCredentials` over both OAuth tokens. Not leaking today — I checked
every call site — but this PR had already hand-written redacting Debug for
`OAuthState` and `TicketStore` for exactly this reason, and the two types
actually holding secrets were the ones that missed out. Both now redact.
Tests: 87 ruview-auth (2 new: lock exclusivity + non-blocking, Debug
redaction), 534 sensing-server (1 new: per-principal quota).
Co-Authored-By: Ruflo & AQE
|
||
|
|
0547fd7344 |
fix(auth): enforce client_id as the audience — Cognitum's stand-in for aud
Found by reading cognitum-one/freetokens, a live sibling service whose browser OAuth landed while this PR was open. Its integration contract states the platform rule outright: "Cognitum access tokens intentionally use custom `client_id` rather than a registered JWT `aud` claim." -- freetokens docs/AUTH_INTEGRATION.md and `src/auth/oauth.ts` enforces it on every sign-in: payload.client_id !== config.OAUTH_CLIENT_ID -> reject RuView did not. An earlier revision here removed the `client_id` check and kept it only for logging, reasoning that clients borrow one another's registrations (musica shipped as `meta-proxy` while its own was pending) and that scope alone must therefore carry the boundary. That reasoned from a TRANSITIONAL state: RuView has its own registered client (identity migration 0017), and the platform does have an audience mechanism — it is simply spelled `client_id`. Consequence of the old behaviour: a Cognitum access token minted for ANY product — meta-proxy, musica, metaharness, freetokens — was accepted by a RuView server provided it carried a sensing scope. Scope was the only thing standing between another product's token and this one. Now there are two boundaries, audience and capability, which is what the platform intends. - `VerifierConfig.allowed_client_ids`; empty = accept any (explicit opt-out). - `RUVIEW_OAUTH_CLIENT_IDS` env, default `ruview`, `*` to disable with a loud warning naming what is being given up. Comma-separated for the migration case where a borrowed registration must be accepted alongside our own. - New `VerifyError::WrongAudience`, checked BEFORE scope, so the failure names the real reason rather than blaming the scope. The existing cross-product test now asserts `WrongAudience` rather than `MissingScope` — the token is refused for the stronger reason. Three new tests: a correctly-scoped token from another product is still refused; the empty-list opt-out accepts anything (pinned so it stays deliberate); multiple allowed clients work. This also corrects the module docs and ADR-271, which claimed "scope is the ONLY capability boundary" — true of the code as written, but not of the platform. Tests: 85 ruview-auth, 533 sensing-server. Co-Authored-By: Ruflo & AQE |
||
|
|
f67a880a1a |
docs: correct three doc-vs-code contradictions found by adversarial review
All three are my own drift — the exact failure this session criticised in other
repos' ADRs and then reproduced.
1. ADR-271 §2 still listed "`iss` matches the configured issuer verbatim" in the
accept-rule. That rule was removed from the code in
|
||
|
|
7a05417493 |
fix(auth): scope gate was fail-OPEN; JWKS lock held across a blocking fetch
Two charges from an adversarial multi-vendor review (qe-court). Both verified
in source before fixing.
--- 1. AUTHORIZATION BYPASS: POST /api/v1/adaptive/train reachable with read ---
`required_scope_for` enumerated admin routes by prefix (`/api/v1/train/`, plus
DELETE on models/recording) and let EVERYTHING ELSE fall through to
`sensing:read`.
`POST /api/v1/adaptive/train` (main.rs:8112 -> handler main.rs:5028) calls
`adaptive_classifier::train_from_recordings()`, writes the model to disk with
`model.save()`, and swaps `state.adaptive_model` used by the live inference
pipeline. It does not start with `/api/v1/train/`, so it landed on
`sensing:read` — the scope `wifi-densepose login` requests BY DEFAULT. Exactly
the blast radius the read/admin split exists to prevent, reachable by the
lowest-privilege token the product issues.
Fixed by inverting the polarity, which is the real defect — a denylist for a
security gate keeps missing routes as routes keep being added:
GET/HEAD/OPTIONS -> sensing:read
any other method -> sensing:admin
...unless the exact path is in READ_SAFE_MUTATIONS (an explicit allowlist of
mutations that change runtime state but destroy nothing: ws-ticket,
model load/unload/activate, calibration start/stop, recording start/stop,
vendor event ingest)
A mutating route added tomorrow is now admin-gated by default. Pinned by
`an_unknown_mutating_route_defaults_to_admin`. `POST /api/v1/ws-ticket` is
allowlisted deliberately and has its own test: were it admin, the read scope
could never open a stream from a browser at all.
Enumerated all 17 mutating /api/v1 routes to build the allowlist rather than
guessing. `POST /api/v1/config/ground-truth` now requires admin — a deliberate
tightening, it writes config.
--- 2. DoS: blocking JWKS fetch under std::sync::Mutex in async middleware ---
Filed INDEPENDENTLY by two prosecutors, which is why it gets fixed rather than
argued about.
`decoding_key_for` locked a `std::sync::Mutex` and held it across a blocking
`ureq` call (3s timeout, longer on a dead link), invoked from inside the async
`require_bearer`. `Mutex::lock()` in an async fn is a blocking syscall, not a
yield point — so one slow JWKS fetch blocked EVERY concurrent request on that
mutex, including ones carrying already-cached valid tokens, and parked the
tokio workers running them. On Pi-class hardware with few workers that stalls
the whole server. It fires on the routine 300s TTL rollover whenever the link
is degraded — the exact offline case the module exists to tolerate. Reachable
by anyone able to send a syntactically valid ES256 header with an unknown kid,
since kid lookup precedes signature verification.
Now three phases: read under the lock, RELEASE, network, re-take only to
install. Cost is a possible duplicated idempotent GET during a rollover, which
is strictly better than serialising every request behind one socket. The
codebase already uses spawn_blocking for outbound I/O elsewhere
(main.rs:2490, 5272); moving verification fully onto spawn_blocking remains a
follow-up — this removes the amplification, not every blocking millisecond.
Tests: 533 sensing-server (7 new scope-polarity), 82 ruview-auth.
Co-Authored-By: Ruflo & AQE
|
||
|
|
6d3fb88677 |
fix(cli): resolve scope from the token on read, and add whoami --refresh
Two gaps that only appear with a credential file written by an earlier build —
i.e. exactly the case a fresh-login test never exercises.
1. `whoami` printed "Scope: (not reported)" for an existing file. The previous
commit resolved scope from the token claim at WRITE time only, so files
written before it stayed blank forever. `effective_scope()` now falls back at
READ time, which fixes existing files and any client that stored only what
the token response carried. The token is authoritative either way; the stored
field is a convenience copy.
2. `whoami` said the token "will refresh on next use" — a promise nothing kept,
because no command called `ensure_fresh`. The refresh path was implemented
and unit-tested but never actually run. `--refresh` exercises it, and goes
through `Session::ensure_fresh` rather than reimplementing a second, subtly
different refresh, so it inherits the single-flight guarantee and the
persist-before-return ordering.
It is a flag, not silent behaviour: refreshing rotates the stored refresh
token — identity spends the old one — so it is a state change, not a read.
VERIFIED AGAINST PRODUCTION, end to end:
whoami on a pre-existing file -> Scope: sensing:read (was "not reported")
whoami --refresh -> both tokens rotated
refresh sha256[:12] 50cfa06b -> 2d37617a
access sha256[:12] 61eebe8d -> 14d8e8de
status: expired -> valid
refreshed token GET /api/v1/models -> 200
refreshed token POST /api/v1/train/start -> 401 (still read-scoped)
That exercises the rotating-refresh path against identity's real reuse
detection — the one place a bug costs the user their session rather than a
retry — and confirms the scope gate survives a refresh.
Tests: 82 with --features login, 44 default, 501 sensing-server.
Co-Authored-By: Ruflo & AQE
|
||
|
|
12635a85b2 |
fix(auth): stop requiring an iss claim Cognitum never issues (G-3 blocker)
The verifier called `Validation::set_issuer` and listed `iss` in `set_required_spec_claims`. Cognitum access tokens have **no `iss` claim** — the real claim set is typ, sub, account_id, org_id, workspace_id, client_id, scope, family_id, jti, iat, exp, setup, workload. So the verifier rejected every genuine token with a flat 401. The whole 41-test suite was green throughout, because the test fixtures included an `iss` the real thing does not have. The tests validated my assumption instead of the platform. Found only by pointing a real token at a real server; no amount of additional unit testing against the same wrong fixture would have caught it. `valid_claims()` now mirrors production exactly, with a comment saying why, so the next person cannot reintroduce the drift by "fixing" an incomplete-looking fixture. What binds a token to its issuer, then: the JWKS. We accept only signatures made by a key served from the configured jwks_uri, so a valid signature IS proof of issuer. meta-llm's verifier — the org's only other resource-side verifier — checks neither `iss` nor `aud`, for the same reason. `VerifierConfig.issuer` stays, now documented as JWKS-derivation and logging rather than a claim assertion. Three tests replace the two that asserted issuer behaviour that cannot exist: a real-shaped (iss-less) token verifies; an unrelated `iss` changes nothing (so identity adding one later cannot silently start failing tokens); and a token signed by a different key is still refused — the complement that proves removing the issuer check did not remove the boundary. Also: report the granted scope from the token's `scope` claim when the /oauth/token envelope omits it, which identity's does. `login` and `whoami` said "(not reported by the server)" while the authoritative answer sat inside the token they had just stored. The claim-peek helper is display-only and documented at length as NOT verification — a client reading its own freshly issued token is a different situation from a server reading a stranger's. VERIFIED END TO END against production (gate G-3, previously open): no credential GET /api/v1/models -> 401 garbage bearer GET /api/v1/models -> 401 real sensing:read token GET /api/v1/models -> 200 real sensing:read token GET /api/v1/recording/list-> 200 real sensing:read token POST /api/v1/train/start -> 401 real sensing:read token POST /api/v1/train/stop -> 401 Token obtained by a human through `wifi-densepose login` against auth.cognitum.one; server ran with RUVIEW_OAUTH_ISSUER set, JWKS fetched live at boot (key_count=1). Tests: 82 with --features login (58 unit + 22 matrix + 2 doctests). Co-Authored-By: Ruflo & AQE |
||
|
|
31fb3d53f6 |
feat(cli): wifi-densepose login — Cognitum sign-in (ADR-271 phase 2)
Phase 1 could verify a token and phase 3 could gate on one, but there was no way for a user to OBTAIN one. This closes that: sign in with a Cognitum account and get a token a RuView sensing server accepts, instead of everyone sharing one static RUVIEW_API_TOKEN string. Lives in `ruview-auth` behind a non-default `login` feature rather than in the CLI, so the Tauri desktop app can reuse it instead of growing a second copy. A server built with default features still gets the verifier and nothing else — no reqwest, no tokio net, no browser launcher. (This amends ADR-271's "no login flow in this crate" note; the reason for that line was to keep the server lean, and a feature gate achieves it without duplication.) Ported from meta-proxy's src/oauth/, cross-checked against musica's cognitum_provider.rs — two independent implementations against this same AS. Where they agree, this follows both: redirect path EXACTLY /oauth/callback, 60-second refresh skew, OOB fallback on SSH/CONTAINER//.dockerenv. Refresh is the part with teeth. Identity rotates refresh tokens with reuse detection, so presenting a spent one revokes the whole session family. Both obvious implementations are wrong: refreshing concurrently looks like replay, and retrying a failed refresh with the same token IS the replay. So `Session::ensure_fresh` holds an async mutex across the await, re-checks expiry after acquiring it (the waiter usually finds the work already done), persists the rotated token BEFORE returning it, and never retries. A missing expires_at counts as expired rather than being given a guessed default. Least scope by default: `login` requests `sensing:read`. `--admin` adds `sensing:admin` explicitly, and requests both because there is no scope hierarchy server-side. A session that streams poses should not casually hold the capability to delete the model it streams through. Credentials are written atomically and 0600 (temp file, chmod BEFORE rename) — the same discipline the seed applies to its cloud key. `logout` is local-only and says so: it makes this machine unable to act as you, but revoking the session everywhere is an account-level action. Also `whoami`, which reports whether the stored token is live — an expired-looking session is the most common reason a command starts 401ing, and it should be visible directly rather than inferred from a failure elsewhere. Verified against PRODUCTION, not just locally: authorize URLs built by this exact code path return HTTP 200 from auth.cognitum.one for both `sensing:read` and `sensing:read sensing:admin`, which exercises the real client_id, scope encoding, PKCE parameters and redirect_uri shape. Tests: 74 with --features login (51 unit + 21 verifier matrix + 2 doctests), including the RFC 7636 Appendix B vector, multi-scope URL encoding (a space that is hand-formatted rather than encoded silently truncates the request), a real TCP callback round-trip, callback timeout, 0600 permissions asserted on disk, atomic-save leaving no temp file, and refresh-window boundaries. Unchanged: 43 with default features, 501 in the sensing server. Co-Authored-By: Ruflo & AQE |
||
|
|
c2bd33e649 |
feat(sensing-server): accept Cognitum OAuth on /api/v1/*, scope-gated (ADR-271 phase 3)
Wires `ruview-auth` into `bearer_auth.rs`. `RUVIEW_OAUTH_ISSUER` enables it;
unset, nothing changes.
Layering, in order:
1. `RUVIEW_API_TOKEN` set and the bearer matches exactly -> allow. Byte-for-
byte today's behaviour.
2. Otherwise, if OAuth is configured, verify the bearer as a Cognitum access
token and require the scope the route needs.
3. Otherwise 401.
The static compare goes first for compatibility, not security: a matching
static token is not a JWT and a JWT never matches the static token. It means an
existing deployment behaves identically even with OAuth switched on.
Scope gate (`required_scope_for`), split by blast radius per ADR-060 —
"can this destroy something", not how many routes it covers:
sensing:admin /api/v1/train/* (hours of Pi CPU, writes models)
DELETE /api/v1/models/{id} (irreversible)
DELETE /api/v1/recording/{id} (irreversible)
sensing:read everything else
Deliberately NOT admin: model load/unload and recording start. They mutate
server state but destroy nothing, and gating them would push routine dashboard
use into requesting delete capability — the opposite of least privilege.
The legacy static token stays un-scope-gated. It predates scopes and carries no
claims, so narrowing it would be a silent breaking change to deployments using
it; migrating to OAuth is how an operator opts into the finer split.
FAIL CLOSED at boot. If OAuth is requested but cannot work — empty issuer, or a
JWKS we cannot fetch — the server logs why and exits rather than serving.
Starting anyway would silently downgrade an operator who asked for OAuth to
either an open API or a shared-secret one, with no signal it happened. The JWKS
is warmed eagerly for the same reason: a bad `jwks_uri` should die at boot with
a legible message, not surface as a puzzling 401 an hour later.
The verified `Principal` is attached to request extensions, so handlers and
audit logs can attribute a request (`sub`, `account_id`, `org_id`,
`workspace_id`, `jti`) instead of knowing only "someone had the secret". That
is the point of moving off a shared bearer.
Verification failures are logged with the reason and returned as a flat 401 —
the reason is useful to an operator and equally useful to an attacker probing
for which claim to forge next.
Also aligns `ruview_auth::extract_bearer` to match the scheme
case-insensitively (RFC 7235 §2.1). The sensing server has always done this
deliberately, with a comment saying why; the two layers disagreeing about what
a valid header looks like would be a latent bug.
Tests: 16 new in `bearer_auth::oauth_tests`, driving a real Router end to end
(request -> middleware -> verifier -> handler) with ES256 tokens signed by a
runtime-generated key. Covers the scope policy as a pure function, read-scoped
tokens refused on delete and train, admin-scoped tokens allowed, an
`inference`-only token from another Cognitum product refused on every route,
garbage and absent bearers, both legacy-token layering directions, the
principal reaching a handler, and the unset case remaining a no-op.
`cargo test -p wifi-densepose-sensing-server --lib --no-default-features`:
501 passed, 0 failed. `ruview-auth`: 43 passed across both feature configs.
Co-Authored-By: Ruflo & AQE
|
||
|
|
499cec7914 |
feat(auth): ruview-auth — offline Cognitum OAuth access-token verification (ADR-271)
RuView's `/api/v1/*` is gated today by `RUVIEW_API_TOKEN`: one shared secret, no expiry, no per-user attribution. This adds the verifier half of replacing that with Cognitum identity — RuView as an OAuth **resource server**, so a user signs in to their own sensing server with their Cognitum account. Note the direction: RuView does not call Cognitum. It never obtains a token for its own use; it verifies tokens users present. Every other Cognitum OAuth integration in the org (meta-proxy, musica, metaharness, the dashboard CLI) is the client side of a plane RuView doesn't have. The one existing resource-server verifier is `meta-llm/src/auth/oauthBearer.ts`, and this crate is a port of its accept-rule — divergence would be a bug, not a preference: a token meta-llm rejects must not be one RuView accepts. Verification is OFFLINE, and that is a requirement rather than an optimisation. RuView runs on Pi-class hardware that loses WAN, and identity publishes no introspection endpoint even when the network is up. So: ES256 over identity's published JWKS, cached by `kid`. What it accepts, mirroring oauthBearer.ts: typ == "access" AND NOT setup AND NOT workload AND account_id non-empty AND exp in the future AND iss matches verbatim AND the required scope is held. Long-lived setup (365-day) and workload credentials are refused outright for identity's own stated reason: their revocation lives in `oauth_setup_tokens`, and RuView — like meta-llm — has no database to check it. A 15-minute access token needs no revocation round-trip because it expires faster than revocation propagates; a 365-day one does. Scope is the capability boundary, and it has to be. Cognitum access tokens carry no `aud`, and `client_id` cannot substitute because clients borrow each other's registrations (musica ships DEFAULT_CLIENT_ID = "meta-proxy"). `sensing:read` covers streams and inference; `sensing:admin` covers training, model delete and recording delete. No hierarchy — admin does not imply read; consent means what it said. Registered in identity migration 0016 (cognitum-one/dashboard#116). Design notes: - `ureq`, not `reqwest`: the sensing server deliberately chose ureq as "the smallest" HTTP client, and pulling reqwest in here would reverse that for the whole graph. Transport sits behind a `JwksFetcher` trait, so a host can supply its own and take no HTTP dependency at all (feature `ureq-transport`, default). - A JWKS refetch failure is survivable while a key set is already cached: a key that verified a minute ago has not stopped being valid because the network blipped, and failing closed there logs every user out of their own sensing server whenever their internet wobbles. We fail closed in exactly one case — no key set has ever been fetched. - Unknown `kid` forces one refetch so rotation is picked up without waiting out the TTL, rate-limited so junk-kid tokens can't become a request amplifier aimed at identity. - Expiry is reported distinctly from a bad signature: on an RTC-less Pi that is usually a clock-sync fault, and an operator must be able to tell them apart. - Test keypairs are generated at runtime, never committed. This repo tracks zero `.pem` files and committed key material shouldn't start here — it also means no fixture can drift out of sync with the JWKS it's served by. Tests: 41 green (19 unit + 21 matrix + 1 doctest) under BOTH `cargo test --no-default-features` (the repo's canonical gate) and default features. The matrix signs real ES256 tokens rather than asserting on strings, and covers alg:none, forged signature, spliced payload, unknown kid, expired, leeway boundaries both sides, wrong issuer, issuer differing only by a trailing slash, missing/!=access typ, setup and workload smuggled onto typ=access, missing and empty account_id, and scope escalation. The highest-value case is `g2_a_genuinely_valid_token_from_another_cognitum_ product_cannot_reach_the_sensing_surface`: a correctly signed, unexpired, right-issuer, right-typ token with client_id=meta-proxy and scope=inference is rejected. Nothing about its signature or identity claims distinguishes it — only scope does. A naive verifier accepts it, and an `inference` token becomes a key to someone's home sensor. No wiring into the sensing server yet; this crate is verification only, with no login flow and no outbound Cognitum calls. Co-Authored-By: Ruflo & AQE |