Files
ruvnet--RuView/v2/crates/ruview-auth/tests/verifier_matrix.rs
T
Dragan Spiridonov 499cec7914 feat(auth): ruview-auth — offline Cognitum OAuth access-token verification (ADR-271)
RuView's `/api/v1/*` is gated today by `RUVIEW_API_TOKEN`: one shared secret,
no expiry, no per-user attribution. This adds the verifier half of replacing
that with Cognitum identity — RuView as an OAuth **resource server**, so a user
signs in to their own sensing server with their Cognitum account.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Ruflo & AQE
2026-07-22 15:08:40 +02:00

406 lines
14 KiB
Rust

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