mirror of
https://github.com/ruvnet/RuView
synced 2026-07-29 18:31:44 +00:00
0547fd7344
Found by reading cognitum-one/freetokens, a live sibling service whose browser OAuth landed while this PR was open. Its integration contract states the platform rule outright: "Cognitum access tokens intentionally use custom `client_id` rather than a registered JWT `aud` claim." -- freetokens docs/AUTH_INTEGRATION.md and `src/auth/oauth.ts` enforces it on every sign-in: payload.client_id !== config.OAUTH_CLIENT_ID -> reject RuView did not. An earlier revision here removed the `client_id` check and kept it only for logging, reasoning that clients borrow one another's registrations (musica shipped as `meta-proxy` while its own was pending) and that scope alone must therefore carry the boundary. That reasoned from a TRANSITIONAL state: RuView has its own registered client (identity migration 0017), and the platform does have an audience mechanism — it is simply spelled `client_id`. Consequence of the old behaviour: a Cognitum access token minted for ANY product — meta-proxy, musica, metaharness, freetokens — was accepted by a RuView server provided it carried a sensing scope. Scope was the only thing standing between another product's token and this one. Now there are two boundaries, audience and capability, which is what the platform intends. - `VerifierConfig.allowed_client_ids`; empty = accept any (explicit opt-out). - `RUVIEW_OAUTH_CLIENT_IDS` env, default `ruview`, `*` to disable with a loud warning naming what is being given up. Comma-separated for the migration case where a borrowed registration must be accepted alongside our own. - New `VerifyError::WrongAudience`, checked BEFORE scope, so the failure names the real reason rather than blaming the scope. The existing cross-product test now asserts `WrongAudience` rather than `MissingScope` — the token is refused for the stronger reason. Three new tests: a correctly-scoped token from another product is still refused; the empty-list opt-out accepts anything (pinned so it stays deliberate); multiple allowed clients work. This also corrects the module docs and ADR-271, which claimed "scope is the ONLY capability boundary" — true of the code as written, but not of the platform. Tests: 85 ruview-auth, 533 sensing-server. Co-Authored-By: Ruflo & AQE
332 lines
13 KiB
Rust
332 lines
13 KiB
Rust
//! Cognitum OAuth access-token verification (ADR-271).
|
|
//!
|
|
//! The accept-rule is ported from `meta-llm/src/auth/oauthBearer.ts` (ADR-045),
|
|
//! the only other resource-server-side verifier of these tokens in the org.
|
|
//! Divergence from it would be a bug, not a preference — a token meta-llm
|
|
//! rejects must not be one RuView accepts.
|
|
//!
|
|
//! ## The trust chain, narrowly
|
|
//!
|
|
//! 1. Only identity's ES256 key — fetched from the published JWKS by `kid` —
|
|
//! can sign an accepted token. No shared secret, no static PEM to leak.
|
|
//! 2. **The algorithm is fixed to ES256 by this code.** The token header's `alg`
|
|
//! is only ever *compared against* that allowlist, never used to *select* an
|
|
//! algorithm. That is what makes `alg: none` and RSA-substitution
|
|
//! non-starters rather than things we defend against case by case.
|
|
//! 3. Signature math is `jsonwebtoken`'s. This module owns claim policy only.
|
|
//!
|
|
//! ## Why `setup` and `workload` tokens are refused outright
|
|
//!
|
|
//! Identity also issues long-lived *setup* (365-day) and *workload* credentials.
|
|
//! Their revocation lives in identity's `oauth_setup_tokens` table, and RuView —
|
|
//! like meta-llm — has **no database and no way to check it**. A 15-minute
|
|
//! access token needs no revocation round-trip because it expires faster than
|
|
//! any realistic revocation propagates; a 365-day one does. Accepting one would
|
|
//! mean honouring a credential that may already have been revoked, so we don't.
|
|
//!
|
|
//! ## There is no `aud` claim, and no `iss` claim either
|
|
//!
|
|
//! Verified against real production tokens: the claim set is exactly
|
|
//! `typ, sub, account_id, org_id, workspace_id, client_id, scope, family_id,
|
|
//! jti, iat, exp, setup, workload`. No audience. No issuer.
|
|
//!
|
|
//! **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
|
|
//! possession of a valid signature *is* proof of issuer. Adding an `iss` claim
|
|
//! check on top would not strengthen that — and requiring a claim identity does
|
|
//! not emit rejects every genuine token, which is exactly what an earlier
|
|
//! revision of this module did.
|
|
//!
|
|
//! **`client_id` is Cognitum's stand-in for `aud`.** `cognitum-one/freetokens`
|
|
//! (live) documents the contract — *"Cognitum access tokens intentionally use
|
|
//! custom `client_id` rather than a registered JWT `aud` claim"* — and rejects
|
|
//! any token whose `client_id` is not its own. This verifier does the same via
|
|
//! [`VerifierConfig::allowed_client_ids`].
|
|
//!
|
|
//! An earlier revision treated `client_id` as unusable because clients borrow
|
|
//! each other's registrations (musica shipped as `meta-proxy` while its own was
|
|
//! pending) and relied on scope alone. That was reasoning from a transitional
|
|
//! state: RuView has its own registered client, and accepting a token minted for
|
|
//! any Cognitum product is a weaker position than the platform intends.
|
|
//!
|
|
//! So there are now TWO boundaries, not one: audience (`client_id`) and
|
|
//! capability (`scope`). Neither is optional garnish.
|
|
|
|
use jsonwebtoken::{decode, decode_header, Algorithm, Validation};
|
|
use serde::Deserialize;
|
|
|
|
use crate::jwks::{JwksCache, JwksError};
|
|
use crate::principal::Principal;
|
|
|
|
/// The `typ` identity stamps on ordinary interactive access tokens
|
|
/// (`jwt.rs`'s `TOKEN_TYP_ACCESS`).
|
|
const TYP_ACCESS: &str = "access";
|
|
|
|
/// Clock leeway for `exp`/`iat`.
|
|
///
|
|
/// Deliberately small. Against a 15-minute token a generous window is a real
|
|
/// extension of a revoked credential's life, so this absorbs ordinary NTP jitter
|
|
/// and nothing more. Hosts without a battery-backed clock (Pi-class) need real
|
|
/// time sync — see [`VerifyError::ExpiredOrNotYetValid`], which is reported
|
|
/// distinctly so "your clock is wrong" is diagnosable rather than presenting as
|
|
/// a generic 401.
|
|
const CLOCK_LEEWAY_SECS: u64 = 30;
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum VerifyError {
|
|
#[error("authorization header is missing or not a Bearer token")]
|
|
MissingBearer,
|
|
#[error("token is not a well-formed JWT: {0}")]
|
|
Malformed(String),
|
|
#[error("token algorithm is not ES256")]
|
|
WrongAlgorithm,
|
|
#[error("could not resolve a verification key: {0}")]
|
|
Jwks(#[from] JwksError),
|
|
#[error("token signature is not valid for identity's published key")]
|
|
BadSignature,
|
|
/// `exp`/`iat` outside the accepted window. Distinct from `BadSignature` on
|
|
/// purpose: on an RTC-less host this is usually a clock-sync problem, not an
|
|
/// attack, and an operator needs to be able to tell those apart.
|
|
#[error("token is expired or not yet valid (check host clock sync)")]
|
|
ExpiredOrNotYetValid,
|
|
#[error("token type {found:?} is not an interactive access token")]
|
|
WrongTokenType { found: Option<String> },
|
|
#[error("long-lived setup/workload credentials are not accepted (unverifiable revocation)")]
|
|
LongLivedCredential,
|
|
#[error("token carries no account_id and cannot be attributed")]
|
|
MissingAccountId,
|
|
#[error("token does not carry the required scope {required:?}")]
|
|
MissingScope { required: String },
|
|
/// Minted for a different Cognitum product. `client_id` is the platform's
|
|
/// audience mechanism in the absence of `aud`.
|
|
#[error("token was issued to client {found:?}, which this server does not accept")]
|
|
WrongAudience { found: String },
|
|
}
|
|
|
|
/// Identity's access-token claims. Mirrors `AccessTokenClaims` in
|
|
/// `dashboard/services/identity/src/jwt.rs`.
|
|
///
|
|
/// `typ` is `Option` because identity types it that way — absence must be
|
|
/// treated as "not an access token", never as a default.
|
|
#[derive(Debug, Deserialize)]
|
|
struct AccessTokenClaims {
|
|
#[serde(default)]
|
|
typ: Option<String>,
|
|
sub: String,
|
|
#[serde(default)]
|
|
account_id: Option<String>,
|
|
#[serde(default)]
|
|
org_id: String,
|
|
#[serde(default)]
|
|
workspace_id: String,
|
|
#[serde(default)]
|
|
client_id: String,
|
|
#[serde(default)]
|
|
scope: String,
|
|
#[serde(default)]
|
|
jti: String,
|
|
exp: i64,
|
|
/// Long-lived, non-rotating setup credential. Absent on older tokens.
|
|
#[serde(default)]
|
|
setup: bool,
|
|
/// Machine workload credential. Absent on older tokens.
|
|
#[serde(default)]
|
|
workload: bool,
|
|
}
|
|
|
|
/// Verifier configuration.
|
|
pub struct VerifierConfig {
|
|
/// The authorization server's origin, e.g. `https://auth.cognitum.one`.
|
|
///
|
|
/// **Not validated against a claim** — Cognitum access tokens carry no
|
|
/// `iss` (see module docs); the JWKS provides the issuer binding. This is
|
|
/// here for logging and for deriving the default `jwks_uri`, so that the
|
|
/// configured issuer and the keys we trust cannot drift apart silently.
|
|
pub issuer: String,
|
|
/// The scope a caller must hold for the route being served.
|
|
pub required_scope: String,
|
|
/// `client_id` values whose tokens this server accepts — the AUDIENCE check.
|
|
///
|
|
/// Cognitum tokens carry no `aud`; the platform uses `client_id` for this
|
|
/// instead. `freetokens` (cognitum-one/freetokens, live) states the contract
|
|
/// plainly — *"Cognitum access tokens intentionally use custom `client_id`
|
|
/// rather than a registered JWT `aud` claim"* — and enforces
|
|
/// `payload.client_id !== OAUTH_CLIENT_ID` on every request.
|
|
///
|
|
/// Empty means accept any client, which is what an earlier revision did on
|
|
/// the reasoning that clients borrow each other's registrations (musica
|
|
/// shipped as `meta-proxy` while its own was pending). That was a
|
|
/// transitional state, not the model: RuView has its own registered client,
|
|
/// so leaving this empty means accepting a token minted for ANY Cognitum
|
|
/// product. Configure it.
|
|
pub allowed_client_ids: Vec<String>,
|
|
}
|
|
|
|
/// Verify a raw JWT and produce a [`Principal`].
|
|
///
|
|
/// Every rejection path returns a typed error; none of them return a partially
|
|
/// trusted principal.
|
|
pub fn verify_access_token(
|
|
token: &str,
|
|
jwks: &JwksCache,
|
|
config: &VerifierConfig,
|
|
) -> Result<Principal, VerifyError> {
|
|
let header = decode_header(token).map_err(|e| VerifyError::Malformed(e.to_string()))?;
|
|
|
|
// Compared, never selected. `decode` below independently enforces the same
|
|
// allowlist; this early check exists so the failure is legible.
|
|
if header.alg != Algorithm::ES256 {
|
|
return Err(VerifyError::WrongAlgorithm);
|
|
}
|
|
let kid = header.kid.ok_or(JwksError::MissingKid)?;
|
|
let key = jwks.decoding_key_for(&kid)?;
|
|
|
|
let mut validation = Validation::new(Algorithm::ES256);
|
|
validation.leeway = CLOCK_LEEWAY_SECS;
|
|
validation.validate_exp = true;
|
|
// No `aud` and no `iss` validation — Cognitum access tokens carry neither.
|
|
// See "What binds a token to its issuer" in the module docs: the JWKS is
|
|
// the binding, and requiring a claim identity does not emit rejects every
|
|
// real token. meta-llm's verifier makes the same two omissions.
|
|
validation.validate_aud = false;
|
|
validation.set_required_spec_claims(&["exp"]);
|
|
|
|
let data = decode::<AccessTokenClaims>(token, &key, &validation).map_err(map_jwt_error)?;
|
|
let claims = data.claims;
|
|
|
|
// ---- Claim policy. Mirrors meta-llm's oauthBearer.ts accept-rule. ----
|
|
|
|
if claims.typ.as_deref() != Some(TYP_ACCESS) {
|
|
return Err(VerifyError::WrongTokenType { found: claims.typ });
|
|
}
|
|
// AUDIENCE. Cognitum's stand-in for `aud` (see VerifierConfig docs).
|
|
if !config.allowed_client_ids.is_empty()
|
|
&& !config
|
|
.allowed_client_ids
|
|
.iter()
|
|
.any(|c| c == &claims.client_id)
|
|
{
|
|
return Err(VerifyError::WrongAudience {
|
|
found: claims.client_id,
|
|
});
|
|
}
|
|
if claims.setup || claims.workload {
|
|
// Belt and braces alongside the `typ` check: identity stamps these as
|
|
// booleans as well, and a credential that sets either must never be
|
|
// honoured here regardless of how it types itself.
|
|
return Err(VerifyError::LongLivedCredential);
|
|
}
|
|
|
|
let account_id = claims.account_id.filter(|a| !a.is_empty());
|
|
let Some(account_id) = account_id else {
|
|
// meta-llm requires this so a token cannot bill an account it doesn't
|
|
// belong to. RuView's reason is attribution: an unattributable principal
|
|
// cannot appear in an audit trail, which is most of the point of moving
|
|
// off a shared static bearer.
|
|
return Err(VerifyError::MissingAccountId);
|
|
};
|
|
|
|
let principal = Principal::new(
|
|
claims.sub,
|
|
account_id,
|
|
claims.org_id,
|
|
claims.workspace_id,
|
|
claims.client_id,
|
|
claims.jti,
|
|
&claims.scope,
|
|
claims.exp,
|
|
);
|
|
|
|
if !principal.has_scope(&config.required_scope) {
|
|
return Err(VerifyError::MissingScope {
|
|
required: config.required_scope.clone(),
|
|
});
|
|
}
|
|
|
|
Ok(principal)
|
|
}
|
|
|
|
/// 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 (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);
|
|
}
|
|
Ok(token)
|
|
}
|
|
|
|
fn map_jwt_error(e: jsonwebtoken::errors::Error) -> VerifyError {
|
|
use jsonwebtoken::errors::ErrorKind;
|
|
match e.kind() {
|
|
ErrorKind::InvalidSignature => VerifyError::BadSignature,
|
|
ErrorKind::ExpiredSignature | ErrorKind::ImmatureSignature => {
|
|
VerifyError::ExpiredOrNotYetValid
|
|
}
|
|
ErrorKind::InvalidAlgorithm | ErrorKind::InvalidAlgorithmName => {
|
|
VerifyError::WrongAlgorithm
|
|
}
|
|
_ => VerifyError::Malformed(e.to_string()),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn extract_bearer_accepts_a_well_formed_header() {
|
|
assert_eq!(extract_bearer("Bearer abc.def.ghi").unwrap(), "abc.def.ghi");
|
|
}
|
|
|
|
#[test]
|
|
fn extract_bearer_rejects_a_missing_prefix() {
|
|
assert!(matches!(
|
|
extract_bearer("abc.def.ghi"),
|
|
Err(VerifyError::MissingBearer)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
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("Basic dXNlcjpwYXNz"),
|
|
Err(VerifyError::MissingBearer)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn extract_bearer_rejects_an_empty_token() {
|
|
assert!(matches!(
|
|
extract_bearer("Bearer "),
|
|
Err(VerifyError::MissingBearer)
|
|
));
|
|
}
|
|
}
|