Files
ruvnet--RuView/v2/crates/ruview-auth/src/lib.rs
T
Dragan Spiridonov 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
2026-07-23 09:57:15 +02:00

90 lines
3.7 KiB
Rust

//! 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};