From 42b08103f6b9e3482b304c40087e3802a9cc9d72 Mon Sep 17 00:00:00 2001 From: Jens Holdgaard Pedersen Date: Tue, 7 Jul 2026 21:23:19 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(auth):=20rfc=200029=20green=20(query/m?= =?UTF-8?q?cp=20binding)=20=E2=80=94=20one=20resolver=20on=20every=20surfa?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §3.3 binding completes across the read side: the querier's /v1/query handler, the MCP transport-level bearer gate, and the per-tool tenant check all resolve through the same async AuthResolver the ingest listeners use — static store first, then OIDC — and the transitional `enforcement_store()` bridge is retired exactly as its own doc promised (RFC 0026 gates now consume the full auth config). - querier.rs / mcp.rs: `Option>` → `AuthResolver` throughout; `check_tenant` is async (an OIDC unseen-kid miss may refetch); open mode = `resolver.is_open()`. - main.rs: both network roles build their resolver via the renamed `auth_resolver` (OIDC discovery at startup; failure names auth.oidc). The querier role therefore now requires a reachable issuer for an oidc-configured startup — the .1 served test gained the fixture issuer accordingly. - ourios-core: `AuthConfig::enforcement_store` removed with its tests migrated to direct `static_tokens` assertions (the empty-store oidc-only semantics are superseded by resolver enforcement, asserted on the wire by the served arms). §5: RFC0029.3/.4/.5 are live against the served binary — .3: tenant_claim [a,b] drives RFC 0026 verbatim (in-set ingest acks, out-of-set whole-batch PERMISSION_DENIED, query 401→400→403 order); .4: wildcard ingests/queries arbitrary tenants; .5: one config with both halves — the static bearer and a JWT authenticate via their own paths, each confined to its own binding on ingest and query (the static token exercising the RFC 0020 ${env:…} secret-hygiene rule). .2/.6 stubs flipped to discharged markers naming their core oracles. Only .7 (Dex) remains. Full gate: 978 passed / 0 failed, clippy -D warnings, rustdoc, fmt, cargo-deny. Co-Authored-By: Claude Fable 5 --- crates/ourios-core/src/auth/mod.rs | 28 +- crates/ourios-server/src/main.rs | 36 +- crates/ourios-server/src/mcp.rs | 37 +- crates/ourios-server/src/querier.rs | 21 +- crates/ourios-server/tests/it/rfc0026_auth.rs | 4 +- crates/ourios-server/tests/it/rfc0027_mcp.rs | 18 +- crates/ourios-server/tests/it/rfc0029_oidc.rs | 369 +++++++++++++++--- 7 files changed, 365 insertions(+), 148 deletions(-) diff --git a/crates/ourios-core/src/auth/mod.rs b/crates/ourios-core/src/auth/mod.rs index 9b24bedae..3bb054c67 100644 --- a/crates/ourios-core/src/auth/mod.rs +++ b/crates/ourios-core/src/auth/mod.rs @@ -130,21 +130,6 @@ pub struct AuthConfig { pub oidc: Option, } -impl AuthConfig { - /// The store the enforcement points consume today. - /// - /// With OIDC configured but no static tokens this is an *empty* store: - /// auth stays enforced (every bearer is rejected) rather than open. The - /// RFC 0029 verifier slice replaces the gates' store parameter with the - /// full config and retires this bridge. - #[must_use] - pub fn enforcement_store(&self) -> TokenStore { - self.static_tokens.clone().unwrap_or(TokenStore { - entries: Vec::new(), - }) - } -} - /// Validate a raw `auth` section's halves into the resolved [`AuthConfig`] /// (RFC 0026 §3.1 + RFC 0029 §3.1). Callers only invoke this for a present /// `auth` section — an absent section is open mode and never reaches here. @@ -266,9 +251,7 @@ impl fmt::Debug for ResolvedToken { /// The validated `auth.tokens` store (RFC 0026 §3.1). Non-empty when it /// comes from [`build_token_store`] (an empty *config list* is a startup -/// error); the one deliberate empty state is -/// [`AuthConfig::enforcement_store`]'s oidc-only bridge, where matching -/// nothing — rejecting every bearer — is the point. +/// error). #[derive(Debug, Clone, PartialEq, Eq)] pub struct TokenStore { entries: Vec, @@ -598,11 +581,6 @@ mod tests { let oidc_only = build_auth_config(None, Some(&oidc_spec())).expect("oidc-only"); assert!(oidc_only.static_tokens.is_none()); assert_eq!(oidc_only.oidc.as_ref().expect("oidc").audience(), "ourios"); - let bridge = oidc_only.enforcement_store(); - assert!( - bridge.authenticate("any-bearer").is_none(), - "the bridge store matches nothing — enforced, not open" - ); let both = build_auth_config( Some(&[spec("edge", "tok-edge", &["acme"])]), @@ -610,7 +588,9 @@ mod tests { ) .expect("both halves"); assert_eq!( - both.enforcement_store() + both.static_tokens + .as_ref() + .expect("static half intact") .authenticate("tok-edge") .expect("static half intact") .name(), diff --git a/crates/ourios-server/src/main.rs b/crates/ourios-server/src/main.rs index 3995bedc0..421588bca 100644 --- a/crates/ourios-server/src/main.rs +++ b/crates/ourios-server/src/main.rs @@ -576,26 +576,12 @@ fn warn_if_open_mode(config: &ServerConfig) { } } -/// The store the listeners enforce against (RFC 0026 §3.2/§3.3) — each -/// role derives its own instance from the one resolved config. Open mode -/// stays `None`; an oidc-only config yields an empty store — enforced, -/// not open — until the RFC 0029 verifier slice teaches the gates the -/// full [`ourios_server::auth::AuthConfig`]. -fn enforcement_store( - config: &ServerConfig, -) -> Option> { - config - .auth - .as_ref() - .map(|auth| std::sync::Arc::new(auth.enforcement_store())) -} - -/// Build the ingest listeners' RFC 0026/0029 credential resolver from the +/// Build a network role's RFC 0026/0029 credential resolver from the /// resolved auth config. OIDC discovery contacts the issuer once, here at /// startup — a failure (unreachable issuer, issuer mismatch, unusable /// JWKS) is a startup error, not a degraded mode (§3.2: with no cached /// keys nothing could ever verify). -async fn ingest_resolver( +async fn auth_resolver( config: &ServerConfig, ) -> Result { use ourios_ingester::receiver::AuthResolver; @@ -679,7 +665,7 @@ async fn main() -> Result<(), Box> { // handle is cheap to share, the compactor keeps the original). store: store.clone(), promoted: config.promoted.clone(), - auth: ingest_resolver(&config).await?, + auth: auth_resolver(&config).await?, }) .await?; println!("receiver gRPC listening on {}", handle.grpc_addr); @@ -700,7 +686,7 @@ async fn main() -> Result<(), Box> { // The querier engine is Store-capable (RFC 0019 slice 2a), so it // reads whichever backend config resolved (local or S3). store: config.store.clone(), - auth: enforcement_store(&config), + auth: auth_resolver(&config).await?, default_window_nanos: params.default_window_nanos, mcp_enabled: params.mcp_enabled, }) @@ -1010,7 +996,11 @@ auth: }) .expect("well-formed file"); let config = server_config_from_file(&file).expect("valid"); - let store = config.auth.expect("auth resolved").enforcement_store(); + let store = config + .auth + .expect("auth resolved") + .static_tokens + .expect("static half"); assert_eq!( store.authenticate("resolved-token").expect("match").name(), "edge-collector", @@ -1055,10 +1045,10 @@ auth: assert_eq!(oidc.issuer(), "https://dex.internal.example"); assert_eq!(oidc.name_claim(), "sub", "core default applied"); assert!(auth.static_tokens.is_none(), "no static half"); - assert!( - auth.enforcement_store().authenticate("any").is_none(), - "oidc-only enforces (rejects) rather than opening the gates", - ); + // Enforced-not-open for oidc-only is a resolver/serving property + // now (the `enforcement_store` bridge is retired): the served + // `rfc0029_oidc` arms assert the wire-level 401. + assert!(auth.oidc.is_some() && auth.static_tokens.is_none()); let err = server_config( "storage:\n local:\n bucket_root: /x\nauth:\n oidc:\n issuer: https://x\n tenant_claim: t\n", diff --git a/crates/ourios-server/src/mcp.rs b/crates/ourios-server/src/mcp.rs index b306969ee..7290cadbb 100644 --- a/crates/ourios-server/src/mcp.rs +++ b/crates/ourios-server/src/mcp.rs @@ -23,9 +23,8 @@ use axum::body::Body; use axum::http::{Request, StatusCode, header}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; -use ourios_core::auth::TokenStore; use ourios_core::tenant::TenantId; -use ourios_ingester::receiver::authenticate_bearer; +use ourios_ingester::receiver::AuthResolver; use ourios_querier::Querier; use ourios_querier::dsl::{self, Statement}; use rmcp::handler::server::ServerHandler; @@ -112,7 +111,7 @@ pub(crate) struct TemplateDriftArgs { pub(crate) struct OuriosMcp { querier: Arc, default_window_nanos: u64, - auth: Option>, + auth: AuthResolver, metrics: Arc, } @@ -136,21 +135,23 @@ impl OuriosMcp { /// `tenant` inside its set. Open mode passes. The transport layer /// already answered 401 for missing/unknown credentials; this is the /// 403 half, per tool call, before any data is touched. - fn check_tenant( + async fn check_tenant( &self, ctx: &rmcp::service::RequestContext, tenant: &str, ) -> Result<(), ErrorData> { - let Some(store) = self.auth.as_deref() else { + if self.auth.is_open() { return Ok(()); - }; + } let authorization = ctx .extensions .get::() .and_then(|parts| parts.headers.get(header::AUTHORIZATION)) .and_then(|value| value.to_str().ok()); - let binding = authenticate_bearer(Some(store), authorization) - .map_err(|_| ErrorData::invalid_request("a valid bearer token is required", None))?; + let binding = + self.auth.authenticate(authorization).await.map_err(|_| { + ErrorData::invalid_request("a valid bearer token is required", None) + })?; match binding { Some(binding) if !binding.tenants().allows(tenant) => Err(ErrorData::invalid_request( "the tenant is outside the authenticated token's allowed set", @@ -166,7 +167,7 @@ impl OuriosMcp { fn new( querier: Arc, default_window_nanos: u64, - auth: Option>, + auth: AuthResolver, metrics: Arc, ) -> Self { Self { @@ -188,7 +189,7 @@ impl OuriosMcp { ctx: rmcp::service::RequestContext, ) -> Result { let tenant_arg = normalize_tenant(&args.tenant)?; - self.check_tenant(&ctx, tenant_arg)?; + self.check_tenant(&ctx, tenant_arg).await?; let statement = dsl::parse_statement(&args.query) .map_err(|e| ErrorData::invalid_params(format!("invalid query: {e}"), None))?; let Statement::Logs(mut query) = statement else { @@ -246,7 +247,7 @@ impl OuriosMcp { ctx: rmcp::service::RequestContext, ) -> Result { let tenant_arg = normalize_tenant(&args.tenant)?; - self.check_tenant(&ctx, tenant_arg)?; + self.check_tenant(&ctx, tenant_arg).await?; let tenant = TenantId::new(tenant_arg); let started = std::time::Instant::now(); let registry = match self.querier.template_registry(&tenant).await { @@ -294,7 +295,7 @@ impl OuriosMcp { ctx: rmcp::service::RequestContext, ) -> Result { let tenant_arg = normalize_tenant(&args.tenant)?; - self.check_tenant(&ctx, tenant_arg)?; + self.check_tenant(&ctx, tenant_arg).await?; // One grammar, one boundary rule: the drift window parses through // the DSL front-end exactly as the JSON API's statement does // (RFC0010.2's half-open rule inherited verbatim). @@ -421,16 +422,12 @@ fn json_content(value: &T) -> Result>, - request: Request, - next: Next, -) -> Response { +async fn require_bearer(auth: AuthResolver, request: Request, next: Next) -> Response { let authorization = request .headers() .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok()); - if authenticate_bearer(auth.as_deref(), authorization).is_err() { + if auth.authenticate(authorization).await.is_err() { return (StatusCode::UNAUTHORIZED, "a valid bearer token is required").into_response(); } next.run(request).await @@ -445,7 +442,7 @@ async fn require_bearer( pub(crate) fn mcp_router( querier: Arc, default_window_nanos: u64, - auth: Option>, + auth: AuthResolver, metrics: Arc, ) -> Router { // (`StreamableHttpServerConfig` is `#[non_exhaustive]`; mutate a @@ -460,7 +457,7 @@ pub(crate) fn mcp_router( // contract): the role never comes up serving a malformed resource. let _ = *GRAMMAR_SECTION; let mut config = StreamableHttpServerConfig::default(); - if auth.is_some() { + if !auth.is_open() { config.allowed_hosts = Vec::new(); } let handler_auth = auth.clone(); diff --git a/crates/ourios-server/src/querier.rs b/crates/ourios-server/src/querier.rs index 93ffc75a1..4f76680c4 100644 --- a/crates/ourios-server/src/querier.rs +++ b/crates/ourios-server/src/querier.rs @@ -32,8 +32,7 @@ use axum::response::{IntoResponse, Response}; use axum::routing::post; use opentelemetry::metrics::{Counter, Histogram}; use opentelemetry::{KeyValue, global}; -use ourios_core::auth::TokenStore; -use ourios_ingester::receiver::auth::{AuthBinding, authenticate_bearer}; +use ourios_ingester::receiver::auth::{AuthBinding, AuthResolver}; use serde::Serialize; use tokio::net::TcpListener; use tokio::sync::watch; @@ -81,7 +80,7 @@ pub struct QuerierConfig { /// every request must carry a known `Authorization: Bearer` credential /// (→ 401) and its `x-ourios-tenant` must fall inside the token's set /// (→ 403); the missing-tenant 400 is unchanged (§3.3). - pub auth: Option>, + pub auth: AuthResolver, /// The look-back applied to a query with no `range(...)` stage — the /// server-supplied default window the DSL compiler expects (RFC 0002 §4 P5; /// RFC 0016 §7). @@ -214,14 +213,18 @@ struct QuerierState { querier: Arc, default_window_nanos: u64, metrics: Arc, - auth: Option>, + auth: AuthResolver, } /// Build the querier role's axum router over a **local** store root (RFC 0016 /// §3.3). Split out from [`serve`] so it can be driven in-process by tests; the /// local backend is the test/dev default and the RFC 0019 regression guard. pub fn router(bucket_root: PathBuf, default_window_nanos: u64) -> Router { - router_with_auth(bucket_root, default_window_nanos, None) + router_with_auth( + bucket_root, + default_window_nanos, + AuthResolver::static_only(None), + ) } /// [`router_with_auth`] plus the RFC 0027 `/mcp` surface — the fully @@ -229,7 +232,7 @@ pub fn router(bucket_root: PathBuf, default_window_nanos: u64) -> Router { pub fn router_with_mcp( bucket_root: PathBuf, default_window_nanos: u64, - auth: Option>, + auth: AuthResolver, mcp_enabled: bool, ) -> Router { router_from_querier( @@ -245,7 +248,7 @@ pub fn router_with_mcp( pub fn router_with_auth( bucket_root: PathBuf, default_window_nanos: u64, - auth: Option>, + auth: AuthResolver, ) -> Router { router_from_querier(Querier::new(bucket_root), default_window_nanos, auth, false) } @@ -256,7 +259,7 @@ pub fn router_with_auth( fn router_from_querier( querier: Querier, default_window_nanos: u64, - auth: Option>, + auth: AuthResolver, mcp_enabled: bool, ) -> Router { let state = QuerierState { @@ -345,7 +348,7 @@ async fn handle_query( let authorization = headers .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok()); - let Ok(binding) = authenticate_bearer(state.auth.as_deref(), authorization) else { + let Ok(binding) = state.auth.authenticate(authorization).await else { // RFC 0026 §3.4: the rejection records on the existing // `ourios.query.duration` histogram, kind `rejected` // (pre-dispatch), tagged with `error.type`. diff --git a/crates/ourios-server/tests/it/rfc0026_auth.rs b/crates/ourios-server/tests/it/rfc0026_auth.rs index 23e6b6f8b..a1b1f0ce3 100644 --- a/crates/ourios-server/tests/it/rfc0026_auth.rs +++ b/crates/ourios-server/tests/it/rfc0026_auth.rs @@ -186,7 +186,7 @@ async fn rfc0026_4_query_status_contract() { ourios_server::querier::router_with_auth( bucket.path().to_path_buf(), 3_600_000_000_000, - Some(auth.clone()), + ourios_ingester::receiver::AuthResolver::static_only(Some(auth.clone())), ) }; @@ -303,7 +303,7 @@ async fn rfc0026_5_wildcard_binding_query() { ourios_server::querier::router_with_auth( bucket.path().to_path_buf(), 3_600_000_000_000, - Some(auth.clone()), + ourios_ingester::receiver::AuthResolver::static_only(Some(auth.clone())), ), Some("Bearer tok-query"), Some(tenant), diff --git a/crates/ourios-server/tests/it/rfc0027_mcp.rs b/crates/ourios-server/tests/it/rfc0027_mcp.rs index f6855cb11..1ab80de0a 100644 --- a/crates/ourios-server/tests/it/rfc0027_mcp.rs +++ b/crates/ourios-server/tests/it/rfc0027_mcp.rs @@ -181,7 +181,7 @@ async fn rfc0027_1_gating_and_placement() { let on = ourios_server::querier::router_with_mcp( bucket.path().to_path_buf(), 3_600_000_000_000, - None, + ourios_ingester::receiver::AuthResolver::static_only(None), true, ); let response = on.oneshot(initialize_request(None)).await.expect("oneshot"); @@ -211,7 +211,7 @@ async fn rfc0027_1_gating_and_placement() { ourios_server::querier::router_with_mcp( bucket.path().to_path_buf(), 3_600_000_000_000, - Some(auth.clone()), + ourios_ingester::receiver::AuthResolver::static_only(Some(auth.clone())), true, ) }; @@ -248,7 +248,7 @@ async fn rfc0027_2_rfc0026_gate_applies_verbatim() { let router = ourios_server::querier::router_with_mcp( bucket.path().to_path_buf(), 3_600_000_000_000, - Some(auth), + ourios_ingester::receiver::AuthResolver::static_only(Some(auth)), true, ); @@ -287,7 +287,7 @@ async fn rfc0027_2_rfc0026_gate_applies_verbatim() { let open = ourios_server::querier::router_with_mcp( bucket.path().to_path_buf(), 3_600_000_000_000, - None, + ourios_ingester::receiver::AuthResolver::static_only(None), true, ); let body = mcp_tool_call( @@ -311,7 +311,7 @@ async fn rfc0027_3_query_logs() { let router = ourios_server::querier::router_with_mcp( bucket.path().to_path_buf(), crate::rfc0016_query_endpoint::SHARED_HUGE_WINDOW, - None, + ourios_ingester::receiver::AuthResolver::static_only(None), true, ); let body = mcp_tool_call( @@ -370,7 +370,7 @@ async fn rfc0027_4_list_templates() { let router = ourios_server::querier::router_with_mcp( bucket.path().to_path_buf(), crate::rfc0016_query_endpoint::SHARED_HUGE_WINDOW, - None, + ourios_ingester::receiver::AuthResolver::static_only(None), true, ); let body = mcp_tool_call( @@ -421,7 +421,7 @@ async fn rfc0027_5_template_drift() { let router = ourios_server::querier::router_with_mcp( bucket.path().to_path_buf(), crate::rfc0016_query_endpoint::SHARED_HUGE_WINDOW, - None, + ourios_ingester::receiver::AuthResolver::static_only(None), true, ); // A wide fixed window covering the seeded audit timestamps. @@ -457,7 +457,7 @@ async fn rfc0027_6_grammar_resource() { let router = ourios_server::querier::router_with_mcp( bucket.path().to_path_buf(), 3_600_000_000_000, - None, + ourios_ingester::receiver::AuthResolver::static_only(None), true, ); let init = serde_json::json!({ @@ -531,7 +531,7 @@ async fn rfc0027_7_output_discipline() { let router = ourios_server::querier::router_with_mcp( bucket.path().to_path_buf(), 3_600_000_000_000, - None, + ourios_ingester::receiver::AuthResolver::static_only(None), true, ); let init = serde_json::json!({ diff --git a/crates/ourios-server/tests/it/rfc0029_oidc.rs b/crates/ourios-server/tests/it/rfc0029_oidc.rs index 65750d028..6f2e9c23f 100644 --- a/crates/ourios-server/tests/it/rfc0029_oidc.rs +++ b/crates/ourios-server/tests/it/rfc0029_oidc.rs @@ -75,6 +75,11 @@ async fn rfc0029_1_startup_configuration_errors() { /// See `docs/rfcs/0029-oidc-bearer-layer.md` §5. #[tokio::test] async fn rfc0029_1_oidc_only_starts_and_enforces() { + // The querier role resolves OIDC at startup now (the binding slice), + // so an oidc-only config needs a reachable issuer: the loopback + // fixture (discovery + JWKS), same as the receiver arms. + let (_, jwk) = ingest_binding::make_key("key-1"); + let issuer = ingest_binding::serve_issuer(jwk).await; let tmp = tempfile::TempDir::new().expect("temp"); let config_path = tmp.path().join("ourios.yaml"); let mut file = std::fs::File::create(&config_path).expect("create config"); @@ -82,8 +87,9 @@ async fn rfc0029_1_oidc_only_starts_and_enforces() { file, "storage:\n local:\n bucket_root: {}\n\ querier:\n enabled: true\n http_addr: 127.0.0.1:0\n\ - auth:\n oidc:\n issuer: https://dex.internal.example\n audience: ourios\n tenant_claim: ourios_tenants\n", + auth:\n oidc:\n issuer: {}\n audience: ourios\n tenant_claim: ourios_tenants\n", tmp.path().display(), + issuer, ) .expect("write config"); @@ -161,66 +167,14 @@ async fn rfc0029_1_oidc_only_starts_and_enforces() { /// Scenario RFC0029.2 — verification matrix. /// See `docs/rfcs/0029-oidc-bearer-layer.md` §5. #[test] -#[ignore = "RFC0029.2 stub — implemented in the verifier green slice"] -fn rfc0029_2_verification_matrix() { - todo!( - "RFC0029.2 — fixture-issuer valid token accepted; expired / \ - nbf-beyond-skew / wrong-aud / wrong-iss / bad-sig / alg:none \ - / HMAC-downgrade / non-JWT all one undifferentiated 401 \ - before wire decode, nothing reaching the WAL" - ); -} - -/// Scenario RFC0029.3 — claim binding drives unchanged enforcement. -/// See `docs/rfcs/0029-oidc-bearer-layer.md` §5. -#[test] -#[ignore = "RFC0029.3 stub — implemented in the binding green slice"] -fn rfc0029_3_claim_binding_enforcement() { - todo!( - "RFC0029.3 — tenant_claim [a, b]: RFC 0026 §5.3/§5.4 verbatim \ - with the OIDC-resolved binding — in-set ingest acks, any \ - out-of-set batch whole-batch 403 with no WAL append, \ - 401→400→403 on query + MCP, name_claim as the name label" - ); -} - -/// Scenario RFC0029.4 — wildcard claim. -/// See `docs/rfcs/0029-oidc-bearer-layer.md` §5. -#[test] -#[ignore = "RFC0029.4 stub — implemented in the binding green slice"] -fn rfc0029_4_wildcard_claim() { - todo!( - "RFC0029.4 — tenant_claim [\"*\"]: ingest and query to \ - arbitrary tenants as if every tenant were listed \ - (RFC 0026 §5.5 parity)" - ); -} - -/// Scenario RFC0029.5 — coexistence and resolution order. -/// See `docs/rfcs/0029-oidc-bearer-layer.md` §5. -#[test] -#[ignore = "RFC0029.5 stub — implemented in the binding green slice"] -fn rfc0029_5_coexistence_and_resolution_order() { - todo!( - "RFC0029.5 — static + oidc side by side, each with its own \ - binding; static-only and oidc-only both serve; no auth \ - section passes the RFC 0026 §5.6 open-mode parity arm \ - unchanged" - ); -} +#[ignore = "RFC0029.2 discharged — `ourios_core::auth::oidc::tests::rfc0029_2_verification_matrix` is the oracle (every arm, one undifferentiated None); the pre-decode/nothing-reaches-the-WAL half is the served ingest_binding arm here"] +fn rfc0029_2_verification_matrix() {} /// Scenario RFC0029.6 — JWKS rotation. /// See `docs/rfcs/0029-oidc-bearer-layer.md` §5. #[test] -#[ignore = "RFC0029.6 stub — implemented in the verifier green slice"] -fn rfc0029_6_jwks_rotation() { - todo!( - "RFC0029.6 — issuer rotates mid-run: unseen kid triggers a \ - JWKS re-fetch and the new-key token verifies without \ - restart; the withdrawn key's tokens are rejected once the \ - refreshed set drops it" - ); -} +#[ignore = "RFC0029.6 discharged — `ourios_core::auth::oidc::tests::rfc0029_6_jwks_rotation` is the oracle (unseen-kid refetch under the real throttle; withdrawn-kid rejection)"] +fn rfc0029_6_jwks_rotation() {} /// Scenario RFC0029.7 — Dex end-to-end with telemetry parity. /// See `docs/rfcs/0029-oidc-bearer-layer.md` §5. @@ -261,7 +215,7 @@ mod ingest_binding { use tokio::time::timeout; /// A fresh ES256 keypair (runtime-generated) and its public JWK. - fn make_key(kid: &str) -> (EncodingKey, serde_json::Value) { + pub(super) fn make_key(kid: &str) -> (EncodingKey, serde_json::Value) { let signing = SigningKey::random(&mut rand::rngs::OsRng); let pem = signing .to_pkcs8_pem(p256::pkcs8::LineEnding::LF) @@ -277,7 +231,7 @@ mod ingest_binding { } /// A loopback issuer serving discovery + a fixed JWKS. - async fn serve_issuer(jwk: serde_json::Value) -> String { + pub(super) async fn serve_issuer(jwk: serde_json::Value) -> String { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind fixture issuer"); @@ -311,7 +265,12 @@ mod ingest_binding { issuer } - fn mint(encoding: &EncodingKey, kid: &str, issuer: &str, tenants: &[&str]) -> String { + pub(super) fn mint( + encoding: &EncodingKey, + kid: &str, + issuer: &str, + tenants: &[&str], + ) -> String { let now = i64::try_from( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -329,7 +288,7 @@ mod ingest_binding { } /// One `ResourceLogs` batch whose tenant derives from `service.name`. - fn batch(tenant: &str) -> ExportLogsServiceRequest { + pub(super) fn batch(tenant: &str) -> ExportLogsServiceRequest { ExportLogsServiceRequest { resource_logs: vec![ResourceLogs { resource: Some(Resource { @@ -530,3 +489,291 @@ mod ingest_binding { ); } } + +// --- RFC 0029 §5 .3/.4/.5 — the query/MCP binding slice: the OIDC-resolved +// binding drives the RFC 0026 contracts verbatim on the served binary. + +mod claim_binding { + use std::io::Write as _; + use std::time::Duration; + + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + use tokio::process::Command; + use tokio::time::timeout; + + use super::ingest_binding::{batch, make_key, mint, serve_issuer}; + + /// Spawn the binary with receiver+querier and the given `auth` YAML + /// block; return (child, grpc, http-receiver, http-querier). + async fn spawn_with_auth( + tmp: &tempfile::TempDir, + auth_yaml: &str, + envs: &[(&str, &str)], + ) -> (tokio::process::Child, String, String, std::net::SocketAddr) { + let wal = tmp.path().join("wal"); + std::fs::create_dir_all(&wal).expect("wal dir"); + let config_path = tmp.path().join("ourios.yaml"); + let mut file = std::fs::File::create(&config_path).expect("create config"); + write!( + file, + "storage:\n local:\n bucket_root: {}\n\ + receiver:\n enabled: true\n grpc_addr: 127.0.0.1:0\n http_addr: 127.0.0.1:0\n wal_root: {}\n\ + querier:\n enabled: true\n http_addr: 127.0.0.1:0\n\ + {auth_yaml}", + tmp.path().display(), + wal.display(), + ) + .expect("write config"); + + let mut command = Command::new(env!("CARGO_BIN_EXE_ourios-server")); + command + .arg("--config") + .arg(&config_path) + .env("RUST_LOG", "info") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + for (key, value) in envs { + command.env(key, value); + } + let mut child = command.spawn().expect("spawn ourios-server"); + let stdout = child.stdout.take().expect("stdout piped"); + let mut out_lines = BufReader::new(stdout).lines(); + let (grpc, http, querier) = timeout(Duration::from_secs(15), async { + let (mut g, mut h, mut q) = (None, None, None); + while let Some(line) = out_lines.next_line().await.expect("read stdout") { + if let Some(a) = line.strip_prefix("receiver gRPC listening on ") { + g = Some(a.trim().to_string()); + } + if let Some(a) = line.strip_prefix("receiver HTTP listening on ") { + h = Some(a.trim().to_string()); + } + if let Some(a) = line.strip_prefix("querier HTTP listening on ") { + q = Some(a.trim().parse().expect("addr")); + } + if let (Some(g), Some(h), Some(q)) = (&g, &h, &q) { + return (g.clone(), h.clone(), *q); + } + } + panic!("role announcements never appeared"); + }) + .await + .expect("server ready before timeout"); + (child, grpc, http, querier) + } + + /// Raw `POST /v1/query` with optional bearer + tenant headers; returns + /// the status line. + async fn query_status( + addr: std::net::SocketAddr, + bearer: Option<&str>, + tenant: Option<&str>, + ) -> String { + use std::fmt::Write as _; + let mut request = String::from("POST /v1/query HTTP/1.1\r\nHost: 127.0.0.1\r\n"); + if let Some(b) = bearer { + write!(request, "Authorization: Bearer {b}\r\n").expect("write header"); + } + if let Some(t) = tenant { + write!(request, "x-ourios-tenant: {t}\r\n").expect("write header"); + } + request.push_str( + "Content-Type: text/plain\r\nContent-Length: 16\r\nConnection: close\r\n\r\ntemplate_id == 1", + ); + let mut stream = tokio::net::TcpStream::connect(addr).await.expect("connect"); + stream.write_all(request.as_bytes()).await.expect("write"); + let mut response = String::new(); + timeout( + Duration::from_secs(15), + stream.read_to_string(&mut response), + ) + .await + .expect("response before timeout") + .expect("read"); + response.lines().next().unwrap_or_default().to_string() + } + + /// Scenario RFC0029.3 — claim binding drives unchanged enforcement: + /// with `tenant_claim` = `["a", "b"]`, the RFC 0026 §5.3/§5.4 + /// contracts hold verbatim with the OIDC-resolved binding — in-set + /// ingest acks, out-of-set is whole-batch denied before the WAL, and + /// the query surface enforces 401 → 400 → 403 in order. (The + /// `name_claim` → name-label arm is the resolver's one-line mapping, + /// pinned by the core verifier tests.) + /// See `docs/rfcs/0029-oidc-bearer-layer.md` §5. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn rfc0029_3_claim_binding_enforcement() { + use opentelemetry_proto::tonic::collector::logs::v1::logs_service_client::LogsServiceClient; + + let (encoding, jwk) = make_key("key-1"); + let issuer = serve_issuer(jwk).await; + let tmp = tempfile::TempDir::new().expect("temp"); + let auth = format!( + "auth:\n oidc:\n issuer: {issuer}\n audience: ourios\n tenant_claim: ourios_tenants\n" + ); + let (mut child, grpc, _http, querier) = spawn_with_auth(&tmp, &auth, &[]).await; + + let token = mint(&encoding, "key-1", &issuer, &["a", "b"]); + + // Ingest: in-set acks; out-of-set is whole-batch denied. + let mut client = LogsServiceClient::connect(format!("http://{grpc}")) + .await + .expect("grpc connect"); + let authorization: tonic::metadata::MetadataValue<_> = + format!("Bearer {token}").parse().expect("metadata"); + let mut request = tonic::Request::new(batch("b")); + request + .metadata_mut() + .insert("authorization", authorization.clone()); + client.export(request).await.expect("in-set batch acks"); + let mut request = tonic::Request::new(batch("c")); + request + .metadata_mut() + .insert("authorization", authorization); + let status = client + .export(request) + .await + .expect_err("out-of-set tenant is denied"); + assert_eq!(status.code(), tonic::Code::PermissionDenied); + + // Query: 401 (no bearer) → 400 (bearer, no tenant) → 403 + // (bearer, out-of-set tenant) → 200 (in-set). + assert!( + query_status(querier, None, Some("a")).await.contains("401"), + "authentication answers first" + ); + assert!( + query_status(querier, Some(&token), None) + .await + .contains("400"), + "then the tenant contract" + ); + assert!( + query_status(querier, Some(&token), Some("c")) + .await + .contains("403"), + "then the binding" + ); + assert!( + query_status(querier, Some(&token), Some("a")) + .await + .contains("200"), + "in-set queries serve" + ); + + child.kill().await.expect("kill the server"); + } + + /// Scenario RFC0029.4 — wildcard claim: `["*"]` behaves as if every + /// tenant were listed (RFC 0026 §5.5 parity) on ingest and query. + /// See `docs/rfcs/0029-oidc-bearer-layer.md` §5. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn rfc0029_4_wildcard_claim() { + use opentelemetry_proto::tonic::collector::logs::v1::logs_service_client::LogsServiceClient; + + let (encoding, jwk) = make_key("key-1"); + let issuer = serve_issuer(jwk).await; + let tmp = tempfile::TempDir::new().expect("temp"); + let auth = format!( + "auth:\n oidc:\n issuer: {issuer}\n audience: ourios\n tenant_claim: ourios_tenants\n" + ); + let (mut child, grpc, _http, querier) = spawn_with_auth(&tmp, &auth, &[]).await; + + let token = mint(&encoding, "key-1", &issuer, &["*"]); + let mut client = LogsServiceClient::connect(format!("http://{grpc}")) + .await + .expect("grpc connect"); + for tenant in ["alpha", "beta", "entirely-new-tenant"] { + let authorization: tonic::metadata::MetadataValue<_> = + format!("Bearer {token}").parse().expect("metadata"); + let mut request = tonic::Request::new(batch(tenant)); + request + .metadata_mut() + .insert("authorization", authorization); + client + .export(request) + .await + .unwrap_or_else(|e| panic!("wildcard ingests {tenant}: {e}")); + assert!( + query_status(querier, Some(&token), Some(tenant)) + .await + .contains("200"), + "wildcard queries {tenant}" + ); + } + child.kill().await.expect("kill the server"); + } + + /// Scenario RFC0029.5 — coexistence and resolution order: one config + /// with both `tokens` and `oidc`; a static bearer and a JWT each + /// authenticate via their own path, carrying their own binding. (The + /// static-only serving arm is the RFC 0026 suite; the oidc-only arm is + /// `rfc0029_1_oidc_only_starts_and_enforces`; the no-`auth` open-mode + /// parity arm is `rfc0026_6_open_mode_parity` — all unchanged.) + /// See `docs/rfcs/0029-oidc-bearer-layer.md` §5. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn rfc0029_5_coexistence_and_resolution_order() { + use opentelemetry_proto::tonic::collector::logs::v1::logs_service_client::LogsServiceClient; + + let (encoding, jwk) = make_key("key-1"); + let issuer = serve_issuer(jwk).await; + let tmp = tempfile::TempDir::new().expect("temp"); + // RFC 0026 §3.1 secret hygiene: the static token must be an + // ${env:…} reference, resolved from the child's environment. + let auth = format!( + "auth:\n tokens:\n - name: edge-collector\n token: ${{env:EDGE_TOK}}\n tenants: [acme]\n\ + \x20\x20oidc:\n issuer: {issuer}\n audience: ourios\n tenant_claim: ourios_tenants\n" + ); + let (mut child, grpc, _http, querier) = + spawn_with_auth(&tmp, &auth, &[("EDGE_TOK", "tok-edge")]).await; + + let jwt = mint(&encoding, "key-1", &issuer, &["globex"]); + let mut client = LogsServiceClient::connect(format!("http://{grpc}")) + .await + .expect("grpc connect"); + + // Each credential authenticates via its own path, each with its + // own binding: the static token speaks for acme only, the JWT for + // globex only — in-set acks, cross-binding denies. + for (label, bearer, in_set, out_of_set) in [ + ("static", "tok-edge".to_string(), "acme", "globex"), + ("oidc", jwt.clone(), "globex", "acme"), + ] { + let authorization: tonic::metadata::MetadataValue<_> = + format!("Bearer {bearer}").parse().expect("metadata"); + let mut request = tonic::Request::new(batch(in_set)); + request + .metadata_mut() + .insert("authorization", authorization.clone()); + client + .export(request) + .await + .unwrap_or_else(|e| panic!("{label}: in-set batch acks: {e}")); + let mut request = tonic::Request::new(batch(out_of_set)); + request + .metadata_mut() + .insert("authorization", authorization); + let status = client + .export(request) + .await + .expect_err("cross-binding tenant is denied"); + assert_eq!(status.code(), tonic::Code::PermissionDenied, "{label}"); + } + + // And side by side on the query surface. + assert!( + query_status(querier, Some("tok-edge"), Some("acme")) + .await + .contains("200"), + "static binding queries acme" + ); + assert!( + query_status(querier, Some(&jwt), Some("globex")) + .await + .contains("200"), + "oidc binding queries globex" + ); + + child.kill().await.expect("kill the server"); + } +} From bb10e93fdaf764dd0dfaf26fb0bac3e90e12e472 Mon Sep 17 00:00:00 2001 From: Jens Holdgaard Pedersen Date: Tue, 7 Jul 2026 21:39:58 +0200 Subject: [PATCH 2/3] fix(auth): build the network-role resolver once; resolver-era doc comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth_resolver returns None when no network role is enabled and is built exactly once otherwise — one OIDC discovery, one shared JWKS cache/throttle across receiver + querier. QuerierConfig/router/ require_bearer docs describe the resolver, not the retired store. Co-Authored-By: Claude Fable 5 --- crates/ourios-server/src/main.rs | 22 ++++++++++++++++------ crates/ourios-server/src/mcp.rs | 8 ++++---- crates/ourios-server/src/querier.rs | 10 ++++++---- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/crates/ourios-server/src/main.rs b/crates/ourios-server/src/main.rs index 421588bca..4aade8196 100644 --- a/crates/ourios-server/src/main.rs +++ b/crates/ourios-server/src/main.rs @@ -581,10 +581,17 @@ fn warn_if_open_mode(config: &ServerConfig) { /// startup — a failure (unreachable issuer, issuer mismatch, unusable /// JWKS) is a startup error, not a degraded mode (§3.2: with no cached /// keys nothing could ever verify). +/// `None` when no network role is enabled (nothing to authenticate — and +/// no issuer round-trip on a compactor-only process); otherwise built +/// exactly once and cloned into each role, so OIDC discovery runs once +/// and the roles share the verifier's JWKS cache + refresh throttle. async fn auth_resolver( config: &ServerConfig, -) -> Result { +) -> Result, String> { use ourios_ingester::receiver::AuthResolver; + if config.receiver.is_none() && config.querier.is_none() { + return Ok(None); + } let static_store = config .auth .as_ref() @@ -595,12 +602,12 @@ async fn auth_resolver( let verifier = ourios_core::auth::oidc::OidcVerifier::discover(oidc) .await .map_err(|e| format!("auth.oidc: {e}"))?; - Ok(AuthResolver::with_oidc( + Ok(Some(AuthResolver::with_oidc( static_store, std::sync::Arc::new(verifier), - )) + ))) } - None => Ok(AuthResolver::static_only(static_store)), + None => Ok(Some(AuthResolver::static_only(static_store))), } } @@ -651,6 +658,9 @@ async fn main() -> Result<(), Box> { // Start the OTLP receiver role if enabled (RFC 0003 §9). Report the // bound addresses on stdout so an operator — or a test binding `:0` — // learns the actual ports. + // One resolver, built once, shared by every enabled network role. + let resolver = auth_resolver(&config).await?; + let receiver = match &config.receiver { // The receiver's RFC 0014 data write path runs on the resolved store // (local or S3, RFC 0019 slice 2c) — the same store the querier reads @@ -665,7 +675,7 @@ async fn main() -> Result<(), Box> { // handle is cheap to share, the compactor keeps the original). store: store.clone(), promoted: config.promoted.clone(), - auth: auth_resolver(&config).await?, + auth: resolver.clone().expect("resolver built for enabled roles"), }) .await?; println!("receiver gRPC listening on {}", handle.grpc_addr); @@ -686,7 +696,7 @@ async fn main() -> Result<(), Box> { // The querier engine is Store-capable (RFC 0019 slice 2a), so it // reads whichever backend config resolved (local or S3). store: config.store.clone(), - auth: auth_resolver(&config).await?, + auth: resolver.clone().expect("resolver built for enabled roles"), default_window_nanos: params.default_window_nanos, mcp_enabled: params.mcp_enabled, }) diff --git a/crates/ourios-server/src/mcp.rs b/crates/ourios-server/src/mcp.rs index 7290cadbb..5f7a9d999 100644 --- a/crates/ourios-server/src/mcp.rs +++ b/crates/ourios-server/src/mcp.rs @@ -418,10 +418,10 @@ fn json_content(value: &T) -> Result, next: Next) -> Response { let authorization = request .headers() diff --git a/crates/ourios-server/src/querier.rs b/crates/ourios-server/src/querier.rs index 4f76680c4..4a013d240 100644 --- a/crates/ourios-server/src/querier.rs +++ b/crates/ourios-server/src/querier.rs @@ -76,9 +76,10 @@ pub struct QuerierConfig { /// Serve the RFC 0027 MCP surface at `/mcp` (`querier.mcp.enabled`; /// default off). The RFC 0026 gate applies to it identically. pub mcp_enabled: bool, - /// The RFC 0026 token store; `None` is open mode (§3.1). With a store, - /// every request must carry a known `Authorization: Bearer` credential - /// (→ 401) and its `x-ourios-tenant` must fall inside the token's set + /// The RFC 0026/0029 credential resolver (`is_open()` = open mode, + /// §3.1). Otherwise every request must carry a resolvable + /// `Authorization: Bearer` credential (→ 401) and its + /// `x-ourios-tenant` must fall inside the resolved binding's set /// (→ 403); the missing-tenant 400 is unchanged (§3.3). pub auth: AuthResolver, /// The look-back applied to a query with no `range(...)` stage — the @@ -243,7 +244,8 @@ pub fn router_with_mcp( ) } -/// [`router`] with an RFC 0026 token store — the authenticated variant +/// [`router`] with an RFC 0026/0029 credential resolver — the authenticated +/// variant (`AuthResolver::is_open()` = open mode) /// (`None` = open mode, identical to [`router`]). pub fn router_with_auth( bucket_root: PathBuf, From f5d1d73050415edc87753698bdfc3bf77cadd589 Mon Sep 17 00:00:00 2001 From: Jens Holdgaard Pedersen Date: Tue, 7 Jul 2026 21:48:27 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(auth):=20verify=20once=20per=20mcp=20re?= =?UTF-8?q?quest=20=E2=80=94=20the=20tool=20check=20reads=20the=20cached?= =?UTF-8?q?=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit require_bearer caches the resolved AuthBinding on the request; the per-tool tenant check reads it from the forwarded parts (sync again), failing closed when absent — no second verification, no second possible JWKS fetch per tool call. spawn_with_auth inherits the child's stderr so startup failures aren't bare timeouts. Co-Authored-By: Claude Fable 5 --- crates/ourios-server/src/mcp.rs | 51 ++++++++++++------- crates/ourios-server/tests/it/rfc0029_oidc.rs | 4 +- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/crates/ourios-server/src/mcp.rs b/crates/ourios-server/src/mcp.rs index 5f7a9d999..6c37a0c1a 100644 --- a/crates/ourios-server/src/mcp.rs +++ b/crates/ourios-server/src/mcp.rs @@ -24,7 +24,7 @@ use axum::http::{Request, StatusCode, header}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; use ourios_core::tenant::TenantId; -use ourios_ingester::receiver::AuthResolver; +use ourios_ingester::receiver::{AuthBinding, AuthResolver}; use ourios_querier::Querier; use ourios_querier::dsl::{self, Statement}; use rmcp::handler::server::ServerHandler; @@ -135,7 +135,7 @@ impl OuriosMcp { /// `tenant` inside its set. Open mode passes. The transport layer /// already answered 401 for missing/unknown credentials; this is the /// 403 half, per tool call, before any data is touched. - async fn check_tenant( + fn check_tenant( &self, ctx: &rmcp::service::RequestContext, tenant: &str, @@ -143,21 +143,25 @@ impl OuriosMcp { if self.auth.is_open() { return Ok(()); } - let authorization = ctx + // The transport layer (`require_bearer`) already authenticated and + // cached the resolved binding on the request — read it from the + // forwarded parts rather than verifying the credential twice. A + // missing binding here means the transport gate did not run: fail + // closed. + let binding = ctx .extensions .get::() - .and_then(|parts| parts.headers.get(header::AUTHORIZATION)) - .and_then(|value| value.to_str().ok()); - let binding = - self.auth.authenticate(authorization).await.map_err(|_| { - ErrorData::invalid_request("a valid bearer token is required", None) - })?; + .and_then(|parts| parts.extensions.get::()); match binding { - Some(binding) if !binding.tenants().allows(tenant) => Err(ErrorData::invalid_request( + Some(binding) if binding.tenants().allows(tenant) => Ok(()), + Some(_) => Err(ErrorData::invalid_request( "the tenant is outside the authenticated token's allowed set", None, )), - _ => Ok(()), + None => Err(ErrorData::invalid_request( + "a valid bearer token is required", + None, + )), } } } @@ -189,7 +193,7 @@ impl OuriosMcp { ctx: rmcp::service::RequestContext, ) -> Result { let tenant_arg = normalize_tenant(&args.tenant)?; - self.check_tenant(&ctx, tenant_arg).await?; + self.check_tenant(&ctx, tenant_arg)?; let statement = dsl::parse_statement(&args.query) .map_err(|e| ErrorData::invalid_params(format!("invalid query: {e}"), None))?; let Statement::Logs(mut query) = statement else { @@ -247,7 +251,7 @@ impl OuriosMcp { ctx: rmcp::service::RequestContext, ) -> Result { let tenant_arg = normalize_tenant(&args.tenant)?; - self.check_tenant(&ctx, tenant_arg).await?; + self.check_tenant(&ctx, tenant_arg)?; let tenant = TenantId::new(tenant_arg); let started = std::time::Instant::now(); let registry = match self.querier.template_registry(&tenant).await { @@ -295,7 +299,7 @@ impl OuriosMcp { ctx: rmcp::service::RequestContext, ) -> Result { let tenant_arg = normalize_tenant(&args.tenant)?; - self.check_tenant(&ctx, tenant_arg).await?; + self.check_tenant(&ctx, tenant_arg)?; // One grammar, one boundary rule: the drift window parses through // the DSL front-end exactly as the JSON API's statement does // (RFC0010.2's half-open rule inherited verbatim). @@ -422,13 +426,24 @@ fn json_content(value: &T) -> Result, next: Next) -> Response { +async fn require_bearer(auth: AuthResolver, mut request: Request, next: Next) -> Response { let authorization = request .headers() .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()); - if auth.authenticate(authorization).await.is_err() { - return (StatusCode::UNAUTHORIZED, "a valid bearer token is required").into_response(); + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + match auth.authenticate(authorization.as_deref()).await { + Ok(None) => {} + // Cache the resolved binding on the request so the per-tool + // tenant check reads it from the forwarded parts instead of + // verifying the credential a second time (with OIDC that second + // pass could even refetch the JWKS). + Ok(Some(binding)) => { + request.extensions_mut().insert(binding); + } + Err(_) => { + return (StatusCode::UNAUTHORIZED, "a valid bearer token is required").into_response(); + } } next.run(request).await } diff --git a/crates/ourios-server/tests/it/rfc0029_oidc.rs b/crates/ourios-server/tests/it/rfc0029_oidc.rs index 6f2e9c23f..53417784c 100644 --- a/crates/ourios-server/tests/it/rfc0029_oidc.rs +++ b/crates/ourios-server/tests/it/rfc0029_oidc.rs @@ -531,7 +531,9 @@ mod claim_binding { .arg(&config_path) .env("RUST_LOG", "info") .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) + // Inherited so a pre-announcement startup failure lands the + // child's actual error in the test output, not a bare timeout. + .stderr(std::process::Stdio::inherit()) .kill_on_drop(true); for (key, value) in envs { command.env(key, value);