From 499cec79140166b0807b50d3d7107754cfcf32c6 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Wed, 22 Jul 2026 15:08:40 +0200 Subject: [PATCH 01/27] =?UTF-8?q?feat(auth):=20ruview-auth=20=E2=80=94=20o?= =?UTF-8?q?ffline=20Cognitum=20OAuth=20access-token=20verification=20(ADR-?= =?UTF-8?q?271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- v2/Cargo.lock | 160 +++++++ v2/Cargo.toml | 6 + v2/crates/ruview-auth/Cargo.toml | 42 ++ v2/crates/ruview-auth/src/jwks.rs | 448 ++++++++++++++++++ v2/crates/ruview-auth/src/lib.rs | 74 +++ v2/crates/ruview-auth/src/principal.rs | 142 ++++++ v2/crates/ruview-auth/src/verify.rs | 261 ++++++++++ .../ruview-auth/tests/verifier_matrix.rs | 405 ++++++++++++++++ 8 files changed, 1538 insertions(+) create mode 100644 v2/crates/ruview-auth/Cargo.toml create mode 100644 v2/crates/ruview-auth/src/jwks.rs create mode 100644 v2/crates/ruview-auth/src/lib.rs create mode 100644 v2/crates/ruview-auth/src/principal.rs create mode 100644 v2/crates/ruview-auth/src/verify.rs create mode 100644 v2/crates/ruview-auth/tests/verifier_matrix.rs diff --git a/v2/Cargo.lock b/v2/Cargo.lock index fd73e14a..9c8b14e5 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -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,20 @@ 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", + "p256", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", + "ureq 2.12.1", +] + [[package]] name = "ruview-swarm" version = "0.1.0" @@ -7666,6 +7800,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 +8279,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" diff --git a/v2/Cargo.toml b/v2/Cargo.toml index a85f5de8..d89c6741 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -28,6 +28,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 diff --git a/v2/crates/ruview-auth/Cargo.toml b/v2/crates/ruview-auth/Cargo.toml new file mode 100644 index 00000000..0f64ac57 --- /dev/null +++ b/v2/crates/ruview-auth/Cargo.toml @@ -0,0 +1,42 @@ +[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 } + +[features] +default = ["ureq-transport"] +ureq-transport = ["dep:ureq"] + +[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" diff --git a/v2/crates/ruview-auth/src/jwks.rs b/v2/crates/ruview-auth/src/jwks.rs new file mode 100644 index 00000000..1a3e391d --- /dev/null +++ b/v2/crates/ruview-auth/src/jwks.rs @@ -0,0 +1,448 @@ +//! 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. + +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 *forced* refetches (the unknown-`kid` path). Without this, a +/// stream of tokens bearing a bogus `kid` becomes an outbound request amplifier +/// pointed at the identity service. +pub const FORCED_REFETCH_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; +} + +/// 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, + kty: String, + crv: Option, + x: Option, + y: Option, +} + +#[derive(Debug, Deserialize)] +struct JwksDocument { + keys: Vec, +} + +struct CacheState { + keys: HashMap, + fetched_at: Option, + last_forced_refetch: Option, +} + +/// `kid`-indexed JWKS cache. +pub struct JwksCache { + url: String, + ttl: Duration, + fetcher: Box, + state: Mutex, +} + +impl JwksCache { + pub fn new(url: impl Into, fetcher: Box) -> Self { + Self::with_ttl(url, fetcher, DEFAULT_CACHE_TTL) + } + + pub fn with_ttl(url: impl Into, fetcher: Box, ttl: Duration) -> Self { + Self { + url: url.into(), + ttl, + fetcher, + state: Mutex::new(CacheState { + keys: HashMap::new(), + fetched_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 { + 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 { + let mut state = self.state.lock().expect("jwks cache poisoned"); + + let stale = match state.fetched_at { + None => true, + Some(at) => at.elapsed() >= self.ttl, + }; + + if stale { + // Routine refresh. A failure here is survivable if we still hold a + // key set (see module docs); it is fatal only if we never had one. + match self.fetch_and_parse() { + Ok(fresh) => { + state.keys = fresh; + state.fetched_at = Some(Instant::now()); + } + Err(e) if state.fetched_at.is_some() => { + tracing::warn!( + url = %self.url, + error = %e, + "JWKS refresh failed; continuing with the previously cached key set" + ); + } + Err(_) => return Err(JwksError::NeverFetched), + } + } + + if let Some(key) = state.keys.get(kid) { + return Ok(key.clone()); + } + + // Unknown kid against a cache we believe is fresh: identity may have + // rotated inside the TTL. One forced refetch, rate-limited so a token + // carrying a junk kid cannot turn every request into an outbound fetch. + let may_force = state + .last_forced_refetch + .map_or(true, |at| at.elapsed() >= FORCED_REFETCH_MIN_INTERVAL); + + if may_force { + state.last_forced_refetch = Some(Instant::now()); + match self.fetch_and_parse() { + Ok(fresh) => { + state.keys = fresh; + state.fetched_at = Some(Instant::now()); + if let Some(key) = state.keys.get(kid) { + tracing::info!(kid = %kid, "JWKS key rotation picked up via forced refetch"); + return Ok(key.clone()); + } + } + Err(e) => { + tracing::warn!(url = %self.url, error = %e, "forced JWKS refetch failed"); + } + } + } + + Err(JwksError::UnknownKid(kid.to_owned())) + } + + fn fetch_and_parse(&self) -> Result, 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, 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 { + 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>, + calls: Arc, + offline: Arc>, + } + + 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 { + Box::new(StubFetcher(self.clone())) + } + } + + struct StubFetcher(StubControl); + + impl JwksFetcher for StubFetcher { + fn fetch(&self, _url: &str) -> Result { + 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_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"); + } +} diff --git a/v2/crates/ruview-auth/src/lib.rs b/v2/crates/ruview-auth/src/lib.rs new file mode 100644 index 00000000..7d887d33 --- /dev/null +++ b/v2/crates/ruview-auth/src/lib.rs @@ -0,0 +1,74 @@ +//! 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 { +//! # 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(), +//! }; +//! +//! let principal = verify_access_token("", &jwks, &config)?; +//! println!("{} on account {}", principal.subject, principal.account_id); +//! # Ok::<(), Box>(()) +//! ``` +//! +//! ## 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.** Obtaining a token (PKCE, loopback, OOB paste) is the +//! client's job and lives elsewhere; this crate only verifies. +//! - **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; + +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}; diff --git a/v2/crates/ruview-auth/src/principal.rs b/v2/crates/ruview-auth/src/principal.rs new file mode 100644 index 00000000..32d99bad --- /dev/null +++ b/v2/crates/ruview-auth/src/principal.rs @@ -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, + /// `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 { + 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)); + } +} diff --git a/v2/crates/ruview-auth/src/verify.rs b/v2/crates/ruview-auth/src/verify.rs new file mode 100644 index 00000000..4dbe1506 --- /dev/null +++ b/v2/crates/ruview-auth/src/verify.rs @@ -0,0 +1,261 @@ +//! 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 +//! +//! Cognitum access tokens carry no audience. Cross-product *identity* is +//! intended — one account, every Cognitum product — so this is by design, not a +//! defect. It does mean **scope is the only capability boundary**: `client_id` +//! cannot serve as one, because clients borrow each other's registrations +//! (musica ships `DEFAULT_CLIENT_ID = "meta-proxy"`). Hence `required_scope` +//! below is not optional garnish; it is the boundary. + +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 issuer is not {expected}")] + WrongIssuer { expected: String }, + #[error("token type {found:?} is not an interactive access token")] + WrongTokenType { found: Option }, + #[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 }, +} + +/// 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, + sub: String, + #[serde(default)] + account_id: Option, + #[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 { + /// Expected `iss`, compared **verbatim**. RFC 8414 §2 requires the issuer to + /// match the origin exactly — a trailing slash or an http/https mismatch is + /// a failure, not a near-miss to be normalised away. + pub issuer: String, + /// The scope a caller must hold for the route being served. + pub required_scope: 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 { + 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.set_issuer(&[config.issuer.as_str()]); + validation.leeway = CLOCK_LEEWAY_SECS; + validation.validate_exp = true; + // No audience validation: these tokens carry no `aud` (see module docs). + validation.validate_aud = false; + validation.set_required_spec_claims(&["exp", "iss"]); + + let data = decode::(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 }); + } + 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. +pub fn extract_bearer(header_value: &str) -> Result<&str, VerifyError> { + let token = header_value + .strip_prefix("Bearer ") + .ok_or(VerifyError::MissingBearer)? + .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::InvalidIssuer => VerifyError::WrongIssuer { + expected: String::new(), + }, + 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_rejects_a_lowercase_scheme() { + // RFC 7235 makes the scheme case-insensitive, but every Cognitum client + // sends "Bearer". Accepting variants would widen the surface for no + // real-world caller, so this is a deliberate strictness. + assert!(matches!( + extract_bearer("bearer abc.def.ghi"), + Err(VerifyError::MissingBearer) + )); + } + + #[test] + fn extract_bearer_rejects_an_empty_token() { + assert!(matches!( + extract_bearer("Bearer "), + Err(VerifyError::MissingBearer) + )); + } +} diff --git a/v2/crates/ruview-auth/tests/verifier_matrix.rs b/v2/crates/ruview-auth/tests/verifier_matrix.rs new file mode 100644 index 00000000..159c8313 --- /dev/null +++ b/v2/crates/ruview-auth/tests/verifier_matrix.rs @@ -0,0 +1,405 @@ +//! 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 = 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 = 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 { + 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, + "iss": TEST_ISSUER, + }) +} + +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(), + } +} + +fn verify(token: &str, required_scope: &str) -> Result { + 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_from_another_issuer_is_rejected() { + let mut c = valid_claims(); + c["iss"] = json!("https://evil.example"); + assert!(matches!( + verify(&sign(&c), scope::SENSING_READ), + Err(VerifyError::WrongIssuer { .. }) + )); +} + +#[test] +fn an_issuer_differing_only_by_a_trailing_slash_is_rejected() { + // RFC 8414 §2: the issuer is compared verbatim. Normalising this away is a + // small kindness that quietly widens who we trust. + let mut c = valid_claims(); + c["iss"] = json!(format!("{TEST_ISSUER}/")); + assert!(matches!( + verify(&sign(&c), scope::SENSING_READ), + Err(VerifyError::WrongIssuer { .. }) + )); +} + +#[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"); + + assert!(matches!( + verify(&sign(&c), scope::SENSING_READ), + Err(VerifyError::MissingScope { .. }) + )); +} + +#[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 { .. }) + )); +} From 92cbeb0c34da6bb66f4863fc4f0147f9218c4b2d Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Wed, 22 Jul 2026 15:10:48 +0200 Subject: [PATCH 02/27] =?UTF-8?q?docs(adr):=20ADR-271=20=E2=80=94=20RuView?= =?UTF-8?q?=20as=20a=20Cognitum=20OAuth=20resource=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit referenced ADR-271 in five places without the ADR existing. This writes it. Records the decision and, more importantly, the direction — RuView verifies tokens users present, it does not obtain tokens to call Cognitum. Every other Cognitum OAuth integration in the org is the client side of a plane RuView does not have; the sole relevant precedent is meta-llm's oauthBearer.ts. Covers: why offline verification is a requirement rather than an optimisation (Pi-class hosts lose WAN, and no introspection endpoint exists); why the accept-rule is a port and not a design; why long-lived setup/workload credentials are refused (no database to check revocation); why scope — not `aud`, not `client_id` — is the capability boundary; and the alternatives, including the `cog_`-minting approach that was tried org-wide and found broken against production. Co-Authored-By: Ruflo & AQE --- .../ADR-271-cognitum-oauth-resource-server.md | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docs/adr/ADR-271-cognitum-oauth-resource-server.md diff --git a/docs/adr/ADR-271-cognitum-oauth-resource-server.md b/docs/adr/ADR-271-cognitum-oauth-resource-server.md new file mode 100644 index 00000000..e63972b8 --- /dev/null +++ b/docs/adr/ADR-271-cognitum-oauth-resource-server.md @@ -0,0 +1,209 @@ +# 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 iss matches the configured issuer verbatim +AND the scope required by the route is held +``` + +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` | `POST /api/v1/train/*`, `DELETE /api/v1/models/{id}`, `DELETE /api/v1/recording/{id}` | + +**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. + +## 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, issuer mismatch including a trailing-slash-only difference, `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**: the login flow (PKCE, loopback, OOB paste), wiring into +`bearer_auth.rs`, WebSocket authentication (ADR-272), and any outbound Cognitum +call. This is verification only. From c2bd33e64938604dc36d1517d5e9c2b1ece52b48 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Wed, 22 Jul 2026 15:33:22 +0200 Subject: [PATCH 03/27] feat(sensing-server): accept Cognitum OAuth on /api/v1/*, scope-gated (ADR-271 phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- v2/Cargo.lock | 4 + v2/crates/ruview-auth/src/verify.rs | 39 +- .../wifi-densepose-sensing-server/Cargo.toml | 11 + .../src/bearer_auth.rs | 606 +++++++++++++++++- .../wifi-densepose-sensing-server/src/main.rs | 30 +- 5 files changed, 651 insertions(+), 39 deletions(-) diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 9c8b14e5..e56013cb 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -11288,15 +11288,19 @@ name = "wifi-densepose-sensing-server" version = "0.3.4" dependencies = [ "axum", + "base64 0.21.7", "chrono", "clap", "criterion", "futures-util", + "jsonwebtoken", "midstreamer-attractor", "midstreamer-temporal-compare", + "p256", "proptest", "rumqttc", "ruvector-mincut", + "ruview-auth", "serde", "serde_json", "sha2", diff --git a/v2/crates/ruview-auth/src/verify.rs b/v2/crates/ruview-auth/src/verify.rs index 4dbe1506..a8b9071a 100644 --- a/v2/crates/ruview-auth/src/verify.rs +++ b/v2/crates/ruview-auth/src/verify.rs @@ -195,11 +195,20 @@ pub fn verify_access_token( } /// 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 token = header_value - .strip_prefix("Bearer ") - .ok_or(VerifyError::MissingBearer)? - .trim(); + 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); } @@ -241,12 +250,24 @@ mod tests { } #[test] - fn extract_bearer_rejects_a_lowercase_scheme() { - // RFC 7235 makes the scheme case-insensitive, but every Cognitum client - // sends "Bearer". Accepting variants would widen the surface for no - // real-world caller, so this is a deliberate strictness. + 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("bearer abc.def.ghi"), + extract_bearer("Basic dXNlcjpwYXNz"), Err(VerifyError::MissingBearer) )); } diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 712c3b7f..89007c3a 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -85,6 +85,11 @@ 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" } + # 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 @@ -118,6 +123,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" diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs index 011343ac..0e75a067 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs @@ -18,19 +18,59 @@ //! //! The header check uses a length-then-byte constant-time compare to avoid //! leaking the token through timing. +//! +//! # Cognitum OAuth (ADR-271) +//! +//! A second, **additive** credential is supported: a Cognitum OAuth access +//! token, verified offline against `auth.cognitum.one`'s published JWKS. It is +//! enabled by setting [`OAUTH_ISSUER_ENV`], and the two schemes layer: +//! +//! 1. If `RUVIEW_API_TOKEN` is set and the presented bearer matches it exactly, +//! the request is allowed — byte-for-byte today's behaviour. +//! 2. Otherwise, if OAuth is configured, the bearer is verified as a JWT and +//! must carry the scope the route requires. +//! 3. Otherwise `401`. +//! +//! Order matters for compatibility, not for security: a static token that +//! matches is not a JWT, and a JWT never matches the static token. Trying the +//! static compare first means an existing deployment's behaviour is unchanged +//! even with OAuth switched on. +//! +//! **Nothing here weakens the unset case.** With neither variable set the +//! middleware is the same no-op it has always been. +//! +//! ## Scope gating +//! +//! Not every route carries the same blast radius, so a single "authenticated" +//! bit is too coarse once we have scopes. [`required_scope_for`] maps a request +//! to `sensing:read` or `sensing:admin` — see its docs for the split and why it +//! is drawn where it is. use std::sync::Arc; use axum::{ extract::{Request, State}, - http::{header::AUTHORIZATION, StatusCode}, + http::{header::AUTHORIZATION, Method, StatusCode}, middleware::Next, response::{IntoResponse, Response}, }; +use ruview_auth::{scope, verify_access_token, JwksCache, UreqFetcher, VerifierConfig}; /// Environment variable that gates the middleware. Unset / empty ⇒ auth off. pub const API_TOKEN_ENV: &str = "RUVIEW_API_TOKEN"; +/// Issuer origin of the Cognitum authorization server. Setting this enables +/// OAuth verification; unset ⇒ OAuth off and behaviour is unchanged. +pub const OAUTH_ISSUER_ENV: &str = "RUVIEW_OAUTH_ISSUER"; + +/// Optional JWKS override. Defaults to `/.well-known/jwks.json`, which +/// is where RFC 8414 metadata points for `auth.cognitum.one`. Overridable so a +/// staging issuer or an air-gapped mirror can be pointed at without a rebuild. +pub const OAUTH_JWKS_URL_ENV: &str = "RUVIEW_OAUTH_JWKS_URL"; + +/// The production Cognitum issuer, for operators who just want it on. +pub const COGNITUM_ISSUER: &str = "https://auth.cognitum.one"; + /// Path prefix the middleware protects when auth is enabled. pub const PROTECTED_PREFIX: &str = "/api/v1/"; @@ -43,11 +83,41 @@ pub const PROTECTED_PREFIX: &str = "/api/v1/"; /// surface change for existing clients. const EXEMPT_PATHS: &[&str] = &["/api/v1/stream/pose"]; -/// Cheap, cloneable handle to the configured token (or `None`). +/// Cognitum OAuth verification state. Built once at boot and shared. +pub struct OAuthState { + jwks: JwksCache, + issuer: String, +} + +impl std::fmt::Debug for OAuthState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OAuthState") + .field("issuer", &self.issuer) + .finish_non_exhaustive() + } +} + +/// Why OAuth could not be configured. Every variant is fatal at boot — see +/// [`AuthState::from_env`]'s contract about failing closed. +#[derive(Debug, thiserror::Error)] +pub enum OAuthConfigError { + #[error("{OAUTH_ISSUER_ENV} is set but empty")] + EmptyIssuer, + #[error("JWKS at {url} is unreachable, so no token could ever be verified: {source}")] + JwksUnreachable { + url: String, + #[source] + source: ruview_auth::JwksError, + }, +} + +/// Cheap, cloneable handle to the configured credentials. #[derive(Debug, Clone, Default)] pub struct AuthState { - /// The expected bearer token, if any. `None` ⇒ middleware is a no-op. + /// The expected static bearer token, if any. token: Option>, + /// Cognitum OAuth verification, if enabled. + oauth: Option>, } impl AuthState { @@ -55,29 +125,108 @@ impl AuthState { pub fn from_token(t: impl Into) -> Self { let s = t.into(); if s.is_empty() { - AuthState { token: None } + AuthState::default() } else { AuthState { token: Some(Arc::new(s)), + oauth: None, } } } - /// Read [`API_TOKEN_ENV`] from the process environment. Returns - /// `AuthState { token: None }` when the variable is unset or empty. - pub fn from_env() -> Self { - match std::env::var(API_TOKEN_ENV) { - Ok(s) if !s.is_empty() => AuthState::from_token(s), - _ => AuthState::default(), - } + /// Read the auth configuration from the process environment. + /// + /// **Fails closed.** If OAuth is requested but cannot be made to work — the + /// issuer is empty, or the JWKS cannot be fetched at boot — this returns + /// `Err` and the caller must refuse to serve `/api/v1/*`. Starting anyway + /// would mean an operator who asked for OAuth silently gets either an open + /// API or a single-shared-secret one, which is precisely the failure mode + /// that makes people distrust an auth switch. + /// + /// The JWKS is fetched eagerly for the same reason: a misconfigured + /// `jwks_uri` should fail at boot with a legible message, not as a puzzling + /// 401 on some user's first request an hour later. + pub fn from_env() -> Result { + let token = match std::env::var(API_TOKEN_ENV) { + Ok(s) if !s.is_empty() => Some(Arc::new(s)), + _ => None, + }; + + let oauth = match std::env::var(OAUTH_ISSUER_ENV) { + Ok(issuer) if !issuer.trim().is_empty() => { + let issuer = issuer.trim().trim_end_matches('/').to_string(); + let jwks_url = std::env::var(OAUTH_JWKS_URL_ENV) + .ok() + .filter(|u| !u.trim().is_empty()) + .unwrap_or_else(|| format!("{issuer}/.well-known/jwks.json")); + + let jwks = JwksCache::new(jwks_url.clone(), Box::new(UreqFetcher::new())); + let key_count = + jwks.warm() + .map_err(|source| OAuthConfigError::JwksUnreachable { + url: jwks_url.clone(), + source, + })?; + tracing::info!( + issuer = %issuer, + jwks_url = %jwks_url, + key_count, + "Cognitum OAuth enabled for /api/v1/*" + ); + Some(Arc::new(OAuthState { jwks, issuer })) + } + Ok(_) => return Err(OAuthConfigError::EmptyIssuer), + Err(_) => None, + }; + + Ok(AuthState { token, oauth }) } /// Whether the middleware will enforce auth on `/api/v1/*` requests. pub fn is_enabled(&self) -> bool { + self.token.is_some() || self.oauth.is_some() + } + + /// Whether Cognitum OAuth verification is active. + pub fn oauth_enabled(&self) -> bool { + self.oauth.is_some() + } + + /// Whether the legacy static `RUVIEW_API_TOKEN` is configured. + pub fn static_token_enabled(&self) -> bool { self.token.is_some() } } +/// The scope a request must carry, split by **blast radius** (ADR-060): can +/// this call destroy something, or only observe? +/// +/// `sensing:admin` covers exactly three things: +/// * `/api/v1/train/*` — burns hours of CPU on a Pi and writes models; +/// * `DELETE /api/v1/models/{id}` — irreversible loss of a trained model; +/// * `DELETE /api/v1/recording/{id}` — irreversible loss of a labelled capture. +/// +/// Everything else is `sensing:read`. Note what is deliberately *not* admin: +/// loading/unloading a model and starting a recording both mutate server state +/// but destroy nothing, and putting them behind the destructive scope would +/// push routine dashboard use into asking for a capability it does not need — +/// the opposite of least privilege. +/// +/// `sensing:read` is 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 fn required_scope_for(method: &Method, path: &str) -> &'static str { + if path.starts_with("/api/v1/train/") { + return scope::SENSING_ADMIN; + } + if method == Method::DELETE + && (path.starts_with("/api/v1/models/") || path.starts_with("/api/v1/recording/")) + { + return scope::SENSING_ADMIN; + } + scope::SENSING_READ +} + /// Constant-time byte slice equality. Returns `false` immediately on length /// mismatch (lengths are not secret here — both sides are fixed tokens). fn ct_eq(a: &[u8], b: &[u8]) -> bool { @@ -96,17 +245,18 @@ fn ct_eq(a: &[u8], b: &[u8]) -> bool { /// [`axum::middleware::from_fn_with_state`]. pub async fn require_bearer( State(auth): State, - request: Request, + mut request: Request, next: Next, ) -> Response { - let Some(expected) = auth.token.clone() else { + if !auth.is_enabled() { return next.run(request).await; - }; + } let path = request.uri().path(); if !path.starts_with(PROTECTED_PREFIX) || EXEMPT_PATHS.contains(&path) { return next.run(request).await; } - let supplied = request + + let Some(supplied) = request .headers() .get(AUTHORIZATION) .and_then(|v| v.to_str().ok()) @@ -120,21 +270,78 @@ pub async fn require_bearer( scheme .eq_ignore_ascii_case("Bearer") .then(|| token.trim_start()) - }); - let ok = supplied - .map(|s| ct_eq(s.as_bytes(), expected.as_bytes())) - .unwrap_or(false); - if ok { - next.run(request).await - } else { - ( - StatusCode::UNAUTHORIZED, - "missing or invalid bearer token (set Authorization: Bearer )\n", - ) - .into_response() + }) + else { + return unauthorized(&auth); + }; + + // 1. Legacy static token. Unchanged, and tried first so an existing + // deployment behaves identically even with OAuth switched on. + if let Some(expected) = auth.token.as_ref() { + if ct_eq(supplied.as_bytes(), expected.as_bytes()) { + return next.run(request).await; + } } + + // 2. Cognitum OAuth (ADR-271). + if let Some(oauth) = auth.oauth.as_ref() { + let required = required_scope_for(request.method(), path); + let config = VerifierConfig { + issuer: oauth.issuer.clone(), + required_scope: required.to_string(), + }; + match verify_access_token(supplied, &oauth.jwks, &config) { + Ok(principal) => { + tracing::debug!( + sub = %principal.subject, + account_id = %principal.account_id, + client_id = %principal.client_id, + jti = %principal.token_id, + scope = %required, + path = %path, + "OAuth request authorized" + ); + // Downstream handlers can attribute the request without + // re-parsing the token. + request.extensions_mut().insert(principal); + return next.run(request).await; + } + Err(e) => { + // Logged, never returned: the reason a token failed is useful + // to an operator and useful to an attacker probing for which + // claim to forge next. The response stays a flat 401. + tracing::debug!(error = %e, path = %path, required_scope = %required, "OAuth verification failed"); + return unauthorized(&auth); + } + } + } + + unauthorized(&auth) } +/// A uniform 401. The hint names whichever credentials are actually accepted, +/// so an operator is not told to set a variable this server ignores — but it +/// never says *why* a presented token failed. +fn unauthorized(auth: &AuthState) -> Response { + let body = match (auth.token.is_some(), auth.oauth.is_some()) { + (true, true) => concat!( + "missing or invalid bearer token\n", + "accepted: Authorization: Bearer , ", + "or a Cognitum OAuth access token with the scope this route requires\n" + ), + (false, true) => concat!( + "missing or invalid bearer token\n", + "accepted: a Cognitum OAuth access token with the scope this route requires\n" + ), + _ => "missing or invalid bearer token (set Authorization: Bearer )\n", + }; + (StatusCode::UNAUTHORIZED, body).into_response() +} + +/// Convenience re-export so handlers can name the type they pull out of +/// request extensions without depending on `ruview-auth` directly. +pub use ruview_auth::Principal as AuthenticatedPrincipal; + #[cfg(test)] mod tests { use super::*; @@ -416,3 +623,348 @@ mod tests { assert_eq!(PROTECTED_PREFIX, "/api/v1/"); } } + +/// ADR-271 — the OAuth path and the scope gate, exercised end to end through a +/// real Router: request → middleware → `ruview-auth` verifier → handler. +/// +/// Tokens are real ES256 JWTs signed with a key generated at test runtime; no +/// key material is committed. The verifier's own accept/reject matrix lives in +/// `ruview-auth`; what is tested here is the wiring — layering with the legacy +/// static token, which scope each route demands, and that a rejected token +/// never reaches a handler. +#[cfg(test)] +mod oauth_tests { + use super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::{delete, get, post}, + Router, + }; + 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}; + use std::sync::OnceLock; + use tower::ServiceExt; + + const KID: &str = "test-kid"; + const ISSUER: &str = "https://auth.test.local"; + + struct TestKey { + pem: String, + x: String, + y: String, + } + + fn key() -> &'static TestKey { + static K: OnceLock = OnceLock::new(); + K.get_or_init(|| { + let sk = SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng); + let point = sk.verifying_key().to_encoded_point(false); + TestKey { + pem: sk.to_pkcs8_pem(LineEnding::LF).unwrap().to_string(), + x: URL_SAFE_NO_PAD.encode(point.x().unwrap()), + y: URL_SAFE_NO_PAD.encode(point.y().unwrap()), + } + }) + } + + struct StaticJwks(String); + impl JwksFetcher for StaticJwks { + fn fetch(&self, _url: &str) -> Result { + Ok(self.0.clone()) + } + } + + fn oauth_state() -> Arc { + let k = key(); + let doc = format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","alg":"ES256","use":"sig","kid":"{KID}","x":"{}","y":"{}"}}]}}"#, + k.x, k.y + ); + Arc::new(OAuthState { + jwks: JwksCache::new("https://stub/jwks.json", Box::new(StaticJwks(doc))), + issuer: ISSUER.to_string(), + }) + } + + fn token_with_scope(scope_claim: &str) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let claims = serde_json::json!({ + "typ": "access", + "sub": "user-1", + "account_id": "acct-1", + "org_id": "org-1", + "workspace_id": "ws-1", + "client_id": "ruview", + "scope": scope_claim, + "jti": "jti-1", + "iat": now - 10, + "exp": now + 900, + "setup": false, + "workload": false, + "iss": ISSUER, + }); + let mut header = Header::new(jsonwebtoken::Algorithm::ES256); + header.kid = Some(KID.to_string()); + encode( + &header, + &claims, + &EncodingKey::from_ec_pem(key().pem.as_bytes()).unwrap(), + ) + .unwrap() + } + + /// Mirrors the real route shapes the scope gate keys off. + fn app(auth: AuthState) -> Router { + Router::new() + .route("/api/v1/info", get(|| async { "ok" })) + .route("/api/v1/models", get(|| async { "ok" })) + .route("/api/v1/models/m1", delete(|| async { "deleted" })) + .route("/api/v1/recording/r1", delete(|| async { "deleted" })) + .route("/api/v1/train/start", post(|| async { "training" })) + .layer(axum::middleware::from_fn_with_state(auth, require_bearer)) + } + + async fn call(auth: AuthState, method: &str, path: &str, bearer: Option<&str>) -> StatusCode { + let mut req = Request::builder().method(method).uri(path); + if let Some(b) = bearer { + req = req.header(AUTHORIZATION, format!("Bearer {b}")); + } + app(auth) + .oneshot(req.body(Body::empty()).unwrap()) + .await + .unwrap() + .status() + } + + fn oauth_only() -> AuthState { + AuthState { + token: None, + oauth: Some(oauth_state()), + } + } + + // ── scope policy (pure) ─────────────────────────────────────────── + + #[test] + fn training_requires_the_admin_scope() { + assert_eq!( + required_scope_for(&Method::POST, "/api/v1/train/start"), + scope::SENSING_ADMIN + ); + } + + #[test] + fn deleting_a_model_or_recording_requires_the_admin_scope() { + assert_eq!( + required_scope_for(&Method::DELETE, "/api/v1/models/m1"), + scope::SENSING_ADMIN + ); + assert_eq!( + required_scope_for(&Method::DELETE, "/api/v1/recording/r1"), + scope::SENSING_ADMIN + ); + } + + #[test] + fn reading_models_is_not_admin_merely_because_the_path_matches() { + // The gate is (method, path), not path alone — GET on the same prefix + // must stay a read. + assert_eq!( + required_scope_for(&Method::GET, "/api/v1/models/m1"), + scope::SENSING_READ + ); + } + + #[test] + fn non_destructive_mutations_stay_read_scoped() { + // Loading a model changes server state but destroys nothing. Putting it + // behind the destructive scope would push routine dashboard use into + // asking for delete capability — the opposite of least privilege. + assert_eq!( + required_scope_for(&Method::POST, "/api/v1/models/load"), + scope::SENSING_READ + ); + assert_eq!( + required_scope_for(&Method::POST, "/api/v1/recording/start"), + scope::SENSING_READ + ); + } + + // ── wiring ──────────────────────────────────────────────────────── + + #[tokio::test] + async fn a_read_scoped_token_reaches_a_read_route() { + let t = token_with_scope(scope::SENSING_READ); + assert_eq!( + call(oauth_only(), "GET", "/api/v1/info", Some(&t)).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn a_read_scoped_token_cannot_delete_a_model() { + // The whole point of the split: a dashboard session streaming poses + // must not be able to destroy the model it streams through. + let t = token_with_scope(scope::SENSING_READ); + assert_eq!( + call(oauth_only(), "DELETE", "/api/v1/models/m1", Some(&t)).await, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn a_read_scoped_token_cannot_start_training() { + let t = token_with_scope(scope::SENSING_READ); + assert_eq!( + call(oauth_only(), "POST", "/api/v1/train/start", Some(&t)).await, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn an_admin_scoped_token_may_delete_and_train() { + let t = token_with_scope("sensing:read sensing:admin"); + assert_eq!( + call(oauth_only(), "DELETE", "/api/v1/models/m1", Some(&t)).await, + StatusCode::OK + ); + assert_eq!( + call(oauth_only(), "POST", "/api/v1/train/start", Some(&t)).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn an_inference_token_from_another_cognitum_product_is_refused_everywhere() { + // The cross-product case. Correctly signed, unexpired, right issuer — + // only the scope stops it. Asserted here at the middleware layer too, + // because this is where a wiring mistake would actually let it through. + let t = token_with_scope("inference"); + for (m, p) in [ + ("GET", "/api/v1/info"), + ("DELETE", "/api/v1/models/m1"), + ("POST", "/api/v1/train/start"), + ] { + assert_eq!( + call(oauth_only(), m, p, Some(&t)).await, + StatusCode::UNAUTHORIZED, + "{m} {p} must reject an inference-only token" + ); + } + } + + #[tokio::test] + async fn a_garbage_bearer_is_refused_when_only_oauth_is_configured() { + assert_eq!( + call(oauth_only(), "GET", "/api/v1/info", Some("not-a-jwt")).await, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn no_credential_is_refused_when_only_oauth_is_configured() { + assert_eq!( + call(oauth_only(), "GET", "/api/v1/info", None).await, + StatusCode::UNAUTHORIZED + ); + } + + // ── layering with the legacy static token ───────────────────────── + + fn both() -> AuthState { + AuthState { + token: Some(Arc::new("legacy-secret".to_string())), + oauth: Some(oauth_state()), + } + } + + #[tokio::test] + async fn the_legacy_static_token_still_works_with_oauth_enabled() { + // Backward compatibility: turning OAuth on must not break a deployment + // that has been using RUVIEW_API_TOKEN. + assert_eq!( + call(both(), "GET", "/api/v1/info", Some("legacy-secret")).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn the_legacy_static_token_is_not_scope_gated() { + // It predates scopes and carries no claims, so it keeps the full + // access it has always had. Narrowing it here would be a silent + // breaking change to existing deployments; migrating to OAuth is how + // an operator opts into the finer split. + assert_eq!( + call(both(), "POST", "/api/v1/train/start", Some("legacy-secret")).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn an_oauth_token_works_alongside_a_configured_static_token() { + let t = token_with_scope(scope::SENSING_READ); + assert_eq!( + call(both(), "GET", "/api/v1/info", Some(&t)).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn a_wrong_static_token_falls_through_to_oauth_and_is_refused() { + assert_eq!( + call(both(), "GET", "/api/v1/info", Some("wrong-secret")).await, + StatusCode::UNAUTHORIZED + ); + } + + // ── attribution ─────────────────────────────────────────────────── + + #[tokio::test] + async fn the_verified_principal_is_available_to_handlers() { + // The reason for moving off a shared secret: requests become + // attributable. If the principal is not in extensions, no handler and + // no audit log can name who called. + async fn echo(req: Request) -> String { + match req.extensions().get::() { + Some(p) => format!("{}|{}|{}", p.subject, p.account_id, p.client_id), + None => "none".to_string(), + } + } + let router = Router::new() + .route("/api/v1/whoami", get(echo)) + .layer(axum::middleware::from_fn_with_state( + oauth_only(), + require_bearer, + )); + let t = token_with_scope(scope::SENSING_READ); + let resp = router + .oneshot( + Request::builder() + .uri("/api/v1/whoami") + .header(AUTHORIZATION, format!("Bearer {t}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + assert_eq!(String::from_utf8_lossy(&body), "user-1|acct-1|ruview"); + } + + // ── the unset case must stay untouched ──────────────────────────── + + #[tokio::test] + async fn with_neither_credential_configured_the_middleware_is_still_a_no_op() { + assert_eq!( + call(AuthState::default(), "POST", "/api/v1/train/start", None).await, + StatusCode::OK + ); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 8acf6c79..57283677 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -7920,9 +7920,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 `. - 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", @@ -7931,7 +7955,7 @@ async fn main() { } } else { info!( - "API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN= to enforce bearer auth." + "API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN= or RUVIEW_OAUTH_ISSUER= to enforce auth." ); } From 31fb3d53f63ace39247b36a451176bffdfcc36a4 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Wed, 22 Jul 2026 17:53:42 +0200 Subject: [PATCH 04/27] =?UTF-8?q?feat(cli):=20`wifi-densepose=20login`=20?= =?UTF-8?q?=E2=80=94=20Cognitum=20sign-in=20(ADR-271=20phase=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- v2/Cargo.lock | 6 + v2/crates/ruview-auth/Cargo.toml | 15 + v2/crates/ruview-auth/src/lib.rs | 6 + v2/crates/ruview-auth/src/login/callback.rs | 223 ++++++++++++ v2/crates/ruview-auth/src/login/client.rs | 251 +++++++++++++ v2/crates/ruview-auth/src/login/flow.rs | 241 ++++++++++++ v2/crates/ruview-auth/src/login/mod.rs | 48 +++ v2/crates/ruview-auth/src/login/pkce.rs | 83 +++++ v2/crates/ruview-auth/src/login/store.rs | 383 ++++++++++++++++++++ v2/crates/wifi-densepose-cli/Cargo.toml | 6 + v2/crates/wifi-densepose-cli/src/auth.rs | 116 ++++++ v2/crates/wifi-densepose-cli/src/lib.rs | 11 + v2/crates/wifi-densepose-cli/src/main.rs | 9 + 13 files changed, 1398 insertions(+) create mode 100644 v2/crates/ruview-auth/src/login/callback.rs create mode 100644 v2/crates/ruview-auth/src/login/client.rs create mode 100644 v2/crates/ruview-auth/src/login/flow.rs create mode 100644 v2/crates/ruview-auth/src/login/mod.rs create mode 100644 v2/crates/ruview-auth/src/login/pkce.rs create mode 100644 v2/crates/ruview-auth/src/login/store.rs create mode 100644 v2/crates/wifi-densepose-cli/src/auth.rs diff --git a/v2/Cargo.lock b/v2/Cargo.lock index e56013cb..0b219542 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -7638,11 +7638,16 @@ dependencies = [ "base64 0.21.7", "jsonwebtoken", "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]] @@ -11055,6 +11060,7 @@ dependencies = [ "ndarray 0.17.2", "num-complex", "predicates", + "ruview-auth", "serde", "serde_json", "tabled", diff --git a/v2/crates/ruview-auth/Cargo.toml b/v2/crates/ruview-auth/Cargo.toml index 0f64ac57..6aadf138 100644 --- a/v2/crates/ruview-auth/Cargo.toml +++ b/v2/crates/ruview-auth/Cargo.toml @@ -23,9 +23,24 @@ 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 } + [features] default = ["ureq-transport"] ureq-transport = ["dep:ureq"] +# Interactive OAuth login: PKCE, loopback callback, OOB paste fallback, +# credential storage, single-flight refresh. Opt in from a CLI or desktop app. +login = ["dep:reqwest", "dep:tokio", "dep:rand", "dep:sha2", "dep:base64", "dep:url"] [dev-dependencies] # Test-only: sign real ES256 tokens so the negative matrix exercises the same diff --git a/v2/crates/ruview-auth/src/lib.rs b/v2/crates/ruview-auth/src/lib.rs index 7d887d33..0bbdbb0c 100644 --- a/v2/crates/ruview-auth/src/lib.rs +++ b/v2/crates/ruview-auth/src/lib.rs @@ -67,6 +67,12 @@ pub mod jwks; pub mod principal; pub mod verify; +/// 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; diff --git a/v2/crates/ruview-auth/src/login/callback.rs b/v2/crates/ruview-auth/src/login/callback.rs new file mode 100644 index 00000000..4b489140 --- /dev/null +++ b/v2/crates/ruview-auth/src/login/callback.rs @@ -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, + pub state: Option, + pub error: Option, +} + +const SUCCESS_PAGE: &str = r#" + +
+

