feat(auth): ruview-auth — offline Cognitum OAuth access-token verification (ADR-271)

RuView's `/api/v1/*` is gated today by `RUVIEW_API_TOKEN`: one shared secret,
no expiry, no per-user attribution. This adds the verifier half of replacing
that with Cognitum identity — RuView as an OAuth **resource server**, so a user
signs in to their own sensing server with their Cognitum account.

Note the direction: RuView does not call Cognitum. It never obtains a token for
its own use; it verifies tokens users present. Every other Cognitum OAuth
integration in the org (meta-proxy, musica, metaharness, the dashboard CLI) is
the client side of a plane RuView doesn't have. The one existing resource-server
verifier is `meta-llm/src/auth/oauthBearer.ts`, and this crate is a port of its
accept-rule — divergence would be a bug, not a preference: a token meta-llm
rejects must not be one RuView accepts.

Verification is OFFLINE, and that is a requirement rather than an optimisation.
RuView runs on Pi-class hardware that loses WAN, and identity publishes no
introspection endpoint even when the network is up. So: ES256 over identity's
published JWKS, cached by `kid`.

What it accepts, mirroring oauthBearer.ts:
  typ == "access" AND NOT setup AND NOT workload AND account_id non-empty AND
  exp in the future AND iss matches verbatim AND the required scope is held.

Long-lived setup (365-day) and workload credentials are refused outright for
identity's own stated reason: their revocation lives in `oauth_setup_tokens`,
and RuView — like meta-llm — has no database to check it. A 15-minute access
token needs no revocation round-trip because it expires faster than revocation
propagates; a 365-day one does.

