feat(admin): 完善代理节点与 OAuth 授权管理

This commit is contained in:
fawney19
2026-04-14 14:09:24 +08:00
parent 593640ac19
commit 861ae81ff0
44 changed files with 2757 additions and 365 deletions

View File

@@ -6,6 +6,7 @@ use super::headers::{
use super::snapshot::GatewayProviderTransportSnapshot;
const DEFAULT_ANTHROPIC_VERSION: &str = "2023-06-01";
const PLACEHOLDER_API_KEY: &str = "__placeholder__";
fn collect_passthrough_headers(
headers: &http::HeaderMap,
@@ -233,10 +234,7 @@ pub fn resolve_local_openai_chat_auth(
if !matches!(auth_type.as_str(), "api_key" | "bearer") {
return None;
}
let secret = transport.key.decrypted_api_key.trim();
if secret.is_empty() {
return None;
}
let secret = resolved_local_secret(transport)?;
Some(("authorization".to_string(), format!("Bearer {secret}")))
}
@@ -245,10 +243,7 @@ pub fn resolve_local_standard_auth(
transport: &GatewayProviderTransportSnapshot,
) -> Option<(String, String)> {
let auth_type = transport.key.auth_type.trim().to_ascii_lowercase();
let secret = transport.key.decrypted_api_key.trim();
if secret.is_empty() {
return None;
}
let secret = resolved_local_secret(transport)?;
match auth_type.as_str() {
"api_key" => Some(("x-api-key".to_string(), secret.to_string())),
@@ -261,10 +256,7 @@ pub fn resolve_local_gemini_auth(
transport: &GatewayProviderTransportSnapshot,
) -> Option<(String, String)> {
let auth_type = transport.key.auth_type.trim().to_ascii_lowercase();
let secret = transport.key.decrypted_api_key.trim();
if secret.is_empty() {
return None;
}
let secret = resolved_local_secret(transport)?;
match auth_type.as_str() {
"api_key" => Some(("x-goog-api-key".to_string(), secret.to_string())),
@@ -273,11 +265,76 @@ pub fn resolve_local_gemini_auth(
}
}
fn resolved_local_secret(transport: &GatewayProviderTransportSnapshot) -> Option<&str> {
let secret = transport.key.decrypted_api_key.trim();
(!secret.is_empty() && secret != PLACEHOLDER_API_KEY).then_some(secret)
}
#[cfg(test)]
mod tests {
use super::{build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth};
use super::{
build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth,
resolve_local_standard_auth,
};
use crate::snapshot::{
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
};
use std::collections::BTreeMap;
fn sample_transport() -> GatewayProviderTransportSnapshot {
GatewayProviderTransportSnapshot {
provider: GatewayProviderTransportProvider {
id: "provider-1".to_string(),
name: "provider".to_string(),
provider_type: "custom".to_string(),
website: None,
is_active: true,
keep_priority_on_conversion: false,
enable_format_conversion: false,
concurrent_limit: None,
max_retries: None,
proxy: None,
request_timeout_secs: None,
stream_first_byte_timeout_secs: None,
config: None,
},
endpoint: GatewayProviderTransportEndpoint {
id: "endpoint-1".to_string(),
provider_id: "provider-1".to_string(),
api_format: "claude:chat".to_string(),
api_family: Some("claude".to_string()),
endpoint_kind: Some("chat".to_string()),
is_active: true,
base_url: "https://example.test".to_string(),
header_rules: None,
body_rules: None,
max_retries: None,
custom_path: None,
config: None,
format_acceptance_config: None,
proxy: None,
},
key: GatewayProviderTransportKey {
id: "key-1".to_string(),
provider_id: "provider-1".to_string(),
name: "key".to_string(),
auth_type: "bearer".to_string(),
is_active: true,
api_formats: None,
allowed_models: None,
capabilities: None,
rate_multipliers: None,
global_priority_by_format: None,
expires_at_unix_secs: None,
proxy: None,
fingerprint: None,
decrypted_api_key: "__placeholder__".to_string(),
decrypted_auth_config: None,
},
}
}
#[test]
fn claude_passthrough_headers_restore_stripped_anthropic_headers() {
let mut headers = http::HeaderMap::new();
@@ -380,4 +437,9 @@ mod tests {
Some("sk-upstream")
);
}
#[test]
fn local_standard_auth_rejects_placeholder_secret() {
assert!(resolve_local_standard_auth(&sample_transport()).is_none());
}
}

View File

@@ -3,10 +3,11 @@ use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use serde_json::{json, Value};
use url::form_urlencoded;
use super::oauth_refresh::{
CachedOAuthEntry, LocalOAuthRefreshAdapter, LocalOAuthRefreshError,
LocalResolvedOAuthRequestAuth,
CachedOAuthEntry, LocalOAuthHttpExecutor, LocalOAuthHttpRequest, LocalOAuthRefreshAdapter,
LocalOAuthRefreshError, LocalResolvedOAuthRequestAuth,
};
use super::snapshot::GatewayProviderTransportSnapshot;
@@ -247,7 +248,7 @@ impl LocalOAuthRefreshAdapter for GenericOAuthRefreshAdapter {
async fn refresh(
&self,
client: &reqwest::Client,
executor: &dyn LocalOAuthHttpExecutor,
transport: &GatewayProviderTransportSnapshot,
entry: Option<&CachedOAuthEntry>,
) -> Result<Option<CachedOAuthEntry>, LocalOAuthRefreshError> {
@@ -265,7 +266,6 @@ impl LocalOAuthRefreshAdapter for GenericOAuthRefreshAdapter {
let token_url = self.token_url_for_template(template);
let scope = (!template.scopes.is_empty()).then(|| template.scopes.join(" "));
let request = client.post(token_url);
let response = if template.uses_json_payload {
let mut body = serde_json::Map::from_iter([
(
@@ -284,44 +284,60 @@ impl LocalOAuthRefreshAdapter for GenericOAuthRefreshAdapter {
if let Some(scope) = scope.as_ref() {
body.insert("scope".to_string(), Value::String(scope.clone()));
}
request
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&Value::Object(body))
.send()
.await
executor
.execute(
template.provider_type,
transport,
&LocalOAuthHttpRequest {
request_id: "provider-oauth:local-refresh-token",
method: reqwest::Method::POST,
url: token_url,
headers: BTreeMap::from([
("content-type".to_string(), "application/json".to_string()),
("accept".to_string(), "application/json".to_string()),
]),
json_body: Some(Value::Object(body)),
body_bytes: None,
},
)
.await?
} else {
let mut form = vec![
("grant_type", "refresh_token".to_string()),
("client_id", template.client_id.to_string()),
("refresh_token", refresh_token.clone()),
];
if let Some(scope) = scope.as_ref() {
form.push(("scope", scope.clone()));
}
if !template.client_secret.trim().is_empty() {
form.push(("client_secret", template.client_secret.to_string()));
}
request
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.form(&form)
.send()
.await
}
.map_err(|source| LocalOAuthRefreshError::Transport {
provider_type: template.provider_type,
source,
})?;
let status = response.status();
let body = response
.text()
.await
.map_err(|source| LocalOAuthRefreshError::Transport {
provider_type: template.provider_type,
source,
})?;
let form_body = {
let mut form = form_urlencoded::Serializer::new(String::new());
form.append_pair("grant_type", "refresh_token");
form.append_pair("client_id", template.client_id);
form.append_pair("refresh_token", refresh_token.as_str());
if let Some(scope) = scope.as_ref() {
form.append_pair("scope", scope);
}
if !template.client_secret.trim().is_empty() {
form.append_pair("client_secret", template.client_secret);
}
form.finish().into_bytes()
};
executor
.execute(
template.provider_type,
transport,
&LocalOAuthHttpRequest {
request_id: "provider-oauth:local-refresh-token",
method: reqwest::Method::POST,
url: token_url,
headers: BTreeMap::from([
(
"content-type".to_string(),
"application/x-www-form-urlencoded".to_string(),
),
("accept".to_string(), "application/json".to_string()),
]),
json_body: None,
body_bytes: Some(form_body),
},
)
.await?
};
let status = reqwest::StatusCode::from_u16(response.status_code).unwrap_or_default();
let body = response.body_text;
if !status.is_success() {
return Err(LocalOAuthRefreshError::HttpStatus {
provider_type: template.provider_type,

View File

@@ -4,8 +4,8 @@ use async_trait::async_trait;
use serde_json::{json, Value};
use super::super::oauth_refresh::{
CachedOAuthEntry, LocalOAuthRefreshAdapter, LocalOAuthRefreshError,
LocalResolvedOAuthRequestAuth,
CachedOAuthEntry, LocalOAuthHttpExecutor, LocalOAuthHttpRequest, LocalOAuthRefreshAdapter,
LocalOAuthRefreshError, LocalResolvedOAuthRequestAuth,
};
use super::super::snapshot::GatewayProviderTransportSnapshot;
use super::auth::{
@@ -34,13 +34,16 @@ impl KiroOAuthRefreshAdapter {
pub async fn refresh_auth_config(
&self,
client: &reqwest::Client,
executor: &dyn LocalOAuthHttpExecutor,
transport: &GatewayProviderTransportSnapshot,
auth_config: &KiroAuthConfig,
) -> Result<KiroAuthConfig, LocalOAuthRefreshError> {
if auth_config.is_idc_auth() {
self.refresh_idc_token(client, auth_config).await
self.refresh_idc_token(executor, transport, auth_config)
.await
} else {
self.refresh_social_token(client, auth_config).await
self.refresh_social_token(executor, transport, auth_config)
.await
}
}
@@ -101,7 +104,8 @@ impl KiroOAuthRefreshAdapter {
async fn refresh_social_token(
&self,
client: &reqwest::Client,
executor: &dyn LocalOAuthHttpExecutor,
transport: &GatewayProviderTransportSnapshot,
auth_config: &KiroAuthConfig,
) -> Result<KiroAuthConfig, LocalOAuthRefreshError> {
let url = self.social_refresh_url(auth_config);
@@ -122,36 +126,42 @@ impl KiroOAuthRefreshAdapter {
})?;
let kiro_version = auth_config.effective_kiro_version();
let user_agent = build_kiro_ide_tag(kiro_version, &machine_id);
let response = client
.post(url)
.header("User-Agent", user_agent)
.header("Host", host)
.header("Accept", "application/json, text/plain, */*")
.header("Content-Type", "application/json")
.header("Connection", "close")
.header("Accept-Encoding", "gzip, compress, deflate, br")
.json(&json!({
"refreshToken": auth_config
.refresh_token
.as_deref()
.map(str::trim)
.unwrap_or_default()
}))
.send()
.await
.map_err(|source| LocalOAuthRefreshError::Transport {
provider_type: PROVIDER_TYPE,
source,
})?;
let response = executor
.execute(
PROVIDER_TYPE,
transport,
&LocalOAuthHttpRequest {
request_id: "provider-oauth:kiro-social-refresh",
method: reqwest::Method::POST,
url,
headers: std::collections::BTreeMap::from([
("user-agent".to_string(), user_agent),
("host".to_string(), host),
(
"accept".to_string(),
"application/json, text/plain, */*".to_string(),
),
("content-type".to_string(), "application/json".to_string()),
("connection".to_string(), "close".to_string()),
(
"accept-encoding".to_string(),
"gzip, compress, deflate, br".to_string(),
),
]),
json_body: Some(json!({
"refreshToken": auth_config
.refresh_token
.as_deref()
.map(str::trim)
.unwrap_or_default()
})),
body_bytes: None,
},
)
.await?;
let status = response.status();
let body = response
.text()
.await
.map_err(|source| LocalOAuthRefreshError::Transport {
provider_type: PROVIDER_TYPE,
source,
})?;
let status = reqwest::StatusCode::from_u16(response.status_code).unwrap_or_default();
let body = response.body_text;
if !status.is_success() {
return Err(LocalOAuthRefreshError::HttpStatus {
provider_type: PROVIDER_TYPE,
@@ -208,7 +218,8 @@ impl KiroOAuthRefreshAdapter {
async fn refresh_idc_token(
&self,
client: &reqwest::Client,
executor: &dyn LocalOAuthHttpExecutor,
transport: &GatewayProviderTransportSnapshot,
auth_config: &KiroAuthConfig,
) -> Result<KiroAuthConfig, LocalOAuthRefreshError> {
let url = self.idc_refresh_url(auth_config);
@@ -218,46 +229,49 @@ impl KiroOAuthRefreshAdapter {
.unwrap_or_else(|| {
format!("oidc.{}.amazonaws.com", auth_config.effective_auth_region())
});
let response = client
.post(url)
.header("Content-Type", "application/json")
.header("Host", host)
.header("x-amz-user-agent", IDC_AMZ_USER_AGENT)
.header("User-Agent", "node")
.header("Accept", "*/*")
.json(&json!({
"clientId": auth_config
.client_id
.as_deref()
.map(str::trim)
.unwrap_or_default(),
"clientSecret": auth_config
.client_secret
.as_deref()
.map(str::trim)
.unwrap_or_default(),
"refreshToken": auth_config
.refresh_token
.as_deref()
.map(str::trim)
.unwrap_or_default(),
"grantType": "refresh_token"
}))
.send()
.await
.map_err(|source| LocalOAuthRefreshError::Transport {
provider_type: PROVIDER_TYPE,
source,
})?;
let response = executor
.execute(
PROVIDER_TYPE,
transport,
&LocalOAuthHttpRequest {
request_id: "provider-oauth:kiro-idc-refresh",
method: reqwest::Method::POST,
url,
headers: std::collections::BTreeMap::from([
("content-type".to_string(), "application/json".to_string()),
("host".to_string(), host),
(
"x-amz-user-agent".to_string(),
IDC_AMZ_USER_AGENT.to_string(),
),
("user-agent".to_string(), "node".to_string()),
("accept".to_string(), "*/*".to_string()),
]),
json_body: Some(json!({
"clientId": auth_config
.client_id
.as_deref()
.map(str::trim)
.unwrap_or_default(),
"clientSecret": auth_config
.client_secret
.as_deref()
.map(str::trim)
.unwrap_or_default(),
"refreshToken": auth_config
.refresh_token
.as_deref()
.map(str::trim)
.unwrap_or_default(),
"grantType": "refresh_token"
})),
body_bytes: None,
},
)
.await?;
let status = response.status();
let body = response
.text()
.await
.map_err(|source| LocalOAuthRefreshError::Transport {
provider_type: PROVIDER_TYPE,
source,
})?;
let status = reqwest::StatusCode::from_u16(response.status_code).unwrap_or_default();
let body = response.body_text;
if !status.is_success() {
return Err(LocalOAuthRefreshError::HttpStatus {
provider_type: PROVIDER_TYPE,
@@ -353,7 +367,7 @@ impl LocalOAuthRefreshAdapter for KiroOAuthRefreshAdapter {
async fn refresh(
&self,
client: &reqwest::Client,
executor: &dyn LocalOAuthHttpExecutor,
transport: &GatewayProviderTransportSnapshot,
entry: Option<&CachedOAuthEntry>,
) -> Result<Option<CachedOAuthEntry>, LocalOAuthRefreshError> {
@@ -361,9 +375,11 @@ impl LocalOAuthRefreshAdapter for KiroOAuthRefreshAdapter {
return Ok(None);
};
let refreshed = if auth_config.is_idc_auth() {
self.refresh_idc_token(client, &auth_config).await?
self.refresh_idc_token(executor, transport, &auth_config)
.await?
} else {
self.refresh_social_token(client, &auth_config).await?
self.refresh_social_token(executor, transport, &auth_config)
.await?
};
Ok(Self::build_cached_entry(&refreshed))
}
@@ -410,7 +426,7 @@ mod tests {
use std::sync::{Arc, Mutex};
use super::super::super::oauth_refresh::{
LocalOAuthRefreshAdapter, LocalResolvedOAuthRequestAuth,
LocalOAuthRefreshAdapter, LocalResolvedOAuthRequestAuth, ReqwestLocalOAuthHttpExecutor,
};
use super::super::super::snapshot::{
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
@@ -565,9 +581,10 @@ mod tests {
"kiro_version":"1.2.3"
}"#,
);
let executor = ReqwestLocalOAuthHttpExecutor::new(reqwest::Client::new());
let entry = adapter
.refresh(&reqwest::Client::new(), &transport, None)
.refresh(&executor, &transport, None)
.await
.expect("refresh should succeed")
.expect("cached entry should exist");
@@ -665,9 +682,10 @@ mod tests {
"profile_arn":"arn:aws:bedrock:demo"
}"#,
);
let executor = ReqwestLocalOAuthHttpExecutor::new(reqwest::Client::new());
let entry = adapter
.refresh(&reqwest::Client::new(), &transport, None)
.refresh(&executor, &transport, None)
.await
.expect("refresh should succeed")
.expect("cached entry should exist");

View File

@@ -29,8 +29,9 @@ pub use network::{
TransportTunnelAttachmentOwner,
};
pub use oauth_refresh::{
supports_local_oauth_request_auth_resolution, CachedOAuthEntry, LocalOAuthRefreshCoordinator,
LocalOAuthRefreshError, LocalResolvedOAuthRequestAuth,
supports_local_oauth_request_auth_resolution, CachedOAuthEntry, LocalOAuthHttpExecutor,
LocalOAuthHttpRequest, LocalOAuthHttpResponse, LocalOAuthRefreshCoordinator,
LocalOAuthRefreshError, LocalResolvedOAuthRequestAuth, ReqwestLocalOAuthHttpExecutor,
};
pub use policy::{
local_gemini_transport_unsupported_reason,

View File

@@ -42,6 +42,22 @@ pub struct CachedOAuthEntry {
pub metadata: Option<Value>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LocalOAuthHttpRequest {
pub request_id: &'static str,
pub method: reqwest::Method,
pub url: String,
pub headers: BTreeMap<String, String>,
pub json_body: Option<Value>,
pub body_bytes: Option<Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalOAuthHttpResponse {
pub status_code: u16,
pub body_text: String,
}
#[derive(Debug, Error)]
pub enum LocalOAuthRefreshError {
#[error("{provider_type} oauth refresh request failed: {source}")]
@@ -63,6 +79,71 @@ pub enum LocalOAuthRefreshError {
},
}
#[async_trait]
pub trait LocalOAuthHttpExecutor: Send + Sync {
async fn execute(
&self,
provider_type: &'static str,
transport: &GatewayProviderTransportSnapshot,
request: &LocalOAuthHttpRequest,
) -> Result<LocalOAuthHttpResponse, LocalOAuthRefreshError>;
}
#[derive(Debug, Clone)]
pub struct ReqwestLocalOAuthHttpExecutor {
client: reqwest::Client,
}
impl ReqwestLocalOAuthHttpExecutor {
pub fn new(client: reqwest::Client) -> Self {
Self { client }
}
}
#[async_trait]
impl LocalOAuthHttpExecutor for ReqwestLocalOAuthHttpExecutor {
async fn execute(
&self,
provider_type: &'static str,
_transport: &GatewayProviderTransportSnapshot,
request: &LocalOAuthHttpRequest,
) -> Result<LocalOAuthHttpResponse, LocalOAuthRefreshError> {
let mut builder = self
.client
.request(request.method.clone(), request.url.as_str());
for (name, value) in &request.headers {
builder = builder.header(name, value);
}
if let Some(json_body) = request.json_body.as_ref() {
builder = builder.json(json_body);
} else if let Some(body_bytes) = request.body_bytes.as_ref() {
builder = builder.body(body_bytes.clone());
}
let response =
builder
.send()
.await
.map_err(|source| LocalOAuthRefreshError::Transport {
provider_type,
source,
})?;
let status_code = response.status().as_u16();
let body_text =
response
.text()
.await
.map_err(|source| LocalOAuthRefreshError::Transport {
provider_type,
source,
})?;
Ok(LocalOAuthHttpResponse {
status_code,
body_text,
})
}
}
#[async_trait]
pub trait LocalOAuthRefreshAdapter: Send + Sync {
fn provider_type(&self) -> &'static str;
@@ -94,7 +175,7 @@ pub trait LocalOAuthRefreshAdapter: Send + Sync {
async fn refresh(
&self,
client: &reqwest::Client,
executor: &dyn LocalOAuthHttpExecutor,
transport: &GatewayProviderTransportSnapshot,
entry: Option<&CachedOAuthEntry>,
) -> Result<Option<CachedOAuthEntry>, LocalOAuthRefreshError>;
@@ -152,7 +233,7 @@ impl LocalOAuthRefreshCoordinator {
pub async fn resolve_with_result(
&self,
client: &reqwest::Client,
executor: &dyn LocalOAuthHttpExecutor,
transport: &GatewayProviderTransportSnapshot,
distributed_lock: Option<&RedisLockRunner>,
distributed_owner: Option<&str>,
@@ -232,7 +313,7 @@ impl LocalOAuthRefreshCoordinator {
};
let refresh_result = adapter
.refresh(client, transport, cached_entry.as_ref())
.refresh(executor, transport, cached_entry.as_ref())
.await;
if let (Some(lock), Some(lease)) = (distributed_lock, distributed_lease.as_ref()) {
if let Err(err) = lock.release(lease).await {
@@ -300,8 +381,9 @@ mod tests {
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
};
use super::{
CachedOAuthEntry, LocalOAuthRefreshAdapter, LocalOAuthRefreshCoordinator,
LocalOAuthRefreshError, LocalOAuthResolution, LocalResolvedOAuthRequestAuth,
CachedOAuthEntry, LocalOAuthHttpExecutor, LocalOAuthRefreshAdapter,
LocalOAuthRefreshCoordinator, LocalOAuthRefreshError, LocalOAuthResolution,
LocalResolvedOAuthRequestAuth, ReqwestLocalOAuthHttpExecutor,
};
use async_trait::async_trait;
use std::sync::Arc;
@@ -351,7 +433,7 @@ mod tests {
async fn refresh(
&self,
_client: &reqwest::Client,
_executor: &dyn LocalOAuthHttpExecutor,
_transport: &GatewayProviderTransportSnapshot,
_entry: Option<&CachedOAuthEntry>,
) -> Result<Option<CachedOAuthEntry>, LocalOAuthRefreshError> {
@@ -427,14 +509,14 @@ mod tests {
refresh_hits: Arc::clone(&refresh_hits),
})]);
let transport = sample_transport();
let client = reqwest::Client::new();
let executor = ReqwestLocalOAuthHttpExecutor::new(reqwest::Client::new());
let first = coordinator
.resolve_with_result(&client, &transport, None, None)
.resolve_with_result(&executor, &transport, None, None)
.await
.expect("first resolve should succeed");
let second = coordinator
.resolve_with_result(&client, &transport, None, None)
.resolve_with_result(&executor, &transport, None, None)
.await
.expect("second resolve should succeed");