mirror of
https://github.com/ruvnet/RuView
synced 2026-07-22 17:23:19 +00:00
feat(sensing-server): accept Cognitum OAuth on /api/v1/*, scope-gated (ADR-271 phase 3)
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
This commit is contained in:
Generated
+4
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 `<issuer>/.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<Arc<String>>,
|
||||
/// Cognitum OAuth verification, if enabled.
|
||||
oauth: Option<Arc<OAuthState>>,
|
||||
}
|
||||
|
||||
impl AuthState {
|
||||
@@ -55,29 +125,108 @@ impl AuthState {
|
||||
pub fn from_token(t: impl Into<String>) -> 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<Self, OAuthConfigError> {
|
||||
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<AuthState>,
|
||||
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 <RUVIEW_API_TOKEN>)\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 <RUVIEW_API_TOKEN>, ",
|
||||
"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 <RUVIEW_API_TOKEN>)\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<TestKey> = 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<String, JwksError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn oauth_state() -> Arc<OAuthState> {
|
||||
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<Body>) -> String {
|
||||
match req.extensions().get::<ruview_auth::Principal>() {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <token>`.
|
||||
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=<token> to enforce bearer auth."
|
||||
"API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN=<token> or RUVIEW_OAUTH_ISSUER=<issuer> to enforce auth."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user