fix(auth): enforce client_id as the audience — Cognitum's stand-in for aud

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
This commit is contained in:
Dragan Spiridonov
2026-07-23 09:31:24 +02:00
parent f67a880a1a
commit 0547fd7344
4 changed files with 130 additions and 7 deletions
@@ -71,6 +71,30 @@ 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";
/// Comma-separated `client_id` values whose tokens this server accepts — the
/// AUDIENCE control. Defaults to [`DEFAULT_CLIENT_ID`].
///
/// Cognitum tokens carry no `aud`; `client_id` is the platform's stand-in, and
/// `cognitum-one/freetokens` enforces exactly this. Set to `*` to accept any
/// Cognitum client — only sensible while borrowing another product's
/// registration, and it means any Cognitum token opens this server.
pub const OAUTH_CLIENT_IDS_ENV: &str = "RUVIEW_OAUTH_CLIENT_IDS";
/// RuView's own registered OAuth client (identity migration `0017`).
pub const DEFAULT_CLIENT_ID: &str = "ruview";
fn allowed_client_ids() -> Vec<String> {
match std::env::var(OAUTH_CLIENT_IDS_ENV) {
Ok(v) if v.trim() == "*" => Vec::new(), // explicit opt-out
Ok(v) if !v.trim().is_empty() => v
.split(',')
.map(|c| c.trim().to_string())
.filter(|c| !c.is_empty())
.collect(),
_ => vec![DEFAULT_CLIENT_ID.to_string()],
}
}
/// Path prefix the middleware protects when auth is enabled.
pub const PROTECTED_PREFIX: &str = "/api/v1/";
@@ -140,6 +164,7 @@ fn is_ws_path(path: &str) -> bool {
pub struct OAuthState {
jwks: JwksCache,
issuer: String,
allowed_client_ids: Vec<String>,
}
impl std::fmt::Debug for OAuthState {
@@ -233,7 +258,17 @@ impl AuthState {
key_count,
"Cognitum OAuth enabled for /api/v1/*"
);
Some(Arc::new(OAuthState { jwks, issuer }))
let allowed_client_ids = allowed_client_ids();
if allowed_client_ids.is_empty() {
tracing::warn!(
"{OAUTH_CLIENT_IDS_ENV}=* — this server accepts a Cognitum token minted \
for ANY product, not just RuView. `client_id` is the platform's stand-in \
for `aud`; disabling it leaves scope as the only boundary."
);
} else {
tracing::info!(accepted_clients = ?allowed_client_ids, "OAuth audience restricted");
}
Some(Arc::new(OAuthState { jwks, issuer, allowed_client_ids }))
}
Ok(_) => return Err(OAuthConfigError::EmptyIssuer),
Err(_) => None,
@@ -445,6 +480,7 @@ pub async fn require_bearer(
let config = VerifierConfig {
issuer: oauth.issuer.clone(),
required_scope: required.to_string(),
allowed_client_ids: oauth.allowed_client_ids.clone(),
};
match verify_access_token(supplied, &oauth.jwks, &config) {
Ok(principal) => {
@@ -851,6 +887,7 @@ mod oauth_tests {
Arc::new(OAuthState {
jwks: JwksCache::new("https://stub/jwks.json", Box::new(StaticJwks(doc))),
issuer: ISSUER.to_string(),
allowed_client_ids: vec!["ruview".to_string()],
})
}