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:
Dragan Spiridonov
2026-07-22 15:33:22 +02:00
parent 92cbeb0c34
commit c2bd33e649
5 changed files with 651 additions and 39 deletions
+30 -9
View File
@@ -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)
));
}