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 { .. }) + )); +}