mirror of
https://github.com/ruvnet/RuView
synced 2026-07-31 18:51:42 +00:00
2bfa60a4624c9be9ee3ece009fb73da4d40dbe19
4 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
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 |
||
|
|
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 |