fix: preserve provider auth metadata

This commit is contained in:
elky
2026-06-20 22:11:42 +08:00
parent 1123095bb7
commit f1c30439ff
51 changed files with 2098 additions and 420 deletions
@@ -3,14 +3,12 @@ use std::collections::BTreeMap;
use serde_json::Value;
use url::form_urlencoded;
const UNSAFE_AUTH_CONFIG_HEADER_NAMES: &[&str] = &[
const UNSAFE_AUTH_CONFIG_HEADER_NAMES: &[&str] = &["content-length", "host", "proxy-authorization"];
const RUNTIME_ONLY_AUTH_CONFIG_HEADER_NAMES: &[&str] = &[
"api-key",
"authorization",
"content-length",
"content-type",
"cookie",
"host",
"proxy-authorization",
"x-api-key",
"x-goog-api-key",
];
@@ -44,14 +42,20 @@ const IGNORABLE_AUTH_CONFIG_METADATA_KEYS: &[&str] = &[
"account_name",
"account_user_id",
"auth_method",
"access_token_import_temporary",
"email",
"expires_at",
"model_regions",
"organizations",
"plan_type",
"project_id",
"provider_type",
"refresh_token_import_error",
"region",
"scope",
"tier",
"token_type",
"updated_at",
"user_id",
"workspace_id",
"workspace_name",
@@ -81,6 +85,30 @@ pub enum LocalAuthConfigAbsorption {
},
}
pub fn apply_local_auth_config_header_overrides(
headers: &mut BTreeMap<String, String>,
raw_auth_config: Option<&str>,
) {
let Some(raw_auth_config) = raw_auth_config
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return;
};
let Ok(parsed) = serde_json::from_str::<Value>(raw_auth_config) else {
return;
};
let Some(object) = parsed.as_object() else {
return;
};
let mut overrides = BTreeMap::new();
collect_auth_config_header_overrides(object, &mut overrides);
for (key, value) in overrides {
headers.insert(key, value);
}
}
pub fn absorb_local_auth_config_safe_subset(
base_url: &str,
header_rules: Option<Value>,
@@ -130,6 +158,12 @@ fn parse_local_auth_config_safe_subset(raw: &str) -> Result<LocalAuthConfigSafeS
let mut path = None;
parse_local_auth_config_object(object, &mut headers, &mut query, &mut path, true)?;
if headers
.keys()
.any(|key| RUNTIME_ONLY_AUTH_CONFIG_HEADER_NAMES.contains(&key.as_str()))
{
return Err(());
}
Ok(LocalAuthConfigSafeSubset {
headers,
@@ -138,6 +172,26 @@ fn parse_local_auth_config_safe_subset(raw: &str) -> Result<LocalAuthConfigSafeS
})
}
fn collect_auth_config_header_overrides(
object: &serde_json::Map<String, Value>,
out: &mut BTreeMap<String, String>,
) {
for (key, value) in object {
let normalized = key.trim().to_ascii_lowercase();
match normalized.as_str() {
"headers" | "extra_headers" | "extraheaders" => {
merge_header_string_map_lenient(out, value);
}
"transport" | "request" => {
if let Some(nested) = value.as_object() {
collect_auth_config_header_overrides(nested, out);
}
}
_ => {}
}
}
}
fn parse_local_auth_config_object(
object: &serde_json::Map<String, Value>,
headers: &mut BTreeMap<String, String>,
@@ -149,7 +203,7 @@ fn parse_local_auth_config_object(
let normalized = key.trim().to_ascii_lowercase();
match normalized.as_str() {
"headers" | "extra_headers" | "extraheaders" => {
merge_string_map(headers, value, normalize_auth_config_header_name)?
merge_header_string_map(headers, value)?
}
"query" | "query_params" | "queryparams" => {
merge_string_map(query, value, normalize_auth_config_query_key)?
@@ -206,6 +260,38 @@ fn parse_static_auth_config_value(value: &Value) -> Option<String> {
}
}
fn merge_header_string_map(out: &mut BTreeMap<String, String>, value: &Value) -> Result<(), ()> {
let object = value.as_object().ok_or(())?;
for (raw_key, raw_value) in object {
let key = normalize_auth_config_header_name(raw_key).ok_or(())?;
let value = parse_static_auth_config_header_value(raw_value).ok_or(())?;
out.insert(key, value);
}
Ok(())
}
fn merge_header_string_map_lenient(out: &mut BTreeMap<String, String>, value: &Value) {
let Some(object) = value.as_object() else {
return;
};
for (raw_key, raw_value) in object {
let Some(key) = normalize_auth_config_header_name(raw_key) else {
continue;
};
let Some(value) = parse_static_auth_config_header_value(raw_value) else {
continue;
};
out.insert(key, value);
}
}
fn parse_static_auth_config_header_value(value: &Value) -> Option<String> {
let value = parse_static_auth_config_value(value)?;
http::header::HeaderValue::from_str(&value)
.is_ok()
.then_some(value)
}
fn normalize_auth_config_header_name(raw: &str) -> Option<String> {
let value = raw.trim().to_ascii_lowercase();
if value.is_empty()
@@ -379,7 +465,10 @@ fn split_path_and_query(path: &str) -> Option<(String, BTreeMap<String, String>)
mod tests {
use serde_json::json;
use super::{absorb_local_auth_config_safe_subset, LocalAuthConfigAbsorption};
use super::{
absorb_local_auth_config_safe_subset, apply_local_auth_config_header_overrides,
LocalAuthConfigAbsorption,
};
#[test]
fn absorbs_static_headers_and_query_into_existing_transport_fields() {
@@ -469,7 +558,7 @@ mod tests {
"https://api.openai.example/v1",
None,
None,
Some(r#"{"headers":{"authorization":"Bearer x"}}"#),
Some(r#"{"headers":{"host":"api.example.test"}}"#),
),
LocalAuthConfigAbsorption::Unsupported
);
@@ -484,6 +573,100 @@ mod tests {
);
}
#[test]
fn applies_header_overrides_even_when_auth_config_has_refresh_token() {
let mut headers = std::collections::BTreeMap::from([
(
"authorization".to_string(),
"Bearer direct-token".to_string(),
),
("content-type".to_string(), "application/json".to_string()),
]);
apply_local_auth_config_header_overrides(
&mut headers,
Some(
r#"{
"refresh_token": "rt-1",
"headers": {
"authorization": "Bearer imported-session",
"content-type": "text/plain",
"host": "blocked.example"
}
}"#,
),
);
assert_eq!(
headers.get("authorization"),
Some(&"Bearer imported-session".to_string())
);
assert_eq!(headers.get("content-type"), Some(&"text/plain".to_string()));
assert!(!headers.contains_key("host"));
}
#[test]
fn ignores_invalid_auth_config_header_values_when_applying_overrides() {
let mut headers = std::collections::BTreeMap::new();
apply_local_auth_config_header_overrides(
&mut headers,
Some(r#"{"headers":{"authorization":"Bearer ok","x-bad":"line\nbreak"}}"#),
);
assert_eq!(headers.get("authorization"), Some(&"Bearer ok".to_string()));
assert!(!headers.contains_key("x-bad"));
}
#[test]
fn keeps_imported_authorization_headers_for_runtime_override() {
assert_eq!(
absorb_local_auth_config_safe_subset(
"https://api.openai.example/v1",
None,
None,
Some(
r#"{
"provider_type": "codex",
"access_token_import_temporary": true,
"headers": {
"authorization": "Bearer imported-session",
"chatgpt-account-id": "acct-1"
}
}"#,
),
),
LocalAuthConfigAbsorption::Unsupported
);
let mut headers = std::collections::BTreeMap::from([(
"authorization".to_string(),
"Bearer direct-token".to_string(),
)]);
apply_local_auth_config_header_overrides(
&mut headers,
Some(
r#"{
"provider_type": "codex",
"access_token_import_temporary": true,
"headers": {
"authorization": "Bearer imported-session",
"chatgpt-account-id": "acct-1"
}
}"#,
),
);
assert_eq!(
headers.get("authorization"),
Some(&"Bearer imported-session".to_string())
);
assert_eq!(
headers.get("chatgpt-account-id"),
Some(&"acct-1".to_string())
);
}
#[test]
fn absorbs_query_only_configs_into_base_url_for_dynamic_path_formats() {
let result = absorb_local_auth_config_safe_subset(
@@ -29,6 +29,7 @@ pub mod windsurf;
pub use aether_oauth as oauth;
pub use auth::{build_passthrough_headers, ensure_upstream_auth_header};
pub use auth_config::apply_local_auth_config_header_overrides;
pub use cache::{provider_transport_snapshot_looks_refreshed, ProviderTransportSnapshotCacheKey};
pub use conversion::{
candidate_common_transport_skip_reason, candidate_transport_pair_skip_reason,
@@ -11,6 +11,7 @@ use crate::url::{build_openai_image_url, build_openai_responses_url};
#[derive(Debug, Clone, Copy)]
pub struct ProviderOpenAiImageHeadersInput<'a> {
pub transport: &'a GatewayProviderTransportSnapshot,
pub headers: &'a http::HeaderMap,
pub auth_header: &'a str,
pub auth_value: &'a str,
@@ -82,6 +83,10 @@ pub fn build_openai_image_headers(
);
provider_request_headers.insert("content-type".to_string(), "application/json".to_string());
provider_request_headers.insert("accept".to_string(), input.accept.to_string());
crate::apply_local_auth_config_header_overrides(
&mut provider_request_headers,
input.transport.key.decrypted_auth_config.as_deref(),
);
if !apply_local_header_rules_with_request_headers(
&mut provider_request_headers,
input.header_rules,
@@ -253,7 +258,9 @@ mod tests {
#[test]
fn builds_json_eventstream_headers_and_applies_rules() {
let transport = sample_transport();
let headers = build_openai_image_headers(ProviderOpenAiImageHeadersInput {
transport: &transport,
headers: &HeaderMap::new(),
auth_header: "authorization",
auth_value: "Bearer secret",
@@ -282,8 +289,46 @@ mod tests {
}
#[test]
fn standard_openai_compatible_image_headers_can_request_json() {
fn auth_config_headers_override_default_authorization() {
let mut transport = sample_transport();
transport.key.decrypted_auth_config = Some(
json!({
"refresh_token": "rt-1",
"headers": {
"authorization": "Bearer imported-session",
"chatgpt-account-id": "acct-1"
}
})
.to_string(),
);
let headers = build_openai_image_headers(ProviderOpenAiImageHeadersInput {
transport: &transport,
headers: &HeaderMap::new(),
auth_header: "authorization",
auth_value: "Bearer refreshed-access-token",
accept: "text/event-stream",
header_rules: None,
provider_request_body: &json!({"model":"gpt-5.4-mini"}),
original_request_body: &json!({"prompt":"draw"}),
})
.expect("headers should build");
assert_eq!(
headers.get("authorization"),
Some(&"Bearer imported-session".to_string())
);
assert_eq!(
headers.get("chatgpt-account-id"),
Some(&"acct-1".to_string())
);
}
#[test]
fn standard_openai_compatible_image_headers_can_request_json() {
let transport = sample_transport();
let headers = build_openai_image_headers(ProviderOpenAiImageHeadersInput {
transport: &transport,
headers: &HeaderMap::new(),
auth_header: "authorization",
auth_value: "Bearer secret",
@@ -250,6 +250,10 @@ pub fn build_standard_provider_request_headers(
)
};
crate::apply_local_auth_config_header_overrides(
&mut headers,
input.transport.key.decrypted_auth_config.as_deref(),
);
let protected_headers = if uses_vertex_query_auth {
&["content-type"][..]
} else {
@@ -469,6 +473,70 @@ mod tests {
);
}
#[test]
fn endpoint_header_rules_do_not_override_protected_authorization() {
let mut transport = sample_transport("openai:responses");
transport.endpoint.header_rules = Some(json!([
{"action":"set","key":"authorization","value":"Bearer imported-session"}
]));
let resolved =
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
transport: &transport,
provider_api_format: "openai:responses",
same_format: true,
headers: &HeaderMap::new(),
auth_header: "authorization",
auth_value: "Bearer jwt-access-token",
extra_headers: &BTreeMap::new(),
header_rules: transport.endpoint.header_rules.as_ref(),
provider_request_body: &json!({"model":"gpt-test"}),
original_request_body: &json!({"model":"gpt-test"}),
upstream_is_stream: false,
})
.expect("headers should build");
assert_eq!(
resolved.headers.get("authorization"),
Some(&"Bearer jwt-access-token".to_string())
);
}
#[test]
fn auth_config_headers_can_override_authorization_when_refresh_token_is_present() {
let mut transport = sample_transport("openai:responses");
transport.key.decrypted_auth_config = Some(
json!({
"refresh_token": "rt-1",
"headers": {
"authorization": "Bearer imported-session"
}
})
.to_string(),
);
let resolved =
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
transport: &transport,
provider_api_format: "openai:responses",
same_format: true,
headers: &HeaderMap::new(),
auth_header: "authorization",
auth_value: "Bearer refreshed-access-token",
extra_headers: &BTreeMap::new(),
header_rules: transport.endpoint.header_rules.as_ref(),
provider_request_body: &json!({"model":"gpt-test"}),
original_request_body: &json!({"model":"gpt-test"}),
upstream_is_stream: false,
})
.expect("headers should build");
assert_eq!(
resolved.headers.get("authorization"),
Some(&"Bearer imported-session".to_string())
);
}
#[test]
fn applies_standard_body_rules_to_surface_built_body() {
let body = apply_standard_provider_request_body_rules(