✓ RuView sign-in complete

+

You can close this tab and return to your terminal.

+
+ +"#; + +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:/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 { + 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 { + 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"); + } +} diff --git a/v2/crates/ruview-auth/src/login/client.rs b/v2/crates/ruview-auth/src/login/client.rs new file mode 100644 index 00000000..e1af1669 --- /dev/null +++ b/v2/crates/ruview-auth/src/login/client.rs @@ -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, + #[serde(default)] + pub account_email: Option, + /// The **rotating** refresh token. Identity revokes the presented one and + /// returns a replacement; see [`refresh`]. + #[serde(default)] + pub refresh_token: Option, + /// 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, + #[serde(default)] + pub scope: Option, +} + +#[derive(Debug, Deserialize)] +struct ErrorBody { + #[serde(default)] + error: Option, + #[serde(default)] + error_description: Option, +} + +async fn parse_token_response(resp: reqwest::Response) -> Result { + let status = resp.status(); + let body = resp.text().await?; + if status.is_success() { + return serde_json::from_str::(&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::(&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 { + 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 { + 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 { + #[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"); + } +} diff --git a/v2/crates/ruview-auth/src/login/flow.rs b/v2/crates/ruview-auth/src/login/flow.rs new file mode 100644 index 00000000..721405a3 --- /dev/null +++ b/v2/crates/ruview-auth/src/login/flow.rs @@ -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 super::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( + opts: &LoginOptions, + out: &mut W, + input: &mut R, +) -> Result { + 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( + opts: &LoginOptions, + http: &reqwest::Client, + issuer: String, + out: &mut W, +) -> Result { + 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( + opts: &LoginOptions, + http: &reqwest::Client, + issuer: String, + out: &mut W, + input: &mut R, +) -> Result { + 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( + opts: &LoginOptions, + http: &reqwest::Client, + token: client::TokenResponse, + issuer: String, + out: &mut W, +) -> Result { + 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 { + 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}"); + } +} diff --git a/v2/crates/ruview-auth/src/login/mod.rs b/v2/crates/ruview-auth/src/login/mod.rs new file mode 100644 index 00000000..14dc6c0a --- /dev/null +++ b/v2/crates/ruview-auth/src/login/mod.rs @@ -0,0 +1,48 @@ +//! 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> { +//! 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; +pub mod client; +pub mod flow; +pub mod pkce; +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, +}; diff --git a/v2/crates/ruview-auth/src/login/pkce.rs b/v2/crates/ruview-auth/src/login/pkce.rs new file mode 100644 index 00000000..a023b68c --- /dev/null +++ b/v2/crates/ruview-auth/src/login/pkce.rs @@ -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); + } +} diff --git a/v2/crates/ruview-auth/src/login/store.rs b/v2/crates/ruview-auth/src/login/store.rs new file mode 100644 index 00000000..71bf4a53 --- /dev/null +++ b/v2/crates/ruview-auth/src/login/store.rs @@ -0,0 +1,383 @@ +//! 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. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +use super::client::{self, OAuthError, TokenResponse}; + +/// 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. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredCredentials { + pub schema_version: u8, + pub access_token: String, + pub refresh_token: Option, + /// Unix seconds. Absent ⇒ treated as already expired, never as "valid". + pub expires_at: Option, + pub scope: Option, + pub account_email: Option, + pub issuer: String, +} + +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); + Self { + schema_version: Self::SCHEMA_VERSION, + access_token: t.access_token, + refresh_token: t.refresh_token, + expires_at, + scope: t.scope, + account_email: t.account_email, + issuer, + } + } + + /// 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, + } + } +} + +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"); + + std::fs::write(&tmp, &json).map_err(|source| StoreError::Unwritable { + path: tmp.clone(), + source, + })?; + restrict_permissions(&tmp)?; + std::fs::rename(&tmp, path).map_err(|source| StoreError::Unwritable { + path: path.to_path_buf(), + source, + })?; + Ok(()) +} + +#[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 { + 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 { + 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, + }), + } +} + +/// 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>, +} + +impl Session { + pub fn load_from(path: PathBuf, http: reqwest::Client) -> Result { + 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 { + 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 { + let mut guard = self.inner.lock().await; + + if !guard.needs_refresh() { + return Ok(guard.access_token.clone()); + } + + 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) -> 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()); + } + + #[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 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); + } +} diff --git a/v2/crates/wifi-densepose-cli/Cargo.toml b/v2/crates/wifi-densepose-cli/Cargo.toml index bc4b58d1..e87e0e9d 100644 --- a/v2/crates/wifi-densepose-cli/Cargo.toml +++ b/v2/crates/wifi-densepose-cli/Cargo.toml @@ -56,6 +56,12 @@ 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"] } thiserror = "2.0" # Time diff --git a/v2/crates/wifi-densepose-cli/src/auth.rs b/v2/crates/wifi-densepose-cli/src/auth.rs new file mode 100644 index 00000000..03cecb4c --- /dev/null +++ b/v2/crates/wifi-densepose-cli/src/auth.rs @@ -0,0 +1,116 @@ +//! `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, +} + +#[derive(Debug, Args)] +pub struct LogoutArgs { + #[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)] + pub credentials_path: Option, +} + +#[derive(Debug, Args)] +pub struct WhoamiArgs { + #[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)] + pub credentials_path: Option, +} + +fn path_or_default(p: Option) -> PathBuf { + p.unwrap_or_else(login::default_credentials_path) +} + +pub async fn login_cmd(args: LoginArgs) -> anyhow::Result<()> { + let scope = if args.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() + }; + + 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 creds = ruview_auth::login::store::load(&path)?; + + println!("Credentials: {}", path.display()); + println!("Issuer: {}", creds.issuer); + match &creds.account_email { + Some(e) => println!("Account: {e}"), + None => println!("Account: (not reported)"), + } + match &creds.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 — it will refresh on next use"); + } 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(()) +} diff --git a/v2/crates/wifi-densepose-cli/src/lib.rs b/v2/crates/wifi-densepose-cli/src/lib.rs index d2b73bd9..79366f04 100644 --- a/v2/crates/wifi-densepose-cli/src/lib.rs +++ b/v2/crates/wifi-densepose-cli/src/lib.rs @@ -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. diff --git a/v2/crates/wifi-densepose-cli/src/main.rs b/v2/crates/wifi-densepose-cli/src/main.rs index e4d48f8b..ad468586 100644 --- a/v2/crates/wifi-densepose-cli/src/main.rs +++ b/v2/crates/wifi-densepose-cli/src/main.rs @@ -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?; } From 714dae9a2cd779d801f377a9a7cdd2ba9f1b6885 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Wed, 22 Jul 2026 17:54:13 +0200 Subject: [PATCH 05/27] =?UTF-8?q?docs(adr):=20amend=20ADR-271=20=E2=80=94?= =?UTF-8?q?=20the=20login=20flow=20lives=20in=20ruview-auth=20behind=20a?= =?UTF-8?q?=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR said the login flow was "not in this crate". It is now, gated behind a non-default `login` feature. The original line existed to keep the sensing server lean; a feature gate achieves that without forcing the Tauri desktop app to grow a second copy of a PKCE + rotating-refresh implementation — the kind of duplication that drifts and then disagrees about something subtle. Recording the change rather than letting the ADR quietly go stale, which is the exact failure this session hit twice in other repos' ADRs. Co-Authored-By: Ruflo & AQE --- .../ADR-271-cognitum-oauth-resource-server.md | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/adr/ADR-271-cognitum-oauth-resource-server.md b/docs/adr/ADR-271-cognitum-oauth-resource-server.md index e63972b8..a243fdf8 100644 --- a/docs/adr/ADR-271-cognitum-oauth-resource-server.md +++ b/docs/adr/ADR-271-cognitum-oauth-resource-server.md @@ -204,6 +204,30 @@ 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**: the login flow (PKCE, loopback, OOB paste), wiring into -`bearer_auth.rs`, WebSocket authentication (ADR-272), and any outbound Cognitum -call. This is verification only. +**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. From 12635a85b24b01d3ee1af3794a60a9c00788d135 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Wed, 22 Jul 2026 18:11:21 +0200 Subject: [PATCH 06/27] fix(auth): stop requiring an `iss` claim Cognitum never issues (G-3 blocker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- v2/crates/ruview-auth/src/login/store.rs | 128 +++++++++++++++++- v2/crates/ruview-auth/src/verify.rs | 46 ++++--- .../ruview-auth/tests/verifier_matrix.rs | 50 +++++-- 3 files changed, 191 insertions(+), 33 deletions(-) diff --git a/v2/crates/ruview-auth/src/login/store.rs b/v2/crates/ruview-auth/src/login/store.rs index 71bf4a53..c7990365 100644 --- a/v2/crates/ruview-auth/src/login/store.rs +++ b/v2/crates/ruview-auth/src/login/store.rs @@ -72,12 +72,22 @@ impl StoredCredentials { 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: t.scope, + scope, account_email: t.account_email, issuer, } @@ -95,6 +105,38 @@ impl StoredCredentials { } } +/// 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 { + 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) @@ -381,3 +423,87 @@ mod tests { 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")); + } +} diff --git a/v2/crates/ruview-auth/src/verify.rs b/v2/crates/ruview-auth/src/verify.rs index a8b9071a..d81e83a0 100644 --- a/v2/crates/ruview-auth/src/verify.rs +++ b/v2/crates/ruview-auth/src/verify.rs @@ -24,14 +24,24 @@ //! 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 +//! ## There is no `aud` claim, and no `iss` claim either //! -//! Cognitum access tokens carry no audience. Cross-product *identity* is -//! intended — one account, every Cognitum product — so this is by design, not a -//! defect. It does mean **scope is the only capability boundary**: `client_id` -//! cannot serve as one, because clients borrow each other's registrations -//! (musica ships `DEFAULT_CLIENT_ID = "meta-proxy"`). Hence `required_scope` -//! below is not optional garnish; it is the boundary. +//! 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. +//! +//! The missing `aud` has a real consequence: **scope is the only capability +//! boundary**. `client_id` cannot serve as one, because clients borrow each +//! other's registrations (musica shipped as `meta-proxy` while its own +//! registration was pending). Hence `required_scope` below is not optional +//! garnish; it is the boundary. use jsonwebtoken::{decode, decode_header, Algorithm, Validation}; use serde::Deserialize; @@ -70,8 +80,6 @@ pub enum VerifyError { /// 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 issuer is not {expected}")] - WrongIssuer { expected: String }, #[error("token type {found:?} is not an interactive access token")] WrongTokenType { found: Option }, #[error("long-lived setup/workload credentials are not accepted (unverifiable revocation)")] @@ -115,9 +123,12 @@ struct AccessTokenClaims { /// Verifier configuration. pub struct VerifierConfig { - /// Expected `iss`, compared **verbatim**. RFC 8414 §2 requires the issuer to - /// match the origin exactly — a trailing slash or an http/https mismatch is - /// a failure, not a near-miss to be normalised away. + /// 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, @@ -143,12 +154,14 @@ pub fn verify_access_token( let key = jwks.decoding_key_for(&kid)?; let mut validation = Validation::new(Algorithm::ES256); - validation.set_issuer(&[config.issuer.as_str()]); validation.leeway = CLOCK_LEEWAY_SECS; validation.validate_exp = true; - // No audience validation: these tokens carry no `aud` (see module docs). + // 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", "iss"]); + validation.set_required_spec_claims(&["exp"]); let data = decode::(token, &key, &validation).map_err(map_jwt_error)?; let claims = data.claims; @@ -222,9 +235,6 @@ fn map_jwt_error(e: jsonwebtoken::errors::Error) -> VerifyError { ErrorKind::ExpiredSignature | ErrorKind::ImmatureSignature => { VerifyError::ExpiredOrNotYetValid } - ErrorKind::InvalidIssuer => VerifyError::WrongIssuer { - expected: String::new(), - }, ErrorKind::InvalidAlgorithm | ErrorKind::InvalidAlgorithmName => { VerifyError::WrongAlgorithm } diff --git a/v2/crates/ruview-auth/tests/verifier_matrix.rs b/v2/crates/ruview-auth/tests/verifier_matrix.rs index 159c8313..7db59f74 100644 --- a/v2/crates/ruview-auth/tests/verifier_matrix.rs +++ b/v2/crates/ruview-auth/tests/verifier_matrix.rs @@ -111,7 +111,10 @@ fn valid_claims() -> serde_json::Value { "exp": now() + 900, // identity's real 15-minute TTL "setup": false, "workload": false, - "iss": TEST_ISSUER, + // 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. }) } @@ -262,24 +265,43 @@ fn a_token_expired_beyond_the_leeway_is_rejected() { // ────────────────────── issuer / type ──────────────────────── #[test] -fn a_token_from_another_issuer_is_rejected() { - let mut c = valid_claims(); - c["iss"] = json!("https://evil.example"); - assert!(matches!( - verify(&sign(&c), scope::SENSING_READ), - Err(VerifyError::WrongIssuer { .. }) - )); +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_issuer_differing_only_by_a_trailing_slash_is_rejected() { - // RFC 8414 §2: the issuer is compared verbatim. Normalising this away is a - // small kindness that quietly widens who we trust. +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!(format!("{TEST_ISSUER}/")); + 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(&sign(&c), scope::SENSING_READ), - Err(VerifyError::WrongIssuer { .. }) + verify(&token, scope::SENSING_READ), + Err(VerifyError::BadSignature) )); } From 6d3fb886779d00ad2b23581a72dd37a724c74361 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Wed, 22 Jul 2026 18:21:49 +0200 Subject: [PATCH 07/27] fix(cli): resolve scope from the token on read, and add `whoami --refresh` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- v2/Cargo.lock | 1 + v2/crates/ruview-auth/src/login/store.rs | 14 ++++++++++++ v2/crates/wifi-densepose-cli/Cargo.toml | 2 ++ v2/crates/wifi-densepose-cli/src/auth.rs | 28 +++++++++++++++++++++--- 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 0b219542..370fc26b 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -11060,6 +11060,7 @@ dependencies = [ "ndarray 0.17.2", "num-complex", "predicates", + "reqwest 0.12.28", "ruview-auth", "serde", "serde_json", diff --git a/v2/crates/ruview-auth/src/login/store.rs b/v2/crates/ruview-auth/src/login/store.rs index c7990365..ac9d2b62 100644 --- a/v2/crates/ruview-auth/src/login/store.rs +++ b/v2/crates/ruview-auth/src/login/store.rs @@ -93,6 +93,20 @@ impl StoredCredentials { } } + /// 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 { + 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 diff --git a/v2/crates/wifi-densepose-cli/Cargo.toml b/v2/crates/wifi-densepose-cli/Cargo.toml index e87e0e9d..f5082939 100644 --- a/v2/crates/wifi-densepose-cli/Cargo.toml +++ b/v2/crates/wifi-densepose-cli/Cargo.toml @@ -62,6 +62,8 @@ anyhow = "1.0" # 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 diff --git a/v2/crates/wifi-densepose-cli/src/auth.rs b/v2/crates/wifi-densepose-cli/src/auth.rs index 03cecb4c..080cb6c2 100644 --- a/v2/crates/wifi-densepose-cli/src/auth.rs +++ b/v2/crates/wifi-densepose-cli/src/auth.rs @@ -45,6 +45,15 @@ pub struct LogoutArgs { pub struct WhoamiArgs { #[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)] pub credentials_path: Option, + + /// 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 { @@ -89,7 +98,18 @@ pub async fn logout_cmd(args: LogoutArgs) -> anyhow::Result<()> { pub async fn whoami_cmd(args: WhoamiArgs) -> anyhow::Result<()> { let path = path_or_default(args.credentials_path); - let creds = ruview_auth::login::store::load(&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); @@ -97,7 +117,9 @@ pub async fn whoami_cmd(args: WhoamiArgs) -> anyhow::Result<()> { Some(e) => println!("Account: {e}"), None => println!("Account: (not reported)"), } - match &creds.scope { + // 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)"), } @@ -105,7 +127,7 @@ pub async fn whoami_cmd(args: WhoamiArgs) -> anyhow::Result<()> { // 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 — it will refresh on next use"); + println!("Status: access token expired or expiring — pass --refresh to renew it now"); } else { println!("Status: access token valid"); } From 7d6d66941afcbc29f2ec11694ae6ee0c9be8aa31 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Wed, 22 Jul 2026 18:41:34 +0200 Subject: [PATCH 08/27] feat(ws): single-use WebSocket ticket store (ADR-272 groundwork) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit finding this exists to close — verified empirically, not inferred. With RUVIEW_API_TOKEN set (operator believes auth is ON), a real WebSocket handshake carrying NO credential: /ws/sensing -> 101 Switching Protocols /ws/introspection -> 101 Switching Protocols /api/v1/stream/pose -> 101 Switching Protocols /api/v1/models -> 401 (control: REST is correctly gated) The REST control plane is locked while the DATA plane — live presence, pose and vitals — is open to anyone who can reach the port. Two of those paths sit outside PROTECTED_PREFIX entirely; the third is the documented EXEMPT_PATHS entry. The exemption was reasonable when added (a browser cannot set Authorization on an upgrade) but its blast radius is larger than it looks, and phase 3 sharpened the contrast by making REST genuinely strong. (To be precise about what was proven: the handshake is accepted. A payload frame was not captured in that window, so this is "the connection is established without a credential", not "data was read".) This commit adds only the store; wiring it into the middleware is a breaking change for browser clients and lands with the UI update. Design notes: - Single use. `consume` REMOVES the entry, so a replay of the same URL fails even inside the TTL. This is what makes a credential-in-a-query tolerable: by the time it reaches an access log or a Referer header, it is spent. - 30-second TTL. Long enough for a page to open a socket; too short to harvest. - It is not the credential. It authorizes one WebSocket. It cannot be replayed against /api/v1/*, cannot be refreshed, and carries no reusable identity. - The grant captures the ISSUING principal's scopes, so a WebSocket inherits exactly the authority of the credential that asked for it — a sensing:read session cannot mint a ticket that outranks itself. - Capped at 512 outstanding, self-healing as tickets expire, so an authenticated but misbehaving caller cannot grow the map without bound. - In-memory because that is correct, not merely convenient: a ticket surviving a restart would outlive the server that vouched for it. Native clients (Python, Rust CLI, TS MCP) are NOT browsers and will send a normal Authorization header on the upgrade instead — tickets would add a round-trip and a second credential path for no benefit. 12 tests: single-use enforced, replay refused, expiry refused AND pruned, unknown ticket refused, 256-bit unpredictability, grant carries issuer scopes, cap enforced and self-healing, and query parsing including `?myticket=x` not being read as `?ticket=x`. Co-Authored-By: Ruflo & AQE --- v2/Cargo.lock | 1 + .../wifi-densepose-sensing-server/Cargo.toml | 2 + .../wifi-densepose-sensing-server/src/lib.rs | 1 + .../src/ws_ticket.rs | 298 ++++++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 370fc26b..bfb70e58 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -11305,6 +11305,7 @@ dependencies = [ "midstreamer-temporal-compare", "p256", "proptest", + "rand 0.8.5", "rumqttc", "ruvector-mincut", "ruview-auth", diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 89007c3a..5267e4d2 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -89,6 +89,8 @@ thiserror = "1" # 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" } +# 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 diff --git a/v2/crates/wifi-densepose-sensing-server/src/lib.rs b/v2/crates/wifi-densepose-sensing-server/src/lib.rs index 374f3802..2156963a 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/lib.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/lib.rs @@ -9,6 +9,7 @@ //! - Real-time CSI introspection / low-latency tap (`introspection`, ADR-099) pub mod bearer_auth; +pub mod ws_ticket; pub mod cli; pub mod dataset; pub mod edge_registry; diff --git a/v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs b/v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs new file mode 100644 index 00000000..c5ab8314 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs @@ -0,0 +1,298 @@ +//! 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); + +/// Cap on outstanding tickets, so a caller with a valid credential cannot grow +/// the map without bound by requesting tickets in a loop. Well above any real +/// page's needs. +const MAX_OUTSTANDING: usize = 512; + +/// 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, + /// `sub` of the issuing principal, for logging. `None` for the static token. + pub subject: Option, +} + +struct Entry { + grant: TicketGrant, + expires_at: Instant, +} + +/// In-memory ticket store. +/// +/// 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>>, +} + +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 { + 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: too many outstanding" + ); + 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 { + 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) { + 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 { + 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 { + 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 issuing_is_refused_once_too_many_are_outstanding() { + let store = TicketStore::new(); + for _ in 0..MAX_OUTSTANDING { + assert!(store.issue(grant()).is_some()); + } + assert!( + store.issue(grant()).is_none(), + "an authenticated but misbehaving caller must not grow the map without bound" + ); + } + + #[test] + fn expired_tickets_free_capacity_again() { + let store = TicketStore::new(); + for _ in 0..MAX_OUTSTANDING { + store.issue(grant()); + } + assert!(store.issue(grant()).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()); + } +} From 6300b1cbd2e0ce7de79e3c7473d01240bb937860 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Wed, 22 Jul 2026 19:04:44 +0200 Subject: [PATCH 09/27] feat(ws): gate WebSocket upgrades behind bearer-or-ticket (ADR-272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the hole measured in 7d6d6694. Before, with RUVIEW_API_TOKEN set, a real handshake carrying no credential: /ws/sensing 101 · /ws/introspection 101 · /api/v1/stream/pose 101 (/api/v1/models correctly 401) After, same server, same handshake: /ws/sensing 401 · /ws/introspection 401 · /api/v1/stream/pose 401 bearer on the upgrade -> 101 POST /api/v1/ws-ticket then ?ticket= -> 101 the same ticket replayed -> 401 a bogus ticket -> 401 POST /api/v1/ws-ticket unauthenticated -> 401 Two ways in, matching what each client can actually do: - Native clients (Python, Rust CLI, TS MCP) send a normal Authorization header on the upgrade. They were never browser-constrained; forcing them through a ticket would add a round-trip and a second credential path for nothing. - Browsers cannot set that header, so they exchange their credential at POST /api/v1/ws-ticket — an ordinary request, where they can — for a 30s single-use ticket passed as ?ticket=. A ticket inherits the issuing principal's scopes, so a sensing:read session cannot mint one that outranks itself, and it is not a REST credential: pinned by a test that `?ticket=` on /api/v1/models is still 401. ESCAPE HATCH (RUVIEW_WS_LEGACY_UNAUTHENTICATED=1) restores the old behaviour for deployments that cannot update server and UI in lockstep. It is a migration aid, not a supported configuration, and it says so on every boot in a warning that names the actual exposure ("the live sensing stream — presence, pose and vital signs — is readable by anyone who can reach this port"). Its blast radius is exactly the WebSocket paths: a test pins that it does not weaken REST. The flag is read once at construction, so a mid-flight environment change cannot silently open these paths on a running server. SUPERSEDES the PR #1313 test `enabled_exempts_pose_stream_websocket`, which asserted the old exemption. Its reasoning about browsers was correct; the conclusion was not. Renamed and inverted rather than deleted, with the history in the doc comment — and the half that still matters (the WebSocket rule must not leak to other /api/v1/* paths) is kept. Deployments with auth OFF see no change at all — pinned by a test. Tests: 522 passed in the sensing server (9 new WS-gating, 12 ticket-store). STILL OUTSTANDING for ADR-272: the browser UI does not yet fetch a ticket, so it needs the escape hatch until ui/services/api.service.js is updated. That is the next commit, not a permanent state. Co-Authored-By: Ruflo & AQE --- .../src/bearer_auth.rs | 307 ++++++++++++++++-- .../wifi-densepose-sensing-server/src/main.rs | 49 +++ .../src/ws_ticket.rs | 10 + 3 files changed, 343 insertions(+), 23 deletions(-) diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs index 0e75a067..e4ab06bb 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs @@ -74,14 +74,44 @@ pub const COGNITUM_ISSUER: &str = "https://auth.cognitum.one"; /// Path prefix the middleware protects when auth is enabled. pub const PROTECTED_PREFIX: &str = "/api/v1/"; -/// `/api/v1/stream/pose` is a WebSocket upgrade endpoint reachable from -/// browser code. Unlike a plain fetch(), the browser `WebSocket` constructor -/// cannot attach an `Authorization` header to the handshake request, so this -/// path can never carry a bearer token from a stock browser client — the -/// same reasoning that already exempts `/ws/sensing` (see module docs). -/// Exempted here rather than moved out of `/api/v1/*` to avoid an API -/// surface change for existing clients. -const EXEMPT_PATHS: &[&str] = &["/api/v1/stream/pose"]; +/// WebSocket upgrade endpoints. Previously ungated — `/ws/*` sat outside +/// [`PROTECTED_PREFIX`] and `/api/v1/stream/pose` was an explicit exemption — +/// because a browser's `WebSocket` constructor cannot attach an +/// `Authorization` header to the handshake. +/// +/// Measured consequence, with `RUVIEW_API_TOKEN` set and a real handshake +/// carrying no credential: all three returned `101 Switching Protocols` while +/// `/api/v1/models` returned `401`. The control plane was locked and the data +/// plane — live presence, pose, vitals — was open. +/// +/// They are now gated, and accept **either** a bearer (native clients, which +/// are not browser-constrained) **or** a single-use ticket (ADR-272). +pub const WS_PATHS: &[&str] = &[ + "/ws/sensing", + "/ws/introspection", + "/api/v1/stream/pose", +]; + +/// Restore the pre-ADR-272 behaviour: WebSocket upgrades accepted with no +/// credential even when auth is on. +/// +/// A migration aid, not a supported configuration. It exists because gating +/// these paths breaks a browser UI that has not yet been updated to fetch a +/// ticket, and some deployments cannot update server and UI in lockstep. It +/// logs a warning naming the exposure on every boot, deliberately hard to +/// ignore in a log. +pub const LEGACY_WS_ENV: &str = "RUVIEW_WS_LEGACY_UNAUTHENTICATED"; + +fn legacy_ws_unauthenticated() -> bool { + matches!( + std::env::var(LEGACY_WS_ENV).as_deref(), + Ok("1") | Ok("true") | Ok("TRUE") + ) +} + +fn is_ws_path(path: &str) -> bool { + WS_PATHS.contains(&path) +} /// Cognitum OAuth verification state. Built once at boot and shared. pub struct OAuthState { @@ -118,6 +148,11 @@ pub struct AuthState { token: Option>, /// Cognitum OAuth verification, if enabled. oauth: Option>, + /// Single-use WebSocket tickets (ADR-272). + tickets: crate::ws_ticket::TicketStore, + /// Cached at construction so a mid-flight env change cannot silently open + /// the WebSocket paths on a running server. + legacy_ws: bool, } impl AuthState { @@ -130,6 +165,8 @@ impl AuthState { AuthState { token: Some(Arc::new(s)), oauth: None, + tickets: crate::ws_ticket::TicketStore::new(), + legacy_ws: legacy_ws_unauthenticated(), } } } @@ -179,7 +216,33 @@ impl AuthState { Err(_) => None, }; - Ok(AuthState { token, oauth }) + let legacy_ws = legacy_ws_unauthenticated(); + if legacy_ws && (token.is_some() || oauth.is_some()) { + tracing::warn!( + "{LEGACY_WS_ENV} is set: WebSocket upgrades ({}) accept connections with NO \ + credential even though API auth is ON. The live sensing stream — presence, \ + pose and vital signs — is readable by anyone who can reach this port. This is \ + a migration aid for UIs not yet updated to fetch a ticket; unset it as soon \ + as the UI is updated.", + WS_PATHS.join(", ") + ); + } + Ok(AuthState { + token, + oauth, + tickets: crate::ws_ticket::TicketStore::new(), + legacy_ws, + }) + } + + /// The ticket store, for the `POST /api/v1/ws-ticket` handler. + pub fn tickets(&self) -> &crate::ws_ticket::TicketStore { + &self.tickets + } + + /// Whether the legacy unauthenticated-WebSocket escape hatch is active. + pub fn legacy_ws_enabled(&self) -> bool { + self.legacy_ws } /// Whether the middleware will enforce auth on `/api/v1/*` requests. @@ -251,8 +314,36 @@ pub async fn require_bearer( if !auth.is_enabled() { return next.run(request).await; } - let path = request.uri().path(); - if !path.starts_with(PROTECTED_PREFIX) || EXEMPT_PATHS.contains(&path) { + let path = request.uri().path().to_string(); + + // WebSocket upgrades: bearer OR single-use ticket (ADR-272). Checked before + // the prefix test because `/api/v1/stream/pose` is both a WS path and under + // the protected prefix. + if is_ws_path(&path) { + if auth.legacy_ws { + return next.run(request).await; + } + if let Some(ticket) = crate::ws_ticket::ticket_from_uri(request.uri()) { + // Consumed here — one attempt per ticket, valid or not, so a + // guessed value cannot be retried and a real one cannot be replayed. + if let Some(grant) = auth.tickets.consume(&ticket) { + let holds_read = grant + .scopes + .as_deref() + // `None` = issued by the legacy static token, which predates + // scopes and carries full authority. + .map_or(true, |s| s.split_whitespace().any(|x| x == scope::SENSING_READ)); + if holds_read { + tracing::debug!(path = %path, subject = ?grant.subject, "WebSocket authorized by ticket"); + return next.run(request).await; + } + tracing::debug!(path = %path, "ticket lacked the scope this stream requires"); + } + return unauthorized(&auth); + } + // No ticket: fall through to the bearer path below, which is how a + // native (non-browser) client authenticates a WebSocket. + } else if !path.starts_with(PROTECTED_PREFIX) { return next.run(request).await; } @@ -285,7 +376,12 @@ pub async fn require_bearer( // 2. Cognitum OAuth (ADR-271). if let Some(oauth) = auth.oauth.as_ref() { - let required = required_scope_for(request.method(), path); + let required = if is_ws_path(&path) { + // A stream is a read, regardless of the HTTP verb on the upgrade. + scope::SENSING_READ + } else { + required_scope_for(request.method(), &path) + }; let config = VerifierConfig { issuer: oauth.issuer.clone(), required_scope: required.to_string(), @@ -298,7 +394,7 @@ pub async fn require_bearer( client_id = %principal.client_id, jti = %principal.token_id, scope = %required, - path = %path, + path = %path.as_str(), "OAuth request authorized" ); // Downstream handlers can attribute the request without @@ -310,7 +406,7 @@ pub async fn require_bearer( // Logged, never returned: the reason a token failed is useful // to an operator and useful to an attacker probing for which // claim to forge next. The response stays a flat 401. - tracing::debug!(error = %e, path = %path, required_scope = %required, "OAuth verification failed"); + tracing::debug!(error = %e, path = %path.as_str(), required_scope = %required, "OAuth verification failed"); return unauthorized(&auth); } } @@ -579,20 +675,28 @@ mod tests { ); } - /// `/api/v1/stream/pose` is a WebSocket upgrade the browser `WebSocket` - /// constructor drives directly — it cannot attach an `Authorization` - /// header, so this path must stay reachable even with auth ON (mirrors - /// the existing `/ws/sensing` exemption, just inside the `/api/v1/*` - /// prefix this time). + /// SUPERSEDED by ADR-272. This was `enabled_exempts_pose_stream_websocket`, + /// which asserted `/api/v1/stream/pose` stayed reachable with no bearer + /// because a browser cannot set `Authorization` on an upgrade (PR #1313). + /// + /// That reasoning about browsers is still true — the conclusion was not. + /// Measured on a server with auth ON: a credential-less handshake to + /// `/api/v1/stream/pose`, `/ws/sensing` and `/ws/introspection` all + /// returned `101`, so the REST control plane was locked while the live + /// sensing stream was open. The browser limitation is now answered by a + /// single-use ticket rather than by an exemption. + /// + /// The half of the original test that still matters is kept: whatever the + /// WebSocket rule is, it must not leak to other `/api/v1/*` paths. #[tokio::test] - async fn enabled_exempts_pose_stream_websocket() { + async fn the_pose_stream_websocket_is_no_longer_exempt() { let r = wrap(AuthState::from_token("s3cr3t")); assert_eq!( status(r.clone(), "GET", "/api/v1/stream/pose", None).await, - StatusCode::OK, - "pose stream WS must stay reachable without a bearer token" + StatusCode::UNAUTHORIZED, + "the pose stream must no longer accept a credential-less upgrade" ); - // The exemption is narrow: it must not leak to other /api/v1/* paths. + // Preserved from the original: the WebSocket rule stays narrow. assert_eq!( status(r, "GET", "/api/v1/info", None).await, StatusCode::UNAUTHORIZED @@ -747,6 +851,8 @@ mod oauth_tests { AuthState { token: None, oauth: Some(oauth_state()), + tickets: crate::ws_ticket::TicketStore::new(), + legacy_ws: false, } } @@ -882,6 +988,8 @@ mod oauth_tests { AuthState { token: Some(Arc::new("legacy-secret".to_string())), oauth: Some(oauth_state()), + tickets: crate::ws_ticket::TicketStore::new(), + legacy_ws: false, } } @@ -968,3 +1076,156 @@ mod oauth_tests { ); } } + +/// ADR-272 — WebSocket gating. These pin the hole that was measured open: +/// with auth ON, a credential-less upgrade to `/ws/sensing` returned 101. +#[cfg(test)] +mod ws_gate_tests { + use super::*; + use crate::ws_ticket::TicketGrant; + use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::get, + Router, + }; + use tower::ServiceExt; + + fn app(auth: AuthState) -> Router { + Router::new() + .route("/ws/sensing", get(|| async { "stream" })) + .route("/ws/introspection", get(|| async { "introspect" })) + .route("/api/v1/stream/pose", get(|| async { "pose" })) + .route("/api/v1/models", get(|| async { "models" })) + .layer(axum::middleware::from_fn_with_state(auth, require_bearer)) + } + + async fn get_status(auth: AuthState, uri: &str, bearer: Option<&str>) -> StatusCode { + let mut req = Request::builder().method("GET").uri(uri); + if let Some(b) = bearer { + req = req.header(AUTHORIZATION, format!("Bearer {b}")); + } + app(auth) + .oneshot(req.body(Body::empty()).unwrap()) + .await + .unwrap() + .status() + } + + fn static_auth() -> AuthState { + AuthState { + token: Some(Arc::new("secret".into())), + oauth: None, + tickets: crate::ws_ticket::TicketStore::new(), + legacy_ws: false, + } + } + + #[tokio::test] + async fn every_websocket_path_refuses_an_unauthenticated_upgrade() { + // The measured regression: all three answered 101 before this change. + for p in WS_PATHS { + assert_eq!( + get_status(static_auth(), p, None).await, + StatusCode::UNAUTHORIZED, + "{p} must not accept a credential-less upgrade" + ); + } + } + + #[tokio::test] + async fn a_native_client_may_authenticate_a_websocket_with_a_bearer() { + // Python / CLI / MCP are not browser-constrained and must not be forced + // through the ticket round-trip. + for p in WS_PATHS { + assert_eq!( + get_status(static_auth(), p, Some("secret")).await, + StatusCode::OK, + "{p} must accept a bearer on the upgrade" + ); + } + } + + #[tokio::test] + async fn a_valid_ticket_authorizes_exactly_one_upgrade() { + let auth = static_auth(); + let ticket = auth + .tickets() + .issue(TicketGrant { scopes: None, subject: None }) + .unwrap(); + let uri = format!("/ws/sensing?ticket={ticket}"); + + assert_eq!(get_status(auth.clone(), &uri, None).await, StatusCode::OK); + assert_eq!( + get_status(auth, &uri, None).await, + StatusCode::UNAUTHORIZED, + "a replayed ticket must fail — this is what makes a URL credential tolerable" + ); + } + + #[tokio::test] + async fn an_unknown_ticket_is_refused() { + assert_eq!( + get_status(static_auth(), "/ws/sensing?ticket=deadbeef", None).await, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn a_ticket_without_the_streams_scope_is_refused() { + // A ticket inherits its issuer's authority and cannot exceed it. + let auth = static_auth(); + let ticket = auth + .tickets() + .issue(TicketGrant { + scopes: Some("inference".into()), + subject: Some("u".into()), + }) + .unwrap(); + assert_eq!( + get_status(auth, &format!("/ws/sensing?ticket={ticket}"), None).await, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn a_ticket_is_not_a_credential_for_the_rest_api() { + // Containment: a ticket buys one WebSocket, never REST access. + let auth = static_auth(); + let ticket = auth + .tickets() + .issue(TicketGrant { scopes: None, subject: None }) + .unwrap(); + assert_eq!( + get_status(auth, &format!("/api/v1/models?ticket={ticket}"), None).await, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn the_legacy_escape_hatch_restores_unauthenticated_websockets() { + let mut auth = static_auth(); + auth.legacy_ws = true; + assert_eq!(get_status(auth, "/ws/sensing", None).await, StatusCode::OK); + } + + #[tokio::test] + async fn the_legacy_escape_hatch_does_not_weaken_the_rest_api() { + // The blast radius of the hatch must be exactly the WebSocket paths. + let mut auth = static_auth(); + auth.legacy_ws = true; + assert_eq!( + get_status(auth, "/api/v1/models", None).await, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn with_auth_off_websockets_stay_open_as_before() { + // Unconfigured deployments must see no behaviour change at all. + assert_eq!( + get_status(AuthState::default(), "/ws/sensing", None).await, + StatusCode::OK + ); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 57283677..b7dc3238 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -8067,6 +8067,9 @@ 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)) .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)) @@ -8124,6 +8127,9 @@ async fn main() { bearer_auth_state.clone(), wifi_densepose_sensing_server::bearer_auth::require_bearer, )) + // ADR-272: the ws-ticket handler needs the store the middleware owns. + // Added AFTER the auth layer so it is still gated by it. + .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 @@ -9087,3 +9093,46 @@ mod observatory_persons_field_position_tests { assert!((p.motion_score - 55.0).abs() < 1e-6, "motion_score stays real"); } } + +/// `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, + 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::(); + let grant = TicketGrant { + scopes: principal.map(|p| p.scopes().collect::>().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= 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(), + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs b/v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs index c5ab8314..d8584963 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs @@ -72,6 +72,9 @@ struct Entry { /// 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. @@ -80,6 +83,13 @@ pub struct TicketStore { inner: Arc>>, } +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() From eb68e07a2c31a45504621d6468bc2b41bd83629a Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Wed, 22 Jul 2026 19:15:20 +0200 Subject: [PATCH 10/27] feat(ui): fetch WebSocket tickets, prefix-match WS paths, and write ADR-272 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes ADR-272. Three parts. 1. PREFIX MATCHING, not an allowlist. Anything under `/ws/` is a WebSocket path, plus `/api/v1/stream/pose` which lives outside it. An allowlist means every WebSocket route added later ships ungated until someone remembers to extend it — the same bug reintroduced on a delay. 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. Pinned by a test that asserts it is gated before it exists. 2. UI wiring. A shared `withWsTicket()` helper mints a ticket immediately before each connection attempt — never cached, because a ticket is single-use and expires in seconds, so reusing one across reconnects fails on the second attempt. Wired into the three sites that open gated sockets: sensing.service.js, websocket-client.js, observatory/js/main.js. It degrades in both directions on purpose: no stored token means auth is off and no ticket is needed; a 404 from /api/v1/ws-ticket means a server predating this ADR, which still exempts WebSockets, so connecting without a ticket is correct there. The same UI therefore works against old and new servers, which is what makes the escape hatch removable later rather than permanent. The long-lived bearer token is still never put in a URL — only the ticket is. 3. ADR-272 itself. Previously cited in five places without existing; the same dangling-reference mistake made with ADR-271 earlier today, so it is written before this lands rather than after someone notices. It records the measured before/after, why a credential in a URL is acceptable here specifically (single use, seconds-long, not the credential), why the escape hatch exists and why it is deliberately uncomfortable, and what is deliberately NOT done — including that /health/metrics stays ungated, with the caveat that this should be revisited if metrics ever carry occupancy-derived values, since that would make them sensing data wearing an ops label. Tests: 526 sensing-server (4 new path-matching), 82 ruview-auth. JS syntax-checked with `node --check`; there is no UI test suite to extend. Co-Authored-By: Ruflo & AQE --- ...DR-272-websocket-authentication-tickets.md | 169 ++++++++++++++++++ ui/observatory/js/main.js | 10 +- ui/services/sensing.service.js | 22 ++- ui/services/websocket-client.js | 13 +- ui/services/ws-ticket.js | 77 ++++++++ .../src/bearer_auth.rs | 66 ++++++- 6 files changed, 346 insertions(+), 11 deletions(-) create mode 100644 docs/adr/ADR-272-websocket-authentication-tickets.md create mode 100644 ui/services/ws-ticket.js diff --git a/docs/adr/ADR-272-websocket-authentication-tickets.md b/docs/adr/ADR-272-websocket-authentication-tickets.md new file mode 100644 index 00000000..48a0bead --- /dev/null +++ b/docs/adr/ADR-272-websocket-authentication-tickets.md @@ -0,0 +1,169 @@ +# 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 send a normal +`Authorization: Bearer` on the handshake. Routing them through a ticket would +add a round-trip and a second credential path for no benefit. + +### 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=` 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. diff --git a/ui/observatory/js/main.js b/ui/observatory/js/main.js index 26abbe2c..b0149368 100644 --- a/ui/observatory/js/main.js +++ b/ui/observatory/js/main.js @@ -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); diff --git a/ui/services/sensing.service.js b/ui/services/sensing.service.js index 2d5635e4..e07a09cd 100644 --- a/ui/services/sensing.service.js +++ b/ui/services/sensing.service.js @@ -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 diff --git a/ui/services/websocket-client.js b/ui/services/websocket-client.js index 208e0780..64f1ca86 100644 --- a/ui/services/websocket-client.js +++ b/ui/services/websocket-client.js @@ -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); } diff --git a/ui/services/ws-ticket.js b/ui/services/ws-ticket.js new file mode 100644 index 00000000..9870562a --- /dev/null +++ b/ui/services/ws-ticket.js @@ -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} + */ +export async function withWsTicket(url) { + const ticket = await mintTicket(); + if (!ticket) return url; + const sep = url.includes('?') ? '&' : '?'; + return `${url}${sep}ticket=${encodeURIComponent(ticket)}`; +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs index e4ab06bb..89638caa 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs @@ -86,6 +86,10 @@ pub const PROTECTED_PREFIX: &str = "/api/v1/"; /// /// They are now gated, and accept **either** a bearer (native clients, which /// are not browser-constrained) **or** a single-use ticket (ADR-272). +/// +/// This list is the set that exists today, used for the boot warning and tests. +/// The runtime rule is [`is_ws_path`], which matches by prefix so routes added +/// later are gated without an edit here. pub const WS_PATHS: &[&str] = &[ "/ws/sensing", "/ws/introspection", @@ -109,8 +113,27 @@ fn legacy_ws_unauthenticated() -> bool { ) } +/// WebSocket upgrade paths that do NOT live under [`WS_PREFIX`] and so must be +/// named explicitly. +pub const WS_PATHS_OUTSIDE_PREFIX: &[&str] = &["/api/v1/stream/pose"]; + +/// Everything under here is treated as a WebSocket upgrade. +pub const WS_PREFIX: &str = "/ws/"; + +/// Is this a WebSocket upgrade path? +/// +/// Matched by **prefix**, not by an allowlist, and that choice is the whole +/// point. An allowlist means every WebSocket route added later is ungated until +/// someone remembers to add it here — which is exactly the bug this module just +/// fixed, reintroduced on a delay. `/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. +/// +/// `/api/v1/stream/pose` is the one upgrade endpoint outside the prefix, so it +/// is named. New WebSocket routes should go under `/ws/` and inherit gating for +/// free. fn is_ws_path(path: &str) -> bool { - WS_PATHS.contains(&path) + path.starts_with(WS_PREFIX) || WS_PATHS_OUTSIDE_PREFIX.contains(&path) } /// Cognitum OAuth verification state. Built once at boot and shared. @@ -1229,3 +1252,44 @@ mod ws_gate_tests { ); } } + +#[cfg(test)] +mod ws_path_matching_tests { + use super::*; + + #[test] + fn every_currently_known_websocket_path_matches() { + for p in WS_PATHS { + assert!(is_ws_path(p), "{p} must be recognised as a WebSocket path"); + } + } + + #[test] + fn a_websocket_route_that_does_not_exist_yet_is_already_gated() { + // `/ws/train/progress` arrives with ADR-186 (PR #1387) and is already + // referenced by the UI. Under an exact-match allowlist it would ship + // unauthenticated. Prefix matching means it is gated on arrival. + assert!(is_ws_path("/ws/train/progress")); + assert!(is_ws_path("/ws/anything-added-in-future")); + } + + #[test] + fn ordinary_rest_paths_are_not_treated_as_websockets() { + for p in [ + "/api/v1/models", + "/api/v1/stream/status", // a plain GET, not an upgrade + "/health", + "/ui/index.html", + "/", + ] { + assert!(!is_ws_path(p), "{p} must not be treated as a WebSocket path"); + } + } + + #[test] + fn a_path_merely_starting_with_ws_is_not_the_ws_prefix() { + // `/wsx/...` must not match `/ws/`. + assert!(!is_ws_path("/wsx/sensing")); + assert!(!is_ws_path("/ws")); + } +} From 3347e258e6b9bc30179ecc5c4766885d9e4ae714 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Thu, 23 Jul 2026 09:14:07 +0200 Subject: [PATCH 11/27] fix(ws): gate the dedicated WS port and the merged field routes (ADR-272 was incomplete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit claimed WebSocket upgrades were gated. They were not. Found by an adversarial cross-vendor review, reproduced, and fixed here. MEASURED BEFORE (auth ON via RUVIEW_API_TOKEN, no credential presented): HTTP port :3991 /ws/sensing -> 401 (what the previous fix covered) HTTP port :3991 /ws/field -> 101 HOLE WS port :3990 /ws/sensing -> 101 HOLE WS port :3990 /ws/field -> 101 HOLE control :3991 /api/v1/models -> 401 Two independent defects, both from the same root cause — routes registered AFTER an axum `.layer()` are silently exempt from it: 1. The dedicated WebSocket server on `--ws-port` was built with ONLY `host_validation::require_allowed_host`. `require_bearer` was never applied to it at all. My earlier verification only ever probed the HTTP port and I generalised from it. This is the worse of the two, because it is the port the UI actually uses: `ui/services/sensing.service.js` maps HTTP 8080 -> WS 8765. So the previous fix protected a path the browser never takes, while the path it does take stayed open. 2. On the HTTP router, `/ws/field` (ADR-262) was `.merge()`d AFTER the `require_bearer` layer, so it bypassed authentication entirely. The auth layer is now applied after the merge, and the comment says why the ordering is load-bearing. MEASURED AFTER, same conditions: :3989 /ws/sensing -> 401 · :3989 /ws/field -> 401 :3988 /ws/sensing -> 401 · :3988 /ws/field -> 401 with bearer on the upgrade: 101 on both ports ticket minted at POST /api/v1/ws-ticket on the HTTP port and redeemed on the WS port: 101 (AuthState shares its TicketStore via Arc) auth OFF: 101 — unchanged, no regression 526 sensing-server tests still pass. TEST GAP, stated rather than papered over: no automated test covers this. The defect is in router WIRING in main.rs, not in logic a unit test reaches — both holes were invisible to 526 green tests and to the ws_gate_tests suite, which builds its own Router and therefore cannot see how the real one is assembled. Catching this class needs an integration test that boots the binary and probes BOTH ports; that is the honest follow-up. Co-Authored-By: Ruflo & AQE --- .../wifi-densepose-sensing-server/src/main.rs | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index b7dc3238..56cf8f81 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -7993,6 +7993,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, @@ -8123,22 +8135,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). - .layer(axum::middleware::from_fn_with_state( - bearer_auth_state.clone(), - wifi_densepose_sensing_server::bearer_auth::require_bearer, - )) // ADR-272: the ws-ticket handler needs the store the middleware owns. - // Added AFTER the auth layer so it is still gated by it. .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`). 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. + // 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, + )) // 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 From 7a054174931268ee4da7141ad8820a940f11baf2 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Thu, 23 Jul 2026 09:18:33 +0200 Subject: [PATCH 12/27] fix(auth): scope gate was fail-OPEN; JWKS lock held across a blocking fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- v2/crates/ruview-auth/src/jwks.rs | 124 +++++++----- .../src/bearer_auth.rs | 179 ++++++++++++++++-- 2 files changed, 238 insertions(+), 65 deletions(-) diff --git a/v2/crates/ruview-auth/src/jwks.rs b/v2/crates/ruview-auth/src/jwks.rs index 1a3e391d..0ff02223 100644 --- a/v2/crates/ruview-auth/src/jwks.rs +++ b/v2/crates/ruview-auth/src/jwks.rs @@ -16,6 +16,26 @@ //! 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; @@ -126,61 +146,65 @@ impl JwksCache { /// Resolve the verification key for a token header's `kid`. pub fn decoding_key_for(&self, kid: &str) -> Result { - let mut state = self.state.lock().expect("jwks cache poisoned"); - - let stale = match state.fetched_at { - None => true, - Some(at) => at.elapsed() >= self.ttl, + // ---- Phase 1: answer from cache, holding the lock only to read. ---- + let (fresh, have_any, may_force) = { + let state = self.state.lock().expect("jwks cache poisoned"); + let fresh = state + .fetched_at + .map_or(false, |at| at.elapsed() < self.ttl); + if fresh { + if let Some(key) = state.keys.get(kid) { + return Ok(key.clone()); + } + } + let may_force = state + .last_forced_refetch + .map_or(true, |at| at.elapsed() >= FORCED_REFETCH_MIN_INTERVAL); + (fresh, state.fetched_at.is_some(), may_force) }; + // Lock released. Everything below may take milliseconds-to-seconds and + // MUST NOT hold it — see the module docs. - if stale { - // Routine refresh. A failure here is survivable if we still hold a - // key set (see module docs); it is fatal only if we never had one. - match self.fetch_and_parse() { - Ok(fresh) => { - state.keys = fresh; - state.fetched_at = Some(Instant::now()); + // A fresh cache that simply lacks this kid: one rate-limited forced + // refetch, in case identity rotated inside the TTL. + if fresh && !may_force { + return Err(JwksError::UnknownKid(kid.to_owned())); + } + if fresh { + self.state + .lock() + .expect("jwks cache poisoned") + .last_forced_refetch = 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); } - Err(e) if state.fetched_at.is_some() => { - tracing::warn!( - url = %self.url, - error = %e, - "JWKS refresh failed; continuing with the previously cached key set" - ); - } - Err(_) => return Err(JwksError::NeverFetched), + tracing::warn!( + url = %self.url, + error = %e, + "JWKS refresh failed; continuing with the previously cached key set" + ); } } - - if let Some(key) = state.keys.get(kid) { - return Ok(key.clone()); - } - - // Unknown kid against a cache we believe is fresh: identity may have - // rotated inside the TTL. One forced refetch, rate-limited so a token - // carrying a junk kid cannot turn every request into an outbound fetch. - let may_force = state - .last_forced_refetch - .map_or(true, |at| at.elapsed() >= FORCED_REFETCH_MIN_INTERVAL); - - if may_force { - state.last_forced_refetch = Some(Instant::now()); - match self.fetch_and_parse() { - Ok(fresh) => { - state.keys = fresh; - state.fetched_at = Some(Instant::now()); - if let Some(key) = state.keys.get(kid) { - tracing::info!(kid = %kid, "JWKS key rotation picked up via forced refetch"); - return Ok(key.clone()); - } - } - Err(e) => { - tracing::warn!(url = %self.url, error = %e, "forced JWKS refetch failed"); - } - } - } - - Err(JwksError::UnknownKid(kid.to_owned())) + state + .keys + .get(kid) + .cloned() + .ok_or_else(|| JwksError::UnknownKid(kid.to_owned())) } fn fetch_and_parse(&self) -> Result, JwksError> { diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs index 89638caa..b57783c6 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs @@ -287,32 +287,69 @@ impl AuthState { /// The scope a request must carry, split by **blast radius** (ADR-060): can /// this call destroy something, or only observe? /// -/// `sensing:admin` covers exactly three things: -/// * `/api/v1/train/*` — burns hours of CPU on a Pi and writes models; -/// * `DELETE /api/v1/models/{id}` — irreversible loss of a trained model; -/// * `DELETE /api/v1/recording/{id}` — irreversible loss of a labelled capture. +/// **Reads are open; writes are closed unless explicitly allowlisted.** /// -/// Everything else is `sensing:read`. Note what is deliberately *not* admin: -/// loading/unloading a model and starting a recording both mutate server state -/// but destroy nothing, and putting them behind the destructive scope would -/// push routine dashboard use into asking for a capability it does not need — -/// the opposite of least privilege. +/// An earlier revision enumerated the admin routes by prefix and let everything +/// else fall through to `sensing:read`. That is the wrong polarity for a +/// security gate and it shipped a real hole: `POST /api/v1/adaptive/train` +/// trains a classifier, overwrites the on-disk model and swaps the live one — +/// but it does not start with `/api/v1/train/`, so it landed on `sensing:read`, +/// the scope `wifi-densepose login` requests BY DEFAULT. A denylist for a scope +/// gate will keep missing routes as routes keep being added. +/// +/// So: `GET`/`HEAD`/`OPTIONS` need `sensing:read`. Any other method needs +/// `sensing:admin` unless its exact path is in [`READ_SAFE_MUTATIONS`] — routes +/// that change runtime state but destroy nothing. A new mutating route added +/// without thought is therefore admin-gated by default, which is the safe way +/// to be wrong. /// /// `sensing:read` is 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 fn required_scope_for(method: &Method, path: &str) -> &'static str { - if path.starts_with("/api/v1/train/") { - return scope::SENSING_ADMIN; + // Reads are open to `sensing:read`. + if !is_mutating(method) { + return scope::SENSING_READ; } - if method == Method::DELETE - && (path.starts_with("/api/v1/models/") || path.starts_with("/api/v1/recording/")) + // A small, explicit allowlist of mutating routes that change runtime state + // but destroy nothing — a dashboard doing its ordinary job. + if READ_SAFE_MUTATIONS.contains(&path) + || (path.starts_with("/api/v1/rf/vendors/") && path.ends_with("/events")) { - return scope::SENSING_ADMIN; + return scope::SENSING_READ; } - scope::SENSING_READ + // Everything else that mutates requires admin. FAIL CLOSED — see the docs + // above for why this is a default rather than a list. + scope::SENSING_ADMIN } +fn is_mutating(method: &Method) -> bool { + !matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) +} + +/// Mutating routes that need only `sensing:read`. +/// +/// Deliberately an ALLOWLIST. Everything absent from it that mutates requires +/// `sensing:admin`, so adding a route without thinking about scope fails safe +/// instead of silently landing on read. +const READ_SAFE_MUTATIONS: &[&str] = &[ + // Browsers must be able to obtain a WebSocket ticket with a read token, + // or the read scope cannot open a stream at all. + "/api/v1/ws-ticket", + // Load/unload/activate: reversible, destroy nothing. + "/api/v1/models/load", + "/api/v1/models/unload", + "/api/v1/models/lora/activate", + "/api/v1/model/sona/activate", + "/api/v1/adaptive/unload", + // Capture and calibration: create data, never destroy it. + "/api/v1/calibration/start", + "/api/v1/calibration/stop", + "/api/v1/pose/calibrate", + "/api/v1/recording/start", + "/api/v1/recording/stop", +]; + /// Constant-time byte slice equality. Returns `false` immediately on length /// mismatch (lengths are not secret here — both sides are fixed tokens). fn ct_eq(a: &[u8], b: &[u8]) -> bool { @@ -1293,3 +1330,115 @@ mod ws_path_matching_tests { assert!(!is_ws_path("/ws")); } } + +/// The scope classifier, after inverting it to fail-closed. The charge that +/// forced this: `POST /api/v1/adaptive/train` trains and overwrites the live +/// model, but did not match the `/api/v1/train/` prefix, so it was reachable +/// with `sensing:read` — the scope `login` requests by default. +#[cfg(test)] +mod scope_gate_polarity_tests { + use super::*; + + #[test] + fn adaptive_train_requires_admin() { + // The reported bypass. Handler calls train_from_recordings(), writes + // the model to disk and swaps the live one. + assert_eq!( + required_scope_for(&Method::POST, "/api/v1/adaptive/train"), + scope::SENSING_ADMIN + ); + } + + #[test] + fn every_known_destructive_route_requires_admin() { + for (m, p) in [ + (Method::POST, "/api/v1/train/start"), + (Method::POST, "/api/v1/train/stop"), + (Method::POST, "/api/v1/adaptive/train"), + (Method::DELETE, "/api/v1/models/m1"), + (Method::DELETE, "/api/v1/recording/r1"), + (Method::POST, "/api/v1/config/ground-truth"), + ] { + assert_eq!( + required_scope_for(&m, p), + scope::SENSING_ADMIN, + "{m} {p} must require admin" + ); + } + } + + #[test] + fn an_unknown_mutating_route_defaults_to_admin() { + // THE property the old denylist lacked. A route added tomorrow is + // admin-gated until someone consciously classifies it as read-safe. + for p in [ + "/api/v1/some/route/invented/later", + "/api/v1/adaptive/retrain-everything", + "/api/v1/models/nuke", + ] { + assert_eq!( + required_scope_for(&Method::POST, p), + scope::SENSING_ADMIN, + "unknown mutating route {p} must fail closed to admin" + ); + assert_eq!( + required_scope_for(&Method::DELETE, p), + scope::SENSING_ADMIN + ); + } + } + + #[test] + fn reads_stay_open_to_the_read_scope() { + for p in [ + "/api/v1/models", + "/api/v1/models/m1", + "/api/v1/recording/list", + "/api/v1/adaptive/status", + "/api/v1/anything/at/all", + ] { + assert_eq!( + required_scope_for(&Method::GET, p), + scope::SENSING_READ, + "GET {p} must stay open to read" + ); + } + } + + #[test] + fn allowlisted_mutations_stay_read_scoped() { + // Non-destructive state changes a dashboard makes routinely. Pushing + // these to admin would force ordinary use to hold delete capability. + for p in READ_SAFE_MUTATIONS { + assert_eq!( + required_scope_for(&Method::POST, p), + scope::SENSING_READ, + "{p} is allowlisted and must stay read-scoped" + ); + } + } + + #[test] + fn a_read_token_can_still_mint_a_websocket_ticket() { + // Load-bearing: if this needed admin, the read scope could never open + // a stream from a browser at all. + assert_eq!( + required_scope_for(&Method::POST, "/api/v1/ws-ticket"), + scope::SENSING_READ + ); + } + + #[test] + fn vendor_event_ingest_is_read_scoped_by_prefix() { + // Path carries a `:vendor` segment, so it cannot be an exact match. + assert_eq!( + required_scope_for(&Method::POST, "/api/v1/rf/vendors/netgear/events"), + scope::SENSING_READ + ); + // ...but the prefix must not become a wildcard for anything under it. + assert_eq!( + required_scope_for(&Method::POST, "/api/v1/rf/vendors/netgear/delete-all"), + scope::SENSING_ADMIN + ); + } +} From f67a880a1aa784f5097aa1f7b1514d630323e918 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Thu, 23 Jul 2026 09:20:31 +0200 Subject: [PATCH 13/27] docs: correct three doc-vs-code contradictions found by adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 12635a85 because Cognitum issues no `iss` and it rejected every real token. The ADR's own "Facts about the tokens" section 30 lines above already said tokens carry no `iss` — the document contradicted itself for a day. Also removed a claim that tests cover "issuer mismatch including a trailing-slash-only difference"; that test was deleted with the rule and no longer exists. 2. `ruview-auth/src/lib.rs:59` said "**No login flow.** ... this crate only verifies" — 15 lines above `pub mod login;`. Now states the accurate thing: the login flow is behind the non-default `login` feature, so a verifying server never compiles it. 3. ADR-271's scope table still described the old prefix-denylist admin set. Replaced with the fail-closed rule that actually ships, including why: `/api/v1/adaptive/train` was reachable with `sensing:read`. Also records a KNOWN INCOMPLETE that the ADRs previously implied was done: the browser cannot obtain an OAuth token at all. `wifi-densepose login` writes ~/.ruview/credentials.json, which a browser cannot read; the UI reads localStorage['ruview-api-token'], populated only by the manual-paste QuickSettings panel. `grep -ril "oauth|cognitum|pkce" ui/` returns nothing. So the WebSocket ticket mechanism ADR-272 introduces "for browsers" is today only exercisable with the legacy static shared secret OAuth was meant to replace. Server-side gating is correct and complete; the browser half of the story these ADRs tell is not built. Recorded rather than left implied. Co-Authored-By: Ruflo & AQE --- .../ADR-271-cognitum-oauth-resource-server.md | 41 +++++++++++++++++-- v2/crates/ruview-auth/src/lib.rs | 6 ++- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/docs/adr/ADR-271-cognitum-oauth-resource-server.md b/docs/adr/ADR-271-cognitum-oauth-resource-server.md index a243fdf8..a89ec047 100644 --- a/docs/adr/ADR-271-cognitum-oauth-resource-server.md +++ b/docs/adr/ADR-271-cognitum-oauth-resource-server.md @@ -81,10 +81,16 @@ accept explicitly: typ == "access" AND NOT setup AND NOT workload AND account_id is a non-empty string AND exp is in the future -AND iss matches the configured issuer verbatim 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 @@ -114,7 +120,17 @@ 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` | `POST /api/v1/train/*`, `DELETE /api/v1/models/{id}`, `DELETE /api/v1/recording/{id}` | +| `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. @@ -154,6 +170,24 @@ take no HTTP dependency at all. - 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 + +`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. + +Deliberately recorded rather than left implied, because the ADRs read as though +the browser path exists. Closing it needs a UI sign-in flow that puts an OAuth +access token where the page can reach it — a separate piece of work. + ## Alternatives considered **Keep `RUVIEW_API_TOKEN` only.** Zero work, and adequate for a single-user @@ -192,8 +226,7 @@ caller and its scopes). 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, issuer mismatch including a trailing-slash-only difference, `typ` -confusion, `setup`/`workload` smuggled onto a `typ=access` token, missing and +leeway, `typ` confusion, `setup`/`workload` smuggled onto a `typ=access` token, missing and empty `account_id`, and scope escalation. The load-bearing case is diff --git a/v2/crates/ruview-auth/src/lib.rs b/v2/crates/ruview-auth/src/lib.rs index 0bbdbb0c..d31a09f3 100644 --- a/v2/crates/ruview-auth/src/lib.rs +++ b/v2/crates/ruview-auth/src/lib.rs @@ -56,8 +56,10 @@ //! //! ## What this crate deliberately does not do //! -//! - **No login flow.** Obtaining a token (PKCE, loopback, OOB paste) is the -//! client's job and lives elsewhere; this crate only verifies. +//! - **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. From 0547fd7344d86cf0d0f56489be241ddfad648eeb Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Thu, 23 Jul 2026 09:31:24 +0200 Subject: [PATCH 14/27] =?UTF-8?q?fix(auth):=20enforce=20client=5Fid=20as?= =?UTF-8?q?=20the=20audience=20=E2=80=94=20Cognitum's=20stand-in=20for=20`?= =?UTF-8?q?aud`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- v2/crates/ruview-auth/src/lib.rs | 2 + v2/crates/ruview-auth/src/verify.rs | 49 +++++++++++++++++-- .../ruview-auth/tests/verifier_matrix.rs | 47 +++++++++++++++++- .../src/bearer_auth.rs | 39 ++++++++++++++- 4 files changed, 130 insertions(+), 7 deletions(-) diff --git a/v2/crates/ruview-auth/src/lib.rs b/v2/crates/ruview-auth/src/lib.rs index d31a09f3..be356502 100644 --- a/v2/crates/ruview-auth/src/lib.rs +++ b/v2/crates/ruview-auth/src/lib.rs @@ -38,6 +38,8 @@ //! 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("", &jwks, &config)?; diff --git a/v2/crates/ruview-auth/src/verify.rs b/v2/crates/ruview-auth/src/verify.rs index d81e83a0..e5bd5c7b 100644 --- a/v2/crates/ruview-auth/src/verify.rs +++ b/v2/crates/ruview-auth/src/verify.rs @@ -37,11 +37,20 @@ //! not emit rejects every genuine token, which is exactly what an earlier //! revision of this module did. //! -//! The missing `aud` has a real consequence: **scope is the only capability -//! boundary**. `client_id` cannot serve as one, because clients borrow each -//! other's registrations (musica shipped as `meta-proxy` while its own -//! registration was pending). Hence `required_scope` below is not optional -//! garnish; it is the boundary. +//! **`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; @@ -88,6 +97,10 @@ pub enum VerifyError { 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 @@ -132,6 +145,21 @@ pub struct VerifierConfig { 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, } /// Verify a raw JWT and produce a [`Principal`]. @@ -171,6 +199,17 @@ pub fn verify_access_token( 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 diff --git a/v2/crates/ruview-auth/tests/verifier_matrix.rs b/v2/crates/ruview-auth/tests/verifier_matrix.rs index 7db59f74..e376d837 100644 --- a/v2/crates/ruview-auth/tests/verifier_matrix.rs +++ b/v2/crates/ruview-auth/tests/verifier_matrix.rs @@ -133,6 +133,8 @@ 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()], } } @@ -388,12 +390,55 @@ fn g2_a_genuinely_valid_token_from_another_cognitum_product_cannot_reach_the_sen 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::MissingScope { .. }) + 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 diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs index b57783c6..0cea8bad 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs @@ -71,6 +71,30 @@ pub const OAUTH_JWKS_URL_ENV: &str = "RUVIEW_OAUTH_JWKS_URL"; /// The production Cognitum issuer, for operators who just want it on. pub const COGNITUM_ISSUER: &str = "https://auth.cognitum.one"; +/// Comma-separated `client_id` values whose tokens this server accepts — the +/// AUDIENCE control. Defaults to [`DEFAULT_CLIENT_ID`]. +/// +/// Cognitum tokens carry no `aud`; `client_id` is the platform's stand-in, and +/// `cognitum-one/freetokens` enforces exactly this. Set to `*` to accept any +/// Cognitum client — only sensible while borrowing another product's +/// registration, and it means any Cognitum token opens this server. +pub const OAUTH_CLIENT_IDS_ENV: &str = "RUVIEW_OAUTH_CLIENT_IDS"; + +/// RuView's own registered OAuth client (identity migration `0017`). +pub const DEFAULT_CLIENT_ID: &str = "ruview"; + +fn allowed_client_ids() -> Vec { + match std::env::var(OAUTH_CLIENT_IDS_ENV) { + Ok(v) if v.trim() == "*" => Vec::new(), // explicit opt-out + Ok(v) if !v.trim().is_empty() => v + .split(',') + .map(|c| c.trim().to_string()) + .filter(|c| !c.is_empty()) + .collect(), + _ => vec![DEFAULT_CLIENT_ID.to_string()], + } +} + /// Path prefix the middleware protects when auth is enabled. pub const PROTECTED_PREFIX: &str = "/api/v1/"; @@ -140,6 +164,7 @@ fn is_ws_path(path: &str) -> bool { pub struct OAuthState { jwks: JwksCache, issuer: String, + allowed_client_ids: Vec, } impl std::fmt::Debug for OAuthState { @@ -233,7 +258,17 @@ impl AuthState { key_count, "Cognitum OAuth enabled for /api/v1/*" ); - Some(Arc::new(OAuthState { jwks, issuer })) + let allowed_client_ids = allowed_client_ids(); + if allowed_client_ids.is_empty() { + tracing::warn!( + "{OAUTH_CLIENT_IDS_ENV}=* — this server accepts a Cognitum token minted \ + for ANY product, not just RuView. `client_id` is the platform's stand-in \ + for `aud`; disabling it leaves scope as the only boundary." + ); + } else { + tracing::info!(accepted_clients = ?allowed_client_ids, "OAuth audience restricted"); + } + Some(Arc::new(OAuthState { jwks, issuer, allowed_client_ids })) } Ok(_) => return Err(OAuthConfigError::EmptyIssuer), Err(_) => None, @@ -445,6 +480,7 @@ pub async fn require_bearer( let config = VerifierConfig { issuer: oauth.issuer.clone(), required_scope: required.to_string(), + allowed_client_ids: oauth.allowed_client_ids.clone(), }; match verify_access_token(supplied, &oauth.jwks, &config) { Ok(principal) => { @@ -851,6 +887,7 @@ mod oauth_tests { Arc::new(OAuthState { jwks: JwksCache::new("https://stub/jwks.json", Box::new(StaticJwks(doc))), issuer: ISSUER.to_string(), + allowed_client_ids: vec!["ruview".to_string()], }) } From b09625ece7ada530a2ab4dd7e3840df99e0c29f1 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Thu, 23 Jul 2026 09:48:09 +0200 Subject: [PATCH 15/27] fix(auth): close the four residual findings from the adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (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 --- v2/Cargo.lock | 1 + v2/crates/ruview-auth/Cargo.toml | 4 +- v2/crates/ruview-auth/src/login/store.rs | 162 +++++++++++++++++- .../src/bearer_auth.rs | 25 ++- .../src/ws_ticket.rs | 98 +++++++++-- 5 files changed, 274 insertions(+), 16 deletions(-) diff --git a/v2/Cargo.lock b/v2/Cargo.lock index bfb70e58..fc1218c9 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -7637,6 +7637,7 @@ version = "0.1.0" dependencies = [ "base64 0.21.7", "jsonwebtoken", + "libc", "p256", "rand 0.8.5", "reqwest 0.12.28", diff --git a/v2/crates/ruview-auth/Cargo.toml b/v2/crates/ruview-auth/Cargo.toml index 6aadf138..2b2c5931 100644 --- a/v2/crates/ruview-auth/Cargo.toml +++ b/v2/crates/ruview-auth/Cargo.toml @@ -34,13 +34,15 @@ 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"] # Interactive OAuth login: PKCE, loopback callback, OOB paste fallback, # credential storage, single-flight refresh. Opt in from a CLI or desktop app. -login = ["dep:reqwest", "dep:tokio", "dep:rand", "dep:sha2", "dep:base64", "dep:url"] +login = ["dep:reqwest", "dep:tokio", "dep:rand", "dep:sha2", "dep:base64", "dep:url", "dep:libc"] [dev-dependencies] # Test-only: sign real ES256 tokens so the negative matrix exercises the same diff --git a/v2/crates/ruview-auth/src/login/store.rs b/v2/crates/ruview-auth/src/login/store.rs index ac9d2b62..17a058a6 100644 --- a/v2/crates/ruview-auth/src/login/store.rs +++ b/v2/crates/ruview-auth/src/login/store.rs @@ -17,6 +17,21 @@ //! 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; @@ -26,6 +41,13 @@ 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; @@ -55,7 +77,10 @@ pub enum StoreError { } /// The persisted session. Deliberately small: this file holds live credentials. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// +/// `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, @@ -67,6 +92,20 @@ pub struct StoredCredentials { 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", &"") + .field("refresh_token", &self.refresh_token.as_ref().map(|_| "")) + .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; @@ -253,6 +292,55 @@ pub fn clear(path: &Path) -> Result { } } +/// 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 { + 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 { + 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 { @@ -304,6 +392,32 @@ impl Session { 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 = 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); }; @@ -370,6 +484,52 @@ mod tests { 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("")); + // 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())); diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs index 0cea8bad..b86b7f39 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs @@ -190,7 +190,13 @@ pub enum OAuthConfigError { } /// Cheap, cloneable handle to the configured credentials. -#[derive(Debug, Clone, Default)] +/// +/// `Debug` is hand-written and REDACTING: this holds the raw +/// `RUVIEW_API_TOKEN`. A derived impl would print it in full the first time +/// anyone writes `tracing::debug!(?auth, ...)`. `OAuthState` and `TicketStore` +/// already redact for the same reason; the type actually holding the secret +/// should not be the one that does not. +#[derive(Clone, Default)] pub struct AuthState { /// The expected static bearer token, if any. token: Option>, @@ -203,6 +209,17 @@ pub struct AuthState { legacy_ws: bool, } +impl std::fmt::Debug for AuthState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AuthState") + .field("static_token", &self.token.as_ref().map(|_| "")) + .field("oauth", &self.oauth) + .field("tickets", &self.tickets) + .field("legacy_ws", &self.legacy_ws) + .finish() + } +} + impl AuthState { /// Build an [`AuthState`] from an explicit string. Empty ⇒ disabled. pub fn from_token(t: impl Into) -> Self { @@ -909,7 +926,11 @@ mod oauth_tests { "exp": now + 900, "setup": false, "workload": false, - "iss": ISSUER, + // NO `iss`. Real Cognitum tokens carry none — mirroring production + // here matters even though the verifier ignores the claim either + // way: a fixture that invents a claim reality lacks is exactly what + // hid the original `iss` bug for a day. See ruview-auth's + // `a_token_with_no_iss_claim_is_accepted_because_cognitum_issues_none`. }); let mut header = Header::new(jsonwebtoken::Algorithm::ES256); header.kid = Some(KID.to_string()); diff --git a/v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs b/v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs index d8584963..8dc00c7a 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs @@ -45,11 +45,21 @@ use rand::RngCore; /// someone who found the URL later. pub const TICKET_TTL: Duration = Duration::from_secs(30); -/// Cap on outstanding tickets, so a caller with a valid credential cannot grow -/// the map without bound by requesting tickets in a loop. Well above any real -/// page's needs. +/// 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 @@ -106,7 +116,20 @@ impl TicketStore { if map.len() >= MAX_OUTSTANDING { tracing::warn!( outstanding = map.len(), - "refusing to issue a WebSocket ticket: too many outstanding" + "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; } @@ -254,24 +277,75 @@ mod tests { } #[test] - fn issuing_is_refused_once_too_many_are_outstanding() { + 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(); - for _ in 0..MAX_OUTSTANDING { - assert!(store.issue(grant()).is_some()); + 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(grant()).is_none(), - "an authenticated but misbehaving caller must not grow the map without bound" + 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 _ in 0..MAX_OUTSTANDING { - store.issue(grant()); + for i in 0..MAX_OUTSTANDING { + store.issue(TicketGrant { + scopes: Some("sensing:read".into()), + subject: Some(format!("user-{i}")), + }); } - assert!(store.issue(grant()).is_none()); + 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() { From 705a167ffea5632ba04f1f80e2e1841c44214b3a Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Thu, 23 Jul 2026 09:50:43 +0200 Subject: [PATCH 16/27] test(auth): boot the real binary and probe BOTH listeners (the wiring gap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two authentication bypasses shipped in this PR and 526 green unit tests could not see either, because every auth test in the crate builds its OWN Router with a hand-picked subset of routes. A synthetic router cannot observe how the real one is assembled — and both defects were assembly, not logic: 1. the dedicated --ws-port listener had no `require_bearer` at all 2. `/ws/field` was .merge()d AFTER the auth layer, which in axum exempts it This spawns the actual `sensing-server` binary (via CARGO_BIN_EXE) on ephemeral ports and speaks raw HTTP/1.1 and real WebSocket upgrades to BOTH listeners. No mocks, no synthetic router, no in-process shortcuts. PROVEN TO HAVE TEETH, which matters more than it passing: with main.rs reverted to eb68e07a (the vulnerable commit), the suite fails — assertion `left != right` failed: http port ACCEPTED an unauthenticated upgrade to /ws/field — this is the bypass that shipped twice and passes again once restored. A regression test that has never been shown to fail is a comment. Five cases: - with auth ON, NO listener accepts an unauthenticated upgrade — all four WS paths x both ports, with a REST 401 control first so the WS assertions cannot pass for the wrong reason - a bearer on the upgrade is accepted on both listeners (native clients are not browser-constrained and must not need the ticket round-trip) - with auth OFF both listeners stay open — the compatibility promise - the legacy escape hatch opens WebSockets WITHOUT weakening REST — scoped, or it is a bypass wearing a migration label - /health stays anonymous on both listeners — a documented exemption, pinned so it stays a decision rather than an accident The child inherits no RUVIEW_* variables (env_remove), or a developer's local export would silently change what the test proves. Ports are reserved by binding :0 and releasing, so a collision surfaces as a boot failure rather than a false pass. Tests: 5 new integration, 534 lib, 87 ruview-auth. Co-Authored-By: Ruflo & AQE --- .../tests/auth_wiring.rs | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 v2/crates/wifi-densepose-sensing-server/tests/auth_wiring.rs diff --git a/v2/crates/wifi-densepose-sensing-server/tests/auth_wiring.rs b/v2/crates/wifi-densepose-sensing-server/tests/auth_wiring.rs new file mode 100644 index 00000000..66f26da3 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/tests/auth_wiring.rs @@ -0,0 +1,263 @@ +//! 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. + fn start(env: &[(&str, &str)]) -> Option { + 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()) + .stderr(Stdio::null()); + for (k, v) in env { + cmd.env(k, v); + } + let child = cmd.spawn().ok()?; + let server = Server { child, http, ws }; + server.await_ready().then_some(server) + } + + fn await_ready(&self) -> bool { + let deadline = Instant::now() + Duration::from_secs(30); + while Instant::now() < deadline { + if TcpStream::connect(("127.0.0.1", self.http)).is_ok() + && TcpStream::connect(("127.0.0.1", self.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"]; + +#[test] +fn with_auth_on_no_listener_accepts_an_unauthenticated_websocket() { + let Some(server) = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]) else { + eprintln!("skipping: sensing-server did not start"); + return; + }; + + // 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 Some(server) = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]) else { + eprintln!("skipping: sensing-server did not start"); + return; + }; + // 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 Some(server) = Server::start(&[]) else { + eprintln!("skipping: sensing-server did not start"); + return; + }; + 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 Some(server) = Server::start(&[ + ("RUVIEW_API_TOKEN", TOKEN), + ("RUVIEW_WS_LEGACY_UNAUTHENTICATED", "1"), + ]) else { + eprintln!("skipping: sensing-server did not start"); + return; + }; + // 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 Some(server) = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]) else { + eprintln!("skipping: sensing-server did not start"); + return; + }; + for (label, port) in [("http", server.http), ("ws", server.ws)] { + assert_eq!( + status(port, "GET", "/health", &[]), + 200, + "{label} port /health must remain anonymous" + ); + } +} From 347698b67c80862d430b65b30aa27b0959379550 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Thu, 23 Jul 2026 09:57:15 +0200 Subject: [PATCH 17/27] =?UTF-8?q?feat(auth):=20browser=20sign-in=20?= =?UTF-8?q?=E2=80=94=20/oauth/start,=20/oauth/callback,=20session=20cookie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- v2/Cargo.lock | 2 + v2/crates/ruview-auth/Cargo.toml | 6 +- v2/crates/ruview-auth/src/lib.rs | 5 + v2/crates/ruview-auth/src/login/flow.rs | 2 +- v2/crates/ruview-auth/src/login/mod.rs | 3 +- v2/crates/ruview-auth/src/{login => }/pkce.rs | 0 .../wifi-densepose-sensing-server/Cargo.toml | 6 +- .../src/bearer_auth.rs | 95 +++- .../src/browser_session.rs | 407 ++++++++++++++++++ .../wifi-densepose-sensing-server/src/lib.rs | 1 + .../wifi-densepose-sensing-server/src/main.rs | 168 ++++++++ 11 files changed, 690 insertions(+), 5 deletions(-) rename v2/crates/ruview-auth/src/{login => }/pkce.rs (100%) create mode 100644 v2/crates/wifi-densepose-sensing-server/src/browser_session.rs diff --git a/v2/Cargo.lock b/v2/Cargo.lock index fc1218c9..26927517 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -11301,6 +11301,7 @@ dependencies = [ "clap", "criterion", "futures-util", + "hmac", "jsonwebtoken", "midstreamer-attractor", "midstreamer-temporal-compare", @@ -11313,6 +11314,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "subtle", "tempfile", "thiserror 1.0.69", "tokio", diff --git a/v2/crates/ruview-auth/Cargo.toml b/v2/crates/ruview-auth/Cargo.toml index 2b2c5931..96df45b3 100644 --- a/v2/crates/ruview-auth/Cargo.toml +++ b/v2/crates/ruview-auth/Cargo.toml @@ -40,9 +40,13 @@ 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 = ["dep:reqwest", "dep:tokio", "dep:rand", "dep:sha2", "dep:base64", "dep:url", "dep:libc"] +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 diff --git a/v2/crates/ruview-auth/src/lib.rs b/v2/crates/ruview-auth/src/lib.rs index be356502..821abed8 100644 --- a/v2/crates/ruview-auth/src/lib.rs +++ b/v2/crates/ruview-auth/src/lib.rs @@ -71,6 +71,11 @@ 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. diff --git a/v2/crates/ruview-auth/src/login/flow.rs b/v2/crates/ruview-auth/src/login/flow.rs index 721405a3..ad46fb97 100644 --- a/v2/crates/ruview-auth/src/login/flow.rs +++ b/v2/crates/ruview-auth/src/login/flow.rs @@ -6,7 +6,7 @@ use std::time::Duration; use super::callback::{looks_headless, open_browser, CallbackServer}; use super::client::{self, OAuthError}; -use super::pkce; +use crate::pkce; use super::store::{self, Session, StoreError}; use crate::scope; diff --git a/v2/crates/ruview-auth/src/login/mod.rs b/v2/crates/ruview-auth/src/login/mod.rs index 14dc6c0a..942f684e 100644 --- a/v2/crates/ruview-auth/src/login/mod.rs +++ b/v2/crates/ruview-auth/src/login/mod.rs @@ -36,9 +36,10 @@ //! 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 pkce; pub mod store; pub use client::{OAuthError, TokenResponse, CLIENT_ID, CLIENT_ID_ENV, OOB_REDIRECT_URI}; diff --git a/v2/crates/ruview-auth/src/login/pkce.rs b/v2/crates/ruview-auth/src/pkce.rs similarity index 100% rename from v2/crates/ruview-auth/src/login/pkce.rs rename to v2/crates/ruview-auth/src/pkce.rs diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 5267e4d2..34347cde 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -88,7 +88,11 @@ 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" } +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" diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs index b86b7f39..dd4e5b7d 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs @@ -315,6 +315,40 @@ impl AuthState { &self.tickets } + /// Issuer origin, when OAuth is enabled. + pub fn oauth_issuer(&self) -> Option { + self.oauth.as_ref().map(|o| o.issuer.clone()) + } + + /// The client id to present when starting a browser sign-in. + pub fn primary_client_id(&self) -> String { + self.oauth + .as_ref() + .and_then(|o| o.allowed_client_ids.first().cloned()) + .unwrap_or_else(|| DEFAULT_CLIENT_ID.to_string()) + } + + /// Verify a token obtained through the browser flow, using the SAME rules + /// as every other request — a sign-in path must not be a softer one. + pub fn verify_for_browser( + &self, + token: &str, + ) -> Result { + let oauth = self + .oauth + .as_ref() + .ok_or(ruview_auth::VerifyError::MissingBearer)?; + verify_access_token( + token, + &oauth.jwks, + &VerifierConfig { + issuer: oauth.issuer.clone(), + required_scope: scope::SENSING_READ.to_string(), + allowed_client_ids: oauth.allowed_client_ids.clone(), + }, + ) + } + /// Whether the legacy unauthenticated-WebSocket escape hatch is active. pub fn legacy_ws_enabled(&self) -> bool { self.legacy_ws @@ -475,7 +509,8 @@ pub async fn require_bearer( .then(|| token.trim_start()) }) else { - return unauthorized(&auth); + // No bearer header at all — a browser session may still authorize this. + return session_or_unauthorized(&auth, request, next).await; }; // 1. Legacy static token. Unchanged, and tried first so an existing @@ -525,9 +560,67 @@ pub async fn require_bearer( } } + // 3. Browser session cookie (ADR-271 browser half). Checked last: it is the + // weakest-bound credential (host-only, no proof-of-possession), so a + // presented bearer or ticket should win. + if auth.oauth.is_some() { + if let Some(cookie_header) = request + .headers() + .get(axum::http::header::COOKIE) + .and_then(|v| v.to_str().ok()) + { + if let Some(session) = crate::browser_session::from_cookie_header(cookie_header) { + let required = if is_ws_path(&path) { + scope::SENSING_READ + } else { + required_scope_for(request.method(), &path) + }; + if session.has_scope(required) { + tracing::debug!( + sub = %session.subject, + scope = %required, + path = %path.as_str(), + "request authorized by browser session" + ); + return next.run(request).await; + } + tracing::debug!( + path = %path.as_str(), + required_scope = %required, + "browser session lacks the scope this route requires" + ); + } + } + } + unauthorized(&auth) } +/// The no-bearer path: try a browser session cookie, else 401. +async fn session_or_unauthorized(auth: &AuthState, request: Request, next: Next) -> Response { + let path = request.uri().path().to_string(); + if auth.oauth.is_some() { + if let Some(h) = request + .headers() + .get(axum::http::header::COOKIE) + .and_then(|v| v.to_str().ok()) + { + if let Some(session) = crate::browser_session::from_cookie_header(h) { + let required = if is_ws_path(&path) { + scope::SENSING_READ + } else { + required_scope_for(request.method(), &path) + }; + if session.has_scope(required) { + tracing::debug!(sub = %session.subject, path = %path.as_str(), "browser session authorized"); + return next.run(request).await; + } + } + } + } + unauthorized(auth) +} + /// A uniform 401. The hint names whichever credentials are actually accepted, /// so an operator is not told to set a variable this server ignores — but it /// never says *why* a presented token failed. diff --git a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs new file mode 100644 index 00000000..f5fe5ad1 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs @@ -0,0 +1,407 @@ +//! Browser sign-in: `GET /oauth/start` → Cognitum → `GET /oauth/callback` +//! → a signed session cookie (ADR-271, browser half). +//! +//! # Why this exists +//! +//! `wifi-densepose login` writes `~/.ruview/credentials.json`. **A browser +//! cannot read that file.** So until now the UI had no way to obtain a Cognitum +//! token at all — the WebSocket ticket mechanism ADR-272 built "for browsers" +//! was only exercisable with the legacy static shared secret that OAuth was +//! meant to replace. An adversarial review found the gap; this closes it. +//! +//! # The pattern, ported from `cognitum-one/freetokens` +//! +//! freetokens (`src/auth/oauth.ts`, live at `freetokens.cognitum.one`) solves +//! exactly this, and the shape is worth stating because it is not the obvious +//! one: +//! +//! **The browser never holds an OAuth token.** The server generates the PKCE +//! verifier and state, keeps them in a signed cookie, performs the code +//! exchange itself, verifies the token, and then issues *its own* session +//! cookie. The access token never reaches page JavaScript, so it cannot be +//! read by an XSS, stored in `localStorage`, or leaked through a URL. +//! +//! # Deviation from freetokens, and why +//! +//! freetokens uses the `__Host-` cookie prefix, which **requires** the `Secure` +//! attribute. It is served only over HTTPS, so that is free. RuView is +//! routinely reached over plain HTTP on a LAN or at `http://localhost`, where a +//! `__Host-`/`Secure` cookie is simply never sent and sign-in would silently +//! fail. So the names carry no prefix and `Secure` is set only when the request +//! arrived over TLS. Every other attribute — `HttpOnly`, `SameSite=Lax`, +//! `Path=/` — matches, and the signature is what actually protects the value. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use subtle::ConstantTimeEq; + +type HmacSha256 = Hmac; + +/// Signing key for both cookies. Absent ⇒ browser sign-in is unavailable and +/// `/oauth/start` answers 503, rather than issuing cookies nobody can verify. +pub const SESSION_SECRET_ENV: &str = "RUVIEW_SESSION_SECRET"; + +/// Public origin this server is reached at, used to build `redirect_uri`. +/// Must match the value registered for the `ruview` OAuth client. +pub const PUBLIC_BASE_URL_ENV: &str = "RUVIEW_PUBLIC_BASE_URL"; + +const TXN_COOKIE: &str = "ruview_oauth_txn"; +const SESSION_COOKIE: &str = "ruview_session"; + +/// The OAuth round-trip is a page load or two. Ten minutes is generous. +const TXN_TTL_SECS: i64 = 600; +/// How long a browser stays signed in before repeating the redirect. +const SESSION_TTL_SECS: i64 = 12 * 3600; + +fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn b64(bytes: &[u8]) -> String { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine; + URL_SAFE_NO_PAD.encode(bytes) +} + +fn unb64(s: &str) -> Option> { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine; + URL_SAFE_NO_PAD.decode(s).ok() +} + +/// `.`. +fn sign(payload: &[u8], secret: &str) -> String { + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("hmac accepts any key size"); + let body = b64(payload); + mac.update(body.as_bytes()); + format!("{body}.{}", b64(&mac.finalize().into_bytes())) +} + +/// Verify and unwrap. Constant-time tag comparison — a byte-at-a-time compare +/// on a MAC is a forgery oracle. +fn unsign(value: &str, secret: &str) -> Option> { + let sep = value.rfind('.')?; + let (body, tag) = (&value[..sep], &value[sep + 1..]); + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?; + mac.update(body.as_bytes()); + let expected = b64(&mac.finalize().into_bytes()); + if expected.as_bytes().ct_eq(tag.as_bytes()).into() { + unb64(body) + } else { + None + } +} + +/// What the transaction cookie carries between `/oauth/start` and the callback. +#[derive(serde::Serialize, serde::Deserialize)] +struct Transaction { + state: String, + verifier: String, + exp: i64, +} + +/// What the session cookie carries after a successful sign-in. +/// +/// Deliberately NOT the access token. The browser gets an assertion that this +/// server already verified one — nothing replayable elsewhere. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BrowserSession { + pub subject: String, + pub account_id: String, + pub scope: String, + pub exp: i64, +} + +impl BrowserSession { + pub fn is_live(&self) -> bool { + self.exp > now() + } + pub fn has_scope(&self, want: &str) -> bool { + self.scope.split_whitespace().any(|s| s == want) + } +} + +fn cookie(name: &str, value: &str, max_age: i64, secure: bool) -> String { + format!( + "{name}={value}; Path=/; Max-Age={max_age}; HttpOnly; SameSite=Lax{}", + if secure { "; Secure" } else { "" } + ) +} + +/// Read one cookie from a raw `Cookie:` header. +pub fn read_cookie(header: &str, name: &str) -> Option { + header.split(';').find_map(|part| { + let (k, v) = part.split_once('=')?; + (k.trim() == name).then(|| v.trim().to_string()) + }) +} + +#[derive(Debug, thiserror::Error)] +pub enum SessionError { + #[error("browser sign-in is not configured on this server")] + NotConfigured, + #[error("the sign-in request is missing or has expired")] + InvalidTransaction, + #[error("the sign-in state did not match — this response did not come from the flow that started")] + StateMismatch, + #[error("Cognitum sign-in could not be completed: {0}")] + ExchangeFailed(String), + #[error("Cognitum returned a token this server will not accept: {0}")] + InvalidToken(String), +} + +fn secret() -> Result { + std::env::var(SESSION_SECRET_ENV) + .ok() + .filter(|s| !s.trim().is_empty()) + .ok_or(SessionError::NotConfigured) +} + +/// Begin sign-in: where to redirect, and the cookie to set. +pub fn begin(issuer: &str, client_id: &str, scope: &str, secure: bool) -> Result<(String, String), SessionError> { + let secret = secret()?; + let req = ruview_auth::pkce::generate(); + let txn = Transaction { + state: req.state.clone(), + verifier: req.code_verifier, + exp: now() + TXN_TTL_SECS, + }; + let payload = serde_json::to_vec(&txn).expect("transaction serializes"); + + let mut url = url_encode_authorize(issuer, client_id, scope, &req.state, &req.code_challenge); + url.push_str(""); // no-op; keeps the builder readable + + Ok(( + url, + cookie(TXN_COOKIE, &sign(&payload, &secret), TXN_TTL_SECS, secure), + )) +} + +fn url_encode_authorize( + issuer: &str, + client_id: &str, + scope: &str, + state: &str, + challenge: &str, +) -> String { + // Percent-encode every value: `scope` legitimately contains a space + // ("sensing:read sensing:admin") and hand-formatting silently truncates it. + fn enc(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + (b as char).to_string() + } + _ => format!("%{b:02X}"), + }) + .collect() + } + format!( + "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256", + issuer.trim_end_matches('/'), + enc(client_id), + enc(&redirect_uri()), + enc(scope), + enc(state), + enc(challenge), + ) +} + +pub fn public_base_url() -> String { + std::env::var(PUBLIC_BASE_URL_ENV) + .ok() + .filter(|s| !s.trim().is_empty()) + .map(|s| s.trim().trim_end_matches('/').to_string()) + .unwrap_or_else(|| "http://127.0.0.1:8080".to_string()) +} + +pub fn redirect_uri() -> String { + format!("{}/oauth/callback", public_base_url()) +} + +/// Cookie that clears the transaction. +pub fn clear_transaction(secure: bool) -> String { + cookie(TXN_COOKIE, "", 0, secure) +} + +/// Cookie that ends the session. +pub fn clear_session(secure: bool) -> String { + cookie(SESSION_COOKIE, "", 0, secure) +} + +/// Validate the callback's `state` against the transaction cookie and return +/// the PKCE verifier needed for the exchange. +pub fn verifier_for_callback(cookie_header: &str, state: &str) -> Result { + let secret = secret()?; + let raw = read_cookie(cookie_header, TXN_COOKIE).ok_or(SessionError::InvalidTransaction)?; + let bytes = unsign(&raw, &secret).ok_or(SessionError::InvalidTransaction)?; + let txn: Transaction = + serde_json::from_slice(&bytes).map_err(|_| SessionError::InvalidTransaction)?; + if txn.exp < now() { + return Err(SessionError::InvalidTransaction); + } + // CSRF: constant-time, and BEFORE the code is spent. + let ok: bool = txn.state.as_bytes().ct_eq(state.as_bytes()).into(); + if !ok { + return Err(SessionError::StateMismatch); + } + Ok(txn.verifier) +} + +/// Issue the session cookie for a verified principal. +pub fn issue(principal: &ruview_auth::Principal, secure: bool) -> Result { + let secret = secret()?; + let session = BrowserSession { + subject: principal.subject.clone(), + account_id: principal.account_id.clone(), + scope: principal.scopes().collect::>().join(" "), + // Never outlive our own ceiling, and never inherit the access token's + // 15 minutes either — this is a browser session, not the token. + exp: now() + SESSION_TTL_SECS, + }; + let payload = serde_json::to_vec(&session).expect("session serializes"); + Ok(cookie( + SESSION_COOKIE, + &sign(&payload, &secret), + SESSION_TTL_SECS, + secure, + )) +} + +/// Recover a live session from a request's `Cookie:` header. +pub fn from_cookie_header(cookie_header: &str) -> Option { + let secret = secret().ok()?; + let raw = read_cookie(cookie_header, SESSION_COOKIE)?; + let bytes = unsign(&raw, &secret)?; + let session: BrowserSession = serde_json::from_slice(&bytes).ok()?; + session.is_live().then_some(session) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET: &str = "test-secret-value"; + + fn session(exp: i64) -> BrowserSession { + BrowserSession { + subject: "user-1".into(), + account_id: "acct-1".into(), + scope: "sensing:read".into(), + exp, + } + } + + #[test] + fn a_signed_value_round_trips() { + let signed = sign(b"hello", SECRET); + assert_eq!(unsign(&signed, SECRET).as_deref(), Some(&b"hello"[..])); + } + + #[test] + fn a_tampered_payload_is_rejected() { + // The whole point of signing: the browser holds this value and can edit + // it. Flipping a byte must invalidate the tag. + let signed = sign(b"hello", SECRET); + let (body, tag) = signed.split_once('.').unwrap(); + let mut bad = body.to_string(); + bad.push('x'); + assert!(unsign(&format!("{bad}.{tag}"), SECRET).is_none()); + } + + #[test] + fn a_value_signed_with_another_secret_is_rejected() { + let signed = sign(b"hello", "a-different-secret"); + assert!(unsign(&signed, SECRET).is_none()); + } + + #[test] + fn a_malformed_cookie_value_is_rejected_rather_than_panicking() { + for bad in ["", ".", "no-separator", "!!!.!!!", "a.b.c"] { + assert!(unsign(bad, SECRET).is_none(), "{bad:?} must not verify"); + } + } + + #[test] + fn cookies_are_httponly_and_samesite_lax() { + // HttpOnly is what keeps page JavaScript — and therefore an XSS — away + // from the session. + let c = cookie("n", "v", 600, false); + assert!(c.contains("HttpOnly"), "{c}"); + assert!(c.contains("SameSite=Lax"), "{c}"); + assert!(c.contains("Path=/"), "{c}"); + assert!(!c.contains("Secure"), "plain HTTP must not set Secure: {c}"); + } + + #[test] + fn secure_is_set_only_over_tls() { + assert!(cookie("n", "v", 600, true).contains("; Secure")); + } + + #[test] + fn a_session_cookie_never_contains_the_access_token() { + // The core property of this design: the browser holds an assertion, + // not a credential it could replay against Cognitum or another service. + let payload = serde_json::to_vec(&session(now() + 3600)).unwrap(); + let rendered = sign(&payload, SECRET); + let decoded = String::from_utf8(unsign(&rendered, SECRET).unwrap()).unwrap(); + assert!(!decoded.contains("eyJ"), "looks like a JWT: {decoded}"); + assert!(decoded.contains("user-1") && decoded.contains("sensing:read")); + } + + #[test] + fn an_expired_session_is_not_live() { + assert!(!session(now() - 1).is_live()); + assert!(session(now() + 60).is_live()); + } + + #[test] + fn session_scope_matching_is_exact() { + let s = session(now() + 60); + assert!(s.has_scope("sensing:read")); + assert!(!s.has_scope("sensing:admin"), "no implied escalation"); + assert!(!s.has_scope("sensing"), "prefixes must not match"); + } + + #[test] + fn reads_a_named_cookie_out_of_a_header() { + let h = "foo=1; ruview_session=abc.def; bar=2"; + assert_eq!(read_cookie(h, "ruview_session").as_deref(), Some("abc.def")); + assert_eq!(read_cookie(h, "absent"), None); + } + + #[test] + fn a_cookie_name_that_merely_ends_with_the_target_is_not_matched() { + // `xruview_session=` must not be read as `ruview_session=`. + assert_eq!(read_cookie("xruview_session=v", "ruview_session"), None); + } + + #[test] + fn the_authorize_url_encodes_a_multi_scope_request() { + let u = url_encode_authorize( + "https://auth.cognitum.one", + "ruview", + "sensing:read sensing:admin", + "st", + "ch", + ); + assert!(u.starts_with("https://auth.cognitum.one/oauth/authorize")); + assert!(u.contains("client_id=ruview")); + assert!(u.contains("code_challenge_method=S256")); + assert!( + u.contains("scope=sensing%3Aread%20sensing%3Aadmin"), + "space must be encoded, not truncated: {u}" + ); + } + + #[test] + fn a_trailing_slash_on_the_issuer_does_not_double_up() { + let u = url_encode_authorize("https://auth.cognitum.one/", "ruview", "s", "st", "ch"); + assert!(!u.contains(".one//oauth"), "{u}"); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/lib.rs b/v2/crates/wifi-densepose-sensing-server/src/lib.rs index 2156963a..b0d802ea 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/lib.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/lib.rs @@ -9,6 +9,7 @@ //! - 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; diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 56cf8f81..7fdfd5e3 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -8082,6 +8082,11 @@ async fn main() { // 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)) .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)) @@ -9153,3 +9158,166 @@ async fn ws_ticket_handler( .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, + 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. + match bs::begin(&issuer, &auth.primary_client_id(), ruview_auth::scope::SENSING_READ, 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, + state: Option, + error: Option, +} + +async fn oauth_callback( + axum::Extension(auth): axum::Extension, + headers: axum::http::HeaderMap, + axum::extract::Query(q): axum::extract::Query, +) -> 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::(&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"); + + ( + axum::http::StatusCode::FOUND, + [ + (axum::http::header::LOCATION, "/ui/".to_string()), + (axum::http::header::SET_COOKIE, session_cookie), + ], + ) + .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); + ( + axum::http::StatusCode::FOUND, + [ + (axum::http::header::LOCATION, "/ui/".to_string()), + ( + axum::http::header::SET_COOKIE, + wifi_densepose_sensing_server::browser_session::clear_session(secure), + ), + ], + ) + .into_response() +} From 9299a3b137577c17da670646d30880461a82a020 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Thu, 23 Jul 2026 10:07:49 +0200 Subject: [PATCH 18/27] feat(ui): Sign in with Cognitum, zero-config session secret, executed JS tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three loose ends from the browser sign-in work. --- 1. The last mile: a button --- The server endpoints existed but nothing in ui/ linked to them, so the feature was unreachable. QuickSettings gains a "Cognitum Account" panel that renders from `GET /oauth/status` and offers Sign in / Sign out. `/oauth/status` is a new endpoint and is deliberately UNGATED — a signed-OUT browser cannot ask a gated API whether sign-in is available. It returns only capability flags and, when a session exists, who it belongs to. Never a credential. The panel distinguishes four states rather than showing a button that might 404: signed in; sign-in available; auth on but OAuth off (points at the existing static-token panel); no auth required. A 404 from /oauth/status means a server predating this work and says so plainly. Sign-in is a full-page navigation, not fetch(): the server replies 302 and the browser must follow it carrying the transaction cookie. An XHR would follow the redirect invisibly and land nowhere. --- 2. RUVIEW_SESSION_SECRET no longer required --- Previously `/oauth/start` returned 503 unless an operator invented a secret — a footgun, since they set RUVIEW_OAUTH_ISSUER, expect sign-in, and get a 503 naming an env var they have never heard of. Now resolved in order: env var, then `/session-secret`, then generate one and persist it 0600 (temp file, chmod before rename — the discipline used for the CLI's credentials). Persisted rather than in-memory so a restart does not silently sign everyone out. The env var still wins, which is what a multi-instance deployment needs: several servers must share a secret or a session issued by one is rejected by the next. If the file cannot be written we log the reason and continue with an in-memory secret rather than refusing sign-in outright. Verified with NO configuration at all: secret generated, file mode 0600, /oauth/start returns 302, /oauth/status reports browser_signin: true. --- 3. The JavaScript is now executed by tests --- `ui/services/ws-ticket.test.mjs`, 9 tests, node:test built-in — no new dependency and no package.json needed. Run: `node --test ui/services/`. Covers: no token means no fetch and an unchanged URL; the bearer reaches the Authorization header and NEVER the URL; `?` vs `&` when a query already exists; ticket URL-encoding; 404 treated as a pre-ADR-272 server (the property that lets one UI work against old and new servers, and therefore lets the legacy escape hatch be removed later); 503 and network failure swallowed rather than breaking the connect path; and that a fresh ticket is minted per call, since tickets are single-use and caching one fails on the second reconnect. STATED PRECISELY, because the distinction matters: this EXECUTES the module in Node with stubbed fetch/localStorage. It is more than the `node --check` it replaces and less than a browser — no real WebSocket upgrade, no real cookies, no page wiring. "The UI JavaScript has never been run" is no longer true of this module. "Browser-tested" still is not. Tests: 547 sensing-server lib, 5 wiring integration, 87 ruview-auth, 9 JS. Co-Authored-By: Ruflo & AQE --- ui/services/ws-ticket.test.mjs | 112 ++++++++++++++++++ ui/utils/quick-settings.js | 73 ++++++++++++ .../src/browser_session.rs | 78 +++++++++++- .../wifi-densepose-sensing-server/src/main.rs | 32 +++++ 4 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 ui/services/ws-ticket.test.mjs diff --git a/ui/services/ws-ticket.test.mjs b/ui/services/ws-ticket.test.mjs new file mode 100644 index 00000000..00f04176 --- /dev/null +++ b/ui/services/ws-ticket.test.mjs @@ -0,0 +1,112 @@ +// Executed tests for the WebSocket ticket helper (ADR-272). +// +// Run: node --test ui/services/ +// +// 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'); +}); diff --git a/ui/utils/quick-settings.js b/ui/utils/quick-settings.js index afae95eb..33c91212 100644 --- a/ui/utils/quick-settings.js +++ b/ui/utils/quick-settings.js @@ -74,6 +74,16 @@ export class QuickSettings { +
+
Cognitum Account
+
+ Checking\u2026 +
+ + +
+
+
API Access
@@ -233,3 +243,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 ? ` \u2014 ${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; +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs index f5fe5ad1..d2663668 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs @@ -31,6 +31,7 @@ //! arrived over TLS. Every other attribute — `HttpOnly`, `SameSite=Lax`, //! `Path=/` — matches, and the signature is what actually protects the value. +use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; use hmac::{Hmac, Mac}; @@ -155,13 +156,86 @@ pub enum SessionError { InvalidToken(String), } -fn secret() -> Result { - std::env::var(SESSION_SECRET_ENV) +/// Process-wide secret, resolved once. +static SECRET: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// Resolve the signing secret: env first, then a persisted file, then generate. +/// +/// Requiring an operator to invent a secret before browser sign-in works is a +/// footgun — they set `RUVIEW_OAUTH_ISSUER`, expect sign-in, and get a 503 that +/// names an env var they have never heard of. A single-host appliance has no +/// reason to need that step, so we generate one and persist it `0600` next to +/// the server's other state. +/// +/// Persisted rather than in-memory so a restart does not silently sign everyone +/// out. The env var still wins, which is what a multi-instance deployment needs +/// — several servers must share a secret or a session issued by one is +/// rejected by the next. +pub fn init_secret(data_dir: &Path) { + let resolved = std::env::var(SESSION_SECRET_ENV) .ok() .filter(|s| !s.trim().is_empty()) + .map(|s| { + tracing::info!("browser session secret: from {SESSION_SECRET_ENV}"); + s + }) + .or_else(|| load_or_create_secret(data_dir)); + let _ = SECRET.set(resolved); +} + +fn load_or_create_secret(data_dir: &Path) -> Option { + let path = data_dir.join("session-secret"); + if let Ok(existing) = std::fs::read_to_string(&path) { + let trimmed = existing.trim().to_string(); + if !trimmed.is_empty() { + tracing::info!(path = %path.display(), "browser session secret: loaded"); + return Some(trimmed); + } + } + let mut bytes = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut bytes); + let generated = b64(&bytes); + if let Err(e) = write_secret(&path, &generated) { + tracing::warn!( + path = %path.display(), + error = %e, + "could not persist a browser session secret; sessions will not survive a restart. \ + Set {SESSION_SECRET_ENV} to fix this permanently." + ); + // Still usable this run — better than refusing sign-in outright. + return Some(generated); + } + tracing::info!(path = %path.display(), "browser session secret: generated"); + Some(generated) +} + +fn write_secret(path: &Path, value: &str) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, value)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // Restrict BEFORE publishing the path, as with the CLI's credentials. + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?; + } + std::fs::rename(&tmp, path) +} + +fn secret() -> Result { + SECRET + .get() + .and_then(|s| s.clone()) .ok_or(SessionError::NotConfigured) } +/// Is browser sign-in usable on this server? +pub fn is_configured() -> bool { + secret().is_ok() +} + /// Begin sign-in: where to redirect, and the cookie to set. pub fn begin(issuer: &str, client_id: &str, scope: &str, secure: bool) -> Result<(String, String), SessionError> { let secret = secret()?; diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 7fdfd5e3..147dd721 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -7673,6 +7673,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 @@ -8087,6 +8091,10 @@ async fn main() { .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)) @@ -9321,3 +9329,27 @@ async fn oauth_logout(headers: axum::http::HeaderMap) -> axum::response::Respons ) .into_response() } + +/// `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, + headers: axum::http::HeaderMap, +) -> axum::Json { + use wifi_densepose_sensing_server::browser_session as bs; + let session = headers + .get(axum::http::header::COOKIE) + .and_then(|v| v.to_str().ok()) + .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()), + })) +} From 4a704acc02cf76a2b928befd94a1e6823895d13c Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Thu, 23 Jul 2026 10:41:11 +0200 Subject: [PATCH 19/27] =?UTF-8?q?fix(ui):=20actually=20call=20refreshSignI?= =?UTF-8?q?nPanel=20=E2=80=94=20VERIFIED=20IN=20A=20REAL=20BROWSER?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `refreshSignInPanel` was exported and never invoked. The Cognitum Account panel would have rendered "Checking…" forever and the Sign in button would never have reflected a completed sign-in. That is the FOURTH defect of this exact shape today — implemented, unit-tested, and never called. The others: the refresh path no command invoked, scope resolved only at write time, and a WebSocket listener with no auth layer. The pattern is consistent: the logic was fine, the caller was missing, and a green suite could not see it. Now refreshed on every panel open — not once at construction — because the session may have been established in another tab or expired since page load. Buttons are also bound at construction so a click works before the first status fetch resolves. VERIFIED END TO END IN A REAL BROWSER (Chrome, http://127.0.0.1:8099/ui/), which is the gap that has been open all session: 1. panel showed "This server requires sign-in." + Sign in with Cognitum 2. clicked -> auth.cognitum.one -> approved 3. server: "browser sign-in complete" sub=ed3efb51-… 4. DevTools: ruview_session cookie on 127.0.0.1, Path=/, HttpOnly, SameSite=Lax 5. after reload the panel reads: "Signed in as UyShaIh1B1gL7km9Xli9kCDSdRT2 — sensing:read" + Sign out So the browser half of gate G-3 is now proven, not argued: PKCE, consent, the server-side code exchange, ES256 verification with audience and scope checks, the signed session cookie, and the UI reading it back. Diagnosis note worth keeping: the first attempt appeared to fail because the browser tab had been open since BEFORE the fix — an already-loaded ES module stays in memory regardless of `Cache-Control: no-store`. The cookie had been stored correctly the whole time; the panel simply never re-queried. The server log showed zero /oauth/status probes, which is what distinguished "fetch never fired" from "fetch got the wrong answer". Temporary cookie diagnostics added to find that are removed here. Tests: 547 sensing-server lib, unchanged. Co-Authored-By: Ruflo & AQE --- ui/utils/quick-settings.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ui/utils/quick-settings.js b/ui/utils/quick-settings.js index 33c91212..7ae00f6b 100644 --- a/ui/utils/quick-settings.js +++ b/ui/utils/quick-settings.js @@ -113,6 +113,13 @@ export class QuickSettings { // Bind events this.panel.querySelector('.qs-close').addEventListener('click', () => this.close()); + // 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); @@ -221,6 +228,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() { From 43737941cb448c33bb766431f65d1f1f28315952 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Thu, 23 Jul 2026 10:46:43 +0200 Subject: [PATCH 20/27] fix(auth): two Set-Cookie headers were collapsing into one; clear the spent transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs, one of which I introduced while fixing the other and caught only by checking the actual response. 1. THE SPENT TRANSACTION COOKIE WAS NEVER CLEARED ON SUCCESS. `clear_transaction` was only used on error paths, so after a successful sign-in `ruview_oauth_txn` lingered for its full 10-minute TTL and every subsequent request carried a dead cookie. Visible in the logs as `names=ruview_oauth_txn,ruview_session`. Now cleared alongside issuing the session, and on logout too. 2. THE FIX FOR (1) BROKE SIGN-IN, AND THE TEST CAUGHT IT. Axum's array-of-tuples response form REPLACES same-name headers rather than appending. Adding a second `Set-Cookie` silently overwrote the first, so the logout response emitted only the transaction clear and — had this shipped — the CALLBACK would have emitted only the transaction clear too, dropping the session cookie and making a successful OAuth round-trip a no-op. Caught by looking at the real response headers rather than trusting the change: `curl -D-` showed one Set-Cookie where there should have been two. Now uses `axum::response::AppendHeaders`. Both cookies verified present. Two tests pin this: one DOCUMENTS the footgun (the array form collapses two Set-Cookie headers into one) and one asserts AppendHeaders emits both. The first exists so the next person to reach for the tidier-looking array form finds out here instead of in production. Also adds a cache-busting query to both redirect targets (`/ui/?signed_in=` and `?signed_out=`). Landing on the same URL let the browser restore the page from the back/forward cache with a stale panel, which is why signing in appeared not to work until a hard refresh — the session was established correctly every time, the page simply was not re-fetched. Tests: 549 sensing-server lib. Co-Authored-By: Ruflo & AQE --- .../src/browser_session.rs | 53 +++++++++++++++++++ .../wifi-densepose-sensing-server/src/main.rs | 35 ++++++++---- 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs index d2663668..4146834f 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs @@ -479,3 +479,56 @@ mod tests { assert!(!u.contains(".one//oauth"), "{u}"); } } + +/// Regression guard for a response-shape mistake that silently broke sign-in. +#[cfg(test)] +mod response_shape_tests { + use axum::response::IntoResponse; + + /// Axum's array-of-tuples form REPLACES same-name headers. Two `Set-Cookie` + /// entries collapse to one — which, on the sign-in callback, dropped the + /// session cookie and made a successful OAuth round-trip a no-op. Only the + /// last cookie survived. + #[test] + fn an_array_of_headers_silently_drops_a_duplicate_set_cookie() { + let resp = ( + axum::http::StatusCode::FOUND, + [ + (axum::http::header::SET_COOKIE, "a=1".to_string()), + (axum::http::header::SET_COOKIE, "b=2".to_string()), + ], + ) + .into_response(); + assert_eq!( + resp.headers() + .get_all(axum::http::header::SET_COOKIE) + .iter() + .count(), + 1, + "documenting the footgun: the array form replaces, it does not append" + ); + } + + /// `AppendHeaders` is what actually emits both. + #[test] + fn append_headers_emits_every_set_cookie() { + let resp = ( + axum::http::StatusCode::FOUND, + axum::response::AppendHeaders([ + (axum::http::header::LOCATION, "/ui/".to_string()), + (axum::http::header::SET_COOKIE, "a=1".to_string()), + (axum::http::header::SET_COOKIE, "b=2".to_string()), + ]), + ) + .into_response(); + let cookies: Vec<_> = resp + .headers() + .get_all(axum::http::header::SET_COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .collect(); + assert_eq!(cookies.len(), 2, "both cookies must reach the browser"); + assert!(cookies.contains(&"a=1") && cookies.contains(&"b=2")); + assert!(resp.headers().get(axum::http::header::LOCATION).is_some()); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 147dd721..7766b82e 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -9302,12 +9302,19 @@ async fn oauth_callback( }; 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, - [ - (axum::http::header::LOCATION, "/ui/".to_string()), + // 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() } @@ -9317,19 +9324,27 @@ async fn oauth_logout(headers: axum::http::HeaderMap) -> axum::response::Respons // 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::http::header::LOCATION, "/ui/".to_string()), - ( - axum::http::header::SET_COOKIE, - wifi_densepose_sensing_server::browser_session::clear_session(secure), - ), - ], + 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 From 9b9754778f64040d84750ba78aaee8f09d7a9f11 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Thu, 23 Jul 2026 11:20:13 +0200 Subject: [PATCH 21/27] fix(ui): service worker cached /oauth/status, freezing browser sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser sign-in worked, but the settings panel kept offering "Sign in with Cognitum" afterwards; only a hard reload showed the true state. The server was correct throughout. Root cause: `ui/sw.js` routed cache-first as its CATCH-ALL for every path outside `/api/` and `/health/`. `/oauth/status` therefore had its first (signed-out) response stored in the Cache API and replayed to the page forever. The Cache API is not the HTTP cache and ignores `Cache-Control` entirely, so the `no-store, no-cache, must-revalidate` the server already sends could not prevent it. A hard reload bypasses the service worker, which is why that alone appeared to fix it, and why signing out re-poisoned the entry. Fixes, in order of blast radius: - `/oauth/` is never handled by the worker. Not network-first — that still writes a copy, which would be replayed the moment the server is briefly unreachable, silently reinstating a stale sign-in state. - Cache-first is now an ALLOWLIST (navigations and static asset extensions) rather than the catch-all. This is the underlying defect: any endpoint added outside `/api/` was frozen on its first response. Unrecognised paths now go to the network untouched. - `CACHE_NAME` bumped to `ruview-v2`, so `activate` evicts the poisoned `ruview-v1` from browsers that already ran the old worker. Verified: the cache list went from `[ruview-v1]` to `[ruview-v2]` on update. - Shell lookups use `ignoreSearch` and store a search-less key, so the `?signed_in=` the callback redirects to does not mint a fresh, never-hit cache entry per sign-in. Also: re-check sign-in state on `pageshow` (bfcache restore, where no script re-runs) and on `visibilitychange` (signed in or out in another tab). Opening the panel alone is not sufficient — it may already be open. Verification, in a real browser driven end-to-end: - Reproduced with a valid session cookie: server returned `signed_in: true` to curl while the page's own fetch got a cached `signed_in: false`, and the request never appeared in the server log at all. - Confirmed the cached entry existed: `caches.open('ruview-v1').match( '/oauth/status')` returned the signed-out body. - After the fix, on a NORMAL (not hard) reload the panel reads "Signed in as - sensing:read" with Sign out shown. Tests: `ui/sw.test.mjs` loads the real `sw.js` with stubbed worker globals and asserts the routing decision per path. 4 of its 10 tests fail against the pre-fix worker and pass after; the other 6 pin pre-existing guards (non-GET, websocket upgrade, cross-origin, API paths, static assets, navigation) and pass in both, so they track behaviour rather than the rewrite. CI: adds a `ui-tests` job. Nothing ran the UI JavaScript before, so both this suite and the ADR-272 ws-ticket suite would have rotted unexecuted — which is the same blind spot that let this defect ship. Removes the temporary `/oauth/status` cookie logging and the panel console diagnostic added while tracking this down. Co-Authored-By: Ruflo & AQE --- .github/workflows/ci.yml | 22 +++ ui/services/ws-ticket.test.mjs | 3 +- ui/sw.js | 45 ++++- ui/sw.test.mjs | 164 ++++++++++++++++++ ui/utils/quick-settings.js | 14 +- .../wifi-densepose-sensing-server/src/main.rs | 6 +- 6 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 ui/sw.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0090af50..58900321 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -171,6 +171,28 @@ jobs: - name: ADR-135 calibration witness proof (determinism guard) run: bash scripts/verify-calibration-proof.sh + # 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: diff --git a/ui/services/ws-ticket.test.mjs b/ui/services/ws-ticket.test.mjs index 00f04176..e96ea4df 100644 --- a/ui/services/ws-ticket.test.mjs +++ b/ui/services/ws-ticket.test.mjs @@ -1,6 +1,7 @@ // Executed tests for the WebSocket ticket helper (ADR-272). // -// Run: node --test ui/services/ +// 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 diff --git a/ui/sw.js b/ui/sw.js index eb84e2b4..8e2bfa3f 100644 --- a/ui/sw.js +++ b/ui/sw.js @@ -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,40 @@ 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))) 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=`, 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 { diff --git a/ui/sw.test.mjs b/ui/sw.test.mjs new file mode 100644 index 00000000..cbe42f90 --- /dev/null +++ b/ui/sw.test.mjs @@ -0,0 +1,164 @@ +// 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 = []; + + const cacheStub = { + addAll: async () => {}, + put: async (req, res) => { cachePuts.push(String(req.url ?? req)); return undefined; }, + keys: async () => [], + 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 }; +} + +/** + * 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, { method = 'GET', mode = 'cors', headers = {} } = {}) { + const { listeners } = loadServiceWorker(); + let handled = false; + const request = { + url: `${ORIGIN}${path}`, + method, + mode, + headers: { get: (k) => headers[k] ?? headers[k.toLowerCase()] ?? null }, + }; + listeners.fetch({ request, respondWith: () => { handled = true; } }); + return handled; +} + +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 handled, so offline fallback survives', () => { + 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); +}); + +// --- 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)); +}); diff --git a/ui/utils/quick-settings.js b/ui/utils/quick-settings.js index 7ae00f6b..480ad1cb 100644 --- a/ui/utils/quick-settings.js +++ b/ui/utils/quick-settings.js @@ -77,7 +77,7 @@ export class QuickSettings {
Cognitum Account
- Checking\u2026 + Checking...
@@ -113,6 +113,16 @@ 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') @@ -293,7 +303,7 @@ export async function refreshSignInPanel(root = document) { if (info.signed_in) { status.textContent = `Signed in${info.account ? ` as ${info.account}` : ''}${ - info.scope ? ` \u2014 ${info.scope}` : '' + info.scope ? ` - ${info.scope}` : '' }`; signIn.hidden = true; signOut.hidden = false; diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 7766b82e..fcde93ea 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -9355,10 +9355,10 @@ async fn oauth_status( headers: axum::http::HeaderMap, ) -> axum::Json { use wifi_densepose_sensing_server::browser_session as bs; - let session = headers + let raw = headers .get(axum::http::header::COOKIE) - .and_then(|v| v.to_str().ok()) - .and_then(bs::from_cookie_header); + .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(), From c72bbc15dd7ebd9e3a525aaa5cb152b0b3fc799c Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Thu, 23 Jul 2026 11:48:59 +0200 Subject: [PATCH 22/27] fix(auth): close /api/field bypass, two fail-opens, and a self-disarming test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 13 ++ ui/sw.js | 61 +++++++-- ui/sw.test.mjs | 80 +++++++++-- v2/crates/ruview-auth/src/login/store.rs | 65 ++++++++- v2/crates/wifi-densepose-cli/src/auth.rs | 52 +++++++- .../src/bearer_auth.rs | 109 ++++++++++++++- .../src/browser_session.rs | 21 ++- .../tests/auth_wiring.rs | 126 +++++++++++++----- 8 files changed, 459 insertions(+), 68 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58900321..2bbdc260 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -171,6 +171,19 @@ 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. diff --git a/ui/sw.js b/ui/sw.js index 8e2bfa3f..f47d00cc 100644 --- a/ui/sw.js +++ b/ui/sw.js @@ -97,7 +97,16 @@ self.addEventListener('fetch', (event) => { // 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))) return; + 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/')) { @@ -140,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)) + ); +} diff --git a/ui/sw.test.mjs b/ui/sw.test.mjs index cbe42f90..7a2f59ff 100644 --- a/ui/sw.test.mjs +++ b/ui/sw.test.mjs @@ -33,10 +33,18 @@ 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) => { cachePuts.push(String(req.url ?? req)); return undefined; }, - keys: 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, }; @@ -63,7 +71,7 @@ function loadServiceWorker() { vm.createContext(sandbox); vm.runInContext(SW_SOURCE, sandbox); - return { listeners, cachePuts, sandbox }; + return { listeners, cachePuts, sandbox, entries }; } /** @@ -72,17 +80,28 @@ function loadServiceWorker() { * request goes to the network untouched, which is the only safe outcome for a * credentialed endpoint. */ -function route(path, { method = 'GET', mode = 'cors', headers = {} } = {}) { - const { listeners } = loadServiceWorker(); +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 }, }; - listeners.fetch({ request, respondWith: () => { handled = true; } }); - return handled; + worker.listeners.fetch({ + request, + respondWith: (p) => { handled = true; responded = p; }, + waitUntil: (p) => { waited.push(p); }, + }); + return { handled, responded, waited, worker }; } let sw; @@ -123,7 +142,8 @@ test('a navigation request is still served cache-first', () => { assert.equal(route('/ui/', { mode: 'navigate' }), true); }); -test('API paths are still handled, so offline fallback survives', () => { +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); }); @@ -153,6 +173,50 @@ test('cross-origin requests are ignored', () => { 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', () => { diff --git a/v2/crates/ruview-auth/src/login/store.rs b/v2/crates/ruview-auth/src/login/store.rs index 17a058a6..80e360e3 100644 --- a/v2/crates/ruview-auth/src/login/store.rs +++ b/v2/crates/ruview-auth/src/login/store.rs @@ -228,11 +228,19 @@ pub fn save(path: &Path, creds: &StoredCredentials) -> Result<(), StoreError> { let json = serde_json::to_vec_pretty(creds).expect("credentials serialize"); let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, &json).map_err(|source| StoreError::Unwritable { + // 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, })?; - restrict_permissions(&tmp)?; std::fs::rename(&tmp, path).map_err(|source| StoreError::Unwritable { path: path.to_path_buf(), source, @@ -240,6 +248,29 @@ pub fn save(path: &Path, creds: &StoredCredentials) -> Result<(), StoreError> { 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; @@ -557,6 +588,36 @@ mod tests { 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"); diff --git a/v2/crates/wifi-densepose-cli/src/auth.rs b/v2/crates/wifi-densepose-cli/src/auth.rs index 080cb6c2..eb97b4b8 100644 --- a/v2/crates/wifi-densepose-cli/src/auth.rs +++ b/v2/crates/wifi-densepose-cli/src/auth.rs @@ -60,14 +60,24 @@ fn path_or_default(p: Option) -> PathBuf { p.unwrap_or_else(login::default_credentials_path) } -pub async fn login_cmd(args: LoginArgs) -> anyhow::Result<()> { - let scope = if args.admin { +/// 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), @@ -136,3 +146,39 @@ pub async fn whoami_cmd(args: WhoamiArgs) -> anyhow::Result<()> { } 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()); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs index dd4e5b7d..34fc339b 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs @@ -86,18 +86,78 @@ pub const DEFAULT_CLIENT_ID: &str = "ruview"; fn allowed_client_ids() -> Vec { match std::env::var(OAUTH_CLIENT_IDS_ENV) { Ok(v) if v.trim() == "*" => Vec::new(), // explicit opt-out - Ok(v) if !v.trim().is_empty() => v - .split(',') - .map(|c| c.trim().to_string()) - .filter(|c| !c.is_empty()) - .collect(), + Ok(v) if !v.trim().is_empty() => { + let parsed: Vec = v + .split(',') + .map(|c| c.trim().to_string()) + .filter(|c| !c.is_empty()) + .collect(); + // An empty Vec is the OPT-OUT sentinel downstream: `verify.rs` skips + // the audience check entirely when the allowlist is empty. So a value + // that is non-empty but parses to nothing — `","`, `" , "`, a stray + // trailing comma — would silently disable the audience boundary and + // admit a token minted for any other Cognitum product. Only the + // literal `*` may turn that check off. + if parsed.is_empty() { + tracing::warn!( + value = %v, + "{OAUTH_CLIENT_IDS_ENV} is set but lists no client id; falling back to \ + {DEFAULT_CLIENT_ID}. Use `*` if you really mean to accept any client." + ); + return vec![DEFAULT_CLIENT_ID.to_string()]; + } + parsed + } _ => vec![DEFAULT_CLIENT_ID.to_string()], } } /// Path prefix the middleware protects when auth is enabled. +/// +/// Retained because it names the bulk of the protected surface and is asserted +/// in tests, but it is NO LONGER the rule. The gate is [`is_anonymous`] — +/// deny-by-default. See that function for why. pub const PROTECTED_PREFIX: &str = "/api/v1/"; +/// Paths that stay reachable with no credential when auth is enabled. +/// +/// # Why an allowlist +/// +/// The gate used to be the inverse: protect `/api/v1/*`, let everything else +/// through. That is exposure-by-default, and it leaked. `/api/field` — the REST +/// sibling of `/ws/field`, serving the same signed `FieldEvent` stream of live +/// presence, pose and vitals — sits at `/api/field`, not `/api/v1/`, so it +/// returned `200` with no credential on BOTH listeners while `/api/v1/models` +/// correctly returned `401`. `/ws/field` was gated in this same PR; its REST +/// twin one path segment over was not. +/// +/// Measured, with `RUVIEW_API_TOKEN` set and no credential supplied: +/// `/api/v1/models` -> 401, `/ws/field` -> 401, `/api/field` -> 200 on :8080 +/// and :8765. +/// +/// With an allowlist, a route added at a new path is gated because nobody +/// remembered to expose it, rather than exposed because nobody remembered to +/// protect it. That is the same inversion already applied to the scope gate in +/// [`required_scope_for`]. +const ANONYMOUS_PREFIXES: &[&str] = &[ + // Orchestrator and load-balancer probes. Documented exemption (ADR-272), + // pinned by `health_stays_anonymous_on_both_listeners`. + "/health", + // Sign-in cannot require being signed in. + "/oauth/", +]; + +/// Is this path reachable without a credential? See [`ANONYMOUS_PREFIXES`]. +pub fn is_anonymous(path: &str) -> bool { + // The dashboard shell itself, mounted with `nest_service("/ui", …)`. It has + // to load for the user to reach the sign-in button at all; the data it then + // fetches is what is protected. + if path == "/" || path == "/ui" || path.starts_with("/ui/") { + return true; + } + ANONYMOUS_PREFIXES.iter().any(|p| path.starts_with(p)) +} + /// WebSocket upgrade endpoints. Previously ungated — `/ws/*` sat outside /// [`PROTECTED_PREFIX`] and `/api/v1/stream/pose` was an explicit exemption — /// because a browser's `WebSocket` constructor cannot attach an @@ -489,7 +549,7 @@ pub async fn require_bearer( } // No ticket: fall through to the bearer path below, which is how a // native (non-browser) client authenticates a WebSocket. - } else if !path.starts_with(PROTECTED_PREFIX) { + } else if is_anonymous(&path) { return next.run(request).await; } @@ -655,6 +715,43 @@ mod tests { }; use tower::ServiceExt; + /// ONE test, not three, because `allowed_client_ids` reads process-global + /// env and cargo runs tests on parallel threads — three tests mutating + /// `RUVIEW_OAUTH_CLIENT_IDS` race and fail intermittently. + #[test] + fn client_id_allowlist_parsing_never_silently_opts_out() { + let read = |v: &str| { + std::env::set_var(OAUTH_CLIENT_IDS_ENV, v); + let ids = allowed_client_ids(); + std::env::remove_var(OAUTH_CLIENT_IDS_ENV); + ids + }; + + // An empty allowlist is the OPT-OUT sentinel in `verify.rs` — it skips + // the audience check entirely. So a value that is non-empty but parses + // to nothing (a stray trailing comma, `","`) would silently turn the + // boundary off and admit a token minted for any other Cognitum product. + // Same fail-open shape as the scope denylist this PR already inverted. + for bad in [",", " , ", ",,,", " "] { + let ids = read(bad); + assert!( + !ids.is_empty(), + "{bad:?} produced an empty allowlist, which disables the audience check" + ); + assert_eq!(ids, vec![DEFAULT_CLIENT_ID.to_string()]); + } + + // The deliberate escape hatch must keep working, or the fix above would + // be a behaviour change wearing a security label. + assert!(read("*").is_empty(), "`*` must remain the way to accept any client"); + + // And a real list must still parse, trailing comma and all. + assert_eq!( + read(" ruview , musica ,"), + vec!["ruview".to_string(), "musica".to_string()] + ); + } + fn ok_handler() -> Router { Router::new() .route("/health", get(|| async { "ok" })) diff --git a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs index 4146834f..6e67e6b9 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs @@ -214,13 +214,26 @@ fn write_secret(path: &Path, value: &str) -> std::io::Result<()> { std::fs::create_dir_all(dir)?; } let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, value)?; + // Created 0600, not written-then-chmodded. `fs::write` creates at + // `0666 & !umask`, so this file — the HMAC key for EVERY browser session — + // was world-readable for the window before the chmod. Anyone who read it + // could forge a session cookie for any account with any scope, including + // `sensing:admin`, which is strictly worse than stealing one session. #[cfg(unix)] { - use std::os::unix::fs::PermissionsExt; - // Restrict BEFORE publishing the path, as with the CLI's credentials. - std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?; + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let _ = std::fs::remove_file(&tmp); + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&tmp)?; + f.write_all(value.as_bytes())?; + f.sync_all()?; } + #[cfg(not(unix))] + std::fs::write(&tmp, value)?; std::fs::rename(&tmp, path) } diff --git a/v2/crates/wifi-densepose-sensing-server/tests/auth_wiring.rs b/v2/crates/wifi-densepose-sensing-server/tests/auth_wiring.rs index 66f26da3..6dbf9633 100644 --- a/v2/crates/wifi-densepose-sensing-server/tests/auth_wiring.rs +++ b/v2/crates/wifi-densepose-sensing-server/tests/auth_wiring.rs @@ -57,7 +57,15 @@ impl Drop for Server { impl Server { /// Spawn the real binary. `env` lets a test choose the auth configuration. - fn start(env: &[(&str, &str)]) -> Option { + /// + /// 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([ @@ -74,27 +82,45 @@ impl Server { .env_remove("RUVIEW_OAUTH_ISSUER") .env_remove("RUVIEW_WS_LEGACY_UNAUTHENTICATED") .stdout(Stdio::null()) - .stderr(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 child = cmd.spawn().ok()?; - let server = Server { child, http, ws }; - server.await_ready().then_some(server) + 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(&self) -> bool { - let deadline = Instant::now() + Duration::from_secs(30); - while Instant::now() < deadline { - if TcpStream::connect(("127.0.0.1", self.http)).is_ok() - && TcpStream::connect(("127.0.0.1", self.ws)).is_ok() - { - return true; - } - std::thread::sleep(Duration::from_millis(200)); +} + +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; } - false + std::thread::sleep(Duration::from_millis(200)); } + false } /// One raw HTTP/1.1 request; returns the status code. @@ -154,12 +180,52 @@ fn ws_upgrade(port: u16, path: &str, bearer: Option<&str>) -> u16 { /// 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 Some(server) = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]) else { - eprintln!("skipping: sensing-server did not start"); - return; - }; + 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. @@ -188,10 +254,7 @@ fn with_auth_on_no_listener_accepts_an_unauthenticated_websocket() { #[test] fn a_bearer_on_the_upgrade_is_accepted_on_both_listeners() { - let Some(server) = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]) else { - eprintln!("skipping: sensing-server did not start"); - return; - }; + 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)] { @@ -206,10 +269,7 @@ fn a_bearer_on_the_upgrade_is_accepted_on_both_listeners() { #[test] fn with_auth_off_both_listeners_stay_open() { // The compatibility promise: an unconfigured deployment sees no change. - let Some(server) = Server::start(&[]) else { - eprintln!("skipping: sensing-server did not start"); - return; - }; + 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!( @@ -222,13 +282,10 @@ fn with_auth_off_both_listeners_stay_open() { #[test] fn the_legacy_escape_hatch_opens_websockets_without_weakening_rest() { - let Some(server) = Server::start(&[ + let server = Server::start(&[ ("RUVIEW_API_TOKEN", TOKEN), ("RUVIEW_WS_LEGACY_UNAUTHENTICATED", "1"), - ]) else { - eprintln!("skipping: sensing-server did not start"); - return; - }; + ]); // 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!( @@ -249,10 +306,7 @@ fn the_legacy_escape_hatch_opens_websockets_without_weakening_rest() { 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 Some(server) = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]) else { - eprintln!("skipping: sensing-server did not start"); - return; - }; + let server = Server::start(&[("RUVIEW_API_TOKEN", TOKEN)]); for (label, port) in [("http", server.http), ("ws", server.ws)] { assert_eq!( status(port, "GET", "/health", &[]), From 6ce50d51587533a00261578886c44b30818204e1 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Thu, 23 Jul 2026 12:08:13 +0200 Subject: [PATCH 23/27] test(auth): cover browser sign-in; ADR-271/272 corrections and remediation plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser sign-in was the newest security surface in this PR and had no executable evidence behind it: browser_session.rs was 534 lines with 13 tests, every one of which hit a private helper (sign, unsign, cookie, read_cookie, is_live, has_scope). No test called issue, from_cookie_header, begin, verifier_for_callback or is_configured, and no test anywhere presented a session cookie to the gate. +10 tests in browser_session, +6 in bearer_auth. Three mutants the adversarial review named, each now verified dead by actually applying the mutation: (a) delete the `state` comparison in verifier_for_callback -> a_callback_whose_state_does_not_match_is_refused FAILED Without it the callback accepts a code from a flow the user never started: login CSRF, victim silently lands in the attacker's session. (b) `session.is_live().then_some(session)` -> `Some(session)` -> an_expired_session_cookie_does_not_authenticate FAILED -> an_expired_browser_session_is_refused FAILED `is_live` was already unit-tested; nothing asserted the CALLER consults it. Same "tested in isolation, call site untested" shape as the earlier refresh-never-invoked defect. (c) `session.has_scope(required)` -> `true` -> a_read_scoped_browser_session_cannot_delete_or_train FAILED Without it any browser session could delete models and start training. Mutant (c) initially appeared to SURVIVE. It did not — there are two has_scope call sites and the first substitution only hit one. Mutating the one in `session_or_unauthorized` kills the test. That accident confirmed a separate finding: the cookie branch inside require_bearer is unreachable when an Authorization header is present, because the OAuth step returns on both arms. It fails closed, so it is not a hole, but "try the next credential" is what the code reads like. Pinned by a_bad_bearer_beats_a_good_cookie_rather_than_falling_back. Adds two crate-internal test seams (init_secret_for_tests, test_cookie_value). test_cookie_value signs through the same path as `issue`, so tests presenting a cookie exercise real verification rather than a test-only bypass. ADR-271: - The "browser cannot obtain an OAuth token" section asserted `grep -ril "oauth|cognitum|pkce" ui/` returns nothing. It now returns three files, invalidated by commits in this same PR. Marked superseded, original retained under a fold, replaced with what actually ships. - Records the two deferred decisions with designs rather than patches: P1 the blocking JWKS fetch on a tokio worker (whose rate limiter is bypassed on exactly the stale path that matters, because fetched_at updates only on success — so after the TTL every request fetches, and a Pi that loses WAN stalls itself with no attacker present); P2 the 12-hour session from a 15-minute token, with three costed options. Capping the session to sensing:read was considered and rejected: the dashboard genuinely issues DELETE /api/v1/models/{id}. - P3 records that dropping `__Host-` costs origin-integrity, not just Secure — read_cookie takes the FIRST match and cookies are not port-scoped, so a same-host writer can shadow a session. Forgery was never the threat that prefix addresses. - Documents redirect_uri's hardcoded default and the unconsumed CLI credential as known-incomplete, per decision to leave both as-is. ADR-272: corrected a claim that would mislead users into a 401. It stated the Python client DOES send Authorization: Bearer on the handshake; ws.py passes no headers at all (zero occurrences of extra_headers or Authorization), so every published client 401s once auth is enabled. Server-side decision unchanged. Verified: sensing-server 566 + 179 + 7 + 5 + 8 + 4 + 16 pass under CI flags, ruview-auth 61 + 25 + 2 with --all-features, auth_wiring 7. Co-Authored-By: Ruflo & AQE --- .../ADR-271-cognitum-oauth-resource-server.md | 162 +++++++++++++- ...DR-272-websocket-authentication-tickets.md | 22 +- .../src/bearer_auth.rs | 123 +++++++++++ .../src/browser_session.rs | 202 ++++++++++++++++++ 4 files changed, 502 insertions(+), 7 deletions(-) diff --git a/docs/adr/ADR-271-cognitum-oauth-resource-server.md b/docs/adr/ADR-271-cognitum-oauth-resource-server.md index a89ec047..6736deeb 100644 --- a/docs/adr/ADR-271-cognitum-oauth-resource-server.md +++ b/docs/adr/ADR-271-cognitum-oauth-resource-server.md @@ -170,7 +170,18 @@ take no HTTP dependency at all. - 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 +## ~~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. + +
+Original text (no longer accurate) `wifi-densepose login` writes to `~/.ruview/credentials.json` — a file a browser cannot read. The UI's `ws-ticket.js` reads a bearer from @@ -184,9 +195,152 @@ 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. -Deliberately recorded rather than left implied, because the ADRs read as though -the browser path exists. Closing it needs a UI sign-in flow that puts an OAuth -access token where the page can reach it — a separate piece of work. +
+ +## 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 and proposed remediation + +Two findings from the 2026-07-23 adversarial review are **not fixed in this +work**. Both are recorded here with a proposed design rather than patched in a +hurry, because each changes a runtime property that deserves its own decision. + +### P1 — the JWKS fetch blocks a tokio worker, and the stale path is unbounded + +`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 + +`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. + +Capping the session at `sensing:read` was considered and **rejected**: the +dashboard genuinely performs admin operations (`model.service.js:136` issues +`DELETE /api/v1/models/{id}`), so that would break shipped functionality. + +**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. | + +**Recommendation: A now, C next.** A is a one-line change that bounds the +window immediately; C is the design that actually matches the risk, since the +damage a stale session can do is concentrated in the mutating routes. B is only +worth it if RuView later needs true cross-device sign-out. + +Whichever is chosen, `SESSION_TTL_SECS` should be pinned by a test asserting the +issued cookie's `Max-Age` matches the session's `exp`, so the two cannot drift. + +### P3 — dropping `__Host-` costs cookie origin-integrity, not just `Secure` + +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=; 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 diff --git a/docs/adr/ADR-272-websocket-authentication-tickets.md b/docs/adr/ADR-272-websocket-authentication-tickets.md index 48a0bead..8b5a5024 100644 --- a/docs/adr/ADR-272-websocket-authentication-tickets.md +++ b/docs/adr/ADR-272-websocket-authentication-tickets.md @@ -50,9 +50,25 @@ 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 send a normal -`Authorization: Bearer` on the handshake. Routing them through a ticket would -add a round-trip and a second credential path for no benefit. +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 diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs index 34fc339b..045f8ca7 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs @@ -1155,6 +1155,129 @@ mod oauth_tests { .status() } + /// Same as [`call`] but presents a browser session cookie instead of a + /// bearer. The cookie is minted through `browser_session`'s real signing + /// path, so this exercises genuine verification. + async fn call_with_session(auth: AuthState, method: &str, path: &str, cookie: &str) -> StatusCode { + let req = Request::builder() + .method(method) + .uri(path) + .header(axum::http::header::COOKIE, cookie); + app(auth) + .oneshot(req.body(Body::empty()).unwrap()) + .await + .unwrap() + .status() + } + + fn session_cookie(scope_claim: &str, ttl: i64) -> String { + format!( + "ruview_session={}", + crate::browser_session::test_cookie_value("sub-b", "acct-b", scope_claim, ttl) + ) + } + + // ── browser session cookie as a credential ──────────────────────── + // + // The session cookie authorizes /api/v1/* and WebSocket upgrades, and had + // no test presenting one at any level — the newest credential in the system + // was the one with no executable evidence behind it. + + #[tokio::test] + async fn a_read_scoped_browser_session_can_read() { + let c = session_cookie(scope::SENSING_READ, 3600); + assert_eq!( + call_with_session(oauth_only(), "GET", "/api/v1/models", &c).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn a_read_scoped_browser_session_cannot_delete_or_train() { + // MUTANT THIS KILLS: replacing `session.has_scope(required)` with + // `true` in the cookie branch. Without this, anyone signed in through + // the browser — the least-bound credential in the system, host-only + // with no proof-of-possession — could delete models and recordings and + // start training, regardless of what they consented to. + let c = session_cookie(scope::SENSING_READ, 3600); + for (method, path) in [ + ("DELETE", "/api/v1/models/m1"), + ("DELETE", "/api/v1/recording/r1"), + ("POST", "/api/v1/train/start"), + ] { + assert_eq!( + call_with_session(oauth_only(), method, path, &c).await, + StatusCode::UNAUTHORIZED, + "{method} {path} accepted a read-only browser session" + ); + } + } + + #[tokio::test] + async fn an_admin_scoped_browser_session_can_delete() { + // The negative above must not pass merely because cookies never work. + let c = session_cookie( + &format!("{} {}", scope::SENSING_READ, scope::SENSING_ADMIN), + 3600, + ); + assert_eq!( + call_with_session(oauth_only(), "DELETE", "/api/v1/models/m1", &c).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn an_expired_browser_session_is_refused() { + let c = session_cookie(scope::SENSING_READ, -1); + assert_eq!( + call_with_session(oauth_only(), "GET", "/api/v1/models", &c).await, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn a_bad_bearer_beats_a_good_cookie_rather_than_falling_back() { + // Pins REAL precedence, which is not what the ordering comment in + // `require_bearer` implies. When an Authorization header is present the + // OAuth step returns on BOTH arms, so the cookie branch that follows it + // is unreachable — a browser holding a valid session that also sends a + // stale bearer gets 401 rather than falling back to its cookie. + // + // Verified empirically: mutating that branch's `has_scope` to `true` + // changes no test outcome, while mutating the one in + // `session_or_unauthorized` fails `a_read_scoped_browser_session_ + // cannot_delete_or_train`. + // + // This fails CLOSED, so it is not a hole — but it was undocumented and + // untested, and "try the next credential" is what the code reads like. + // Pinned here so changing it is a decision rather than an accident. + let c = session_cookie(scope::SENSING_READ, 3600); + let req = Request::builder() + .method("GET") + .uri("/api/v1/models") + .header(AUTHORIZATION, "Bearer not-a-valid-token") + .header(axum::http::header::COOKIE, &c); + let status = app(oauth_only()) + .oneshot(req.body(Body::empty()).unwrap()) + .await + .unwrap() + .status(); + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "a presented bearer is authoritative; the cookie is not consulted after it fails" + ); + } + + #[tokio::test] + async fn a_forged_browser_session_is_refused() { + let c = "ruview_session=Zm9yZ2Vk.bm90LWEtdmFsaWQtbWFj"; + assert_eq!( + call_with_session(oauth_only(), "GET", "/api/v1/models", c).await, + StatusCode::UNAUTHORIZED + ); + } + fn oauth_only() -> AuthState { AuthState { token: None, diff --git a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs index 6e67e6b9..ff86c559 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs @@ -369,12 +369,214 @@ pub fn from_cookie_header(cookie_header: &str) -> Option { session.is_live().then_some(session) } +/// Install a usable signing secret for tests in this crate. +/// +/// Idempotent: `SECRET` is a `OnceLock`, so whichever test gets there first +/// wins and the rest reuse it. Nothing here depends on the secret's VALUE, only +/// on the process having one, so the race is benign. +#[cfg(test)] +pub(crate) fn init_secret_for_tests() { + let _ = SECRET.set(Some("crate-test-session-secret".to_string())); +} + +/// Mint a session cookie VALUE (not a `Set-Cookie` header) for tests elsewhere +/// in this crate — `bearer_auth`, which needs to present one. +/// +/// Deliberately goes through the same `sign` path as [`issue`], so a test that +/// presents this is exercising the real verification path rather than a +/// test-only bypass. `ttl` may be negative to forge an already-expired session. +#[cfg(test)] +pub(crate) fn test_cookie_value(subject: &str, account_id: &str, scope: &str, ttl: i64) -> String { + init_secret_for_tests(); + let secret = secret().expect("secret installed above"); + let session = BrowserSession { + subject: subject.to_string(), + account_id: account_id.to_string(), + scope: scope.to_string(), + exp: now() + ttl, + }; + sign(&serde_json::to_vec(&session).expect("serializes"), &secret) +} + #[cfg(test)] mod tests { use super::*; const SECRET: &str = "test-secret-value"; + /// Pull a cookie's value out of a `Set-Cookie` header, as a browser would + /// when later sending it back in a `Cookie:` header. + fn value_of(set_cookie: &str) -> String { + set_cookie + .split(';') + .next() + .and_then(|kv| kv.split_once('=')) + .map(|(_, v)| v.to_string()) + .expect("Set-Cookie has name=value") + } + + fn query_param(url: &str, key: &str) -> String { + url.split(['?', '&']) + .find_map(|p| p.strip_prefix(&format!("{key}="))) + .unwrap_or_else(|| panic!("{key} missing from {url}")) + .to_string() + } + + // ── public API: the surface that had no tests at all ────────────── + // + // Every test below this line covers a PUBLIC function. The tests that + // already existed all targeted private helpers (sign, unsign, cookie, + // read_cookie, is_live, has_scope), so `issue`, `from_cookie_header`, + // `begin`, `verifier_for_callback` and `is_configured` — the entire + // browser sign-in flow — had no executable evidence behind them. + + #[test] + fn a_server_with_a_secret_reports_browser_sign_in_as_available() { + init_secret_for_tests(); + assert!(is_configured(), "sign-in must be offered once a secret exists"); + } + + #[test] + fn begin_produces_an_authorize_url_carrying_every_required_parameter() { + init_secret_for_tests(); + let (url, set_cookie) = + begin("https://auth.cognitum.one/", "ruview", "sensing:read", false).unwrap(); + + assert!(url.starts_with("https://auth.cognitum.one/oauth/authorize?"), "{url}"); + assert!(url.contains("response_type=code"), "{url}"); + assert!(url.contains("client_id=ruview"), "{url}"); + // S256 only — the AS rejects `plain`, so getting this wrong is a + // sign-in that always fails. + assert!(url.contains("code_challenge_method=S256"), "{url}"); + assert!(!query_param(&url, "code_challenge").is_empty(), "{url}"); + assert!(!query_param(&url, "state").is_empty(), "{url}"); + // The scope's space must survive encoding or the AS sees one scope. + let (u2, _) = begin("https://a.example", "ruview", "sensing:read sensing:admin", false).unwrap(); + assert!(u2.contains("sensing%3Aread%20sensing%3Aadmin"), "{u2}"); + + // The verifier must never be in the URL — only its S256 hash. + assert!(set_cookie.starts_with(TXN_COOKIE), "{set_cookie}"); + assert!(set_cookie.contains("HttpOnly"), "the verifier must not be script-readable"); + } + + #[test] + fn the_callback_returns_the_verifier_when_the_state_matches() { + init_secret_for_tests(); + let (url, set_cookie) = begin("https://a.example", "ruview", "sensing:read", false).unwrap(); + let state = query_param(&url, "state"); + let header = format!("{TXN_COOKIE}={}", value_of(&set_cookie)); + + let verifier = verifier_for_callback(&header, &state).expect("matching state"); + assert!(verifier.len() >= 43, "PKCE verifier looks too short: {}", verifier.len()); + } + + #[test] + fn a_callback_whose_state_does_not_match_is_refused() { + // MUTANT THIS KILLS: deleting the `state` comparison in + // `verifier_for_callback`. Without it the callback accepts a code from + // a flow the user never started — login CSRF: an attacker completes + // their own authorization, feeds the victim the resulting callback URL, + // and the victim's browser silently ends up in the ATTACKER's session. + init_secret_for_tests(); + let (_url, set_cookie) = begin("https://a.example", "ruview", "sensing:read", false).unwrap(); + let header = format!("{TXN_COOKIE}={}", value_of(&set_cookie)); + + assert!(matches!( + verifier_for_callback(&header, "state-from-a-different-flow"), + Err(SessionError::StateMismatch) + )); + // Empty is the degenerate case a naive comparison lets through. + assert!(matches!( + verifier_for_callback(&header, ""), + Err(SessionError::StateMismatch) + )); + } + + #[test] + fn a_callback_with_no_transaction_or_a_forged_one_is_refused() { + init_secret_for_tests(); + // No cookie at all. + assert!(matches!( + verifier_for_callback("other=1", "any"), + Err(SessionError::InvalidTransaction) + )); + // Present but not signed by us: an attacker choosing their own verifier + // would defeat PKCE entirely. + assert!(matches!( + verifier_for_callback(&format!("{TXN_COOKIE}=bm90LXNpZ25lZA.deadbeef"), "any"), + Err(SessionError::InvalidTransaction) + )); + } + + #[test] + fn an_expired_transaction_is_refused_even_with_the_right_state() { + init_secret_for_tests(); + let secret = secret().unwrap(); + let txn = Transaction { + state: "s".into(), + verifier: "v".into(), + exp: now() - 1, + }; + let header = format!( + "{TXN_COOKIE}={}", + sign(&serde_json::to_vec(&txn).unwrap(), &secret) + ); + assert!(matches!( + verifier_for_callback(&header, "s"), + Err(SessionError::InvalidTransaction) + )); + } + + #[test] + fn an_issued_session_round_trips_with_its_subject_account_and_scope() { + init_secret_for_tests(); + let raw = test_cookie_value("sub-1", "acct-1", "sensing:read", 3600); + let session = from_cookie_header(&format!("{SESSION_COOKIE}={raw}")) + .expect("a freshly issued session must be recoverable"); + + assert_eq!(session.subject, "sub-1"); + assert_eq!(session.account_id, "acct-1"); + assert!(session.has_scope("sensing:read")); + assert!(!session.has_scope("sensing:admin"), "scope must not be widened in transit"); + } + + #[test] + fn an_expired_session_cookie_does_not_authenticate() { + // MUTANT THIS KILLS: `session.is_live().then_some(session)` -> + // `Some(session)` in `from_cookie_header`. `is_live` IS unit-tested, + // but nothing asserted that the caller consults it — the recurring + // "tested in isolation, call site untested" shape. Without this, a + // signed cookie authenticates forever and the session TTL is decorative. + init_secret_for_tests(); + let raw = test_cookie_value("sub-1", "acct-1", "sensing:read", -1); + assert!(from_cookie_header(&format!("{SESSION_COOKIE}={raw}")).is_none()); + } + + #[test] + fn a_session_signed_with_another_secret_does_not_authenticate() { + init_secret_for_tests(); + // Forged with a different key: the payload is well-formed and unexpired, + // so only the MAC stands between it and a valid session. + let forged = sign( + &serde_json::to_vec(&BrowserSession { + subject: "attacker".into(), + account_id: "acct-attacker".into(), + scope: "sensing:admin".into(), + exp: now() + 3600, + }) + .unwrap(), + "a-different-secret", + ); + assert!(from_cookie_header(&format!("{SESSION_COOKIE}={forged}")).is_none()); + } + + #[test] + fn clearing_cookies_expires_them_immediately() { + for c in [clear_session(false), clear_transaction(false)] { + assert!(c.contains("Max-Age=0"), "{c}"); + } + } + fn session(exp: i64) -> BrowserSession { BrowserSession { subject: "user-1".into(), From 89cceaf8350ee603ca758f1630925899089f8074 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Thu, 23 Jul 2026 13:13:42 +0200 Subject: [PATCH 24/27] =?UTF-8?q?fix(auth):=20close=20P1/P2/P3=20=E2=80=94?= =?UTF-8?q?=20JWKS=20stall,=2012h=20session,=20cookie=20shadowing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=; 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 --- .../ADR-271-cognitum-oauth-resource-server.md | 15 +- ui/services/api.service.js | 26 ++ v2/crates/ruview-auth/src/jwks.rs | 123 +++++++-- .../src/bearer_auth.rs | 168 ++++++++++++- .../src/browser_session.rs | 234 +++++++++++++++++- 5 files changed, 531 insertions(+), 35 deletions(-) diff --git a/docs/adr/ADR-271-cognitum-oauth-resource-server.md b/docs/adr/ADR-271-cognitum-oauth-resource-server.md index 6736deeb..60e16f50 100644 --- a/docs/adr/ADR-271-cognitum-oauth-resource-server.md +++ b/docs/adr/ADR-271-cognitum-oauth-resource-server.md @@ -235,13 +235,14 @@ 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 and proposed remediation +## Open problems — RESOLVED 2026-07-23 -Two findings from the 2026-07-23 adversarial review are **not fixed in this -work**. Both are recorded here with a proposed design rather than patched in a -hurry, because each changes a runtime property that deserves its own decision. +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 +### 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 @@ -283,7 +284,7 @@ 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 +### 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 @@ -314,7 +315,7 @@ worth it if RuView later needs true cross-device sign-out. Whichever is chosen, `SESSION_TTL_SECS` should be pinned by a test asserting the issued cookie's `Max-Age` matches the session's `exp`, so the two cannot drift. -### P3 — dropping `__Host-` costs cookie origin-integrity, not just `Secure` +### 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 diff --git a/ui/services/api.service.js b/ui/services/api.service.js index 4e5daee7..b9052b4a 100644 --- a/ui/services/api.service.js +++ b/ui/services/api.service.js @@ -86,6 +86,32 @@ export class ApiService { // Process response through interceptors const processedResponse = await this.processResponse(response, url); + // Step-up re-authentication (ADR-271 P2). + // + // A browser session outlives the ~15-minute access token that created it, + // and Cognitum publishes no introspection endpoint, so the server refuses + // PRIVILEGED actions from a session older than a few minutes. That is a + // 401, but it means something different from "you are not signed in" — + // the user IS signed in, and the fix is to prove it again. The server + // marks it with an RFC 6750 error code so the two are distinguishable. + // + // Without this branch a stale-session delete surfaces as a generic + // "Request failed" and the user has no way to know that signing in again + // resolves it. + if (processedResponse.status === 401) { + const challenge = processedResponse.headers.get('WWW-Authenticate') || ''; + if (challenge.includes('reauthentication required')) { + // Full-page redirect: the flow ends by setting a cookie, which an + // XHR cannot do usefully. Returns here with a fresh auth_time and, + // while the Cognitum session is alive, no prompt. + window.location.href = '/oauth/start'; + // Never settles — the navigation is already underway, and resolving + // would let the caller render an error for an operation that is + // simply being retried after sign-in. + return new Promise(() => {}); + } + } + // Handle errors if (!processedResponse.ok) { const error = await processedResponse.json().catch(() => ({ diff --git a/v2/crates/ruview-auth/src/jwks.rs b/v2/crates/ruview-auth/src/jwks.rs index 0ff02223..56c026fd 100644 --- a/v2/crates/ruview-auth/src/jwks.rs +++ b/v2/crates/ruview-auth/src/jwks.rs @@ -49,10 +49,16 @@ use serde::Deserialize; /// without putting an outbound request on every verify. pub const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(300); -/// Floor between *forced* refetches (the unknown-`kid` path). Without this, a -/// stream of tokens bearing a bogus `kid` becomes an outbound request amplifier -/// pointed at the identity service. -pub const FORCED_REFETCH_MIN_INTERVAL: Duration = Duration::from_secs(30); +/// 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. @@ -102,6 +108,7 @@ struct JwksDocument { struct CacheState { keys: HashMap, fetched_at: Option, + last_attempt_at: Option, last_forced_refetch: Option, } @@ -126,6 +133,7 @@ impl JwksCache { state: Mutex::new(CacheState { keys: HashMap::new(), fetched_at: None, + last_attempt_at: None, last_forced_refetch: None, }), } @@ -147,36 +155,83 @@ impl JwksCache { /// Resolve the verification key for a token header's `kid`. pub fn decoding_key_for(&self, kid: &str) -> Result { // ---- Phase 1: answer from cache, holding the lock only to read. ---- - let (fresh, have_any, may_force) = { + 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) = state.keys.get(kid) { - return Ok(key.clone()); + if let Some(key) = cached { + return Ok(key); } } let may_force = state .last_forced_refetch - .map_or(true, |at| at.elapsed() >= FORCED_REFETCH_MIN_INTERVAL); - (fresh, state.fetched_at.is_some(), may_force) + .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. - // A fresh cache that simply lacks this kid: one rate-limited forced - // refetch, in case identity rotated inside the TTL. - if fresh && !may_force { - return Err(JwksError::UnknownKid(kid.to_owned())); - } + // 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(); @@ -452,6 +507,46 @@ mod tests { ); } + #[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( diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs index 045f8ca7..891b4fb7 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs @@ -589,12 +589,39 @@ pub async fn require_bearer( } else { required_scope_for(request.method(), &path) }; - let config = VerifierConfig { - issuer: oauth.issuer.clone(), - required_scope: required.to_string(), - allowed_client_ids: oauth.allowed_client_ids.clone(), + // Verification can hit the network: a `kid` miss or an expired cache + // makes `JwksCache` perform a BLOCKING `ureq` fetch (3s connect + 3s + // read). Doing that inline parks the tokio worker running this request, + // and on Pi-class hardware with few workers a handful of concurrent + // misses stalls the whole server — including `/health`. + // + // `main.rs` already does exactly this for the token exchange, noting it + // as "the same mistake this codebase had to fix in jwks.rs". The hot + // verification path had not been given the same treatment. + let token = supplied.to_string(); + let oauth = Arc::clone(oauth); + let required_owned = required.to_string(); + let verified = tokio::task::spawn_blocking(move || { + let config = VerifierConfig { + issuer: oauth.issuer.clone(), + required_scope: required_owned, + allowed_client_ids: oauth.allowed_client_ids.clone(), + }; + verify_access_token(&token, &oauth.jwks, &config) + }) + .await; + + let verified = match verified { + Ok(v) => v, + Err(join) => { + // The blocking task panicked or was cancelled. Fail closed: + // "we could not verify" is never "the request is authorized". + tracing::error!(error = %join, path = %path.as_str(), "JWKS verification task failed"); + return unauthorized(&auth); + } }; - match verify_access_token(supplied, &oauth.jwks, &config) { + + match verified { Ok(principal) => { tracing::debug!( sub = %principal.subject, @@ -635,6 +662,19 @@ pub async fn require_bearer( } else { required_scope_for(request.method(), &path) }; + // Step-up: a privileged action needs a RECENT authentication, + // not merely a live session. The session outlives the access + // token that created it and Cognitum offers no introspection, + // so a stale session is authority we cannot revoke — bounded + // here to the routes where that authority actually does damage. + if required == scope::SENSING_ADMIN && !session.recently_authenticated() { + tracing::debug!( + sub = %session.subject, + path = %path.as_str(), + "browser session is too old for a privileged action; re-authentication required" + ); + return reauthentication_required(&auth); + } if session.has_scope(required) { tracing::debug!( sub = %session.subject, @@ -671,6 +711,19 @@ async fn session_or_unauthorized(auth: &AuthState, request: Request, next: Next) } else { required_scope_for(request.method(), &path) }; + // Step-up: a privileged action needs a RECENT authentication, + // not merely a live session. The session outlives the access + // token that created it and Cognitum offers no introspection, + // so a stale session is authority we cannot revoke — bounded + // here to the routes where that authority actually does damage. + if required == scope::SENSING_ADMIN && !session.recently_authenticated() { + tracing::debug!( + sub = %session.subject, + path = %path.as_str(), + "browser session is too old for a privileged action; re-authentication required" + ); + return reauthentication_required(&auth); + } if session.has_scope(required) { tracing::debug!(sub = %session.subject, path = %path.as_str(), "browser session authorized"); return next.run(request).await; @@ -700,6 +753,27 @@ fn unauthorized(auth: &AuthState) -> Response { (StatusCode::UNAUTHORIZED, body).into_response() } +/// 401 for a browser session that is valid but too old for a privileged action. +/// +/// Distinguished from a plain 401 by an RFC 6750 §3 `WWW-Authenticate` error +/// code, because the client's correct response is different: not "sign in", +/// which it already has, but "prove it again". Without a distinguishable signal +/// the UI would surface a stale-session delete as a generic failure, and the +/// user would have no idea that re-signing-in fixes it. +/// +/// This leaks nothing: the caller already knows it was refused, and the code +/// says only that the *session age* was the reason. +fn reauthentication_required(auth: &AuthState) -> Response { + let mut resp = unauthorized(auth); + resp.headers_mut().insert( + axum::http::header::WWW_AUTHENTICATE, + axum::http::HeaderValue::from_static( + r#"Bearer error="invalid_token", error_description="reauthentication required for a privileged action""#, + ), + ); + resp +} + /// Convenience re-export so handlers can name the type they pull out of /// request extensions without depending on `ruview-auth` directly. pub use ruview_auth::Principal as AuthenticatedPrincipal; @@ -1226,6 +1300,90 @@ mod oauth_tests { ); } + // ── step-up: privileged actions need a RECENT authentication ────── + + fn aged_admin_cookie(age: i64) -> String { + format!( + "ruview_session={}", + crate::browser_session::test_cookie_value_aged( + "sub-b", + "acct-b", + &format!("{} {}", scope::SENSING_READ, scope::SENSING_ADMIN), + 3600, + age, + ) + ) + } + + #[tokio::test] + async fn a_stale_but_live_session_can_still_read() { + // Step-up must not degrade into "re-authenticate every 5 minutes". The + // dashboard's primary use is watching a live stream; reads ride the + // full session lifetime. + let c = aged_admin_cookie(crate::browser_session::ADMIN_REVERIFY_SECS + 60); + assert_eq!( + call_with_session(oauth_only(), "GET", "/api/v1/models", &c).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn a_stale_session_cannot_delete_even_holding_the_admin_scope() { + // The session outlives the ~15-minute access token that created it, and + // Cognitum publishes no introspection endpoint — so a stale session is + // authority nobody can revoke. Bounded to the routes where it does + // damage: holding `sensing:admin` is necessary but no longer sufficient. + let c = aged_admin_cookie(crate::browser_session::ADMIN_REVERIFY_SECS + 60); + for (method, path) in [ + ("DELETE", "/api/v1/models/m1"), + ("DELETE", "/api/v1/recording/r1"), + ("POST", "/api/v1/train/start"), + ] { + assert_eq!( + call_with_session(oauth_only(), method, path, &c).await, + StatusCode::UNAUTHORIZED, + "{method} {path} accepted a stale session for a privileged action" + ); + } + } + + #[tokio::test] + async fn a_freshly_authenticated_session_can_delete() { + // The negative above must not pass merely because admin never works. + let c = aged_admin_cookie(0); + assert_eq!( + call_with_session(oauth_only(), "DELETE", "/api/v1/models/m1", &c).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn a_session_cookie_predating_auth_time_cannot_perform_admin_actions() { + // Cookies issued before `auth_time` existed deserialize with 0, which is + // infinitely stale. They must degrade to read-only rather than being + // treated as freshly authenticated — fail closed, and self-healing the + // next time the user signs in. + let legacy = serde_json::json!({ + "subject": "sub-old", + "account_id": "acct-old", + "scope": "sensing:read sensing:admin", + "exp": chrono::Utc::now().timestamp() + 3600, + }); + let raw = crate::browser_session::test_sign_for_tests(&serde_json::to_vec(&legacy).unwrap()); + let c = format!("ruview_session={raw}"); + + assert_eq!( + call_with_session(oauth_only(), "GET", "/api/v1/models", &c).await, + StatusCode::OK, + "an old cookie must keep working for reads" + ); + assert_eq!( + call_with_session(oauth_only(), "DELETE", "/api/v1/models/m1", &c).await, + StatusCode::UNAUTHORIZED, + "an old cookie must not carry privileged authority" + ); + } + #[tokio::test] async fn an_expired_browser_session_is_refused() { let c = session_cookie(scope::SENSING_READ, -1); diff --git a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs index ff86c559..b863bf52 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs @@ -54,7 +54,32 @@ const SESSION_COOKIE: &str = "ruview_session"; /// The OAuth round-trip is a page load or two. Ten minutes is generous. const TXN_TTL_SECS: i64 = 600; /// How long a browser stays signed in before repeating the redirect. -const SESSION_TTL_SECS: i64 = 12 * 3600; +/// +/// One hour, down from twelve. The session cookie is an assertion that this +/// server verified a Cognitum access token; that token lives ~15 minutes, and +/// Cognitum publishes no introspection endpoint, so there is no way to ask +/// whether the grant behind a session still stands. Every second of this TTL is +/// time a revoked or disabled account keeps working. Twelve hours made that +/// window a working day. +/// +/// An hour is short enough to bound the damage and long enough that the +/// re-auth redirect is rare; because the user's Cognitum session is normally +/// still alive, that redirect is usually silent. +pub const SESSION_TTL_SECS: i64 = 3600; + +/// How recently the user must have actually authenticated for this server to +/// honour a **privileged** (`sensing:admin`) request from a browser session. +/// +/// Reads ride the full [`SESSION_TTL_SECS`]; deleting models and recordings, and +/// starting training, do not. This is step-up-by-recency: the blast radius of a +/// stale session is the mutating routes, so those are what get re-verified, +/// rather than making every user re-authenticate hourly for a dashboard whose +/// primary use is watching a live stream. +/// +/// When a session is too old for an admin action the request is refused, and +/// the UI sends the user back through `/oauth/start` — which returns with a +/// fresh `auth_time` and, if their Cognitum session is live, no prompt. +pub const ADMIN_REVERIFY_SECS: i64 = 300; fn now() -> i64 { SystemTime::now() @@ -116,6 +141,15 @@ pub struct BrowserSession { pub account_id: String, pub scope: String, pub exp: i64, + /// When the user last actually authenticated against Cognitum, unix + /// seconds — the OIDC `auth_time` idea, used here for step-up. + /// + /// `#[serde(default)]` so a cookie issued before this field existed still + /// deserializes. It then reads as `0`, which is infinitely stale, so such a + /// session can still read but cannot perform a privileged action until the + /// user signs in again. Fail-closed, and self-healing on next sign-in. + #[serde(default)] + pub auth_time: i64, } impl BrowserSession { @@ -125,6 +159,14 @@ impl BrowserSession { pub fn has_scope(&self, want: &str) -> bool { self.scope.split_whitespace().any(|s| s == want) } + /// Has the user authenticated recently enough for a privileged action? + /// + /// See [`ADMIN_REVERIFY_SECS`]. Note this is deliberately NOT "is the + /// session live" — a session can be perfectly valid for reads and still too + /// old to delete a model. + pub fn recently_authenticated(&self) -> bool { + now() - self.auth_time < ADMIN_REVERIFY_SECS + } } fn cookie(name: &str, value: &str, max_age: i64, secure: bool) -> String { @@ -135,11 +177,75 @@ fn cookie(name: &str, value: &str, max_age: i64, secure: bool) -> String { } /// Read one cookie from a raw `Cookie:` header. +/// +/// Returns the FIRST match, which is only safe when the caller has already +/// established there is exactly one — see [`read_all_cookies`] and the +/// shadowing attack it exists to stop. Kept for callers that genuinely want +/// first-match semantics; the credential paths do not. pub fn read_cookie(header: &str, name: &str) -> Option { - header.split(';').find_map(|part| { - let (k, v) = part.split_once('=')?; - (k.trim() == name).then(|| v.trim().to_string()) - }) + read_all_cookies(header, name).into_iter().next() +} + +/// Every value sent under `name`, in header order. +/// +/// # Why this is not `read_cookie` +/// +/// A `Cookie:` header can legitimately carry the same name more than once — +/// cookies are keyed by (name, domain, path), and RFC 6265 §5.4 orders +/// longer-`Path` matches FIRST. Cookies are also not isolated by port or by +/// scheme, so *any* other service on the same host, or a plain-HTTP MITM +/// injecting a `Set-Cookie`, can add one. +/// +/// Taking the first match therefore let an attacker **shadow** a victim's +/// session: sign in normally, capture your own validly-signed +/// `ruview_session`, then get it set with `Path=/ui` on the victim's browser. +/// The victim then sends both, the attacker's first, and it verifies — +/// because it is genuinely signed. The victim silently operates inside the +/// attacker's session; `/oauth/status` reports the attacker's account and the +/// victim's recordings are attributed to them. +/// +/// Note the shape of this: the signature was doing its job the whole time. +/// Forgery is not the threat here, and "the signature protects the value" does +/// not answer it. The `__Host-` prefix would — it forbids `Domain` and pins +/// `Path=/` — but it also requires `Secure`, and RuView is routinely reached +/// over plain HTTP on a LAN, where such a cookie is never sent at all. +/// +/// So the callers resolve ambiguity themselves: accept only when exactly one +/// candidate verifies. An attacker can still cause a *refusal* by planting a +/// second valid cookie, which is a nuisance; they can no longer cause a +/// silent takeover, which is a compromise. +pub fn read_all_cookies(header: &str, name: &str) -> Vec { + header + .split(';') + .filter_map(|part| { + let (k, v) = part.split_once('=')?; + (k.trim() == name).then(|| v.trim().to_string()) + }) + .collect() +} + +/// Unwrap the one candidate that verifies, or `None` if zero or several do. +/// +/// Several verifying means the browser sent two genuinely-signed cookies of the +/// same name — which a legitimate client never does, and which is exactly the +/// shadowing attack described on [`read_all_cookies`]. Refusing is correct: we +/// cannot tell which one the user meant, and guessing is how the takeover works. +fn unsign_unambiguous(header: &str, name: &str, secret: &str) -> Option> { + let mut verified = read_all_cookies(header, name) + .into_iter() + .filter_map(|raw| unsign(&raw, secret)); + let first = verified.next()?; + match verified.next() { + None => Some(first), + Some(_) => { + tracing::warn!( + cookie = name, + "request carried more than one validly-signed {name}; refusing rather than \ + guessing which is the user's — see read_all_cookies" + ); + None + } + } } #[derive(Debug, thiserror::Error)] @@ -325,8 +431,10 @@ pub fn clear_session(secure: bool) -> String { /// the PKCE verifier needed for the exchange. pub fn verifier_for_callback(cookie_header: &str, state: &str) -> Result { let secret = secret()?; - let raw = read_cookie(cookie_header, TXN_COOKIE).ok_or(SessionError::InvalidTransaction)?; - let bytes = unsign(&raw, &secret).ok_or(SessionError::InvalidTransaction)?; + // Unambiguous: a second validly-signed txn cookie would let an attacker + // substitute their own PKCE verifier, defeating the binding entirely. + let bytes = unsign_unambiguous(cookie_header, TXN_COOKIE, &secret) + .ok_or(SessionError::InvalidTransaction)?; let txn: Transaction = serde_json::from_slice(&bytes).map_err(|_| SessionError::InvalidTransaction)?; if txn.exp < now() { @@ -350,6 +458,10 @@ pub fn issue(principal: &ruview_auth::Principal, secure: bool) -> Result Result Option { let secret = secret().ok()?; - let raw = read_cookie(cookie_header, SESSION_COOKIE)?; - let bytes = unsign(&raw, &secret)?; + let bytes = unsign_unambiguous(cookie_header, SESSION_COOKIE, &secret)?; let session: BrowserSession = serde_json::from_slice(&bytes).ok()?; session.is_live().then_some(session) } @@ -387,6 +498,29 @@ pub(crate) fn init_secret_for_tests() { /// test-only bypass. `ttl` may be negative to forge an already-expired session. #[cfg(test)] pub(crate) fn test_cookie_value(subject: &str, account_id: &str, scope: &str, ttl: i64) -> String { + // Freshly authenticated, so step-up does not interfere with tests about + // scope or expiry. Use `test_cookie_value_aged` to exercise step-up itself. + test_cookie_value_aged(subject, account_id, scope, ttl, 0) +} + +/// Sign an arbitrary payload with the test secret, so a test elsewhere in the +/// crate can construct a cookie whose SHAPE differs from the current struct — +/// e.g. one issued before a field existed. +#[cfg(test)] +pub(crate) fn test_sign_for_tests(payload: &[u8]) -> String { + init_secret_for_tests(); + sign(payload, &secret().expect("secret installed above")) +} + +/// As [`test_cookie_value`], but `age` seconds since the user authenticated. +#[cfg(test)] +pub(crate) fn test_cookie_value_aged( + subject: &str, + account_id: &str, + scope: &str, + ttl: i64, + age: i64, +) -> String { init_secret_for_tests(); let secret = secret().expect("secret installed above"); let session = BrowserSession { @@ -394,6 +528,7 @@ pub(crate) fn test_cookie_value(subject: &str, account_id: &str, scope: &str, tt account_id: account_id.to_string(), scope: scope.to_string(), exp: now() + ttl, + auth_time: now() - age, }; sign(&serde_json::to_vec(&session).expect("serializes"), &secret) } @@ -563,6 +698,7 @@ mod tests { account_id: "acct-attacker".into(), scope: "sensing:admin".into(), exp: now() + 3600, + auth_time: now(), }) .unwrap(), "a-different-secret", @@ -570,6 +706,85 @@ mod tests { assert!(from_cookie_header(&format!("{SESSION_COOKIE}={forged}")).is_none()); } + // ── cookie shadowing (P3) ───────────────────────────────────────── + + #[test] + fn every_value_sent_under_a_name_is_visible_not_just_the_first() { + let h = "ruview_session=attacker; other=x; ruview_session=victim"; + assert_eq!( + read_all_cookies(h, "ruview_session"), + vec!["attacker".to_string(), "victim".to_string()] + ); + } + + #[test] + fn a_shadowing_cookie_cannot_silently_take_over_the_session() { + // THE ATTACK. Cookies are keyed by (name, domain, path) and RFC 6265 + // §5.4 sends longer-`Path` matches FIRST. They are not isolated by port + // or scheme, so any other service on this host — or a plain-HTTP MITM + // injecting Set-Cookie — can plant one. + // + // The attacker signs in legitimately, captures their OWN validly-signed + // cookie, and gets it set with `Path=/ui` on the victim's browser. Under + // first-match the victim's browser sends the attacker's cookie first, it + // verifies (it IS genuinely signed), and the victim silently operates + // inside the attacker's session. + // + // The signature was never the problem, which is why "it's signed" does + // not answer this. + init_secret_for_tests(); + let attacker = test_cookie_value("attacker", "acct-attacker", "sensing:read", 3600); + let victim = test_cookie_value("victim", "acct-victim", "sensing:read", 3600); + + let header = format!("ruview_session={attacker}; ruview_session={victim}"); + assert!( + from_cookie_header(&header).is_none(), + "two validly-signed sessions must be refused, not resolved by order" + ); + + // Order must not matter — the victim's cookie arriving first is the same + // ambiguity, not a pass. + let reversed = format!("ruview_session={victim}; ruview_session={attacker}"); + assert!(from_cookie_header(&reversed).is_none()); + } + + #[test] + fn a_junk_shadow_cookie_does_not_lock_the_real_user_out() { + // Only ONE candidate verifies, so there is no ambiguity to refuse. This + // matters: if any duplicate name caused a refusal, planting garbage + // would be a trivial denial of service against every user. + init_secret_for_tests(); + let real = test_cookie_value("victim", "acct-victim", "sensing:read", 3600); + for header in [ + format!("ruview_session=not-even-signed; ruview_session={real}"), + format!("ruview_session={real}; ruview_session=bm9wZQ.deadbeef"), + ] { + let s = from_cookie_header(&header).expect("the genuine cookie must still work"); + assert_eq!(s.subject, "victim"); + } + } + + #[test] + fn a_shadowing_transaction_cookie_cannot_substitute_a_pkce_verifier() { + // Same attack against the sign-in transaction: a second validly-signed + // txn cookie would let an attacker supply their own verifier and state, + // which defeats the PKCE binding rather than merely confusing it. + init_secret_for_tests(); + let (url_a, cookie_a) = begin("https://a.example", "ruview", "sensing:read", false).unwrap(); + let (_url_b, cookie_b) = begin("https://a.example", "ruview", "sensing:read", false).unwrap(); + let state_a = query_param(&url_a, "state"); + + let header = format!( + "{TXN_COOKIE}={}; {TXN_COOKIE}={}", + value_of(&cookie_a), + value_of(&cookie_b) + ); + assert!(matches!( + verifier_for_callback(&header, &state_a), + Err(SessionError::InvalidTransaction) + )); + } + #[test] fn clearing_cookies_expires_them_immediately() { for c in [clear_session(false), clear_transaction(false)] { @@ -583,6 +798,7 @@ mod tests { account_id: "acct-1".into(), scope: "sensing:read".into(), exp, + auth_time: now(), } } From f7cc68bd5c9a446ff61f6c02fb7dc450fe53c1e2 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Thu, 23 Jul 2026 14:03:54 +0200 Subject: [PATCH 25/27] fix(auth): check scope before step-up, so read-only callers are not sent in a circle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by an empirical route sweep during the pre-merge pass, not by a test. The step-up gate fired BEFORE the scope check, so a caller holding only `sensing:read` who attempted a privileged action with a session older than ADMIN_REVERIFY_SECS received the RFC 6750 "reauthentication required" challenge. `api.service.js` acts on that by redirecting through /oauth/start — and the user returns with exactly the same scopes and is refused again. One wasted round trip, and a reason for the refusal that was simply untrue: they were not refused for staleness, they were refused for capability. Now the scope check comes first, so only a caller who actually HOLDS `sensing:admin` is ever asked to prove the session is fresh. Not a loop before (the second refusal is a plain 401 with no challenge) and not a security issue either way — both paths refuse. It was misleading, and it cost a redirect. Also confirms the deny-by-default gate does not over-reach. Probed the real binary with auth off vs on: /, /ui/*, /health, /health/{live,ready,metrics,version}, /oauth/{status,start} unchanged /api/field, /api/v1/* 200 -> 401 (intended) /metrics, /favicon.ico 404 -> 401 (never existed; now uniform) Nothing that returned 2xx before returns 401 now, which is the property that matters for existing deployments. Tests: +2. `a_read_only_user_is_not_sent_to_reauthenticate_pointlessly` fails against the old ordering with the challenge header present; its counterpart `an_admin_holder_with_a_stale_session_does_get_the_challenge` ensures the fix does not simply disable the signal. Verified: workspace 176 suites clean, ruview-auth 62+25+2 --all-features. Co-Authored-By: Ruflo & AQE --- .../src/bearer_auth.rs | 87 ++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs index 891b4fb7..783ed7c1 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs @@ -667,7 +667,16 @@ pub async fn require_bearer( // token that created it and Cognitum offers no introspection, // so a stale session is authority we cannot revoke — bounded // here to the routes where that authority actually does damage. - if required == scope::SENSING_ADMIN && !session.recently_authenticated() { + // + // Ordered AFTER the scope check on purpose. Asking someone who + // does not hold `sensing:admin` to re-authenticate sends them + // through a redirect that cannot possibly help: they come back + // with the same scopes and are refused again. Only a caller who + // actually holds the capability is asked to prove it is fresh. + if session.has_scope(required) + && required == scope::SENSING_ADMIN + && !session.recently_authenticated() + { tracing::debug!( sub = %session.subject, path = %path.as_str(), @@ -716,7 +725,16 @@ async fn session_or_unauthorized(auth: &AuthState, request: Request, next: Next) // token that created it and Cognitum offers no introspection, // so a stale session is authority we cannot revoke — bounded // here to the routes where that authority actually does damage. - if required == scope::SENSING_ADMIN && !session.recently_authenticated() { + // + // Ordered AFTER the scope check on purpose. Asking someone who + // does not hold `sensing:admin` to re-authenticate sends them + // through a redirect that cannot possibly help: they come back + // with the same scopes and are refused again. Only a caller who + // actually holds the capability is asked to prove it is fresh. + if session.has_scope(required) + && required == scope::SENSING_ADMIN + && !session.recently_authenticated() + { tracing::debug!( sub = %session.subject, path = %path.as_str(), @@ -1347,6 +1365,71 @@ mod oauth_tests { } } + #[tokio::test] + async fn a_read_only_user_is_not_sent_to_reauthenticate_pointlessly() { + // The scope check must come FIRST. A caller without `sensing:admin` + // cannot be helped by re-authenticating — they return with the same + // scopes and are refused again — so sending the step-up challenge would + // cost them a redirect and tell them something untrue about why they + // were refused. Only a caller who actually HOLDS the capability is + // asked to prove it is fresh. + let stale_read_only = format!( + "ruview_session={}", + crate::browser_session::test_cookie_value_aged( + "sub-b", + "acct-b", + scope::SENSING_READ, + 3600, + crate::browser_session::ADMIN_REVERIFY_SECS + 60, + ) + ); + let req = Request::builder() + .method("DELETE") + .uri("/api/v1/models/m1") + .header(axum::http::header::COOKIE, &stale_read_only); + let resp = app(oauth_only()) + .oneshot(req.body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let challenge = resp + .headers() + .get(axum::http::header::WWW_AUTHENTICATE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + assert!( + !challenge.contains("reauthentication required"), + "a read-only caller must not be told to re-authenticate: {challenge:?}" + ); + } + + #[tokio::test] + async fn an_admin_holder_with_a_stale_session_does_get_the_challenge() { + // The counterpart: the signal must actually fire for the caller it can + // help, or the UI never learns to send them back through /oauth/start. + let c = aged_admin_cookie(crate::browser_session::ADMIN_REVERIFY_SECS + 60); + let req = Request::builder() + .method("DELETE") + .uri("/api/v1/models/m1") + .header(axum::http::header::COOKIE, &c); + let resp = app(oauth_only()) + .oneshot(req.body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let challenge = resp + .headers() + .get(axum::http::header::WWW_AUTHENTICATE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + assert!( + challenge.contains("reauthentication required"), + "an admin holder with a stale session must be told to re-authenticate: {challenge:?}" + ); + } + #[tokio::test] async fn a_freshly_authenticated_session_can_delete() { // The negative above must not pass merely because admin never works. From 1ed0bc57ef8338294ca274dc20b7270f00b22c58 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Thu, 23 Jul 2026 14:31:01 +0200 Subject: [PATCH 26/27] docs(adr): correct a false claim about browser scope; pin the decision in code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cross-vendor pre-merge sweep found no merge blockers, but did surface an error in ADR-271 that I wrote — and a coherence gap behind it. ADR-271 P2 stated that capping the browser session at `sensing:read` was "considered and rejected, because the dashboard genuinely performs admin operations". That is wrong. `/oauth/start` (main.rs:9206) already requests `SENSING_READ` and nothing else, deliberately, with a comment saying so. The browser session is ALREADY read-only, so the breakage I claimed capping would cause is simply the current behaviour. Two consequences, now stated in the ADR instead of left to be discovered: 1. The UI's admin controls do not work from a browser OAuth session. `model.service.js:136` issues DELETE /api/v1/models/{id}, which 401s. Admin work needs the CLI (`login --admin`) or a pasted admin bearer. A gap in a new feature, not a regression — the token-paste path is unchanged. 2. The ADMIN_REVERIFY_SECS step-up control added in the previous commit guards a case that cannot currently arise. No browser session holds `sensing:admin`, so the freshness branch never fires in production. Its tests pass because the crate-internal seam mints an admin cookie the real flow never produces. That second point is worth being blunt about: it is the same shape as several defects this branch already fixed — correct code, green tests, unreachable call site. The difference is that here the guard is deliberately ahead of the need rather than mistakenly behind it, and saying which one it is matters. So the constant is now named `BROWSER_SIGNIN_SCOPE` rather than inlined, with the cost of widening it documented at the definition, and two tests: `browser_sign_in_stays_read_only_until_someone_decides_otherwise` pins the value, and `the_authorize_url_actually_carries_that_scope` proves it reaches the wire — asserting on the constant alone would pass even if `begin` were called with something else, which is exactly the isolation failure being guarded against. The ADR also records the coherent way to add browser-side admin if wanted: escalate-on-demand via the RFC 6750 challenge, keeping least privilege by default rather than asking every user to consent to delete capability to watch a stream. Not bundled here — it needs a scope parameter and a UI affordance. Sweep verdict: no merge blockers. Two other non-blocking risks it raised are accurate and unchanged: concurrent JWKS refresh at the stale boundary is not atomic (a duplicated idempotent GET, already documented as an accepted cost), and the service worker's SHELL_ASSETS use root-relative paths while the UI mounts under /ui, so offline shell precaching is incomplete — pre-existing, unrelated to this branch. Verified: workspace 176 suites clean. Co-Authored-By: Ruflo & AQE --- .../ADR-271-cognitum-oauth-resource-server.md | 40 ++++++++++++++-- .../src/browser_session.rs | 46 +++++++++++++++++++ .../wifi-densepose-sensing-server/src/main.rs | 5 +- 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/docs/adr/ADR-271-cognitum-oauth-resource-server.md b/docs/adr/ADR-271-cognitum-oauth-resource-server.md index 60e16f50..7c50da64 100644 --- a/docs/adr/ADR-271-cognitum-oauth-resource-server.md +++ b/docs/adr/ADR-271-cognitum-oauth-resource-server.md @@ -295,9 +295,43 @@ authority that cannot be revoked. Cognitum publishes no introspection endpoint 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. -Capping the session at `sensing:read` was considered and **rejected**: the -dashboard genuinely performs admin operations (`model.service.js:136` issues -`DELETE /api/v1/models/{id}`), so that would break shipped functionality. +**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. + +If browser-side admin is wanted, the coherent design is **escalate on demand**: +keep `sensing:read` as the default, and have the RFC 6750 challenge send the +user back through `/oauth/start` with `sensing:admin` requested, returning a +session that holds admin AND a fresh `auth_time`. That preserves least privilege +by default, makes the challenge meaningful, and is the only option that does not +ask every user to consent to delete capability just to watch a stream. It needs +a scope parameter on `/oauth/start` and a UI affordance, so it is deliberately +not bundled into this change. **Three options, with the tradeoff each carries:** diff --git a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs index b863bf52..917fc8f1 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs @@ -81,6 +81,27 @@ pub const SESSION_TTL_SECS: i64 = 3600; /// fresh `auth_time` and, if their Cognitum session is live, no prompt. pub const ADMIN_REVERIFY_SECS: i64 = 300; +/// The scope `/oauth/start` requests. Read-only, deliberately. +/// +/// Named rather than inlined because it is a decision, not a detail, and it has +/// two consequences that are easy to widen by accident: +/// +/// 1. **The UI's admin controls do not work from a browser session.** +/// `model.service.js` issues `DELETE /api/v1/models/{id}`; from a +/// Cognitum-signed-in browser that is a 401. Admin work goes through the CLI +/// (`wifi-densepose login --admin`) or a pasted admin bearer. +/// 2. **[`ADMIN_REVERIFY_SECS`] therefore guards a case that cannot yet arise.** +/// No browser session holds `sensing:admin`, so the freshness branch never +/// fires in production today. It becomes load-bearing the instant this +/// constant grows, which is the right ordering — but do not mistake its +/// passing tests for evidence that the control is exercised. +/// +/// Widening this to include `sensing:admin` would make every browser sign-in +/// consent to delete capability just to watch a stream. The coherent way to add +/// browser-side admin is escalate-on-demand: keep this read-only and let the +/// RFC 6750 challenge send the user back through `/oauth/start` asking for more. +pub const BROWSER_SIGNIN_SCOPE: &str = ruview_auth::scope::SENSING_READ; + fn now() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -785,6 +806,31 @@ mod tests { )); } + #[test] + fn browser_sign_in_stays_read_only_until_someone_decides_otherwise() { + // Pins the decision documented on BROWSER_SIGNIN_SCOPE. Widening it is + // legitimate, but it must be a choice: it makes every browser sign-in + // consent to delete capability, and it activates the ADMIN_REVERIFY_SECS + // branch that is currently unreachable in production. + assert_eq!(BROWSER_SIGNIN_SCOPE, ruview_auth::scope::SENSING_READ); + assert!( + !BROWSER_SIGNIN_SCOPE.split_whitespace().any(|s| s == ruview_auth::scope::SENSING_ADMIN), + "browser sign-in must not silently request admin: {BROWSER_SIGNIN_SCOPE}" + ); + } + + #[test] + fn the_authorize_url_actually_carries_that_scope() { + // The constant is only worth pinning if it reaches the wire. Asserting + // on the constant alone would pass even if `begin` were called with + // something else — the same "tested in isolation, call site untested" + // shape that produced several defects in this branch. + init_secret_for_tests(); + let (url, _) = begin("https://a.example", "ruview", BROWSER_SIGNIN_SCOPE, false).unwrap(); + assert!(url.contains("scope=sensing%3Aread"), "{url}"); + assert!(!url.contains("sensing%3Aadmin"), "{url}"); + } + #[test] fn clearing_cookies_expires_them_immediately() { for c in [clear_session(false), clear_transaction(false)] { diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index fcde93ea..0e6be4a2 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -9202,8 +9202,9 @@ async fn oauth_start( }; 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. - match bs::begin(&issuer, &auth.primary_client_id(), ruview_auth::scope::SENSING_READ, secure) { + // 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, [ From 56327d0931e2749a8186e88f1e16a7198979b621 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Thu, 23 Jul 2026 14:47:31 +0200 Subject: [PATCH 27/27] decide: browser sessions are read-only permanently; drop the dead step-up client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision: browser-side admin is not wanted. `BROWSER_SIGNIN_SCOPE` stays `sensing:read`, and the escalate-on-demand design sketched while this was open is not being built. Destructive operations — training, model delete, recording delete — keep their home in the CLI, where `--admin` is explicit and typed by a person. 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. Consequences, now settled rather than open: - The UI's admin controls are unreachable from a Cognitum browser session, and that is intended. The manual token-paste field is unchanged and still carries whatever authority the pasted token has, so nothing that worked before stops working. - REMOVED the client-side step-up redirect from ui/services/api.service.js. It caught an RFC 6750 challenge that can never be issued to a browser, and it ended in `return new Promise(() => {})` — so if any other 401 had ever grown that header, every caller awaiting it would have hung forever with no error and no timeout. Dead code with a trap in it is worse than no code. - KEPT ADMIN_REVERIFY_SECS as a server-side backstop. Fail-closed and free, so if the requested scope is ever widened the freshness requirement is already in place. Documented at its definition as a backstop specifically so nobody reads its passing tests as evidence the control is exercised — the tests reach it through a crate-internal seam that mints an admin cookie the real flow does not produce. ADR-271 also stops hedging on the session TTL: chosen is A at one hour. Option B (server-side refresh-token store) is not built, and the ADR now names the residual instead of implying it is closed — within one hour a revoked Cognitum grant still reads sensing data through an existing browser session. There is no introspection endpoint, so nothing short of B closes that, and one hour is the size of the hole we accepted. Adds `the_cookie_max_age_matches_the_session_expiry`, which the previous ADR revision asked for and nobody had written: Max-Age and the payload's `exp` are two independent expressions of one lifetime, and drift means either the browser presents a session we reject or we hold authority the browser discarded. Verified: workspace 176 suites clean, UI 22, api.service.js parses. Co-Authored-By: Ruflo & AQE --- .../ADR-271-cognitum-oauth-resource-server.md | 60 ++++++++++++++----- ui/services/api.service.js | 35 ++++------- .../src/browser_session.rs | 43 ++++++++++--- 3 files changed, 94 insertions(+), 44 deletions(-) diff --git a/docs/adr/ADR-271-cognitum-oauth-resource-server.md b/docs/adr/ADR-271-cognitum-oauth-resource-server.md index 7c50da64..e02a8d8a 100644 --- a/docs/adr/ADR-271-cognitum-oauth-resource-server.md +++ b/docs/adr/ADR-271-cognitum-oauth-resource-server.md @@ -324,14 +324,37 @@ to trip over: the right time for the guard to already exist, but it is not evidence that the control is exercised today. -If browser-side admin is wanted, the coherent design is **escalate on demand**: -keep `sensing:read` as the default, and have the RFC 6750 challenge send the -user back through `/oauth/start` with `sensing:admin` requested, returning a -session that holds admin AND a fresh `auth_time`. That preserves least privilege -by default, makes the challenge meaningful, and is the only option that does not -ask every user to consent to delete capability just to watch a stream. It needs -a scope parameter on `/oauth/start` and a UI affordance, so it is deliberately -not bundled into this change. +### 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:** @@ -341,13 +364,22 @@ not bundled into this change. | **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. | -**Recommendation: A now, C next.** A is a one-line change that bounds the -window immediately; C is the design that actually matches the risk, since the -damage a stale session can do is concentrated in the mutating routes. B is only -worth it if RuView later needs true cross-device sign-out. +**Chosen: A, at one hour** — `SESSION_TTL_SECS` is 3600, down from 12 hours. -Whichever is chosen, `SESSION_TTL_SECS` should be pinned by a test asserting the -issued cookie's `Max-Age` matches the session's `exp`, so the two cannot drift. +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** diff --git a/ui/services/api.service.js b/ui/services/api.service.js index b9052b4a..49695af9 100644 --- a/ui/services/api.service.js +++ b/ui/services/api.service.js @@ -86,31 +86,20 @@ export class ApiService { // Process response through interceptors const processedResponse = await this.processResponse(response, url); - // Step-up re-authentication (ADR-271 P2). + // NOTE: there is deliberately no step-up re-authentication branch here. // - // A browser session outlives the ~15-minute access token that created it, - // and Cognitum publishes no introspection endpoint, so the server refuses - // PRIVILEGED actions from a session older than a few minutes. That is a - // 401, but it means something different from "you are not signed in" — - // the user IS signed in, and the fix is to prove it again. The server - // marks it with an RFC 6750 error code so the two are distinguishable. + // 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. // - // Without this branch a stale-session delete surfaces as a generic - // "Request failed" and the user has no way to know that signing in again - // resolves it. - if (processedResponse.status === 401) { - const challenge = processedResponse.headers.get('WWW-Authenticate') || ''; - if (challenge.includes('reauthentication required')) { - // Full-page redirect: the flow ends by setting a cookie, which an - // XHR cannot do usefully. Returns here with a fresh auth_time and, - // while the Cognitum session is alive, no prompt. - window.location.href = '/oauth/start'; - // Never settles — the navigation is already underway, and resolving - // would let the caller render an error for an operation that is - // simply being retried after sign-in. - return new Promise(() => {}); - } - } + // 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) { diff --git a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs index 917fc8f1..4a008041 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs @@ -76,9 +76,14 @@ pub const SESSION_TTL_SECS: i64 = 3600; /// rather than making every user re-authenticate hourly for a dashboard whose /// primary use is watching a live stream. /// -/// When a session is too old for an admin action the request is refused, and -/// the UI sends the user back through `/oauth/start` — which returns with a -/// fresh `auth_time` and, if their Cognitum session is live, no prompt. +/// **This is a backstop, not an active control.** Browser sign-in requests +/// `sensing:read` only and always will ([`BROWSER_SIGNIN_SCOPE`]), so no browser +/// session holds `sensing:admin` and this branch is never reached in production. +/// It is kept because it is cheap and fail-closed: if the requested scope is +/// ever widened, the freshness requirement is already in place rather than +/// something someone has to remember to add. Its tests exercise it through a +/// crate-internal seam that mints an admin cookie the real flow does not +/// produce — do not read them as evidence the control is exercised. pub const ADMIN_REVERIFY_SECS: i64 = 300; /// The scope `/oauth/start` requests. Read-only, deliberately. @@ -96,10 +101,10 @@ pub const ADMIN_REVERIFY_SECS: i64 = 300; /// constant grows, which is the right ordering — but do not mistake its /// passing tests for evidence that the control is exercised. /// -/// Widening this to include `sensing:admin` would make every browser sign-in -/// consent to delete capability just to watch a stream. The coherent way to add -/// browser-side admin is escalate-on-demand: keep this read-only and let the -/// RFC 6750 challenge send the user back through `/oauth/start` asking for more. +/// **Decided 2026-07-23: browser-side admin is not wanted.** This stays +/// read-only. Widening it would make every browser sign-in consent to delete +/// capability just to watch a stream, and the destructive operations have a +/// deliberate home — the CLI, where `--admin` is explicit and typed. pub const BROWSER_SIGNIN_SCOPE: &str = ruview_auth::scope::SENSING_READ; fn now() -> i64 { @@ -806,6 +811,30 @@ mod tests { )); } + #[test] + fn the_cookie_max_age_matches_the_session_expiry() { + // Two independent expressions of the same lifetime: the cookie's + // Max-Age (when the browser stops sending it) and the payload's `exp` + // (when we stop accepting it). If they drift, one silently wins — + // a longer Max-Age means the browser keeps presenting a session we + // reject, a shorter one means we hold authority the browser discards. + init_secret_for_tests(); + let raw = test_cookie_value("s", "a", "sensing:read", SESSION_TTL_SECS); + let session = from_cookie_header(&format!("{SESSION_COOKIE}={raw}")).unwrap(); + + let set_cookie = cookie(SESSION_COOKIE, &raw, SESSION_TTL_SECS, false); + assert!( + set_cookie.contains(&format!("Max-Age={SESSION_TTL_SECS}")), + "{set_cookie}" + ); + // Same lifetime, allowing a second for the clock ticking between them. + assert!( + (session.exp - now() - SESSION_TTL_SECS).abs() <= 1, + "cookie Max-Age and session exp disagree: exp-now={}, Max-Age={SESSION_TTL_SECS}", + session.exp - now() + ); + } + #[test] fn browser_sign_in_stays_read_only_until_someone_decides_otherwise() { // Pins the decision documented on BROWSER_SIGNIN_SCOPE. Widening it is