Files
ruvnet--RuView/v2/crates/ruview-auth/tests/verifier_matrix.rs
T
Dragan Spiridonov 12635a85b2 fix(auth): stop requiring an iss claim Cognitum never issues (G-3 blocker)
The verifier called `Validation::set_issuer` and listed `iss` in
`set_required_spec_claims`. Cognitum access tokens have **no `iss` claim** — the
real claim set is typ, sub, account_id, org_id, workspace_id, client_id, scope,
family_id, jti, iat, exp, setup, workload. So the verifier rejected every
genuine token with a flat 401.

The whole 41-test suite was green throughout, because the test fixtures included
an `iss` the real thing does not have. The tests validated my assumption instead
of the platform. Found only by pointing a real token at a real server; no amount
of additional unit testing against the same wrong fixture would have caught it.

`valid_claims()` now mirrors production exactly, with a comment saying why, so
the next person cannot reintroduce the drift by "fixing" an incomplete-looking
fixture.

What binds a token to its issuer, then: the JWKS. We accept only signatures made
by a key served from the configured jwks_uri, so a valid signature IS proof of
issuer. meta-llm's verifier — the org's only other resource-side verifier —
checks neither `iss` nor `aud`, for the same reason. `VerifierConfig.issuer`
stays, now documented as JWKS-derivation and logging rather than a claim
assertion.

Three tests replace the two that asserted issuer behaviour that cannot exist:
a real-shaped (iss-less) token verifies; an unrelated `iss` changes nothing (so
identity adding one later cannot silently start failing tokens); and a token
signed by a different key is still refused — the complement that proves removing
the issuer check did not remove the boundary.

Also: report the granted scope from the token's `scope` claim when the
/oauth/token envelope omits it, which identity's does. `login` and `whoami` said
"(not reported by the server)" while the authoritative answer sat inside the
token they had just stored. The claim-peek helper is display-only and documented
at length as NOT verification — a client reading its own freshly issued token is
a different situation from a server reading a stranger's.

VERIFIED END TO END against production (gate G-3, previously open):
  no credential            GET  /api/v1/models        -> 401
  garbage bearer           GET  /api/v1/models        -> 401
  real sensing:read token  GET  /api/v1/models        -> 200
  real sensing:read token  GET  /api/v1/recording/list-> 200
  real sensing:read token  POST /api/v1/train/start   -> 401
  real sensing:read token  POST /api/v1/train/stop    -> 401
Token obtained by a human through `wifi-densepose login` against
auth.cognitum.one; server ran with RUVIEW_OAUTH_ISSUER set, JWKS fetched live
at boot (key_count=1).

Tests: 82 with --features login (58 unit + 22 matrix + 2 doctests).

Co-Authored-By: Ruflo & AQE
2026-07-22 18:11:21 +02:00

428 lines
15 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,
// NOTE: no `iss`. Real Cognitum access tokens carry none — verified
// against production. An earlier fixture added one, the verifier was
// built to require it, and the whole suite passed while rejecting every
// genuine token. Fixtures mirror production or they prove nothing.
})
}
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_with_no_iss_claim_is_accepted_because_cognitum_issues_none() {
// THE regression test for this module's worst bug to date.
//
// An earlier revision required and validated `iss`. Cognitum access tokens
// have no `iss` claim, so that rejected every real token — while the suite
// stayed green, because the fixtures had an `iss` the real thing lacks.
// `valid_claims()` now mirrors production, so this passing means the
// verifier accepts the shape that actually exists.
let claims = valid_claims();
assert!(
claims.get("iss").is_none(),
"fixture must mirror production, which emits no iss"
);
verify(&sign(&claims), scope::SENSING_READ).expect("a real-shaped token must verify");
}
#[test]
fn an_unrelated_iss_claim_does_not_change_the_outcome() {
// If identity ever starts emitting `iss`, we neither require nor reject on
// it — the JWKS is the issuer binding. This pins that adding the claim
// cannot silently start failing tokens.
let mut c = valid_claims();
c["iss"] = json!("https://something.else.example");
verify(&sign(&c), scope::SENSING_READ)
.expect("issuer binding is the JWKS, not a claim");
}
#[test]
fn the_jwks_is_what_actually_binds_a_token_to_its_issuer() {
// The complement of the two above: with no `iss` check, the signature is
// the ONLY thing standing between us and a forged token. A different key
// must therefore be refused — otherwise removing the issuer check would
// have removed the boundary entirely.
let token = sign_with(&valid_claims(), other_key());
assert!(matches!(
verify(&token, scope::SENSING_READ),
Err(VerifyError::BadSignature)
));
}
#[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 { .. })
));
}