diff --git a/crates/busbar-contract/src/auth_calls.rs b/crates/busbar-contract/src/auth_calls.rs index 4984b1243f..1500958255 100644 --- a/crates/busbar-contract/src/auth_calls.rs +++ b/crates/busbar-contract/src/auth_calls.rs @@ -407,6 +407,24 @@ pub trait AuthAxis: Send + Sync { Ok(None) } + /// [`AuthAxis::serving`] for a binding whose token endpoint the operator spelled with a + /// `user:pass@`: the host stripped it from `settings` before any plugin could read it, and + /// `endpoint_basic` is the `authorization: Basic` value it spelled, which the host presents on + /// the plugin's mint as 1.5.5 sent it. A plugin never receives it. Default: the value is not + /// presented. + /// + /// # Errors + /// The serving plugin would not open for its outbound styles. + fn serving_presenting( + &self, + style: &str, + settings: &serde_json::Value, + endpoint_basic: Option<&[u8]>, + ) -> Result, String> { + let _ = endpoint_basic; + self.serving(style, settings) + } + /// CHECK, NEVER DIAL (the `--validate` dry run): bind `credential` under `settings` on a fresh /// instance of the auth plugin serving `style`, one granted no need, and answer the refusals it /// names for the credential itself (its `credential:` lines, their text), in its own words. diff --git a/crates/busbar-kernel/src/appbuild.rs b/crates/busbar-kernel/src/appbuild.rs index 5d0477df30..ce54ba709c 100644 --- a/crates/busbar-kernel/src/appbuild.rs +++ b/crates/busbar-kernel/src/appbuild.rs @@ -155,6 +155,23 @@ pub fn stateful_plane_ephemeral_store_warn( /// Map a provider's `Option` to the NEUTRAL [`AuthStyleInput`] the carrier holds /// (`None` ⇒ the protocol's native auth). The fallback plane maps it to the style its lane's credential /// is bound under, on the auth plugin serving that style. +/// THE TOKEN ENDPOINT AS A PLANE MAY SEE IT (row 74, F25): `token_url` with its userinfo stripped, +/// and the `Authorization: Basic` value that userinfo spelled, held opaque for the host to present +/// on the mint as 1.5.5's reqwest POST did (v1.5.5 +/// `crates/busbar/src/egress_auth/oauth_client_credentials.rs:119-126`). A plane never receives a +/// URL carrying `user:pass@`. +pub(crate) fn lane_token_endpoint( + written: Option<&str>, +) -> (Option, Option) { + let Some(written) = written else { + return (None, None); + }; + ( + Some(crate::net_guard::strip_userinfo(written).into_owned()), + crate::egress::engine::UrlUserinfo::of(written), + ) +} + fn auth_style_of(auth: Option) -> AuthStyleInput { match auth { None => AuthStyleInput::Default, @@ -875,11 +892,16 @@ pub fn build_app_from_config( let base_url = crate::net_guard::strip_userinfo(provider_cfg.base_url.trim_end_matches('/')) .into_owned(); + // ...and the Basic that userinfo spelled, held opaque for the kernel's hop to present as + // 1.5.5 did (see `UrlUserinfo`): the plane never reads it. + let url_userinfo = crate::egress::engine::UrlUserinfo::of(&provider_cfg.base_url); + let (token_url, token_userinfo) = lane_token_endpoint(provider_cfg.token_url.as_deref()); lane_inputs.push(LaneInput { model: ld.model.clone(), provider: ld.provider.clone(), protocol: protocol.to_string(), base_url, + url_userinfo, path: provider_cfg.path.clone(), path_base: provider_cfg.path_base.clone(), organization: provider_cfg.organization.clone(), @@ -888,7 +910,8 @@ pub fn build_app_from_config( api_key: busbar_contract::redacted::Redacted::new(api_key), auth_style: auth_style_of(provider_cfg.auth), scope: provider_cfg.scope.clone(), - token_url: provider_cfg.token_url.clone(), + token_url, + token_userinfo, subject: provider_cfg.subject.clone(), error_map: provider_cfg.error_map.clone(), health: provider_cfg.health.as_ref().map(health_input_of), diff --git a/crates/busbar-kernel/src/bound_credential.rs b/crates/busbar-kernel/src/bound_credential.rs index 3fd9ff0934..d50f01e1f2 100644 --- a/crates/busbar-kernel/src/bound_credential.rs +++ b/crates/busbar-kernel/src/bound_credential.rs @@ -216,9 +216,30 @@ pub fn bind( axis: &dyn AuthAxis, binding: &StyleBinding, credential: &[u8], +) -> Result, String> { + bind_presenting(axis, binding, credential, None) +} + +/// [`bind`] for a binding whose token endpoint the operator spelled with a `user:pass@` (row 74, +/// F25): `binding.params` names the stripped endpoint, and `endpoint` is the Basic value that +/// userinfo spelled, held opaque, handed to the axis that presents it on the mint as 1.5.5 sent it +/// ([`AuthAxis::serving_presenting`]). The plane that built the binding never reads it. +/// +/// # Errors +/// +/// As [`bind`]. +pub fn bind_presenting( + axis: &dyn AuthAxis, + binding: &StyleBinding, + credential: &[u8], + endpoint: Option<&crate::egress::engine::UrlUserinfo>, ) -> Result, String> { let serving = axis - .serving(&binding.style, &binding.params)? + .serving_presenting( + &binding.style, + &binding.params, + endpoint.map(|basic| basic.value().as_bytes()), + )? .ok_or_else(|| { format!( "no linked or dropped-in auth plugin serves the style '{}'", @@ -274,3 +295,7 @@ pub fn prebuild_auth( credential.headers_for(api_key, &ctx), )) } + +#[cfg(test)] +#[path = "tests/bound_credential_tests.rs"] +mod tests; diff --git a/crates/busbar-kernel/src/config/sections.rs b/crates/busbar-kernel/src/config/sections.rs index 9417c36841..226645b981 100644 --- a/crates/busbar-kernel/src/config/sections.rs +++ b/crates/busbar-kernel/src/config/sections.rs @@ -260,10 +260,11 @@ impl super::RootCfg { .into_iter() .filter(|(_, p)| !p.allow_metadata_hosts.is_empty()) .flat_map(|(_, p)| { - // The base URL as the served flow dials it: its userinfo stripped (row 74, F25), so - // the carve-out matches the host the dial names rather than reading nothing. - std::iter::once(crate::net_guard::strip_userinfo(&p.base_url).into_owned()) - .chain(p.token_url.iter().cloned()) + // Each URL as the served flow dials it: its userinfo stripped (row 74, F25), so the + // carve-out matches the host the dial names rather than reading nothing. + std::iter::once(&p.base_url) + .chain(&p.token_url) + .map(|url| crate::net_guard::strip_userinfo(url).into_owned()) .map(|url| (url, p.allow_metadata_hosts.clone())) }) .collect(); diff --git a/crates/busbar-kernel/src/config_validate/mod.rs b/crates/busbar-kernel/src/config_validate/mod.rs index a2dafc33c2..e29dca1e2f 100644 --- a/crates/busbar-kernel/src/config_validate/mod.rs +++ b/crates/busbar-kernel/src/config_validate/mod.rs @@ -2383,7 +2383,11 @@ fn validate_providers_with( || scheme_is(tu, "https") || (host_private && scheme_is(tu, "http")); if !tu_scheme_ok { - errors.push(if scheme_is(tu, "http") { + // Repeated with its userinfo redacted: a `user:pass@` in token_url is accepted + // and rides the mint as 1.5.5's did (row 74, F25), never a message. + let plaintext = scheme_is(tu, "http"); + let tu = crate::net_guard::redact_userinfo(tu); + errors.push(if plaintext { format!( "provider '{provider_name}' token_url must use https for a public host (got '{tu}'); it carries the client secret, so plaintext http is permitted only for a private/loopback token endpoint" ) @@ -2398,6 +2402,7 @@ fn validate_providers_with( cfg.allow_all_metadata, &cfg.blocked_metadata_hosts, ) { + let tu = crate::net_guard::redact_userinfo(tu); errors.push(format!( "provider '{provider_name}' token_url '{tu}' targets a blocked cloud-metadata host '{host}' (the client secret is POSTed there; cloud-metadata/IMDS endpoints are denied — override via this provider's allow_metadata_hosts, security.allow_metadata_hosts, or security.allow_all_metadata)" )); @@ -2423,8 +2428,10 @@ fn validate_providers_with( if !cred.trim().is_empty() { // The credential's shape is the serving auth plugin's to judge, in its own words, // through the same `open_outbound` the build binds it with (never dialing). + // The endpoint as the plugin is handed it at the build: its userinfo stripped. + let token_url = provider_cfg.token_url.as_deref().unwrap_or_default(); let params = serde_json::json!({ - "token_url": provider_cfg.token_url.clone().unwrap_or_default(), + "token_url": crate::net_guard::strip_userinfo(token_url), "scope": provider_cfg.scope.clone().unwrap_or_default(), }); for e in outbound_credential_refusals("oauth-client-credentials", &cred, ¶ms) { @@ -2602,6 +2609,9 @@ pub fn vet_token_uri(token_uri: &str, posture: &MetadataPosture) -> Result<(), S .map(host_is_private_or_loopback) .unwrap_or(false); if !(scheme_is(token_uri, "https") || (host_private && scheme_is(token_uri, "http"))) { + // Repeated with its userinfo redacted: a `user:pass@` in token_uri rides the mint as + // 1.5.5's did (row 74, F25), never a message. + let token_uri = crate::net_guard::redact_userinfo(token_uri); return Err(format!( "service-account token_uri must use https for a public host (got '{token_uri}'); it receives the signed JWT assertion, so plaintext http is permitted only for a private/loopback endpoint" )); @@ -2612,6 +2622,7 @@ pub fn vet_token_uri(token_uri: &str, posture: &MetadataPosture) -> Result<(), S posture.allow_all, &posture.blocked_hosts, ) { + let token_uri = crate::net_guard::redact_userinfo(token_uri); return Err(format!( "service-account token_uri '{token_uri}' targets a blocked cloud-metadata host '{host}' (the signed assertion would be POSTed there; cloud-metadata/IMDS endpoints are denied — override via this provider's allow_metadata_hosts, security.allow_metadata_hosts, or security.allow_all_metadata)" )); diff --git a/crates/busbar-kernel/src/config_validate/tests/tests.rs b/crates/busbar-kernel/src/config_validate/tests/tests.rs index 01b0a61544..42f9217884 100644 --- a/crates/busbar-kernel/src/config_validate/tests/tests.rs +++ b/crates/busbar-kernel/src/config_validate/tests/tests.rs @@ -3267,6 +3267,54 @@ fn a_base_url_userinfo_is_accepted_and_no_validation_error_repeats_it() { "each refusal still names the host it is about: {about:?}" ); } + // The token endpoint (row 74, F25): accepted as 1.5.5 accepted it, and repeated redacted. + let with_token_url = |token_url: String| { + let mut providers = HashMap::new(); + let mut p = make_provider(proto_b(), "https://api.example.com", "API_KEY"); + p.token_url = Some(token_url); + p.scope = Some("api://x/.default".into()); + p.auth = Some(config::ProviderAuth::OAuthClientCredentials); + providers.insert("myprovider".to_string(), p); + let mut models = HashMap::new(); + models.insert("m".to_string(), make_model("myprovider", 10)); + let mut pools = HashMap::new(); + pools.insert("p".to_string(), make_pool(vec![make_member("m")])); + validate(&make_root_cfg(providers, models, pools)) + }; + let accepted = with_token_url(format!("https://{PLANTED}login.example.com/token")); + assert!( + accepted.is_ok(), + "a token_url spelling user:pass@ validates, as in 1.5.5: {accepted:?}" + ); + let plaintext = format!("https://{PLANTED}login.example.com/token").replacen("s://", "://", 1); + for (token_url, host) in [ + ( + format!("ftp://{PLANTED}login.example.com/token"), + "login.example.com", + ), + (plaintext, "login.example.com"), + ( + format!("https://{PLANTED}169.254.169.254/token"), + "169.254.169.254", + ), + ] { + let errors = with_token_url(token_url).expect_err("the planted endpoint is refused"); + let about: Vec<&String> = errors.iter().filter(|e| e.contains("token_url")).collect(); + assert!( + !about.is_empty(), + "a token_url refusal was written: {errors:?}" + ); + for e in &errors { + assert!( + !e.contains("hunter2") && !e.contains("svcuser"), + "a validation error repeated the planted spelling: {e}" + ); + } + assert!( + about.iter().all(|e| e.contains(host)), + "each refusal still names the host it is about: {about:?}" + ); + } } /// An EMPTY `affinity.header_name` must be REJECTED at boot. It passes diff --git a/crates/busbar-kernel/src/egress/engine/client.rs b/crates/busbar-kernel/src/egress/engine/client.rs index ee996296c0..a63ecb7545 100644 --- a/crates/busbar-kernel/src/egress/engine/client.rs +++ b/crates/busbar-kernel/src/egress/engine/client.rs @@ -179,6 +179,11 @@ async fn send_request( _ => return Err(EngineError::new(ErrorKind::UserUnsupportedVersion)), } + // A provider base URL's userinfo, handed back by its plane as an opaque extension, rides here + // and nowhere earlier: the Basic field 1.5.5 sent, first, when the request names no + // `authorization` of its own (row 74, F25; see `super::UrlUserinfo`). + super::present_url_userinfo(&mut req); + // Preparation, part 2 — absolute-form required: scheme+authority IS the pool key. The CONNECT // scheme-inference arm is unreachable for busbar (the tunnel lives below the connector) but // kept for surface fidelity. diff --git a/crates/busbar-kernel/src/egress/engine/mod.rs b/crates/busbar-kernel/src/egress/engine/mod.rs index 9d1041172e..33de1722e8 100644 --- a/crates/busbar-kernel/src/egress/engine/mod.rs +++ b/crates/busbar-kernel/src/egress/engine/mod.rs @@ -488,6 +488,150 @@ fn strip_userinfo_into_basic_auth(uri: http::Uri, headers: &mut http::HeaderMap) http::Uri::from_parts(parts).expect("removing userinfo keeps the URI well-formed") } +/// A PROVIDER BASE URL'S USERINFO, AS 1.5.5 PRESENTED IT (row 74, F25): the `Authorization: Basic` +/// value its `user[:pass]@` spells, held core-side and presented at the egress hop alone. +/// +/// 1.5.5 built every provider request with reqwest over `{base_url}{path}`, its credential after +/// (v1.5.5 `crates/busbar/src/proxy/engine/mod.rs:1669-1670`, `proxy/engine/walk.rs:583-584`, +/// `health.rs:313-314`). reqwest 0.12.28's `RequestBuilder::new` +/// (`src/async_impl/request.rs:169-182`) took the URL's userinfo out (`extract_authority`, +/// `:581-605`: percent-decoded, nothing when the user is empty and there is +/// no password) and added it FIRST as `authorization: Basic base64(user:pass)`, sensitive +/// (`src/util.rs:4-25`); the lane's own credential then went in through `.headers(auth)`, whose +/// `replace_headers` (`src/util.rs:50-77`) REPLACED that field when the credential named +/// `authorization` and left it in place otherwise. So: the Basic field rides, first, exactly when +/// the request's own credential names no `authorization`. +/// +/// The value is read outside the kernel only by the composition root's own connection opens +/// ([`UrlUserinfo::leading`]); `Debug` prints a marker and the header is sensitive. A plane that +/// carries one never reads it: it hands it back to the kernel's hop as an opaque request extension +/// ([`present_url_userinfo`]). +#[derive(Clone)] +pub struct UrlUserinfo(http::HeaderValue); + +impl UrlUserinfo { + /// The Basic value `url`'s authority spells, or `None` when it spells no user and no password. + /// The authority ends at the first `/`, `?`, `#` or `\`, and the userinfo is everything before + /// its LAST `@`; the user is what precedes the first `:`, the pass what follows it (an empty + /// pass is none). Both are percent-decoded as reqwest decoded them: a user that does not decode + /// to UTF-8 presents nothing, a pass that does not is no pass (`extract_authority`'s two arms). + #[must_use] + pub fn of(url: &str) -> Option { + use base64::Engine as _; + use zeroize::Zeroize as _; + let (_, rest) = url.split_once("://")?; + let end = rest.find(['/', '?', '#', '\\']).unwrap_or(rest.len()); + let (spelled, _) = rest[..end].rsplit_once('@')?; + let (user, pass) = match spelled.split_once(':') { + Some((user, pass)) => (user, Some(pass).filter(|p| !p.is_empty())), + None => (spelled, None), + }; + let mut plain = percent_decoded(user)?; + // A pass that does not decode is no pass, as reqwest's `password` arm read it. + let mut pass = pass.and_then(percent_decoded); + if plain.is_empty() && pass.is_none() { + return None; + } + plain.push(':'); + if let Some(pass) = pass.as_mut() { + plain.push_str(pass); + pass.zeroize(); + } + let mut encoded = base64::engine::general_purpose::STANDARD.encode(plain.as_bytes()); + plain.zeroize(); + let value = http::HeaderValue::from_maybe_shared(Bytes::from(format!("Basic {encoded}"))); + encoded.zeroize(); + let mut value = value.ok()?; + value.set_sensitive(true); + Some(Self(value)) + } + + /// The field value, for the kernel's own hops. + pub(crate) fn value(&self) -> &http::HeaderValue { + &self.0 + } + + /// The value as it crosses from the kernel to the composition root's auth axis + /// ([`busbar_contract::auth_calls::AuthAxis::serving_presenting`]), and back: `None` for bytes + /// no field value can carry. Sensitive, like every value this type holds. + #[must_use] + pub fn from_value(bytes: &[u8]) -> Option { + let mut value = http::HeaderValue::from_bytes(bytes).ok()?; + value.set_sensitive(true); + Some(Self(value)) + } + + /// A host-side connection open's head `fields` with this value LEADING them under + /// `authorization`, or `None` when they already name `authorization` (the value then rides + /// nowhere): 1.5.5's order and condition, for the composition root's own opens (a minting auth + /// plugin's token endpoint). Never handed to a plane. + #[must_use] + pub fn leading<'a>( + &'a self, + fields: &[(&'a str, &'a [u8])], + ) -> Option> { + const AUTHORIZATION: &str = "authorization"; + if fields + .iter() + .any(|(n, _)| n.eq_ignore_ascii_case(AUTHORIZATION)) + { + return None; + } + Some( + std::iter::once((AUTHORIZATION, self.0.as_bytes())) + .chain(fields.iter().copied()) + .collect(), + ) + } +} + +impl std::fmt::Debug for UrlUserinfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("UrlUserinfo()") + } +} + +/// `%XX` sequences decoded to bytes, the result read as UTF-8 (`None` when it is not). A `%` that +/// opens no two-digit hex escape is kept as written, as the percent-decoder reqwest used kept it. +fn percent_decoded(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let hex = |b: u8| (b as char).to_digit(16); + match (bytes[i], bytes.get(i + 1), bytes.get(i + 2)) { + (b'%', Some(&h), Some(&l)) if hex(h).is_some() && hex(l).is_some() => { + let (h, l) = (hex(h).unwrap_or(0), hex(l).unwrap_or(0)); + out.push((h * 16 + l) as u8); + i += 3; + } + (b, _, _) => { + out.push(b); + i += 1; + } + } + } + String::from_utf8(out).ok() +} + +/// THE HOP THAT PRESENTS A BASE URL'S USERINFO: a request carrying a [`UrlUserinfo`] extension +/// goes out with `authorization: Basic …` as its FIRST field when, and only when, it names no +/// `authorization` of its own — 1.5.5's order and condition (see [`UrlUserinfo`]). The extension is +/// taken off either way, so nothing past this hop holds it. A request without one is untouched. +pub(crate) fn present_url_userinfo(req: &mut http::Request>) { + let Some(userinfo) = req.extensions_mut().remove::() else { + return; + }; + if req.headers().contains_key(http::header::AUTHORIZATION) { + return; + } + let rest = std::mem::take(req.headers_mut()); + let mut fields = http::HeaderMap::with_capacity(rest.len() + 1); + fields.insert(http::header::AUTHORIZATION, userinfo.0); + fields.extend(rest); + *req.headers_mut() = fields; +} + /// Assemble one egress request from boot-precomputed parts, POST (the one method this /// fixed-destination forward path speaks). Deliberately NOT [`request`]: the caller's `http::Uri` /// was validated at boot (no userinfo can survive config validation), so this path keeps zero diff --git a/crates/busbar-kernel/src/egress/engine/tests/engine_tests.rs b/crates/busbar-kernel/src/egress/engine/tests/engine_tests.rs index 9b3bd47f52..5e2e0fca8f 100644 --- a/crates/busbar-kernel/src/egress/engine/tests/engine_tests.rs +++ b/crates/busbar-kernel/src/egress/engine/tests/engine_tests.rs @@ -655,3 +655,122 @@ fn proxy_env_refusals_never_echo_the_proxy_password() { ); } } + +/// ROW 74 (F25): a provider base URL's `user[:pass]@` becomes the Basic value 1.5.5's reqwest built +/// (`extract_authority` + `util::basic_auth`, reqwest 0.12.28): the LAST `@` of the authority ends +/// the userinfo, the first `:` splits user from pass, both are percent-decoded, an empty pass is +/// none, and a spelling with neither user nor pass (or a user that is not UTF-8) presents nothing. The +/// value is sensitive and its `Debug` is a marker. +#[test] +fn a_base_url_userinfo_becomes_the_basic_value_reqwest_built() { + use base64::Engine as _; + let basic = |plain: &str| { + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(plain) + ) + }; + for (url, plain) in [ + ( + "https://svcuser:hunter2-P4ss@api.example.com/v1", + "svcuser:hunter2-P4ss", + ), + ( + "https://svcuser:hun@ter2@api.example.com", + "svcuser:hun@ter2", + ), + ( + "https://svcuser:p%40ss%3A1@api.example.com", + "svcuser:p@ss:1", + ), + ("https://svcuser@api.example.com", "svcuser:"), + ("https://svcuser:@api.example.com", "svcuser:"), + ("https://:hunter2@api.example.com", ":hunter2"), + // A pass that does not decode to UTF-8 is no pass (reqwest's `password` arm). + ("https://svcuser:%FF@api.example.com", "svcuser:"), + ] { + let userinfo = UrlUserinfo::of(url).expect("the userinfo presents"); + assert_eq!(userinfo.value().to_str().unwrap(), basic(plain), "{url}"); + assert!(userinfo.value().is_sensitive(), "{url}"); + assert_eq!(format!("{userinfo:?}"), "UrlUserinfo()"); + } + for url in [ + "https://api.example.com/v1", + "https://api.example.com/x@y", + "https://@api.example.com", + "https://:@api.example.com", + // A user that does not decode to UTF-8 presents nothing (reqwest's `username` arm). + "https://%FF:hunter2@api.example.com", + "https://:%FF@api.example.com", + "api.example.com", + ] { + assert!(UrlUserinfo::of(url).is_none(), "{url} presents nothing"); + } +} + +/// ROW 74 (F25), THE HOP: a request carrying a [`UrlUserinfo`] goes out with the Basic field FIRST +/// when its own fields name no `authorization` (a dialect whose credential rides another field), +/// and without it when they do (the credential replaced it in 1.5.5's `replace_headers`). The +/// extension never survives the hop; a request without one is untouched. +#[test] +fn the_hop_presents_the_userinfo_first_unless_the_request_names_authorization() { + let userinfo = + UrlUserinfo::of("https://svcuser:hunter2-P4ss@api.example.com").expect("a userinfo"); + let request_with = |fields: &[(&'static str, &'static str)], carry: bool| { + let mut headers = http::HeaderMap::new(); + for (n, v) in fields { + headers.append(*n, http::HeaderValue::from_static(*v)); + } + let mut req = egress_request( + "https://api.example.com/v1/messages".parse().unwrap(), + headers, + Bytes::new(), + ); + if carry { + req.extensions_mut().insert(userinfo.clone()); + } + present_url_userinfo(&mut req); + assert!(req.extensions().get::().is_none()); + req.headers() + .iter() + .map(|(n, v)| (n.as_str().to_string(), v.to_str().unwrap().to_string())) + .collect::>() + }; + let basic = userinfo.value().to_str().unwrap().to_string(); + let owned = |v: &[(&str, &str)]| -> Vec<(String, String)> { + v.iter() + .map(|(n, v)| ((*n).to_string(), (*v).to_string())) + .collect() + }; + assert_eq!( + request_with( + &[("x-api-key", "k"), ("content-type", "application/json")], + true + ), + owned(&[ + ("authorization", &basic), + ("x-api-key", "k"), + ("content-type", "application/json"), + ]), + "a credential under another field leaves the Basic field in, first" + ); + assert_eq!( + request_with( + &[ + ("authorization", "Bearer k"), + ("content-type", "application/json") + ], + true + ), + owned(&[ + ("authorization", "Bearer k"), + ("content-type", "application/json") + ]), + "a credential named `authorization` displaces it" + ); + assert_eq!( + request_with(&[("x-api-key", "k")], false), + owned(&[("x-api-key", "k")]), + "no userinfo, nothing presented" + ); +} diff --git a/crates/busbar-kernel/src/egress/engine/tests/pool_tests.rs b/crates/busbar-kernel/src/egress/engine/tests/pool_tests.rs index 12b426d39b..7d6455053b 100644 --- a/crates/busbar-kernel/src/egress/engine/tests/pool_tests.rs +++ b/crates/busbar-kernel/src/egress/engine/tests/pool_tests.rs @@ -853,6 +853,66 @@ async fn h1_wire_request_line_and_host_match_legacy() { assert!(heads[2].starts_with("GET / HTTP/1.1\r\n")); } +/// ROW 74 (F25), ON THE WIRE: a base URL's userinfo, handed to the owned client as its opaque +/// extension, reaches the far end as `authorization: Basic …`, the FIRST field, when the request's +/// credential rides another field, and not at all when the credential is `authorization` — the two +/// shapes 1.5.5's reqwest build sent. The far end's `host` is the bare authority either way. +#[tokio::test] +async fn h1_wire_carries_a_base_url_userinfo_as_1_5_5_did() { + let fixture = spawn_http(CannedResponse::ok("wire"), 100); + let port = fixture.addr.port(); + let owned = EngineClient::assemble( + plain_connector( + EgressResolver::Pinned { + host: Arc::from("wire.test"), + addr: fixture.addr.ip(), + }, + 4, + Duration::from_secs(10), + ), + cfg(4), + ); + let userinfo = UrlUserinfo::of(&format!("http://svcuser:hunter2-P4ss@wire.test:{port}")) + .expect("the base spells a userinfo"); + for credential in [("x-api-key", "k"), ("authorization", "Bearer k")] { + let mut headers = http::HeaderMap::new(); + headers.insert(credential.0, http::HeaderValue::from_static(credential.1)); + let mut req = egress_request( + format!("http://wire.test:{port}/v1/messages") + .parse() + .expect("uri"), + headers, + Bytes::from_static(b"{}"), + ); + req.extensions_mut().insert(userinfo.clone()); + let resp = owned.request(req).await.expect("owned sends"); + let _ = resp.into_body().collect().await; + } + let heads = fixture.request_heads(); + assert_eq!(heads.len(), 2); + let basic = userinfo.value().to_str().expect("ascii").to_string(); + assert!( + heads[0].starts_with(&format!( + "POST /v1/messages HTTP/1.1\r\nauthorization: {basic}\r\nx-api-key: k\r\n" + )), + "the Basic field rides first, before the dialect's own credential: {:?}", + heads[0] + ); + assert!(heads[0].contains(&format!("host: wire.test:{port}\r\n"))); + assert!( + heads[1].starts_with("POST /v1/messages HTTP/1.1\r\nauthorization: Bearer k\r\n"), + "an `authorization` credential displaces it: {:?}", + heads[1] + ); + assert!(!heads[1].contains("Basic"), "{:?}", heads[1]); + for head in &heads { + assert!( + !head.contains("svcuser") && !head.contains("hunter2"), + "the userinfo never rides in the clear: {head:?}" + ); + } +} + /// Host formatting drops the scheme-default port exactly as legacy's `get_non_default_port` /// (443-on-https / 80-on-http off the header; everything else on it). Unit-pinned because no /// loopback fixture can listen on the default ports. diff --git a/crates/busbar-kernel/src/plane_driver/far_end.rs b/crates/busbar-kernel/src/plane_driver/far_end.rs index d49183e3f9..81653a0d98 100644 --- a/crates/busbar-kernel/src/plane_driver/far_end.rs +++ b/crates/busbar-kernel/src/plane_driver/far_end.rs @@ -76,6 +76,9 @@ use super::route::{FarEnd, FarPiece, OutboundRequest, Pick}; /// The bytes one read of the far end takes. const READ_BYTES: usize = 16 * 1024; +/// The field a base URL's userinfo rides under, and the one name that displaces it. +const AUTHORIZATION: &[u8] = b"authorization"; + /// The longest pause between two offers of a held far end's frame its connection's buffer had no /// room for, milliseconds. const WRITE_PAUSE_MAX_MS: u64 = 64; @@ -92,8 +95,12 @@ pub struct MemberRoute { /// The plane instance's declared need the connection opens on, resolved once at config load /// by the member's auth key ([`super::resolve_member_needs`]). pub need: NeedId, - /// The provider's `base_url`, as the operator spelled it. + /// The provider's `base_url`, as the operator spelled it, less any `user:pass@` (row 74, F25). pub base_url: String, + /// The `Authorization: Basic` that userinfo spelled, held opaque: every attempt's head carries + /// it FIRST when the auth binding's fields name no `authorization`, as 1.5.5 sent it (see + /// [`crate::egress::engine::UrlUserinfo`]). `None` when the base URL spelled none. + pub userinfo: Option, /// The auth binding the root opened at generation seal; `None` = no auth fields. pub auth: Option, /// The provider the member is served by: the metering row's provider column, as 1.5.5's @@ -1020,7 +1027,20 @@ impl EgressFarEnd<'_> { let (_, path) = split(&url); // The head's fields the framer encodes: the auth fields, then the plane's. It holds the // auth values, so it wipes itself when the open has taken it. - let mut head = Head(Vec::with_capacity(request.fields.len() + auth.len())); + let mut head = Head(Vec::with_capacity(request.fields.len() + auth.len() + 1)); + // The base URL's userinfo, FIRST, when the auth fields name no `authorization`: 1.5.5's + // request builder put it there and its credential replaced it only by that name (row 74). + let userinfo = route.userinfo.as_ref().filter(|_| { + !auth + .iter() + .any(|f| f.name.eq_ignore_ascii_case(AUTHORIZATION)) + }); + if let Some(userinfo) = userinfo { + head.0.push(( + String::from_utf8_lossy(AUTHORIZATION).into_owned(), + userinfo.value().as_bytes().to_vec(), + )); + } // The auth fields FIRST, then the plane's: 1.5.5's egress header order. head.0.extend(auth.iter().map(|f| { ( @@ -1034,6 +1054,7 @@ impl EgressFarEnd<'_> { .fields .iter() .filter(|(n, _)| !auth.iter().any(|f| f.name.eq_ignore_ascii_case(n))) + .filter(|(n, _)| userinfo.is_none() || !n.eq_ignore_ascii_case(AUTHORIZATION)) .map(|(n, v)| (String::from_utf8_lossy(n).into_owned(), v.clone())), ); drop(auth); diff --git a/crates/busbar-kernel/src/plane_driver/tests/far_end_tests.rs b/crates/busbar-kernel/src/plane_driver/tests/far_end_tests.rs index f786af27bc..cee1c9ac74 100644 --- a/crates/busbar-kernel/src/plane_driver/tests/far_end_tests.rs +++ b/crates/busbar-kernel/src/plane_driver/tests/far_end_tests.rs @@ -361,6 +361,9 @@ struct Bearer { stall: std::sync::atomic::AtomicBool, /// Refuses to field the attempt. refuse: std::sync::atomic::AtomicBool, + /// Presents the credential under `x-api-key` (a dialect whose credential is not + /// `authorization`) instead of as `authorization: Bearer`. + api_key: std::sync::atomic::AtomicBool, facts: Mutex>, /// The point each call was made at and the body it lent. points: Mutex>)>>, @@ -415,9 +418,14 @@ impl OutboundAuth for Bearer { .push((r.method.clone(), r.authority.clone(), r.path.clone())); self.points.lock().unwrap().push((r.point, r.body.clone())); self.extensions.lock().unwrap().push(r.extensions.clone()); + let (name, value): (&[u8], &[u8]) = if self.api_key.load(Ordering::SeqCst) { + (b"x-api-key", b"sk-test") + } else { + (b"authorization", b"Bearer sk-test") + }; Some(Fields::Ready(vec![AuthField { - name: b"authorization".to_vec(), - value: b"Bearer sk-test".to_vec().into(), + name: name.to_vec(), + value: value.to_vec().into(), sensitive: true, }])) } @@ -476,6 +484,7 @@ fn rig( MemberRoute { need: NeedId(0), base_url: format!("https://{host}/v1/"), + userinfo: None, provider: format!("p{k}"), keep: super::ResponseKeep::default(), rides: Vec::new(), @@ -1085,6 +1094,65 @@ async fn a_target_that_is_not_a_path_is_refused_before_the_dial() { assert!(r.book.observed.lock().unwrap().is_empty()); } +/// ROW 74 (F25): a member whose base URL spelled `user:pass@` carries the Basic value that userinfo +/// spells as its attempt's FIRST field when the auth binding presents its credential under another +/// field, and not at all when the binding presents `authorization` — 1.5.5's request builder put +/// it first and its credential replaced it only by that name. A plane field named `authorization` +/// never doubles it; nothing of the userinfo reaches the target or the auth call's authority. +#[tokio::test] +async fn a_base_url_userinfo_rides_first_unless_the_binding_presents_authorization() { + let userinfo = + crate::egress::engine::UrlUserinfo::of("https://svcuser:hunter2-P4ss@a.test/v1/") + .expect("the base spells a userinfo"); + let basic = userinfo.value().as_bytes().to_vec(); + for (api_key, expected) in [ + ( + true, + vec![ + ("authorization", basic.clone()), + ("x-api-key", b"sk-test".to_vec()), + ("content-type", b"application/json".to_vec()), + ], + ), + ( + false, + vec![ + ("authorization", b"Bearer sk-test".to_vec()), + ("content-type", b"application/json".to_vec()), + ], + ), + ] { + let mut r = rig( + &[("a.test", Script::Answer(200, None, vec![b"ok"]))], + OnExhausted::Status503, + None, + ); + r.auth.api_key.store(api_key, Ordering::SeqCst); + r.egress + .routes + .get_mut(&DestinationId::new(1)) + .expect("m0's route") + .userinfo = Some(userinfo.clone()); + let t = token(); + let far = r.egress.unit(route()); + assert!(matches!(far.member(&t, 1).await, Pick::Member { .. })); + let mut req = request(); + req.fields + .push((b"Authorization".to_vec(), b"Bearer caller".to_vec())); + assert!(far.send(&t, req).await); + let _ = drain(&far, &t).await; + let opened = r.table.opened.lock().unwrap().clone(); + let head: Vec<(&str, Vec)> = opened[0] + .1 + .iter() + .map(|(n, v)| (n.as_str(), v.clone())) + .collect(); + assert_eq!(head, expected, "x-api-key binding: {api_key}"); + assert_eq!(opened[0].0, "https://a.test/v1/chat"); + assert_eq!(r.auth.facts.lock().unwrap()[0].1, "a.test"); + } +} + /// A plane field whose name is not an RFC 9110 token never leaves: `authorization:x` = `v` would be /// re-read as a second `authorization` (= `x: v`) when the framer renders and parses its own head, /// stepping around the same-name auth replacement. The hop is refused before the auth call and the diff --git a/crates/busbar-kernel/src/plane_host/build_input.rs b/crates/busbar-kernel/src/plane_host/build_input.rs index 40f9305ed2..55b9e2a0f9 100644 --- a/crates/busbar-kernel/src/plane_host/build_input.rs +++ b/crates/busbar-kernel/src/plane_host/build_input.rs @@ -89,8 +89,12 @@ pub struct LaneInput { /// The lane's protocol NAME (the registry's interned dialect key), carried as an owned `String`; /// the plane re-interns it against the protocol registry. pub protocol: String, - /// The provider's upstream base URL, already trailing-slash-trimmed. + /// The provider's upstream base URL, already trailing-slash-trimmed, its userinfo stripped. pub base_url: String, + /// The `Authorization: Basic` the operator's base URL spelled as `user:pass@` (row 74, F25), + /// opaque: the plane hands it back to the kernel's hop on each request it sends to this lane, + /// and the hop presents it as 1.5.5 did. `None` when the base URL spelled no userinfo. + pub url_userinfo: Option, /// Optional upstream request-path override. pub path: Option, /// Optional path-BASE override (URL-model protocols). @@ -114,8 +118,13 @@ pub struct LaneInput { pub auth_style: AuthStyleInput, /// OAuth scope (`auth: oauth-client-credentials` / an override for `jwt-bearer`). pub scope: Option, - /// OAuth token endpoint (`auth: oauth-client-credentials`). + /// OAuth token endpoint (`auth: oauth-client-credentials`), its userinfo stripped: a plane never + /// receives a URL carrying `user:pass@` (row 74, F25). pub token_url: Option, + /// The `Authorization: Basic` the operator's `token_url` spelled as `user:pass@`, opaque: the + /// plane hands it back to the kernel's bind, and the host presents it on the mint as 1.5.5 did. + /// `None` when the endpoint spelled no userinfo. + pub token_userinfo: Option, /// JWT-bearer `sub` claim (`auth: jwt-bearer`). pub subject: Option, /// The provider's response error-map (upstream code → canonical), cloned verbatim. diff --git a/crates/busbar-kernel/src/plane_host/tests/build_input_tests.rs b/crates/busbar-kernel/src/plane_host/tests/build_input_tests.rs index cfe6f1f48d..ff481838da 100644 --- a/crates/busbar-kernel/src/plane_host/tests/build_input_tests.rs +++ b/crates/busbar-kernel/src/plane_host/tests/build_input_tests.rs @@ -12,6 +12,7 @@ fn lane(api_key: &str) -> LaneInput { provider: "p".to_string(), protocol: "widget".to_string(), base_url: "https://example.invalid".to_string(), + url_userinfo: None, path: None, path_base: None, organization: None, @@ -21,6 +22,7 @@ fn lane(api_key: &str) -> LaneInput { auth_style: AuthStyleInput::Default, scope: None, token_url: None, + token_userinfo: None, subject: None, error_map: HashMap::new(), health: None, diff --git a/crates/busbar-kernel/src/plane_host/tests/config_tables_tests.rs b/crates/busbar-kernel/src/plane_host/tests/config_tables_tests.rs index 59d4abe27a..3647969d5e 100644 --- a/crates/busbar-kernel/src/plane_host/tests/config_tables_tests.rs +++ b/crates/busbar-kernel/src/plane_host/tests/config_tables_tests.rs @@ -15,6 +15,7 @@ fn lane(model: &str, provider: &str) -> LaneInput { provider: provider.to_string(), protocol: "widget".to_string(), base_url: format!("https://{provider}.invalid"), + url_userinfo: None, path: None, path_base: None, organization: None, @@ -24,6 +25,7 @@ fn lane(model: &str, provider: &str) -> LaneInput { auth_style: AuthStyleInput::Default, scope: None, token_url: None, + token_userinfo: None, subject: None, error_map: HashMap::new(), health: None, diff --git a/crates/busbar-kernel/src/test_support/mod.rs b/crates/busbar-kernel/src/test_support/mod.rs index ab67742e0c..b7b74390a2 100644 --- a/crates/busbar-kernel/src/test_support/mod.rs +++ b/crates/busbar-kernel/src/test_support/mod.rs @@ -696,7 +696,10 @@ impl LaneSpec { model: self.model.clone(), provider: self.provider.clone(), protocol: self.protocol.to_string(), - base_url: self.base_url.trim_end_matches('/').to_string(), + // As `appbuild` seats it: the userinfo stripped, its Basic held opaque beside it. + base_url: busbar_kernel::net_guard::strip_userinfo(self.base_url.trim_end_matches('/')) + .into_owned(), + url_userinfo: busbar_kernel::egress::engine::UrlUserinfo::of(&self.base_url), path: self.path.clone(), path_base: self.path_base.clone(), organization: self.organization.clone(), @@ -706,6 +709,7 @@ impl LaneSpec { auth_style, scope: None, token_url: None, + token_userinfo: None, subject: None, error_map: self.error_map.clone(), health: self diff --git a/crates/busbar-kernel/src/tests/bound_credential_tests.rs b/crates/busbar-kernel/src/tests/bound_credential_tests.rs new file mode 100644 index 0000000000..9ab588737b --- /dev/null +++ b/crates/busbar-kernel/src/tests/bound_credential_tests.rs @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Busbar Inc and contributors + +//! THE TOKEN ENDPOINT'S USERINFO, FROM THE CONFIG TO THE AXIS (row 74, F25): the endpoint a plane +//! builds its binding from carries no `user:pass@`, and the Basic value that userinfo spelled +//! reaches the auth axis from the host, beside the settings and never inside them. + +use super::*; + +/// An auth axis that records each outbound serving it is asked for, and serves none. +#[derive(Default)] +struct Recording { + seen: std::sync::Mutex>)>>, +} + +impl AuthAxis for Recording { + fn linked_names(&self) -> Vec { + Vec::new() + } + + fn answers(&self, _: &str) -> bool { + false + } + + fn linked(&self, _: &str) -> bool { + false + } + + fn operator(&self) -> Option<(String, String)> { + None + } + + fn open( + &self, + _: &str, + _: &str, + _: &serde_json::Value, + ) -> Result, String> { + Err("no inbound row".to_string()) + } + + fn serving_presenting( + &self, + style: &str, + settings: &serde_json::Value, + endpoint_basic: Option<&[u8]>, + ) -> Result, String> { + self.seen.lock().unwrap().push(( + style.to_string(), + settings.clone(), + endpoint_basic.map(<[u8]>::to_vec), + )); + Ok(None) + } +} + +#[test] +fn a_token_endpoints_userinfo_reaches_the_axis_beside_the_settings_never_inside_them() { + use base64::Engine as _; + let (plane_visible, endpoint) = crate::appbuild::lane_token_endpoint(Some( + "https://svcuser:hunter2-P4ss@login.example.com/tenant/token", + )); + assert_eq!( + plane_visible.as_deref(), + Some("https://login.example.com/tenant/token"), + "the endpoint a plane is handed carries no userinfo" + ); + let endpoint = endpoint.expect("the userinfo is held, opaque"); + assert!( + crate::appbuild::lane_token_endpoint(Some("https://login.example.com/tenant/token")) + .1 + .is_none(), + "an endpoint with no userinfo holds none" + ); + let binding = StyleBinding { + style: "s".to_string(), + params: serde_json::json!({ "token_url": plane_visible }), + uses_key: false, + statics: &[], + sent: Vec::new(), + }; + let axis = Recording::default(); + assert!(bind_presenting(&axis, &binding, b"id:secret", Some(&endpoint)).is_err()); + let seen = axis.seen.lock().unwrap(); + let (style, settings, basic) = &seen[0]; + assert_eq!(style, "s"); + assert!( + !settings.to_string().contains("hunter2") && !settings.to_string().contains("svcuser"), + "the settings carry no userinfo: {settings}" + ); + let expected = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode("svcuser:hunter2-P4ss") + ); + assert_eq!( + basic.as_deref(), + Some(expected.as_bytes()), + "the Basic value, from the host" + ); + // A binding with no endpoint userinfo hands none. + drop(seen); + assert!(bind(&axis, &binding, b"id:secret").is_err()); + assert_eq!(axis.seen.lock().unwrap()[1].2, None); +} diff --git a/crates/busbar-kernel/src/tests/members/egress/route/mod.rs b/crates/busbar-kernel/src/tests/members/egress/route/mod.rs index 93219b32ef..e431a2db78 100644 --- a/crates/busbar-kernel/src/tests/members/egress/route/mod.rs +++ b/crates/busbar-kernel/src/tests/members/egress/route/mod.rs @@ -205,6 +205,7 @@ impl Node { spelled: Vec::new(), need: NeedId(0), base_url: format!("https://{lane}.test/"), + userinfo: None, auth: None, provider: (*lane).to_string(), keep: ResponseKeep::default(), diff --git a/crates/busbar-llm/src/engine/attempt/assemble.rs b/crates/busbar-llm/src/engine/attempt/assemble.rs index fea928f470..a609360b33 100644 --- a/crates/busbar-llm/src/engine/attempt/assemble.rs +++ b/crates/busbar-llm/src/engine/attempt/assemble.rs @@ -148,7 +148,11 @@ pub(super) async fn build( egress_headers.insert(name.clone(), value.clone()); } let uri = with_query.unwrap_or_else(|| target.uri.clone()); - let hreq = crate::engine::egress_request(uri, egress_headers, payload); + let mut hreq = crate::engine::egress_request(uri, egress_headers, payload); + // The base URL's userinfo, opaque, handed back to the kernel's hop that presents it (row 74). + if let Some(userinfo) = &hop.lane_row().url_userinfo { + hreq.extensions_mut().insert(userinfo.clone()); + } drop(_cb_reqwest); Ok(hreq) } diff --git a/crates/busbar-llm/src/engine/build_runtime.rs b/crates/busbar-llm/src/engine/build_runtime.rs index 9c3012c08c..e7db0c850c 100644 --- a/crates/busbar-llm/src/engine/build_runtime.rs +++ b/crates/busbar-llm/src/engine/build_runtime.rs @@ -182,6 +182,7 @@ pub(crate) fn build_runtime( provider: li.provider.clone(), signing_host, base_url, + url_userinfo: li.url_userinfo.clone(), authority, lane_id, api_key: busbar_contract::redacted::Redacted::new(api_key), diff --git a/crates/busbar-llm/src/engine/credential.rs b/crates/busbar-llm/src/engine/credential.rs index f604fcecb8..dd3be059d9 100644 --- a/crates/busbar-llm/src/engine/credential.rs +++ b/crates/busbar-llm/src/engine/credential.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use busbar_contract::protocol::{CredentialHeader, EgressScheme, ProtocolDecl}; use busbar_kernel::bound_credential::{ - bind, CredentialProvider, DeclaredBuilder, NoCredential, StyleBinding, + bind_presenting, CredentialProvider, DeclaredBuilder, NoCredential, StyleBinding, }; use busbar_kernel::plane_host::{AuthStyleInput, LaneInput, PlaneBuildInput}; use serde_json::{json, Map, Value}; @@ -288,7 +288,13 @@ pub(crate) fn credential_for( let Some(reach) = &input.auths else { return Arc::new(NoCredential); }; - match bind(&*reach.0, &binding, api_key.as_bytes()) { + // The token endpoint's userinfo (row 74, F25): stripped from `lane.token_url` before + // this plane saw it, and handed back opaque for the host to present on the mint. + let endpoint = match lane.auth_style { + AuthStyleInput::OAuthClientCredentials => lane.token_userinfo.as_ref(), + _ => None, + }; + match bind_presenting(&*reach.0, &binding, api_key.as_bytes(), endpoint) { Ok(inner) => match read { Some(region_of_host) => Arc::new(PerSignatureRegionRead { inner, diff --git a/crates/busbar-llm/src/engine/health.rs b/crates/busbar-llm/src/engine/health.rs index 0f92872175..6eaacefe3a 100644 --- a/crates/busbar-llm/src/engine/health.rs +++ b/crates/busbar-llm/src/engine/health.rs @@ -381,7 +381,12 @@ pub(crate) async fn probe_lane(host: &dyn EngineHost, i: usize, timeout: Duratio ACCEPT, axum::http::HeaderValue::from_static(crate::engine::egress_accept(egress_name, false)), ); - let req = crate::engine::egress_request(uri, headers, bytes::Bytes::from(body)); + let mut req = crate::engine::egress_request(uri, headers, bytes::Bytes::from(body)); + // The base URL's userinfo rides the probe as it rides organic traffic (row 74; 1.5.5's probe + // was the same reqwest build over `{base_url}{path}`, v1.5.5 `crates/busbar/src/health.rs:313`). + if let Some(userinfo) = &lane.url_userinfo { + req.extensions_mut().insert(userinfo.clone()); + } // TIMEOUT RE-PROVISION: reqwest's per-request `.timeout(timeout)` bounded the ENTIRE probe // lifecycle — connect, headers, AND the body read. The owned client carries no per-request // total timeout, so re-provide the same bound as ONE deadline shared by the send below and the diff --git a/crates/busbar-llm/src/engine/tables.rs b/crates/busbar-llm/src/engine/tables.rs index 828eb1e111..eeb258f556 100644 --- a/crates/busbar-llm/src/engine/tables.rs +++ b/crates/busbar-llm/src/engine/tables.rs @@ -16,6 +16,10 @@ pub(crate) struct Lane { pub(crate) model: String, pub(crate) provider: String, pub(crate) base_url: String, + /// The `user:pass@` the operator's base URL spelled, as the kernel holds it: opaque, handed back + /// to the kernel's egress hop on every request this lane sends, which presents it as 1.5.5 did + /// (row 74, F25). This plane never reads it. + pub(crate) url_userinfo: Option, /// THE DIAL TARGET THIS LANE NAMES, as the node's interned `&'static str` — `base_url` seated /// through `busbar_contract::Registration` ONCE, here, when the generation's lane table is /// built. A planned leg names its upstream as a borrowed static string, and a configured diff --git a/crates/busbar-llm/src/engine/tests/auth_style_tests.rs b/crates/busbar-llm/src/engine/tests/auth_style_tests.rs index 3f9262ab29..7b2453cfd8 100644 --- a/crates/busbar-llm/src/engine/tests/auth_style_tests.rs +++ b/crates/busbar-llm/src/engine/tests/auth_style_tests.rs @@ -33,6 +33,7 @@ fn lane_with_auth(auth: Option<&str>) -> Lane { provider: "azure".to_string(), signing_host: "res.openai.azure.com".to_string(), base_url: "https://res.openai.azure.com".to_string(), + url_userinfo: None, // The seated names this fixture never plans a leg with — spelled as the literals the row // would have been built from, so the fixture stays a lane row and not a second interner. authority: "https://res.openai.azure.com", diff --git a/crates/busbar-llm/src/engine/tests/health_tests.rs b/crates/busbar-llm/src/engine/tests/health_tests.rs index de3a8bb2a3..ec3d1f31b2 100644 --- a/crates/busbar-llm/src/engine/tests/health_tests.rs +++ b/crates/busbar-llm/src/engine/tests/health_tests.rs @@ -663,7 +663,8 @@ async fn a_shortened_interval_takes_effect_on_the_inherited_schedule() { /// stripped the userinfo for the host it dialled and signed). Driven over the REAL boot /// (`build_app_from_config`, through `build_once`) and a REAL dial to a loopback far end: the /// active probe rides the same owned egress stack as organic traffic. The far end sees the stripped -/// authority in `Host` and no credential the userinfo spelled (no `Basic` built from it), and the +/// authority in `Host` and the lane's `authorization` credential alone (it displaces the Basic field +/// the userinfo spells, as 1.5.5's reqwest build did), and the /// lane row the plane built (its dial base, its signed host) and the neutral lane table the /// `/stats`-adjacent readers render carry the stripped base, never the planted spelling. RED before /// the fix: the lane carrier kept the base URL as written, so every table downstream held it. @@ -713,6 +714,10 @@ async fn a_userinfo_base_url_boots_and_its_lane_dials_the_stripped_host() { // The lane row the plane built, and the neutral table core renders, hold the stripped base. let lane = &app.llm_runtime().lanes[0]; assert_eq!(lane.base_url, bare, "the lane dials the stripped base"); + assert!( + lane.url_userinfo.is_some(), + "the userinfo is held, opaque, for the kernel's hop" + ); assert_eq!( lane.signing_host, authority, "the signed host is the dialled one" @@ -735,16 +740,16 @@ async fn a_userinfo_base_url_boots_and_its_lane_dials_the_stripped_host() { Some(authority.as_str()), "the far end was dialled at the stripped authority" ); - for name in ["authorization", "proxy-authorization", "x-api-key"] { - if let Some(value) = state.get_last_request_header(name) { - assert!( - !value.contains("svcuser") - && !value.contains("hunter2") - && !value.to_ascii_lowercase().starts_with("basic "), - "`{name}` carried the planted spelling: {value}" - ); - } - } + // This test build's credential is `authorization: Bearer `, so 1.5.5's reqwest build + // replaced the Basic field the userinfo spelled: the far end hears the credential alone. + assert_eq!( + state.get_last_request_header("authorization").as_deref(), + Some("Bearer sk-row74"), + "an `authorization` credential displaces the userinfo's Basic field, as in 1.5.5" + ); + assert!(state + .get_last_request_header("proxy-authorization") + .is_none()); server.shutdown().await; } diff --git a/crates/busbar-llm/src/engine/tests/ingress_integration_tests.rs b/crates/busbar-llm/src/engine/tests/ingress_integration_tests.rs index a915f9915c..a37b12c8bf 100644 --- a/crates/busbar-llm/src/engine/tests/ingress_integration_tests.rs +++ b/crates/busbar-llm/src/engine/tests/ingress_integration_tests.rs @@ -651,6 +651,73 @@ fn anthropic_ok_body() -> serde_json::Value { }) } +/// ROW 74 (F25), THE ORGANIC FORWARD: a lane whose base URL spells `user:pass@` and whose credential +/// presents no `authorization` (here: a keyless lane, `api_key` empty, so no credential field at +/// all) reaches its far end at the stripped authority with `authorization: Basic +/// base64(user:pass)` — what 1.5.5's reqwest build over `{base_url}{path}` sent (v1.5.5 +/// `crates/busbar/src/proxy/engine/mod.rs:1669-1670`). The plane never reads the value: the kernel's +/// hop presents it from the opaque handle the lane carries. +#[tokio::test] +async fn a_userinfo_base_url_presents_its_basic_when_the_credential_names_no_authorization() { + use base64::Engine as _; + crate::testkit::install_test_seams(); + busbar_kernel::snapshot::init(); + let state = StdArc::new(MockServerState::new()); + state.push(MockResponse::Ok { + status: StatusCode::OK, + body: openai_ok_body(), + }); + let server = MockServer::new(state.clone()).await; + let bare = server.base_url(); + let planted = bare.replacen("://", "://svcuser:hunter2-P4ss@", 1); + + let app = TestApp::new() + .lane( + LaneSpec::new("glm-4.5", crate::proto_codec::PROTO_OPENAI, &planted) + .provider("zai") + .api_key(""), + ) + .pool("co", &[(0, 1)]) + .build(); + let (_host, _rt) = crate::engine::test_host_rt(&app); + let (addr, handle) = serve(app).await; + + let resp = reqwest::Client::new() + .post(format!("http://{addr}/v2/chat")) + .credential("t") + .body( + json!({ + "model": "co", + "messages": [{"role": "user", "content": "hello"}] + }) + .to_string(), + ) + .send() + .await + .unwrap(); + assert_eq!( + resp.status().as_u16(), + 200, + "served through the userinfo lane" + ); + let expected = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode("svcuser:hunter2-P4ss") + ); + assert_eq!( + state.get_last_request_header("authorization").as_deref(), + Some(expected.as_str()), + "the far end hears the userinfo as 1.5.5 sent it" + ); + assert_eq!( + state.get_last_request_header("host").as_deref(), + bare.split_once("://").map(|(_, authority)| authority), + "dialled at the stripped authority" + ); + handle.abort(); + server.shutdown().await; +} + /// Cohere client → OpenAI backend: a native Cohere `/v2/chat` request must round-trip through /// the IR to an OpenAI backend and back, returning a 2xx the Cohere SDK can parse. #[tokio::test] diff --git a/crates/busbar/src/root/dispatch.rs b/crates/busbar/src/root/dispatch.rs index 4e6b10242d..41b82fc64e 100644 --- a/crates/busbar/src/root/dispatch.rs +++ b/crates/busbar/src/root/dispatch.rs @@ -112,8 +112,19 @@ impl busbar_contract::auth_calls::AuthAxis for RootAuthAxis { style: &str, settings: &serde_json::Value, ) -> Result, String> { + self.serving_presenting(style, settings, None) + } + + fn serving_presenting( + &self, + style: &str, + settings: &serde_json::Value, + endpoint_basic: Option<&[u8]>, + ) -> Result, String> { + let endpoint = + endpoint_basic.and_then(busbar_kernel::egress::engine::UrlUserinfo::from_value); Ok(crate::root::door_steps::process_auths() - .serving(style, settings)? + .serving_with(style, settings, endpoint)? .map( |(auth, decl)| busbar_contract::auth_calls::OutboundServing { auth, diff --git a/crates/busbar/src/root/door_steps.rs b/crates/busbar/src/root/door_steps.rs index 89ea0446d5..2b21c1a51a 100644 --- a/crates/busbar/src/root/door_steps.rs +++ b/crates/busbar/src/root/door_steps.rs @@ -1467,6 +1467,9 @@ pub struct ProviderRoute { /// sent and signed the host after it (v1.5.5 `crates/busbar/src/proxy/egress.rs:18`; row 74, F25), /// so the member's route, its sealed reach, its binding and every dial read that host alone. pub base_url: String, + /// The `Authorization: Basic` that userinfo spelled, held opaque for the member's route: its + /// attempts carry it as 1.5.5 sent it (see `busbar_kernel::egress::engine::UrlUserinfo`). + pub userinfo: Option, /// The default wire protocol (#51). pub protocol: String, /// The `error_map`, catalog-merged; empty = none stated. @@ -1546,6 +1549,7 @@ pub fn provider_routes( name.clone(), ProviderRoute { base_url: busbar_kernel::net_guard::strip_userinfo(&p.base_url).into_owned(), + userinfo: busbar_kernel::egress::engine::UrlUserinfo::of(&p.base_url), protocol: p.protocol.clone(), error_map: p .error_map @@ -1786,26 +1790,73 @@ impl OutboundAuths { &self, style: &str, settings: &serde_json::Value, + ) -> Result, String> { + self.serving_with(style, settings, None) + } + + /// [`Self::serving`], with `presented` the Basic value a token endpoint's userinfo spelled + /// when the host stripped it before the settings reached a plane (row 74, F25): presented on the + /// mint exactly as one [`endpoint_userinfo`] reads off the settings is. + /// + /// # Errors + /// + /// As [`Self::serving`]. + pub fn serving_with( + &self, + style: &str, + settings: &serde_json::Value, + presented: Option, ) -> Result, String> { use crate::root::loader::dispatch::auth_outbound::OutboundInstance; + // A token endpoint spelled `user:pass@` (row 74, F25): the plugin is opened over, and its + // binding handed, the stripped endpoint, and the userinfo rides its mint as 1.5.5 sent it + // ([`endpoint_userinfo`]); or the host already stripped it and hands its Basic value here. + let endpoint = endpoint_userinfo(settings); + let (stated, userinfo) = match &endpoint { + Some((stripped, userinfo)) => (stripped, userinfo.clone().or(presented)), + None => (settings, presented), + }; for row in self.facts() { let Some(decl) = row.styles.iter().find(|s| s.name == style).cloned() else { continue; }; - let settings = if row.per_binding { - serde_json::to_vec(settings).map_err(|e| e.to_string())? + let open_with = if row.per_binding { + serde_json::to_vec(stated).map_err(|e| e.to_string())? } else { b"{}".to_vec() }; + // Keyed on the settings as written, so two bindings whose endpoints differ only in + // their userinfo never share an instance (each one's mint presents its own). let key = if row.per_binding { - format!("{}\0{}", row.name, String::from_utf8_lossy(&settings)) + let written = serde_json::to_vec(settings).map_err(|e| e.to_string())?; + let presenting = userinfo + .as_ref() + .map(|u| u.leading(&[])) + .unwrap_or_default(); + let presenting = presenting + .first() + .map(|(_, value)| String::from_utf8_lossy(value).into_owned()) + .unwrap_or_default(); + format!( + "{}\0{}\0{presenting}", + row.name, + String::from_utf8_lossy(&written) + ) } else { row.name.clone() }; let instance = match self.opened.get(&key) { Some(instance) => instance, None => { - let Some(plugin) = self.load(&row.name, row.door) else { + let loaded = match &userinfo { + Some(userinfo) if row.per_binding => self.load_with( + &row.name, + row.door, + presenting(self.conns.clone(), userinfo), + ), + _ => self.load(&row.name, row.door), + }; + let Some(plugin) = loaded else { continue; }; let (instance, installed) = self.opened.get_or_open(&key, || { @@ -1813,7 +1864,7 @@ impl OutboundAuths { plugin, Arc::clone(&self.dispatcher), 0, - &settings, + &open_with, ) })?; // ITS TICK SCHEDULE, once per instance, on the runtime the composition runs on. @@ -1825,15 +1876,288 @@ impl OutboundAuths { instance } }; - return Ok(Some(( - instance as Arc, - decl, - ))); + let auth = instance as Arc; + let auth = if endpoint.is_some() { + Arc::new(EndpointStripped(auth)) + as Arc + } else { + auth + }; + return Ok(Some((auth, decl))); } Ok(None) } } +/// The settings keys a minting style's token endpoint is written under: the configured +/// `token_url` (`oauth-client-credentials`) and the service account's `token_uri` (`jwt-bearer`). +const TOKEN_ENDPOINT_SETTINGS: [&str; 2] = ["token_url", "token_uri"]; + +/// A TOKEN ENDPOINT SPELLED `user:pass@`, AS 1.5.5 REACHED IT (row 74, F25). 1.5.5 POSTed the client +/// credentials with reqwest to `token_url` as written (v1.5.5 +/// `crates/busbar/src/egress_auth/oauth_client_credentials.rs:119-126`, `.post(&self.token_url) +/// .form(..)`), and reqwest 0.12.28's builder took the userinfo out of the URL and sent it FIRST as +/// `authorization: Basic base64(user:pass)` (`src/async_impl/request.rs:169-182`, +/// `extract_authority` `:581-605`, `util::basic_auth` `src/util.rs:4-25`); the form named no +/// `authorization`, so it always rode. `jwt-bearer` POSTed its signed assertion to the service +/// account's `token_uri` the same way (v1.5.5 `crates/busbar/src/egress_auth/jwt_bearer.rs:175-181`, +/// `.post(&self.token_uri).form(..)`), so the same rule holds for it. Here the endpoint the plugin +/// is handed in its settings is the stripped one, +/// so its need is pinned to, and its mint dials, the host after the `@` and the userinfo is in no +/// plugin's settings, log or error; the Basic value rides the mint's open from the host side +/// ([`presenting`]). `None` when the settings name no endpoint, or one with no userinfo. +fn endpoint_userinfo( + settings: &serde_json::Value, +) -> Option<( + serde_json::Value, + Option, +)> { + let mut stripped_settings: Option = None; + let mut userinfo = None; + for key in TOKEN_ENDPOINT_SETTINGS { + let Some(written) = settings.get(key).and_then(serde_json::Value::as_str) else { + continue; + }; + let std::borrow::Cow::Owned(stripped) = busbar_kernel::net_guard::strip_userinfo(written) + else { + continue; + }; + userinfo = userinfo.or_else(|| busbar_kernel::egress::engine::UrlUserinfo::of(written)); + stripped_settings.get_or_insert_with(|| settings.clone())[key] = + serde_json::Value::String(stripped); + } + stripped_settings.map(|stripped| (stripped, userinfo)) +} + +/// A served binding whose endpoint was stripped: its `open_outbound` is handed the stripped +/// settings too, whatever its caller wrote ([`endpoint_userinfo`]); every other call is the +/// instance's own. +struct EndpointStripped(Arc); + +impl busbar_contract::auth_calls::OutboundAuth for EndpointStripped { + fn open_outbound( + &self, + style: &str, + credential: &[u8], + settings: &serde_json::Value, + ) -> Result { + let stripped = endpoint_userinfo(settings).map(|(stripped, _)| stripped); + self.0 + .open_outbound(style, credential, stripped.as_ref().unwrap_or(settings)) + } + + fn fields_now( + &self, + handle: u64, + request: &busbar_contract::auth_calls::FieldsRequest, + ) -> Option { + self.0.fields_now(handle, request) + } + + fn fields( + &self, + handle: u64, + request: busbar_contract::auth_calls::FieldsRequest, + deadline_ns: u64, + ) -> Box { + self.0.fields(handle, request, deadline_ns) + } + + fn ready(&self, handle: u64) -> bool { + self.0.ready(handle) + } +} + +/// The connection table one binding's minting instance declares its needs on and opens its mint +/// through: the process's table, with the endpoint's Basic value FIRST on every open whose fields +/// name no `authorization` (1.5.5's order and condition, see [`endpoint_userinfo`]) and any +/// userinfo left in the open's target taken out. A table that grants no need stays as it is. +fn presenting( + conns: crate::root::loader::dispatch::ConnTable, + userinfo: &busbar_kernel::egress::engine::UrlUserinfo, +) -> crate::root::loader::dispatch::ConnTable { + use crate::root::loader::dispatch::ConnTable; + match conns { + ConnTable::Host(inner) => ConnTable::Host(Arc::new(PresentingConns { + inner, + userinfo: userinfo.clone(), + })), + other => other, + } +} + +/// See [`presenting`]: every call but `open` is the inner table's own. +struct PresentingConns { + inner: Arc, + userinfo: busbar_kernel::egress::engine::UrlUserinfo, +} + +impl busbar_contract::conn::Conns for PresentingConns { + fn open( + &self, + caller: busbar_contract::conn::InstanceId, + need: busbar_contract::conn::NeedId, + desc: &busbar_contract::conn::OpenDesc<'_>, + ) -> Result { + let target = busbar_kernel::net_guard::strip_userinfo(desc.target); + let led = self.userinfo.leading(desc.fields); + let fields = led.as_deref().unwrap_or(desc.fields); + self.inner.open( + caller, + need, + &busbar_contract::conn::OpenDesc { + target: &target, + fields, + ..*desc + }, + ) + } + + fn write( + &self, + caller: busbar_contract::conn::InstanceId, + conn: busbar_contract::conn::ConnId, + bytes: &[u8], + end: bool, + text: bool, + ) -> Result { + self.inner.write(caller, conn, bytes, end, text) + } + + fn read( + &self, + caller: busbar_contract::conn::InstanceId, + conn: busbar_contract::conn::ConnId, + ticket: busbar_contract::conn::Ticket, + buf: &mut [u8], + ) -> Result { + self.inner.read(caller, conn, ticket, buf) + } + + fn wait( + &self, + caller: busbar_contract::conn::InstanceId, + set: &[busbar_contract::conn::ConnId], + ticket: busbar_contract::conn::Ticket, + ) -> Result { + self.inner.wait(caller, set, ticket) + } + + fn facts( + &self, + caller: busbar_contract::conn::InstanceId, + conn: busbar_contract::conn::ConnId, + ) -> Result { + self.inner.facts(caller, conn) + } + + fn close( + &self, + caller: busbar_contract::conn::InstanceId, + conn: busbar_contract::conn::ConnId, + ) -> Result<(), busbar_contract::conn::ConnError> { + self.inner.close(caller, conn) + } +} + +impl busbar_contract::conn::DeclaredConns for PresentingConns { + fn declare( + &self, + owner: busbar_contract::conn::InstanceId, + need: busbar_contract::conn::NeedId, + spec: &busbar_contract::abi::mechanism::rendering::ReadNeed, + target: Option<&str>, + trust: Option<&str>, + ) -> Result<(), busbar_contract::conn::ConnError> { + self.inner.declare(owner, need, spec, target, trust) + } + + fn declared( + &self, + owner: busbar_contract::conn::InstanceId, + need: busbar_contract::conn::NeedId, + ) -> Option> { + self.inner.declared(owner, need) + } + + fn upgrade_secure( + &self, + caller: busbar_contract::conn::InstanceId, + conn: busbar_contract::conn::ConnId, + name: Option<&str>, + trust: Option<&str>, + verify_off: bool, + ticket: busbar_contract::conn::Ticket, + ) -> Result<(), busbar_contract::conn::ConnError> { + self.inner + .upgrade_secure(caller, conn, name, trust, verify_off, ticket) + } + + fn framed( + &self, + owner: busbar_contract::conn::InstanceId, + need: busbar_contract::conn::NeedId, + ) -> bool { + self.inner.framed(owner, need) + } + + fn serves_scheme(&self, transport: &str) -> bool { + self.inner.serves_scheme(transport) + } + + fn declare_program( + &self, + owner: busbar_contract::conn::InstanceId, + need: busbar_contract::conn::NeedId, + spec: &busbar_contract::abi::mechanism::rendering::ReadNeed, + program: &busbar_contract::conn::Program, + ) -> Result<(), busbar_contract::conn::ConnError> { + self.inner.declare_program(owner, need, spec, program) + } + + fn cause( + &self, + owner: busbar_contract::conn::InstanceId, + conn: busbar_contract::conn::ConnId, + ) -> Option { + self.inner.cause(owner, conn) + } + + fn hold_authority(&self, owner: busbar_contract::conn::InstanceId) { + self.inner.hold_authority(owner); + } + + fn bind_auth( + &self, + owner: busbar_contract::conn::InstanceId, + need: busbar_contract::conn::NeedId, + origin: &str, + binding: busbar_contract::conn::ConnAuth, + ) { + self.inner.bind_auth(owner, need, origin, binding); + } + + fn auth_of( + &self, + owner: busbar_contract::conn::InstanceId, + need: busbar_contract::conn::NeedId, + target: &str, + ) -> Option { + self.inner.auth_of(owner, need, target) + } + + fn declare_member_programs( + &self, + owner: busbar_contract::conn::InstanceId, + need: busbar_contract::conn::NeedId, + spec: &busbar_contract::abi::mechanism::rendering::ReadNeed, + programs: &[(String, busbar_contract::conn::Program)], + ) -> Result<(), busbar_contract::conn::ConnError> { + self.inner + .declare_member_programs(owner, need, spec, programs) + } +} + impl OutboundAuths { /// CHECK, NEVER DIAL: `credential` bound under `settings` on a FRESH instance of the plugin /// serving `style`, loaded with no need granted (so it mints nothing), and the refusals it names @@ -2355,6 +2679,7 @@ pub fn member_routes( rides: Vec::new(), need, base_url, + userinfo: None, auth, provider: entry, keep: busbar_kernel::plane_driver::ResponseKeep::default(), @@ -2372,6 +2697,7 @@ pub fn member_routes( rides: Vec::new(), need, base_url: entry.clone(), + userinfo: None, auth: None, provider: entry, keep: busbar_kernel::plane_driver::ResponseKeep::default(), @@ -2427,6 +2753,7 @@ pub fn member_routes( MemberRoute { need, base_url: r.provider.base_url.clone(), + userinfo: r.provider.userinfo.clone(), auth: Some(AuthBinding { auth, handle, diff --git a/crates/busbar/src/root/tests/door_steps.rs b/crates/busbar/src/root/tests/door_steps.rs index 73279f1c26..6c363c368d 100644 --- a/crates/busbar/src/root/tests/door_steps.rs +++ b/crates/busbar/src/root/tests/door_steps.rs @@ -372,6 +372,7 @@ fn styled() -> crate::root::loader::dispatch::kinds::plane::ServedFacts { fn provider(protocol: &str, style: Option<&str>) -> super::ProviderRoute { super::ProviderRoute { base_url: "http://127.0.0.1:9".to_string(), + userinfo: None, protocol: protocol.to_string(), error_map: Default::default(), credential: busbar_contract::secret_ref::SecretRef::none(), @@ -576,6 +577,10 @@ fn a_provider_base_url_with_userinfo_is_reached_at_its_stripped_authority() { ); let route = &routes["p"]; assert_eq!(route.base_url, "https://api.example.com:8443/v1"); + assert!( + route.userinfo.is_some(), + "the userinfo is held, opaque, for the member's attempts to present as 1.5.5 did" + ); let shown = format!("{route:?}"); assert!( !shown.contains("hunter2") && !shown.contains("svcuser"), @@ -681,6 +686,8 @@ pub(crate) struct TokenEndpoint { slab: busbar_contract::conn::ConnSlab<()>, declared: std::sync::Mutex)>>, opened: std::sync::Mutex>, + /// Each open's head fields, as the table received them. + fields: std::sync::Mutex)>>>, replies: std::sync::Mutex, /// The reply body to a request body, when the endpoint answers by what it was asked (an RFC /// 8693 exchange's token names its scope); `None` = the minted replies above. @@ -758,6 +765,12 @@ impl busbar_contract::conn::Conns for TokenEndpoint { ) -> Result { use busbar_contract::conn::PieceKind; self.slab.check_need(caller, need)?; + self.fields.lock().unwrap().push( + desc.fields + .iter() + .map(|(n, v)| ((*n).to_string(), v.to_vec())) + .collect(), + ); let mut opened = self.opened.lock().unwrap(); opened.push(( need.0, @@ -881,98 +894,138 @@ async fn a_member_under_an_oauth_grant_presents_its_minted_then_refreshed_bearer }; use busbar_contract::auth_calls::FieldsRequest; const TOKEN_URL: &str = "https://login.example.com/tenant/oauth2/v2.0/token"; - let key_file = - std::env::temp_dir().join(format!("busbar-door-steps-oauth-{}", std::process::id())); - std::fs::write(&key_file, "oracle-client-0001:oracle:secret:with:colons").expect("key"); - let providers: std::collections::BTreeMap = [( - "p".to_string(), - super::ProviderRoute { - base_url: "http://127.0.0.1:9".to_string(), - protocol: "d".to_string(), - error_map: Default::default(), - credential: busbar_contract::secret_ref::SecretRef::file( - key_file.display().to_string(), - ), - style: Some("oauth-client-credentials".to_string()), - params: super::StyleParams { - token_url: Some(TOKEN_URL.to_string()), - scope: Some("https://cognitiveservices.azure.com/.default".to_string()), - ..super::StyleParams::default() - }, - metadata: busbar_kernel::config_validate::MetadataPosture::default(), - }, - )] - .into(); - let table = std::sync::Arc::new(TokenEndpoint::default()); - let secrets = busbar_kernel::config::secret::SecretResolver::builtins_only(); - let reach = super::DoorReach { - providers: &providers, - secrets: &secrets, - auths: std::sync::Arc::new(linked_mint_plugin(&table)), - conns: std::sync::Arc::new(busbar_core_connector::Connector::new()), - stream_ceiling_secs: 1, - models: None, - upgrades: Vec::new(), - }; - let section: serde_yaml::Value = - serde_yaml::from_str("models: {m: {provider: p}}").expect("yaml"); - let facts = crate::root::loader::dispatch::kinds::plane::ServedFacts { - need_auths: vec![(DIRECTION_OUTBOUND, "oauth-client-credentials")], - dialects: vec!["d"], - ..Default::default() + let basic = { + use base64::Engine as _; + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode("svcuser:hunter2-P4ss") + ) }; - let routes = super::member_routes(§ion, &DoorPools::of(§ion), &facts, &reach) - .expect("the member resolves"); - let _ = std::fs::remove_file(&key_file); - let binding = routes["m"].auth.clone().expect("its credential is bound"); - assert!( - table.declared.lock().unwrap().contains(&( - 0, - EGRESS_OPERATOR_INFRASTRUCTURE, - Some(TOKEN_URL.to_string()) - )), - "the plugin's operator-infrastructure need (ARCHITECT D1 2026-10-05, MINT CLASS (B)), \ - pinned to the provider's token_url: {:?}", - table.declared.lock().unwrap() - ); + // The endpoint as written, plain and spelling a userinfo (row 74, F25). + for written in [ + TOKEN_URL.to_string(), + TOKEN_URL.replacen("://", "://svcuser:hunter2-P4ss@", 1), + ] { + let key_file = + std::env::temp_dir().join(format!("busbar-door-steps-oauth-{}", std::process::id())); + std::fs::write(&key_file, "oracle-client-0001:oracle:secret:with:colons").expect("key"); + let providers: std::collections::BTreeMap = [( + "p".to_string(), + super::ProviderRoute { + base_url: "http://127.0.0.1:9".to_string(), + userinfo: None, + protocol: "d".to_string(), + error_map: Default::default(), + credential: busbar_contract::secret_ref::SecretRef::file( + key_file.display().to_string(), + ), + style: Some("oauth-client-credentials".to_string()), + params: super::StyleParams { + token_url: Some(written.clone()), + scope: Some("https://cognitiveservices.azure.com/.default".to_string()), + ..super::StyleParams::default() + }, + metadata: busbar_kernel::config_validate::MetadataPosture::default(), + }, + )] + .into(); + let table = std::sync::Arc::new(TokenEndpoint::default()); + let secrets = busbar_kernel::config::secret::SecretResolver::builtins_only(); + let reach = super::DoorReach { + providers: &providers, + secrets: &secrets, + auths: std::sync::Arc::new(linked_mint_plugin(&table)), + conns: std::sync::Arc::new(busbar_core_connector::Connector::new()), + stream_ceiling_secs: 1, + models: None, + upgrades: Vec::new(), + }; + let section: serde_yaml::Value = + serde_yaml::from_str("models: {m: {provider: p}}").expect("yaml"); + let facts = crate::root::loader::dispatch::kinds::plane::ServedFacts { + need_auths: vec![(DIRECTION_OUTBOUND, "oauth-client-credentials")], + dialects: vec!["d"], + ..Default::default() + }; + let routes = super::member_routes(§ion, &DoorPools::of(§ion), &facts, &reach) + .expect("the member resolves"); + let _ = std::fs::remove_file(&key_file); + let binding = routes["m"].auth.clone().expect("its credential is bound"); + assert!( + table.declared.lock().unwrap().contains(&( + 0, + EGRESS_OPERATOR_INFRASTRUCTURE, + Some(TOKEN_URL.to_string()) + )), + "the plugin's operator-infrastructure need (ARCHITECT D1 2026-10-05, MINT CLASS (B)), \ + pinned to the provider's token_url: {:?}", + table.declared.lock().unwrap() + ); - // The first request waits for the first mint (no empty credential), then carries it. - let first = tokio::time::timeout( - std::time::Duration::from_secs(10), - binding - .auth - .fields(binding.handle, FieldsRequest::default(), 0), - ) - .await - .expect("the first mint lands"); - assert_eq!( - presented(Some(&first)).as_deref(), - Some("Bearer oracle-minted-1") - ); - // The refresh lands ahead of the first token's expiry; the next request carries it. - let refreshed = tokio::time::timeout(std::time::Duration::from_secs(10), async { - loop { - let now = binding + // The first request waits for the first mint (no empty credential), then carries it. + let first = tokio::time::timeout( + std::time::Duration::from_secs(10), + binding .auth - .fields_now(binding.handle, &FieldsRequest::default()); - if presented(now.as_ref()).as_deref() == Some("Bearer oracle-minted-2") { - return; + .fields(binding.handle, FieldsRequest::default(), 0), + ) + .await + .expect("the first mint lands"); + assert_eq!( + presented(Some(&first)).as_deref(), + Some("Bearer oracle-minted-1") + ); + // The refresh lands ahead of the first token's expiry; the next request carries it. + let refreshed = tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + let now = binding + .auth + .fields_now(binding.handle, &FieldsRequest::default()); + if presented(now.as_ref()).as_deref() == Some("Bearer oracle-minted-2") { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; + }) + .await; + assert!(refreshed.is_ok(), "the refreshed bearer is presented"); + let opened = table.opened.lock().unwrap(); + assert_eq!(opened.len(), 2, "the mint and the refresh: {opened:?}"); + assert!(opened.iter().all(|(need, target, words, _)| *need == 0 + && target == TOKEN_URL + && words == b"/tenant/oauth2/v2.0/token")); + assert_eq!( + opened[1].3, + "grant_type=client_credentials&client_id=oracle-client-0001&client_secret=\ + oracle%3Asecret%3Awith%3Acolons&scope=https%3A%2F%2Fcognitiveservices.azure.com%2F.default" + ); + drop(opened); + // ROW 74 (F25): the endpoint as the plugin was handed it is the stripped one (its need is + // pinned there and its mint dialled there, above), and a `user:pass@` the operator wrote rides + // each mint FIRST as `authorization: Basic`, as 1.5.5's reqwest POST sent it (v1.5.5 + // `crates/busbar/src/egress_auth/oauth_client_credentials.rs:119-126`); a plain endpoint's mint + // carries none. + let fields = table.fields.lock().unwrap(); + assert_eq!(fields.len(), 2); + for head in fields.iter() { + let lead = head.first().map(|(n, v)| (n.as_str(), v.as_slice())); + if written == TOKEN_URL { + assert!( + !head + .iter() + .any(|(n, _)| n.eq_ignore_ascii_case("authorization")), + "a plain endpoint's mint presents no authorization: {head:?}" + ); + } else { + assert_eq!(lead, Some(("authorization", basic.as_bytes())), "{head:?}"); + } + assert!( + head.iter() + .all(|(_, v)| !String::from_utf8_lossy(v).contains("hunter2")), + "the userinfo rides only as its Basic value: {head:?}" + ); } - }) - .await; - assert!(refreshed.is_ok(), "the refreshed bearer is presented"); - let opened = table.opened.lock().unwrap(); - assert_eq!(opened.len(), 2, "the mint and the refresh: {opened:?}"); - assert!(opened.iter().all(|(need, target, words, _)| *need == 0 - && target == TOKEN_URL - && words == b"/tenant/oauth2/v2.0/token")); - assert_eq!( - opened[1].3, - "grant_type=client_credentials&client_id=oracle-client-0001&client_secret=\ - oracle%3Asecret%3Awith%3Acolons&scope=https%3A%2F%2Fcognitiveservices.azure.com%2F.default" - ); + } } /// A test-only 2048-bit PKCS#8 RSA private key (generated for the kernel's `jwt_bearer` tests and @@ -1030,6 +1083,7 @@ fn jwt_bearer_routes( "p".to_string(), super::ProviderRoute { base_url: "http://127.0.0.1:9".to_string(), + userinfo: None, protocol: "d".to_string(), error_map: Default::default(), credential: busbar_contract::secret_ref::SecretRef::file( @@ -1121,6 +1175,132 @@ async fn a_member_under_jwt_bearer_mints_at_its_service_accounts_token_uri() { ); } +/// ROW 74 (F25), `jwt-bearer`: 1.5.5 POSTed the signed assertion with reqwest to the service +/// account's `token_uri` as written (v1.5.5 `crates/busbar/src/egress_auth/jwt_bearer.rs:175-181`), +/// whose builder took a `user:pass@` out of the URL and sent it FIRST as `authorization: Basic` (the +/// form names no `authorization`). A service account whose `token_uri` spells one is sealed with its +/// mint need pinned to the stripped endpoint, its mint dials the stripped endpoint, and each mint +/// leads with the Basic value; no field carries the userinfo in the clear. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_service_account_token_uri_userinfo_rides_the_mint_as_1_5_5_sent_it() { + use base64::Engine as _; + use busbar_contract::auth_calls::FieldsRequest; + const TOKEN_URI: &str = "https://oauth2.example.com/token"; + let written = TOKEN_URI.replacen("://", "://svcuser:hunter2-P4ss@", 1); + let table = std::sync::Arc::new(TokenEndpoint::default()); + let routes = jwt_bearer_routes( + &written, + busbar_kernel::config_validate::MetadataPosture::default(), + &table, + ) + .expect("a token_uri spelling a userinfo seals, as 1.5.5 accepted it"); + let binding = routes["m"].auth.clone().expect("its credential is bound"); + assert!( + table + .declared + .lock() + .unwrap() + .iter() + .any(|(_, _, target)| target.as_deref() == Some(TOKEN_URI)), + "the mint need is pinned to the stripped endpoint: {:?}", + table.declared.lock().unwrap() + ); + let minted = tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + let now = binding + .auth + .fields_now(binding.handle, &FieldsRequest::default()); + if presented(now.as_ref()).is_some_and(|a| a.starts_with("Bearer oracle-minted-")) { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + }) + .await; + assert!(minted.is_ok(), "a bearer minted at the stripped endpoint"); + assert_eq!(table.opened.lock().unwrap()[0].1, TOKEN_URI); + let basic = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode("svcuser:hunter2-P4ss") + ); + let fields = table.fields.lock().unwrap(); + let head = &fields[0]; + assert_eq!( + head.first().map(|(n, v)| (n.as_str(), v.as_slice())), + Some(("authorization", basic.as_bytes())), + "{head:?}" + ); + assert!( + head.iter() + .all(|(_, v)| !String::from_utf8_lossy(v).contains("hunter2")), + "{head:?}" + ); +} + +/// ROW 74 (F25), THE IN-PROCESS LANE'S SHAPE: the host stripped a `token_url`'s userinfo before a +/// plane built its binding (`appbuild`), so the settings the axis is handed name the stripped +/// endpoint and the Basic value arrives beside them ([`super::OutboundAuths::serving_with`]). The +/// mint need is pinned to the stripped endpoint and every mint leads with the Basic value, as +/// 1.5.5's reqwest POST sent it (v1.5.5 `crates/busbar/src/egress_auth/oauth_client_credentials.rs:119-126`). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_token_url_stripped_before_the_plane_still_presents_its_basic_on_the_mint() { + use base64::Engine as _; + use busbar_contract::auth_calls::FieldsRequest; + const TOKEN_URL: &str = "https://login.example.com/tenant/oauth2/v2.0/token"; + let held = busbar_kernel::egress::engine::UrlUserinfo::of(&TOKEN_URL.replacen( + "://", + "://svcuser:hunter2-P4ss@", + 1, + )) + .expect("the userinfo, held by the host"); + let table = std::sync::Arc::new(TokenEndpoint::default()); + let auths = linked_mint_plugin(&table); + let style = super::style_word(busbar_kernel::config::ProviderAuth::OAuthClientCredentials); + // What the plane built its binding from: the stripped endpoint. + let settings = serde_json::json!({ + "token_url": TOKEN_URL, + "scope": "https://x.example.com/.default", + }); + let (auth, _) = auths + .serving_with(style, &settings, Some(held)) + .expect("the plugin opens") + .expect("a linked plugin serves the style"); + let handle = auth + .open_outbound( + style, + b"oracle-client-0001:oracle:secret:with:colons", + &settings, + ) + .expect("the binding opens"); + let first = tokio::time::timeout( + std::time::Duration::from_secs(10), + auth.fields(handle, FieldsRequest::default(), 0), + ) + .await + .expect("the first mint lands"); + assert_eq!( + presented(Some(&first)).as_deref(), + Some("Bearer oracle-minted-1") + ); + assert!(table + .declared + .lock() + .unwrap() + .iter() + .any(|(_, _, target)| target.as_deref() == Some(TOKEN_URL))); + let basic = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode("svcuser:hunter2-P4ss") + ); + let fields = table.fields.lock().unwrap(); + assert_eq!( + fields[0].first().map(|(n, v)| (n.as_str(), v.as_slice())), + Some(("authorization", basic.as_bytes())), + "the far end hears the Basic value on the token POST: {:?}", + fields[0] + ); +} + /// RED (1.5.5 `main.rs:2960-2966` over `egress_auth/jwt_bearer.rs::validate_token_uri`, v1.5.5): A /// SERVICE ACCOUNT'S `token_uri` IS JUDGED AT THE SEAL, in 1.5.5's words — plaintext to a public /// host and a cloud-metadata host are refused, naming the provider; the provider's metadata posture diff --git a/crates/busbar/src/root/tests/serve_planes.rs b/crates/busbar/src/root/tests/serve_planes.rs index f4ae9916ef..f9a6c4ed90 100644 --- a/crates/busbar/src/root/tests/serve_planes.rs +++ b/crates/busbar/src/root/tests/serve_planes.rs @@ -344,6 +344,7 @@ fn a_door_plane_is_handed_its_referenced_providers_dialect_facts_at_open() { let _late = composed_services(); let route = |protocol: &str, error_map: &[(&str, &str)]| ProviderRoute { base_url: "http://127.0.0.1:9".to_string(), + userinfo: None, protocol: protocol.to_string(), error_map: error_map .iter()