Scope is the capability boundary, and it has to be. Cognitum access tokens carry
no `aud`, and `client_id` cannot substitute because clients borrow each other's
registrations (musica ships DEFAULT_CLIENT_ID = "meta-proxy"). `sensing:read`
covers streams and inference; `sensing:admin` covers training, model delete and
recording delete. No hierarchy — admin does not imply read; consent means what
it said. Registered in identity migration 0016 (cognitum-one/dashboard#116).

Design notes:
- `ureq`, not `reqwest`: the sensing server deliberately chose ureq as "the
  smallest" HTTP client, and pulling reqwest in here would reverse that for the
  whole graph. Transport sits behind a `JwksFetcher` trait, so a host can supply
  its own and take no HTTP dependency at all (feature `ureq-transport`, default).
- A JWKS refetch failure is survivable while a key set is already cached: a key
  that verified a minute ago has not stopped being valid because the network
  blipped, and failing closed there logs every user out of their own sensing
  server whenever their internet wobbles. We fail closed in exactly one case —
  no key set has ever been fetched.
- Unknown `kid` forces one refetch so rotation is picked up without waiting out
  the TTL, rate-limited so junk-kid tokens can't become a request amplifier
  aimed at identity.
- Expiry is reported distinctly from a bad signature: on an RTC-less Pi that is
  usually a clock-sync fault, and an operator must be able to tell them apart.
- Test keypairs are generated at runtime, never committed. This repo tracks zero
  `.pem` files and committed key material shouldn't start here — it also means
  no fixture can drift out of sync with the JWKS it's served by.

Tests: 41 green (19 unit + 21 matrix + 1 doctest) under BOTH
`cargo test --no-default-features` (the repo's canonical gate) and default
features. The matrix signs real ES256 tokens rather than asserting on strings,
and covers alg:none, forged signature, spliced payload, unknown kid, expired,
leeway boundaries both sides, wrong issuer, issuer differing only by a trailing
slash, missing/!=access typ, setup and workload smuggled onto typ=access,
missing and empty account_id, and scope escalation.

The highest-value case is `g2_a_genuinely_valid_token_from_another_cognitum_
product_cannot_reach_the_sensing_surface`: a correctly signed, unexpired,
right-issuer, right-typ token with client_id=meta-proxy and scope=inference is
rejected. Nothing about its signature or identity claims distinguishes it — only
scope does. A naive verifier accepts it, and an `inference` token becomes a key
to someone's home sensor.

No wiring into the sensing server yet; this crate is verification only, with no
login flow and no outbound Cognitum calls.

Co-Authored-By: Ruflo & AQE
This commit is contained in:
Dragan Spiridonov
2026-07-22 15:08:40 +02:00
parent aa7cb449bc
commit 499cec7914
8 changed files with 1538 additions and 0 deletions
Generated
+160
View File
@@ -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"
+6
View File
@@ -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
+42
View File
@@ -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"
+448
View File
@@ -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<String, JwksError>;
}
/// 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<String>,
kty: String,
crv: Option<String>,
x: Option<String>,
y: Option<String>,
}
#[derive(Debug, Deserialize)]
struct JwksDocument {
keys: Vec<Jwk>,
}
struct CacheState {
keys: HashMap<String, DecodingKey>,
fetched_at: Option<Instant>,
last_forced_refetch: Option<Instant>,
}
/// `kid`-indexed JWKS cache.
pub struct JwksCache {
url: String,
ttl: Duration,
fetcher: Box<dyn JwksFetcher>,
state: Mutex<CacheState>,
}
impl JwksCache {
pub fn new(url: impl Into<String>, fetcher: Box<dyn JwksFetcher>) -> Self {
Self::with_ttl(url, fetcher, DEFAULT_CACHE_TTL)
}
pub fn with_ttl(url: impl Into<String>, fetcher: Box<dyn JwksFetcher>, 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<usize, JwksError> {
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<DecodingKey, JwksError> {
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<HashMap<String, DecodingKey>, 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<HashMap<String, DecodingKey>, 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<String, JwksError> {
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<Mutex<String>>,
calls: Arc<AtomicUsize>,
offline: Arc<Mutex<bool>>,
}
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<StubFetcher> {
Box::new(StubFetcher(self.clone()))
}
}
struct StubFetcher(StubControl);
impl JwksFetcher for StubFetcher {
fn fetch(&self, _url: &str) -> Result<String, JwksError> {
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");
}
}
+74
View File
@@ -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<String, JwksError> {
//! # let _ = url;
//! // ... GET `url`, return the body ...
//! # unimplemented!()
//! }
//! }
//!
//! let jwks = JwksCache::new(
//! "https://auth.cognitum.one/.well-known/jwks.json",
//! Box::new(MyFetcher),
//! );
//! // Fail at boot, not on a user's first request.
//! jwks.warm().expect("JWKS reachable at startup");
//!
//! let config = VerifierConfig {
//! issuer: "https://auth.cognitum.one".to_string(),
//! required_scope: scope::SENSING_READ.to_string(),
//! };
//!
//! let principal = verify_access_token("<jwt>", &jwks, &config)?;
//! println!("{} on account {}", principal.subject, principal.account_id);
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
//!
//! ## Scope is the capability boundary
//!
//! Cognitum access tokens carry no `aud`, and `client_id` is unreliable because
//! clients borrow each other's registrations. So the scope claim is the only
//! thing separating "may watch the sensing stream" from "may delete the trained
//! model". Callers pick [`VerifierConfig::required_scope`] per route:
//! [`scope::SENSING_READ`] for streams and inference,
//! [`scope::SENSING_ADMIN`] for training, model delete and recording delete.
//!
//! ## What this crate deliberately does not do
//!
//! - **No login flow.** 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};
+142
View File
@@ -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<String>,
/// `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<Item = &str> {
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));
}
}
+261
View File
@@ -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<String> },
#[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<String>,
sub: String,
#[serde(default)]
account_id: Option<String>,
#[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<Principal, VerifyError> {
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::<AccessTokenClaims>(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)
));
}
}
@@ -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<TestKey> = 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<TestKey> = 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<String, JwksError> {
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<ruview_auth::Principal, VerifyError> {
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 { .. })
));
}