mirror of
https://github.com/ruvnet/RuView
synced 2026-08-08 20:11:43 +00:00
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:
@@ -133,6 +133,8 @@ fn config_for(required_scope: &str) -> VerifierConfig {
|
||||
VerifierConfig {
|
||||
issuer: TEST_ISSUER.to_string(),
|
||||
required_scope: required_scope.to_string(),
|
||||
// Mirrors production: RuView accepts only tokens minted for itself.
|
||||
allowed_client_ids: vec!["ruview".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,12 +390,55 @@ fn g2_a_genuinely_valid_token_from_another_cognitum_product_cannot_reach_the_sen
|
||||
c["client_id"] = json!("meta-proxy");
|
||||
c["scope"] = json!("inference");
|
||||
|
||||
// Rejected on AUDIENCE now (client_id), which is the stronger of the two
|
||||
// reasons — it fires before scope is even considered.
|
||||
assert!(matches!(
|
||||
verify(&sign(&c), scope::SENSING_READ),
|
||||
Err(VerifyError::MissingScope { .. })
|
||||
Err(VerifyError::WrongAudience { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_minted_for_another_cognitum_product_is_refused_even_with_the_right_scope() {
|
||||
// The audience check standing alone. Same user, same signature, correct
|
||||
// sensing:read scope — but minted for freetokens, so not for this server.
|
||||
// `cognitum-one/freetokens` enforces the mirror image of this.
|
||||
let mut c = valid_claims();
|
||||
c["client_id"] = json!("freetokens");
|
||||
assert!(matches!(
|
||||
verify(&sign(&c), scope::SENSING_READ),
|
||||
Err(VerifyError::WrongAudience { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_audience_list_accepts_any_client() {
|
||||
// The documented opt-out (RUVIEW_OAUTH_CLIENT_IDS=*). Pinned so the
|
||||
// behaviour is deliberate rather than accidental.
|
||||
let mut c = valid_claims();
|
||||
c["client_id"] = json!("some-other-product");
|
||||
let cfg = VerifierConfig {
|
||||
issuer: TEST_ISSUER.to_string(),
|
||||
required_scope: scope::SENSING_READ.to_string(),
|
||||
allowed_client_ids: vec![],
|
||||
};
|
||||
verify_access_token(&sign(&c), &jwks_serving_test_key(), &cfg)
|
||||
.expect("an empty allowlist means accept any client");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_allowed_clients_are_honoured() {
|
||||
// Migration case: accepting a borrowed registration alongside our own.
|
||||
let mut c = valid_claims();
|
||||
c["client_id"] = json!("meta-proxy");
|
||||
let cfg = VerifierConfig {
|
||||
issuer: TEST_ISSUER.to_string(),
|
||||
required_scope: scope::SENSING_READ.to_string(),
|
||||
allowed_client_ids: vec!["ruview".into(), "meta-proxy".into()],
|
||||
};
|
||||
verify_access_token(&sign(&c), &jwks_serving_test_key(), &cfg).expect("both accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn g2_a_read_scoped_session_cannot_reach_the_admin_surface() {
|
||||
// The routine case the least-scope rule exists for: a dashboard streaming
|
||||
|
||||
Reference in New Issue
Block a user