Share runtime authentication with model providers and catalogs (#57607)

## Why

Model catalog refreshes need to use the same registered actor and credential owner as model requests when runtime authentication is enabled.

## What changed

- Add `ProviderAuthContext` to carry credentials and optional runtime authentication into model providers and catalog clients.
- Select runtime authentication independently for inference and catalog destinations. Reject unsupported OpenAI origins while preserving provider authentication for other destinations.
- Include API organization and project selectors in runtime registration. Reuse registered runtimes across projects within an organization and replace them when the organization changes.
- Preserve transport restrictions and account bindings, and strip sensitive headers on cross-origin redirects.
- Apply the catalog timeout to authentication and fetching while allowing credential refresh and registration to finish safely.

## Testing

Add coverage for runtime-authenticated catalogs, provider isolation, resource selection, gateway authentication, cross-origin redirects, and timeout recovery that preserves registration and refreshed tokens.

GitOrigin-RevId: c14f1a02159031ea0af1f9b26ea890f8dca7d6ad
This commit is contained in:
cooper-oai
2026-10-28 00:51:29 +00:00
committed by copyberry
parent 939f5decb2
commit 9cce575134
36 changed files with 2111 additions and 217 deletions
+3 -1
View File
@@ -178,7 +178,9 @@ impl HttpClientBuilder {
request_url: &str,
route_class: ClientRouteClass,
) -> Result<HttpClient, BuildRouteAwareHttpClientError> {
if http_client_factory.network_policy().is_managed() {
if http_client_factory.network_policy().is_managed()
|| http_client_factory.protects_sensitive_headers()
{
return Ok(crate::RouteAwareClientPool::with_builder(
http_client_factory.clone(),
route_class,
+4
View File
@@ -10,6 +10,7 @@ mod custom_ca;
mod error;
mod native_roots;
mod network_policy;
mod openai_endpoints;
mod openai_headers;
mod outbound_proxy;
mod request;
@@ -47,6 +48,9 @@ pub use crate::network_policy::NetworkPolicy;
pub use crate::network_policy::NetworkPolicyController;
pub use crate::network_policy::NetworkPolicyDenied;
pub use crate::network_policy::NetworkPolicyRevision;
pub use crate::openai_endpoints::OpenAiDestination;
pub use crate::openai_endpoints::classify_openai_api_url;
pub use crate::openai_endpoints::is_openai_api_url;
pub use crate::openai_headers::CHATGPT_ACCOUNT_ID_HEADER;
pub use crate::openai_headers::OPENAI_ORGANIZATION_HEADER;
pub use crate::openai_headers::OPENAI_PROJECT_HEADER;
@@ -214,6 +214,30 @@ impl NetworkPolicy {
self
}
/// Bind a caller's restrictions to a captured credential policy without widening either.
/// Different managed controllers or account bindings cannot authorize the same request.
pub fn with_account_binding(mut self, credentials: &Self) -> Option<Self> {
if let Some(state) = &credentials.state {
if let Some(caller) = &self.state {
if !Arc::ptr_eq(caller, state) {
return None;
}
} else {
self.state = Some(Arc::clone(state));
}
}
if let (Some(caller), Some(credentials)) = (self.account, credentials.account)
&& caller != credentials
{
return None;
}
self.account = self.account.or(credentials.account);
if let Some(endpoints) = &credentials.endpoints {
self = self.restrict_to_endpoints(endpoints.as_ref().clone());
}
Some(self)
}
/// Whether this transport participates in the application policy lifecycle.
pub fn is_managed(&self) -> bool {
self.state.is_some()
@@ -7,6 +7,81 @@ use reqwest::Url;
use super::*;
#[test]
fn credential_binding_keeps_both_endpoint_restrictions_and_account_revocation() {
let controller = NetworkPolicyController::default();
let policy = controller.policy();
assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted));
let a = Url::parse("https://example.com/a").unwrap();
let b = Url::parse("https://example.com/b").unwrap();
let c = Url::parse("https://example.com/c").unwrap();
let credentials = policy
.clone()
.for_current_account()
.restrict_to_endpoints(BTreeSet::from([b.clone(), c.clone()]));
let bound = policy
.clone()
.restrict_to_endpoints(BTreeSet::from([a.clone(), b.clone()]))
.with_account_binding(&credentials)
.unwrap();
assert!(matches!(
bound.acquire(&a),
Err(NetworkPolicyDenied::Destination)
));
assert!(matches!(
bound.acquire(&c),
Err(NetworkPolicyDenied::Destination)
));
let permit = bound.acquire(&b).unwrap();
policy.invalidate();
assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted));
assert!(matches!(
bound.acquire(&b),
Err(NetworkPolicyDenied::Revoked)
));
assert_eq!(permit.check(), Err(NetworkPolicyDenied::Revoked));
}
#[test]
fn credential_binding_cannot_replace_a_callers_policy_or_revive_its_account() {
let controller = NetworkPolicyController::default();
let policy = controller.policy();
let allowed = Url::parse("https://example.com/allowed").unwrap();
assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted));
let caller = policy
.clone()
.for_current_account()
.restrict_to_endpoints(BTreeSet::from([allowed.clone()]));
let bound = caller
.clone()
.with_account_binding(&NetworkPolicy::unmanaged())
.unwrap();
assert!(bound.acquire(&allowed).is_ok());
assert!(matches!(
bound.acquire(&Url::parse("https://example.com/other").unwrap()),
Err(NetworkPolicyDenied::Destination)
));
assert!(
caller
.clone()
.with_account_binding(&NetworkPolicyController::default().policy())
.is_none()
);
policy.invalidate();
assert!(controller.publish(policy.revision(), DestinationPolicy::Unrestricted));
assert!(
caller
.with_account_binding(&policy.for_current_account())
.is_none()
);
assert!(matches!(
bound.acquire(&allowed),
Err(NetworkPolicyDenied::Revoked)
));
}
#[test]
fn restricted_policy_matches_exact_secure_hosts() {
let controller = NetworkPolicyController::default();
@@ -0,0 +1,155 @@
//! Classify OpenAI API destinations without treating an unsupported origin as another provider.
use crate::is_allowed_chatgpt_host;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OpenAiDestination {
Supported,
Unsupported,
Other,
}
pub fn classify_openai_api_url(destination: &str) -> OpenAiDestination {
let Ok(url) = reqwest::Url::parse(destination) else {
return OpenAiDestination::Other;
};
let Some(host) = url.host_str() else {
return OpenAiDestination::Other;
};
let host = host.strip_suffix('.').unwrap_or(host);
let openai_owned = [
"openai.com",
"openai.org",
"openai-staging.com",
"chatgpt.com",
"chatgpt-staging.com",
]
.iter()
.any(|domain| {
let host = host.trim_end_matches('.');
host == *domain
|| host
.strip_suffix(*domain)
.is_some_and(|prefix| prefix.ends_with('.'))
});
if !openai_owned {
return OpenAiDestination::Other;
}
let supported_host = matches!(
host,
"api.openai.com"
| "gov.api.openai.com"
| "us.api.openai.com"
| "eu.api.openai.com"
| "au.api.openai.com"
| "ca.api.openai.com"
| "jp.api.openai.com"
| "in.api.openai.com"
| "sg.api.openai.com"
| "kr.api.openai.com"
| "gb.api.openai.com"
| "ae.api.openai.com"
| "mtls.api.openai.com"
| "mtls-us.api.openai.com"
| "mtls-eu.api.openai.com"
| "api.openai.org"
| "api.openai-staging.com"
) || is_allowed_chatgpt_host(host);
if supported_host
&& url.scheme() == "https"
&& url.port_or_known_default() == Some(443)
&& url.username().is_empty()
&& url.password().is_none()
{
OpenAiDestination::Supported
} else {
OpenAiDestination::Unsupported
}
}
pub fn is_openai_api_url(destination: &str) -> bool {
classify_openai_api_url(destination) == OpenAiDestination::Supported
}
#[cfg(test)]
mod tests {
use super::OpenAiDestination;
use super::classify_openai_api_url;
use super::is_openai_api_url;
#[test]
fn unsupported_openai_origins_are_distinct_from_other_providers() {
for destination in [
"https://unknown.api.openai.com/v1",
"https://api.openai.com../v1",
"https://api.openai.com:8443/v1",
"http://api.openai.com/v1",
"https://user@api.openai.com/v1",
"https://other.openai.org/v1",
] {
assert_eq!(
classify_openai_api_url(destination),
OpenAiDestination::Unsupported,
"{destination}"
);
}
for destination in [
"https://api.openai.com.example.test/v1",
"https://example.test/v1",
"http://127.0.0.1:8080/v1",
] {
assert_eq!(
classify_openai_api_url(destination),
OpenAiDestination::Other,
"{destination}"
);
}
}
#[test]
fn runtime_credentials_require_a_trusted_https_origin() {
for destination in [
"https://api.openai.com/v1",
"https://api.openai.com.:443/v1",
"https://gov.api.openai.com/v1",
"https://eu.api.openai.com/v1",
"https://us.api.openai.com/v1",
"https://au.api.openai.com/v1",
"https://ca.api.openai.com/v1",
"https://jp.api.openai.com/v1",
"https://in.api.openai.com/v1",
"https://sg.api.openai.com/v1",
"https://kr.api.openai.com/v1",
"https://gb.api.openai.com/v1",
"https://ae.api.openai.com/v1",
"https://mtls.api.openai.com/v1",
"https://mtls-us.api.openai.com/v1",
"https://mtls-eu.api.openai.com/v1",
"https://api.openai.org/v1",
"https://api.openai-staging.com/v1",
"https://chatgpt.com/backend-api",
"https://chat.openai.com/backend-api",
"https://us.chatgpt.com/backend-api",
"https://chatgpt-staging.com/backend-api",
"https://us.chatgpt-staging.com/backend-api",
] {
assert!(is_openai_api_url(destination), "{destination}");
}
for destination in [
"http://api.openai.com/v1",
"https://api.openai.com:8443/v1",
"https://user@api.openai.com/v1",
"https://user:password@chatgpt.com/backend-api",
"https://api.openai.com.example.test/v1",
"https://eu.api.openai.com.example.test/v1",
"https://unknown.api.openai.com/v1",
"https://evilchatgpt.com/backend-api",
"https://chatgpt.com.example.test/backend-api",
"https://foo.chat.openai.com/backend-api",
"http://127.0.0.1:8080/v1",
"https://example.test/v1",
] {
assert!(!is_openai_api_url(destination), "{destination}");
}
}
}
@@ -167,6 +167,7 @@ impl fmt::Debug for OutboundProxyRoute {
pub struct HttpClientFactory {
outbound_proxy_policy: OutboundProxyPolicy,
system_proxy_fallback: bool,
protect_sensitive_headers: bool,
chatgpt_cookie_store: Option<Arc<ChatGptCookieStore>>,
network_policy: NetworkPolicy,
}
@@ -175,6 +176,7 @@ impl PartialEq for HttpClientFactory {
fn eq(&self, other: &Self) -> bool {
self.outbound_proxy_policy == other.outbound_proxy_policy
&& self.system_proxy_fallback == other.system_proxy_fallback
&& self.protect_sensitive_headers == other.protect_sensitive_headers
&& self.network_policy == other.network_policy
&& self
.chatgpt_cookie_store
@@ -194,6 +196,7 @@ impl fmt::Debug for HttpClientFactory {
f.debug_struct("HttpClientFactory")
.field("outbound_proxy_policy", &self.outbound_proxy_policy)
.field("system_proxy_fallback", &self.system_proxy_fallback)
.field("protect_sensitive_headers", &self.protect_sensitive_headers)
.field("network_policy", &self.network_policy)
.finish()
}
@@ -205,11 +208,24 @@ impl HttpClientFactory {
Self {
outbound_proxy_policy,
system_proxy_fallback: false,
protect_sensitive_headers: false,
chatgpt_cookie_store: None,
network_policy: NetworkPolicy::unmanaged(),
}
}
/// Follows redirects while keeping headers marked sensitive on the original origin.
/// Reqwest's default redirect handling only recognizes standard credential headers.
pub fn with_sensitive_header_protection(mut self) -> Self {
self.protect_sensitive_headers = true;
self
}
/// Whether redirect handling removes sensitive headers when the origin changes.
pub fn protects_sensitive_headers(&self) -> bool {
self.protect_sensitive_headers
}
/// Carries the account/configuration owner's application policy into every transport.
pub fn with_network_policy(mut self, policy: NetworkPolicy) -> Self {
self.network_policy = policy;
@@ -564,6 +564,7 @@ impl RouteAwareClientPool {
&& (self.http_client_factory.outbound_proxy_policy()
== OutboundProxyPolicy::RespectSystemProxy
|| self.http_client_factory.network_policy().is_managed()
|| self.http_client_factory.protects_sensitive_headers()
|| self.rustls_clients.is_some())
}
@@ -211,7 +211,12 @@ impl RouteAwareClientPool {
if redirects >= MAX_REDIRECTS {
return Err(RouteAwareRequestError::TooManyRedirects);
}
remove_sensitive_headers(next_request.headers_mut(), &current_url, &next_request_url);
remove_sensitive_headers(
next_request.headers_mut(),
&current_url,
&next_request_url,
self.http_client_factory.protects_sensitive_headers(),
);
insert_referer(next_request.headers_mut(), &current_url, &next_request_url);
request = next_request;
redirects += 1;
@@ -23,6 +23,57 @@ use super::*;
use crate::OutboundProxyPolicy;
use crate::request_draft::RequestDraft;
#[tokio::test]
async fn redirects_honor_sensitive_header_protection_without_changing_default_behavior() {
for policy in [
OutboundProxyPolicy::ReqwestDefault,
OutboundProxyPolicy::RespectSystemProxy,
] {
for protect_sensitive_headers in [false, true] {
let (target, target_server) = spawn_response_server(vec![
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".into(),
]);
let (source, source_server) = spawn_response_server(vec![
"HTTP/1.1 307 Temporary Redirect\r\nLocation: /same\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into(),
format!("HTTP/1.1 307 Temporary Redirect\r\nLocation: http://{target}/final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"),
]);
let url = format!("http://{source}/start");
let factory = HttpClientFactory::new(policy);
let factory = if protect_sensitive_headers {
factory.with_sensitive_header_protection()
} else {
factory
};
let client = factory.build_client(&url, ClientRouteClass::Api).unwrap();
let mut proof = HeaderValue::from_static("private-proof");
proof.set_sensitive(/*val*/ true);
let response = client
.get(&url)
.header("authorization", "Bearer private-token")
.header("x-custom-proof", proof)
.header("x-public", "retained")
.send()
.await
.unwrap();
assert_eq!(response.text().await.unwrap(), "ok");
let source_requests = source_server.join().unwrap();
assert_eq!(source_requests.len(), 2);
for request in source_requests {
assert!(request.contains("authorization: Bearer private-token\r\n"));
assert!(request.contains("x-custom-proof: private-proof\r\n"));
}
let target_requests = target_server.join().unwrap();
assert_eq!(target_requests.len(), 1);
assert!(!target_requests[0].contains("private-token"));
assert_eq!(
target_requests[0].contains("private-proof"),
!protect_sensitive_headers
);
assert!(target_requests[0].contains("x-public: retained\r\n"));
}
}
}
#[tokio::test]
async fn initial_url_auth_is_built_once_after_routing_and_retained_only_on_same_origin_redirects() {
let (first_proxy, first_server) = spawn_response_server(vec![
@@ -7,9 +7,10 @@
//! for the new URL without giving [`crate::RouteAwareClientPool`] an opportunity to resolve the
//! redirect destination.
//!
//! The pool therefore disables reqwest redirects for `RespectSystemProxy`, observes each redirect
//! response, and uses these helpers to construct the next request before resolving its route. The
//! helpers preserve the relevant reqwest method/body behavior while applying stricter
//! The pool disables reqwest redirects when route or sensitive-header policy requires checking
//! each hop. It observes each redirect response and uses these helpers to construct the next
//! request before resolving its route. The helpers preserve the relevant reqwest
//! method/body behavior while applying stricter
//! origin-sensitive credential and `Referer` rules at the boundary between requests.
use std::time::Duration;
@@ -99,12 +100,28 @@ pub(super) fn remove_sensitive_headers(
headers: &mut HeaderMap,
previous: &reqwest::Url,
next: &reqwest::Url,
protect_sensitive_headers: bool,
) {
if !same_origin(previous, next) {
for header in [AUTHORIZATION, COOKIE, PROXY_AUTHORIZATION, WWW_AUTHENTICATE] {
headers.remove(header);
}
headers.remove("cookie2");
if protect_sensitive_headers {
let sensitive: Vec<_> = headers
.keys()
.filter(|name| {
headers
.get_all(*name)
.iter()
.any(http::HeaderValue::is_sensitive)
})
.cloned()
.collect();
for name in sensitive {
headers.remove(name);
}
}
}
}
@@ -219,7 +219,12 @@ fn redirect_credentials_are_retained_only_for_the_same_origin() {
(COOKIE, HeaderValue::from_static("session=secret")),
]);
remove_sensitive_headers(&mut headers, &previous, &next);
remove_sensitive_headers(
&mut headers,
&previous,
&next,
/*protect_sensitive_headers*/ false,
);
assert_eq!(
(