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
+2
View File
@@ -38,6 +38,8 @@
//! let config = VerifierConfig {
//! issuer: "https://auth.cognitum.one".to_string(),
//! required_scope: scope::SENSING_READ.to_string(),
//! // Audience: Cognitum has no `aud`, so `client_id` carries it.
//! allowed_client_ids: vec!["ruview".to_string()],
//! };
//!
//! let principal = verify_access_token("<jwt>", &jwks, &config)?;
+44 -5
View File
@@ -37,11 +37,20 @@
//! not emit rejects every genuine token, which is exactly what an earlier
//! revision of this module did.
//!
//! The missing `aud` has a real consequence: **scope is the only capability
//! boundary**. `client_id` cannot serve as one, because clients borrow each
//! other's registrations (musica shipped as `meta-proxy` while its own
//! registration was pending). Hence `required_scope` below is not optional
//! garnish; it is the boundary.
//! **`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;
@@ -88,6 +97,10 @@ pub enum VerifyError {
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
@@ -132,6 +145,21 @@ pub struct VerifierConfig {
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`].
@@ -171,6 +199,17 @@ pub fn verify_access_token(
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