mirror of
https://github.com/ruvnet/RuView
synced 2026-08-02 19:11:46 +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:
@@ -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)
|
||||
));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user