From c2bd33e64938604dc36d1517d5e9c2b1ece52b48 Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Wed, 22 Jul 2026 15:33:22 +0200 Subject: [PATCH] feat(sensing-server): accept Cognitum OAuth on /api/v1/*, scope-gated (ADR-271 phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires `ruview-auth` into `bearer_auth.rs`. `RUVIEW_OAUTH_ISSUER` enables it; unset, nothing changes. Layering, in order: 1. `RUVIEW_API_TOKEN` set and the bearer matches exactly -> allow. Byte-for- byte today's behaviour. 2. Otherwise, if OAuth is configured, verify the bearer as a Cognitum access token and require the scope the route needs. 3. Otherwise 401. The static compare goes first for compatibility, not security: a matching static token is not a JWT and a JWT never matches the static token. It means an existing deployment behaves identically even with OAuth switched on. Scope gate (`required_scope_for`), split by blast radius per ADR-060 — "can this destroy something", not how many routes it covers: sensing:admin /api/v1/train/* (hours of Pi CPU, writes models) DELETE /api/v1/models/{id} (irreversible) DELETE /api/v1/recording/{id} (irreversible) sensing:read everything else Deliberately NOT admin: model load/unload and recording start. They mutate server state but destroy nothing, and gating them would push routine dashboard use into requesting delete capability — the opposite of least privilege. The legacy static token stays un-scope-gated. It predates scopes and carries no claims, so narrowing it would be a silent breaking change to deployments using it; migrating to OAuth is how an operator opts into the finer split. FAIL CLOSED at boot. If OAuth is requested but cannot work — empty issuer, or a JWKS we cannot fetch — the server logs why and exits rather than serving. Starting anyway would silently downgrade an operator who asked for OAuth to either an open API or a shared-secret one, with no signal it happened. The JWKS is warmed eagerly for the same reason: a bad `jwks_uri` should die at boot with a legible message, not surface as a puzzling 401 an hour later. The verified `Principal` is attached to request extensions, so handlers and audit logs can attribute a request (`sub`, `account_id`, `org_id`, `workspace_id`, `jti`) instead of knowing only "someone had the secret". That is the point of moving off a shared bearer. Verification failures are logged with the reason and returned as a flat 401 — the reason is useful to an operator and equally useful to an attacker probing for which claim to forge next. Also aligns `ruview_auth::extract_bearer` to match the scheme case-insensitively (RFC 7235 §2.1). The sensing server has always done this deliberately, with a comment saying why; the two layers disagreeing about what a valid header looks like would be a latent bug. Tests: 16 new in `bearer_auth::oauth_tests`, driving a real Router end to end (request -> middleware -> verifier -> handler) with ES256 tokens signed by a runtime-generated key. Covers the scope policy as a pure function, read-scoped tokens refused on delete and train, admin-scoped tokens allowed, an `inference`-only token from another Cognitum product refused on every route, garbage and absent bearers, both legacy-token layering directions, the principal reaching a handler, and the unset case remaining a no-op. `cargo test -p wifi-densepose-sensing-server --lib --no-default-features`: 501 passed, 0 failed. `ruview-auth`: 43 passed across both feature configs. Co-Authored-By: Ruflo & AQE --- v2/Cargo.lock | 4 + v2/crates/ruview-auth/src/verify.rs | 39 +- .../wifi-densepose-sensing-server/Cargo.toml | 11 + .../src/bearer_auth.rs | 606 +++++++++++++++++- .../wifi-densepose-sensing-server/src/main.rs | 30 +- 5 files changed, 651 insertions(+), 39 deletions(-) diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 9c8b14e5..e56013cb 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -11288,15 +11288,19 @@ name = "wifi-densepose-sensing-server" version = "0.3.4" dependencies = [ "axum", + "base64 0.21.7", "chrono", "clap", "criterion", "futures-util", + "jsonwebtoken", "midstreamer-attractor", "midstreamer-temporal-compare", + "p256", "proptest", "rumqttc", "ruvector-mincut", + "ruview-auth", "serde", "serde_json", "sha2", diff --git a/v2/crates/ruview-auth/src/verify.rs b/v2/crates/ruview-auth/src/verify.rs index 4dbe1506..a8b9071a 100644 --- a/v2/crates/ruview-auth/src/verify.rs +++ b/v2/crates/ruview-auth/src/verify.rs @@ -195,11 +195,20 @@ pub fn verify_access_token( } /// Extract a bearer token from an `Authorization` header value. +/// +/// The scheme is matched **case-insensitively** per RFC 7235 §2.1, and leading +/// whitespace before the token is tolerated. This mirrors what +/// `wifi-densepose-sensing-server`'s existing `bearer_auth` already does +/// deliberately, so a client sending `bearer`/`BEARER` is not rejected by one +/// layer and accepted by the other. The token itself is never normalised. pub fn extract_bearer(header_value: &str) -> Result<&str, VerifyError> { - let token = header_value - .strip_prefix("Bearer ") - .ok_or(VerifyError::MissingBearer)? - .trim(); + let (scheme, token) = header_value + .split_once(' ') + .ok_or(VerifyError::MissingBearer)?; + if !scheme.eq_ignore_ascii_case("Bearer") { + return Err(VerifyError::MissingBearer); + } + let token = token.trim(); if token.is_empty() { return Err(VerifyError::MissingBearer); } @@ -241,12 +250,24 @@ mod tests { } #[test] - fn extract_bearer_rejects_a_lowercase_scheme() { - // RFC 7235 makes the scheme case-insensitive, but every Cognitum client - // sends "Bearer". Accepting variants would widen the surface for no - // real-world caller, so this is a deliberate strictness. + fn extract_bearer_accepts_any_scheme_casing() { + // RFC 7235 §2.1: the auth-scheme is case-insensitive. The sensing + // server's own middleware already matches it that way on purpose, and + // the two layers must not disagree about what a valid header looks like. + for header in ["Bearer t.o.k", "bearer t.o.k", "BEARER t.o.k"] { + assert_eq!(extract_bearer(header).unwrap(), "t.o.k", "for {header:?}"); + } + } + + #[test] + fn extract_bearer_tolerates_extra_space_before_the_token() { + assert_eq!(extract_bearer("Bearer t.o.k").unwrap(), "t.o.k"); + } + + #[test] + fn extract_bearer_rejects_a_different_scheme() { assert!(matches!( - extract_bearer("bearer abc.def.ghi"), + extract_bearer("Basic dXNlcjpwYXNz"), Err(VerifyError::MissingBearer) )); } diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 712c3b7f..89007c3a 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -85,6 +85,11 @@ ureq = { version = "2", default-features = false, features = ["tls", "json" sha2 = "0.10" thiserror = "1" +# ADR-271 — Cognitum OAuth access-token verification. Reuses the `ureq` +# transport above rather than pulling a second HTTP stack: `ruview-auth`'s +# JWKS fetch sits behind a trait, and its default feature is the ureq one. +ruview-auth = { path = "../ruview-auth" } + # ADR-115 §3.8 — MQTT publisher (HA-DISCO). # Gated behind the `mqtt` feature so the default binary stays small for users # who don't need Home Assistant integration. `rumqttc` is the chosen Rust MQTT @@ -118,6 +123,12 @@ criterion = { version = "0.5", features = ["html_reports"] } # (random Unicode, control chars, etc.). Pinned to a small version that # doesn't pull in proptest-derive (we don't need it). proptest = { version = "1.5", default-features = false, features = ["std"] } +# ADR-271 — sign real ES256 tokens so the middleware's OAuth path is exercised +# end to end (router → middleware → verifier), not just mocked at the seam. +# Keys are generated at test runtime; none are committed. +jsonwebtoken = "9" +p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] } +base64 = "0.21" [[bench]] name = "mqtt_throughput" diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs index 011343ac..0e75a067 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs @@ -18,19 +18,59 @@ //! //! The header check uses a length-then-byte constant-time compare to avoid //! leaking the token through timing. +//! +//! # Cognitum OAuth (ADR-271) +//! +//! A second, **additive** credential is supported: a Cognitum OAuth access +//! token, verified offline against `auth.cognitum.one`'s published JWKS. It is +//! enabled by setting [`OAUTH_ISSUER_ENV`], and the two schemes layer: +//! +//! 1. If `RUVIEW_API_TOKEN` is set and the presented bearer matches it exactly, +//! the request is allowed — byte-for-byte today's behaviour. +//! 2. Otherwise, if OAuth is configured, the bearer is verified as a JWT and +//! must carry the scope the route requires. +//! 3. Otherwise `401`. +//! +//! Order matters for compatibility, not for security: a static token that +//! matches is not a JWT, and a JWT never matches the static token. Trying the +//! static compare first means an existing deployment's behaviour is unchanged +//! even with OAuth switched on. +//! +//! **Nothing here weakens the unset case.** With neither variable set the +//! middleware is the same no-op it has always been. +//! +//! ## Scope gating +//! +//! Not every route carries the same blast radius, so a single "authenticated" +//! bit is too coarse once we have scopes. [`required_scope_for`] maps a request +//! to `sensing:read` or `sensing:admin` — see its docs for the split and why it +//! is drawn where it is. use std::sync::Arc; use axum::{ extract::{Request, State}, - http::{header::AUTHORIZATION, StatusCode}, + http::{header::AUTHORIZATION, Method, StatusCode}, middleware::Next, response::{IntoResponse, Response}, }; +use ruview_auth::{scope, verify_access_token, JwksCache, UreqFetcher, VerifierConfig}; /// Environment variable that gates the middleware. Unset / empty ⇒ auth off. pub const API_TOKEN_ENV: &str = "RUVIEW_API_TOKEN"; +/// Issuer origin of the Cognitum authorization server. Setting this enables +/// OAuth verification; unset ⇒ OAuth off and behaviour is unchanged. +pub const OAUTH_ISSUER_ENV: &str = "RUVIEW_OAUTH_ISSUER"; + +/// Optional JWKS override. Defaults to `/.well-known/jwks.json`, which +/// is where RFC 8414 metadata points for `auth.cognitum.one`. Overridable so a +/// staging issuer or an air-gapped mirror can be pointed at without a rebuild. +pub const OAUTH_JWKS_URL_ENV: &str = "RUVIEW_OAUTH_JWKS_URL"; + +/// The production Cognitum issuer, for operators who just want it on. +pub const COGNITUM_ISSUER: &str = "https://auth.cognitum.one"; + /// Path prefix the middleware protects when auth is enabled. pub const PROTECTED_PREFIX: &str = "/api/v1/"; @@ -43,11 +83,41 @@ pub const PROTECTED_PREFIX: &str = "/api/v1/"; /// surface change for existing clients. const EXEMPT_PATHS: &[&str] = &["/api/v1/stream/pose"]; -/// Cheap, cloneable handle to the configured token (or `None`). +/// Cognitum OAuth verification state. Built once at boot and shared. +pub struct OAuthState { + jwks: JwksCache, + issuer: String, +} + +impl std::fmt::Debug for OAuthState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OAuthState") + .field("issuer", &self.issuer) + .finish_non_exhaustive() + } +} + +/// Why OAuth could not be configured. Every variant is fatal at boot — see +/// [`AuthState::from_env`]'s contract about failing closed. +#[derive(Debug, thiserror::Error)] +pub enum OAuthConfigError { + #[error("{OAUTH_ISSUER_ENV} is set but empty")] + EmptyIssuer, + #[error("JWKS at {url} is unreachable, so no token could ever be verified: {source}")] + JwksUnreachable { + url: String, + #[source] + source: ruview_auth::JwksError, + }, +} + +/// Cheap, cloneable handle to the configured credentials. #[derive(Debug, Clone, Default)] pub struct AuthState { - /// The expected bearer token, if any. `None` ⇒ middleware is a no-op. + /// The expected static bearer token, if any. token: Option>, + /// Cognitum OAuth verification, if enabled. + oauth: Option>, } impl AuthState { @@ -55,29 +125,108 @@ impl AuthState { pub fn from_token(t: impl Into) -> Self { let s = t.into(); if s.is_empty() { - AuthState { token: None } + AuthState::default() } else { AuthState { token: Some(Arc::new(s)), + oauth: None, } } } - /// Read [`API_TOKEN_ENV`] from the process environment. Returns - /// `AuthState { token: None }` when the variable is unset or empty. - pub fn from_env() -> Self { - match std::env::var(API_TOKEN_ENV) { - Ok(s) if !s.is_empty() => AuthState::from_token(s), - _ => AuthState::default(), - } + /// Read the auth configuration from the process environment. + /// + /// **Fails closed.** If OAuth is requested but cannot be made to work — the + /// issuer is empty, or the JWKS cannot be fetched at boot — this returns + /// `Err` and the caller must refuse to serve `/api/v1/*`. Starting anyway + /// would mean an operator who asked for OAuth silently gets either an open + /// API or a single-shared-secret one, which is precisely the failure mode + /// that makes people distrust an auth switch. + /// + /// The JWKS is fetched eagerly for the same reason: a misconfigured + /// `jwks_uri` should fail at boot with a legible message, not as a puzzling + /// 401 on some user's first request an hour later. + pub fn from_env() -> Result { + let token = match std::env::var(API_TOKEN_ENV) { + Ok(s) if !s.is_empty() => Some(Arc::new(s)), + _ => None, + }; + + let oauth = match std::env::var(OAUTH_ISSUER_ENV) { + Ok(issuer) if !issuer.trim().is_empty() => { + let issuer = issuer.trim().trim_end_matches('/').to_string(); + let jwks_url = std::env::var(OAUTH_JWKS_URL_ENV) + .ok() + .filter(|u| !u.trim().is_empty()) + .unwrap_or_else(|| format!("{issuer}/.well-known/jwks.json")); + + let jwks = JwksCache::new(jwks_url.clone(), Box::new(UreqFetcher::new())); + let key_count = + jwks.warm() + .map_err(|source| OAuthConfigError::JwksUnreachable { + url: jwks_url.clone(), + source, + })?; + tracing::info!( + issuer = %issuer, + jwks_url = %jwks_url, + key_count, + "Cognitum OAuth enabled for /api/v1/*" + ); + Some(Arc::new(OAuthState { jwks, issuer })) + } + Ok(_) => return Err(OAuthConfigError::EmptyIssuer), + Err(_) => None, + }; + + Ok(AuthState { token, oauth }) } /// Whether the middleware will enforce auth on `/api/v1/*` requests. pub fn is_enabled(&self) -> bool { + self.token.is_some() || self.oauth.is_some() + } + + /// Whether Cognitum OAuth verification is active. + pub fn oauth_enabled(&self) -> bool { + self.oauth.is_some() + } + + /// Whether the legacy static `RUVIEW_API_TOKEN` is configured. + pub fn static_token_enabled(&self) -> bool { self.token.is_some() } } +/// The scope a request must carry, split by **blast radius** (ADR-060): can +/// this call destroy something, or only observe? +/// +/// `sensing:admin` covers exactly three things: +/// * `/api/v1/train/*` — burns hours of CPU on a Pi and writes models; +/// * `DELETE /api/v1/models/{id}` — irreversible loss of a trained model; +/// * `DELETE /api/v1/recording/{id}` — irreversible loss of a labelled capture. +/// +/// Everything else is `sensing:read`. Note what is deliberately *not* admin: +/// loading/unloading a model and starting a recording both mutate server state +/// but destroy nothing, and putting them behind the destructive scope would +/// push routine dashboard use into asking for a capability it does not need — +/// the opposite of least privilege. +/// +/// `sensing:read` is not "harmless": for a presence and vital-signs sensor, +/// read access tells the holder who is home. It is *non-destructive*, which is +/// a weaker claim. +pub fn required_scope_for(method: &Method, path: &str) -> &'static str { + if path.starts_with("/api/v1/train/") { + return scope::SENSING_ADMIN; + } + if method == Method::DELETE + && (path.starts_with("/api/v1/models/") || path.starts_with("/api/v1/recording/")) + { + return scope::SENSING_ADMIN; + } + scope::SENSING_READ +} + /// Constant-time byte slice equality. Returns `false` immediately on length /// mismatch (lengths are not secret here — both sides are fixed tokens). fn ct_eq(a: &[u8], b: &[u8]) -> bool { @@ -96,17 +245,18 @@ fn ct_eq(a: &[u8], b: &[u8]) -> bool { /// [`axum::middleware::from_fn_with_state`]. pub async fn require_bearer( State(auth): State, - request: Request, + mut request: Request, next: Next, ) -> Response { - let Some(expected) = auth.token.clone() else { + if !auth.is_enabled() { return next.run(request).await; - }; + } let path = request.uri().path(); if !path.starts_with(PROTECTED_PREFIX) || EXEMPT_PATHS.contains(&path) { return next.run(request).await; } - let supplied = request + + let Some(supplied) = request .headers() .get(AUTHORIZATION) .and_then(|v| v.to_str().ok()) @@ -120,21 +270,78 @@ pub async fn require_bearer( scheme .eq_ignore_ascii_case("Bearer") .then(|| token.trim_start()) - }); - let ok = supplied - .map(|s| ct_eq(s.as_bytes(), expected.as_bytes())) - .unwrap_or(false); - if ok { - next.run(request).await - } else { - ( - StatusCode::UNAUTHORIZED, - "missing or invalid bearer token (set Authorization: Bearer )\n", - ) - .into_response() + }) + else { + return unauthorized(&auth); + }; + + // 1. Legacy static token. Unchanged, and tried first so an existing + // deployment behaves identically even with OAuth switched on. + if let Some(expected) = auth.token.as_ref() { + if ct_eq(supplied.as_bytes(), expected.as_bytes()) { + return next.run(request).await; + } } + + // 2. Cognitum OAuth (ADR-271). + if let Some(oauth) = auth.oauth.as_ref() { + let required = required_scope_for(request.method(), path); + let config = VerifierConfig { + issuer: oauth.issuer.clone(), + required_scope: required.to_string(), + }; + match verify_access_token(supplied, &oauth.jwks, &config) { + Ok(principal) => { + tracing::debug!( + sub = %principal.subject, + account_id = %principal.account_id, + client_id = %principal.client_id, + jti = %principal.token_id, + scope = %required, + path = %path, + "OAuth request authorized" + ); + // Downstream handlers can attribute the request without + // re-parsing the token. + request.extensions_mut().insert(principal); + return next.run(request).await; + } + Err(e) => { + // Logged, never returned: the reason a token failed is useful + // to an operator and useful to an attacker probing for which + // claim to forge next. The response stays a flat 401. + tracing::debug!(error = %e, path = %path, required_scope = %required, "OAuth verification failed"); + return unauthorized(&auth); + } + } + } + + unauthorized(&auth) } +/// A uniform 401. The hint names whichever credentials are actually accepted, +/// so an operator is not told to set a variable this server ignores — but it +/// never says *why* a presented token failed. +fn unauthorized(auth: &AuthState) -> Response { + let body = match (auth.token.is_some(), auth.oauth.is_some()) { + (true, true) => concat!( + "missing or invalid bearer token\n", + "accepted: Authorization: Bearer , ", + "or a Cognitum OAuth access token with the scope this route requires\n" + ), + (false, true) => concat!( + "missing or invalid bearer token\n", + "accepted: a Cognitum OAuth access token with the scope this route requires\n" + ), + _ => "missing or invalid bearer token (set Authorization: Bearer )\n", + }; + (StatusCode::UNAUTHORIZED, body).into_response() +} + +/// Convenience re-export so handlers can name the type they pull out of +/// request extensions without depending on `ruview-auth` directly. +pub use ruview_auth::Principal as AuthenticatedPrincipal; + #[cfg(test)] mod tests { use super::*; @@ -416,3 +623,348 @@ mod tests { assert_eq!(PROTECTED_PREFIX, "/api/v1/"); } } + +/// ADR-271 — the OAuth path and the scope gate, exercised end to end through a +/// real Router: request → middleware → `ruview-auth` verifier → handler. +/// +/// Tokens are real ES256 JWTs signed with a key generated at test runtime; no +/// key material is committed. The verifier's own accept/reject matrix lives in +/// `ruview-auth`; what is tested here is the wiring — layering with the legacy +/// static token, which scope each route demands, and that a rejected token +/// never reaches a handler. +#[cfg(test)] +mod oauth_tests { + use super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::{delete, get, post}, + Router, + }; + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use jsonwebtoken::{encode, EncodingKey, Header}; + use p256::ecdsa::SigningKey; + use p256::pkcs8::{EncodePrivateKey, LineEnding}; + use ruview_auth::jwks::{JwksError, JwksFetcher}; + use std::sync::OnceLock; + use tower::ServiceExt; + + const KID: &str = "test-kid"; + const ISSUER: &str = "https://auth.test.local"; + + struct TestKey { + pem: String, + x: String, + y: String, + } + + fn key() -> &'static TestKey { + static K: OnceLock = OnceLock::new(); + K.get_or_init(|| { + let sk = SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng); + let point = sk.verifying_key().to_encoded_point(false); + TestKey { + pem: sk.to_pkcs8_pem(LineEnding::LF).unwrap().to_string(), + x: URL_SAFE_NO_PAD.encode(point.x().unwrap()), + y: URL_SAFE_NO_PAD.encode(point.y().unwrap()), + } + }) + } + + struct StaticJwks(String); + impl JwksFetcher for StaticJwks { + fn fetch(&self, _url: &str) -> Result { + Ok(self.0.clone()) + } + } + + fn oauth_state() -> Arc { + let k = key(); + let doc = format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","alg":"ES256","use":"sig","kid":"{KID}","x":"{}","y":"{}"}}]}}"#, + k.x, k.y + ); + Arc::new(OAuthState { + jwks: JwksCache::new("https://stub/jwks.json", Box::new(StaticJwks(doc))), + issuer: ISSUER.to_string(), + }) + } + + fn token_with_scope(scope_claim: &str) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let claims = serde_json::json!({ + "typ": "access", + "sub": "user-1", + "account_id": "acct-1", + "org_id": "org-1", + "workspace_id": "ws-1", + "client_id": "ruview", + "scope": scope_claim, + "jti": "jti-1", + "iat": now - 10, + "exp": now + 900, + "setup": false, + "workload": false, + "iss": ISSUER, + }); + let mut header = Header::new(jsonwebtoken::Algorithm::ES256); + header.kid = Some(KID.to_string()); + encode( + &header, + &claims, + &EncodingKey::from_ec_pem(key().pem.as_bytes()).unwrap(), + ) + .unwrap() + } + + /// Mirrors the real route shapes the scope gate keys off. + fn app(auth: AuthState) -> Router { + Router::new() + .route("/api/v1/info", get(|| async { "ok" })) + .route("/api/v1/models", get(|| async { "ok" })) + .route("/api/v1/models/m1", delete(|| async { "deleted" })) + .route("/api/v1/recording/r1", delete(|| async { "deleted" })) + .route("/api/v1/train/start", post(|| async { "training" })) + .layer(axum::middleware::from_fn_with_state(auth, require_bearer)) + } + + async fn call(auth: AuthState, method: &str, path: &str, bearer: Option<&str>) -> StatusCode { + let mut req = Request::builder().method(method).uri(path); + if let Some(b) = bearer { + req = req.header(AUTHORIZATION, format!("Bearer {b}")); + } + app(auth) + .oneshot(req.body(Body::empty()).unwrap()) + .await + .unwrap() + .status() + } + + fn oauth_only() -> AuthState { + AuthState { + token: None, + oauth: Some(oauth_state()), + } + } + + // ── scope policy (pure) ─────────────────────────────────────────── + + #[test] + fn training_requires_the_admin_scope() { + assert_eq!( + required_scope_for(&Method::POST, "/api/v1/train/start"), + scope::SENSING_ADMIN + ); + } + + #[test] + fn deleting_a_model_or_recording_requires_the_admin_scope() { + assert_eq!( + required_scope_for(&Method::DELETE, "/api/v1/models/m1"), + scope::SENSING_ADMIN + ); + assert_eq!( + required_scope_for(&Method::DELETE, "/api/v1/recording/r1"), + scope::SENSING_ADMIN + ); + } + + #[test] + fn reading_models_is_not_admin_merely_because_the_path_matches() { + // The gate is (method, path), not path alone — GET on the same prefix + // must stay a read. + assert_eq!( + required_scope_for(&Method::GET, "/api/v1/models/m1"), + scope::SENSING_READ + ); + } + + #[test] + fn non_destructive_mutations_stay_read_scoped() { + // Loading a model changes server state but destroys nothing. Putting it + // behind the destructive scope would push routine dashboard use into + // asking for delete capability — the opposite of least privilege. + assert_eq!( + required_scope_for(&Method::POST, "/api/v1/models/load"), + scope::SENSING_READ + ); + assert_eq!( + required_scope_for(&Method::POST, "/api/v1/recording/start"), + scope::SENSING_READ + ); + } + + // ── wiring ──────────────────────────────────────────────────────── + + #[tokio::test] + async fn a_read_scoped_token_reaches_a_read_route() { + let t = token_with_scope(scope::SENSING_READ); + assert_eq!( + call(oauth_only(), "GET", "/api/v1/info", Some(&t)).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn a_read_scoped_token_cannot_delete_a_model() { + // The whole point of the split: a dashboard session streaming poses + // must not be able to destroy the model it streams through. + let t = token_with_scope(scope::SENSING_READ); + assert_eq!( + call(oauth_only(), "DELETE", "/api/v1/models/m1", Some(&t)).await, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn a_read_scoped_token_cannot_start_training() { + let t = token_with_scope(scope::SENSING_READ); + assert_eq!( + call(oauth_only(), "POST", "/api/v1/train/start", Some(&t)).await, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn an_admin_scoped_token_may_delete_and_train() { + let t = token_with_scope("sensing:read sensing:admin"); + assert_eq!( + call(oauth_only(), "DELETE", "/api/v1/models/m1", Some(&t)).await, + StatusCode::OK + ); + assert_eq!( + call(oauth_only(), "POST", "/api/v1/train/start", Some(&t)).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn an_inference_token_from_another_cognitum_product_is_refused_everywhere() { + // The cross-product case. Correctly signed, unexpired, right issuer — + // only the scope stops it. Asserted here at the middleware layer too, + // because this is where a wiring mistake would actually let it through. + let t = token_with_scope("inference"); + for (m, p) in [ + ("GET", "/api/v1/info"), + ("DELETE", "/api/v1/models/m1"), + ("POST", "/api/v1/train/start"), + ] { + assert_eq!( + call(oauth_only(), m, p, Some(&t)).await, + StatusCode::UNAUTHORIZED, + "{m} {p} must reject an inference-only token" + ); + } + } + + #[tokio::test] + async fn a_garbage_bearer_is_refused_when_only_oauth_is_configured() { + assert_eq!( + call(oauth_only(), "GET", "/api/v1/info", Some("not-a-jwt")).await, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn no_credential_is_refused_when_only_oauth_is_configured() { + assert_eq!( + call(oauth_only(), "GET", "/api/v1/info", None).await, + StatusCode::UNAUTHORIZED + ); + } + + // ── layering with the legacy static token ───────────────────────── + + fn both() -> AuthState { + AuthState { + token: Some(Arc::new("legacy-secret".to_string())), + oauth: Some(oauth_state()), + } + } + + #[tokio::test] + async fn the_legacy_static_token_still_works_with_oauth_enabled() { + // Backward compatibility: turning OAuth on must not break a deployment + // that has been using RUVIEW_API_TOKEN. + assert_eq!( + call(both(), "GET", "/api/v1/info", Some("legacy-secret")).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn the_legacy_static_token_is_not_scope_gated() { + // It predates scopes and carries no claims, so it keeps the full + // access it has always had. Narrowing it here would be a silent + // breaking change to existing deployments; migrating to OAuth is how + // an operator opts into the finer split. + assert_eq!( + call(both(), "POST", "/api/v1/train/start", Some("legacy-secret")).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn an_oauth_token_works_alongside_a_configured_static_token() { + let t = token_with_scope(scope::SENSING_READ); + assert_eq!( + call(both(), "GET", "/api/v1/info", Some(&t)).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn a_wrong_static_token_falls_through_to_oauth_and_is_refused() { + assert_eq!( + call(both(), "GET", "/api/v1/info", Some("wrong-secret")).await, + StatusCode::UNAUTHORIZED + ); + } + + // ── attribution ─────────────────────────────────────────────────── + + #[tokio::test] + async fn the_verified_principal_is_available_to_handlers() { + // The reason for moving off a shared secret: requests become + // attributable. If the principal is not in extensions, no handler and + // no audit log can name who called. + async fn echo(req: Request) -> String { + match req.extensions().get::() { + Some(p) => format!("{}|{}|{}", p.subject, p.account_id, p.client_id), + None => "none".to_string(), + } + } + let router = Router::new() + .route("/api/v1/whoami", get(echo)) + .layer(axum::middleware::from_fn_with_state( + oauth_only(), + require_bearer, + )); + let t = token_with_scope(scope::SENSING_READ); + let resp = router + .oneshot( + Request::builder() + .uri("/api/v1/whoami") + .header(AUTHORIZATION, format!("Bearer {t}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + assert_eq!(String::from_utf8_lossy(&body), "user-1|acct-1|ruview"); + } + + // ── the unset case must stay untouched ──────────────────────────── + + #[tokio::test] + async fn with_neither_credential_configured_the_middleware_is_still_a_no_op() { + assert_eq!( + call(AuthState::default(), "POST", "/api/v1/train/start", None).await, + StatusCode::OK + ); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 8acf6c79..57283677 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -7920,9 +7920,33 @@ async fn main() { // #443: optional bearer-token auth on `/api/v1/*`. `RUVIEW_API_TOKEN` // unset/empty ⇒ middleware is a no-op (LAN-mode default preserved); set ⇒ // every `/api/v1/*` request must carry `Authorization: Bearer `. - let bearer_auth_state = wifi_densepose_sensing_server::bearer_auth::AuthState::from_env(); + // + // ADR-271: additionally, `RUVIEW_OAUTH_ISSUER` enables Cognitum OAuth + // verification alongside (not instead of) the static token. + // + // FAIL CLOSED. If OAuth was requested but cannot work — empty issuer, or a + // JWKS we cannot fetch at boot — we exit rather than serve. Starting anyway + // would silently downgrade an operator who asked for OAuth to either an + // open API or a single-shared-secret one, and they would have no signal + // that it happened. A loud death at boot is the kind thing here. + let bearer_auth_state = + match wifi_densepose_sensing_server::bearer_auth::AuthState::from_env() { + Ok(s) => s, + Err(e) => { + error!( + "API auth: OAuth was requested but cannot be initialised: {e}. \ + Refusing to start — unset RUVIEW_OAUTH_ISSUER to run without it." + ); + std::process::exit(1); + } + }; if bearer_auth_state.is_enabled() { - info!("API auth: bearer-token enforcement ON for /api/v1/* (RUVIEW_API_TOKEN set)"); + if bearer_auth_state.oauth_enabled() { + info!("API auth: ON for /api/v1/* — Cognitum OAuth (ADR-271){}", + if bearer_auth_state.static_token_enabled() { " + static RUVIEW_API_TOKEN" } else { "" }); + } else { + info!("API auth: bearer-token enforcement ON for /api/v1/* (RUVIEW_API_TOKEN set)"); + } if bind_ip.is_unspecified() { warn!( "API auth ON but bind-addr is {} — consider --bind-addr 127.0.0.1 for LAN-only deployments", @@ -7931,7 +7955,7 @@ async fn main() { } } else { info!( - "API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN= to enforce bearer auth." + "API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN= or RUVIEW_OAUTH_ISSUER= to enforce auth." ); }