mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-11 03:39:49 +08:00
feat(provider): 原生接入 Windsurf provider
This commit is contained in:
@@ -29,6 +29,10 @@ use crate::handlers::admin::request::{AdminAppState, AdminProviderOAuthTemplate}
|
||||
use crate::GatewayError;
|
||||
use aether_admin::provider::oauth::parse_admin_provider_oauth_kiro_batch_import_entries;
|
||||
use aether_contracts::ProxySnapshot;
|
||||
use aether_oauth::core::OAuthError;
|
||||
use aether_oauth::provider::{
|
||||
ProviderOAuthImportInput, ProviderOAuthService, ProviderOAuthTransportContext,
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
struct AdminProviderOAuthResolvedBatchImport {
|
||||
@@ -37,6 +41,16 @@ struct AdminProviderOAuthResolvedBatchImport {
|
||||
expires_at: Option<u64>,
|
||||
}
|
||||
|
||||
fn sanitize_windsurf_batch_import_error(error: &OAuthError) -> String {
|
||||
match error {
|
||||
OAuthError::InvalidRequest(_) => "Windsurf 凭据验证失败: 请求参数无效".to_string(),
|
||||
OAuthError::HttpStatus { status_code, .. } => {
|
||||
format!("Windsurf 凭据验证失败: HTTP {status_code}")
|
||||
}
|
||||
_ => "Windsurf 凭据验证失败".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn estimate_admin_provider_oauth_batch_import_total(
|
||||
provider_type: &str,
|
||||
raw_credentials: &str,
|
||||
@@ -98,6 +112,65 @@ async fn resolve_admin_provider_oauth_batch_import_tokens(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
if provider_type.eq_ignore_ascii_case("windsurf") {
|
||||
let token_for_import = refresh_token.or(access_token);
|
||||
let ctx = ProviderOAuthTransportContext {
|
||||
provider_id: String::new(),
|
||||
provider_type: provider_type.to_string(),
|
||||
endpoint_id: None,
|
||||
key_id: None,
|
||||
auth_type: Some("oauth".to_string()),
|
||||
decrypted_api_key: None,
|
||||
decrypted_auth_config: None,
|
||||
provider_config: None,
|
||||
endpoint_config: None,
|
||||
key_config: None,
|
||||
network: aether_oauth::network::OAuthNetworkContext::provider_operation(
|
||||
request_proxy.clone(),
|
||||
),
|
||||
};
|
||||
let executor = crate::oauth::GatewayOAuthHttpExecutor::new(*state);
|
||||
let result = ProviderOAuthService::with_builtin_adapters()
|
||||
.import_credentials(
|
||||
&executor,
|
||||
&ctx,
|
||||
ProviderOAuthImportInput {
|
||||
provider_type: provider_type.to_string(),
|
||||
name: entry
|
||||
.raw_credentials
|
||||
.as_ref()
|
||||
.and_then(|raw| raw.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
refresh_token: token_for_import.map(ToOwned::to_owned),
|
||||
raw_credentials: entry.raw_credentials.clone(),
|
||||
network: ctx.network.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| sanitize_windsurf_batch_import_error(&error))?;
|
||||
let access_token = result.token_set.access_token.trim().to_string();
|
||||
if access_token.is_empty() {
|
||||
return Err("Windsurf 凭据验证返回缺少 apiKey/sessionToken".to_string());
|
||||
}
|
||||
let auth_config = result
|
||||
.auth_config
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| "Windsurf 凭据验证返回缺少 auth_config".to_string())?;
|
||||
return Ok(AdminProviderOAuthResolvedBatchImport {
|
||||
access_token,
|
||||
auth_config,
|
||||
expires_at: result.token_set.expires_at_unix_secs,
|
||||
});
|
||||
}
|
||||
|
||||
let Some(template) = template else {
|
||||
return Err(ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL.to_string());
|
||||
};
|
||||
|
||||
if let Some(refresh_token) = refresh_token {
|
||||
let Some(template) = template else {
|
||||
if provider_type_supports_access_token_import(provider_type) {
|
||||
@@ -228,6 +301,25 @@ pub(super) async fn execute_admin_provider_oauth_batch_import(
|
||||
};
|
||||
|
||||
let template = admin_provider_oauth_template(provider_type);
|
||||
if template.is_none() && !provider_type.eq_ignore_ascii_case("windsurf") {
|
||||
return Ok(AdminProviderOAuthBatchImportOutcome {
|
||||
total: entries.len(),
|
||||
success: 0,
|
||||
failed: entries.len(),
|
||||
results: entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, _)| {
|
||||
json!({
|
||||
"index": index,
|
||||
"status": "error",
|
||||
"error": ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL,
|
||||
"replaced": false,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
|
||||
let endpoint_resolution =
|
||||
resolve_provider_oauth_runtime_endpoints(state, &provider, provider_type).await?;
|
||||
@@ -251,6 +343,25 @@ pub(super) async fn execute_admin_provider_oauth_batch_import(
|
||||
let mut failed = 0usize;
|
||||
|
||||
for (index, entry) in entries.iter().enumerate() {
|
||||
if let Some(error) = entry.parse_error.as_ref() {
|
||||
failed += 1;
|
||||
results.push(json!({
|
||||
"index": index,
|
||||
"status": "error",
|
||||
"error": error,
|
||||
"replaced": false,
|
||||
}));
|
||||
maybe_report_admin_provider_oauth_batch_import_progress(
|
||||
&mut progress,
|
||||
entries.len(),
|
||||
success,
|
||||
failed,
|
||||
&results,
|
||||
)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let resolved_import = match resolve_admin_provider_oauth_batch_import_tokens(
|
||||
state,
|
||||
template,
|
||||
@@ -418,3 +529,33 @@ pub(super) async fn execute_admin_provider_oauth_batch_import(
|
||||
results,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::sanitize_windsurf_batch_import_error;
|
||||
use aether_oauth::core::OAuthError;
|
||||
|
||||
#[test]
|
||||
fn windsurf_batch_import_error_redacts_http_body() {
|
||||
let error = OAuthError::HttpStatus {
|
||||
status_code: 401,
|
||||
body_excerpt: "sessionToken=devin-session-token$secret".to_string(),
|
||||
};
|
||||
|
||||
let detail = sanitize_windsurf_batch_import_error(&error);
|
||||
|
||||
assert_eq!(detail, "Windsurf 凭据验证失败: HTTP 401");
|
||||
assert!(!detail.contains("devin-session-token$secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_batch_import_error_redacts_provider_detail() {
|
||||
let error = OAuthError::invalid_response("apiKey=sk-secret token=secret-token");
|
||||
|
||||
let detail = sanitize_windsurf_batch_import_error(&error);
|
||||
|
||||
assert_eq!(detail, "Windsurf 凭据验证失败");
|
||||
assert!(!detail.contains("sk-secret"));
|
||||
assert!(!detail.contains("secret-token"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,13 @@ pub(in super::super) async fn handle_admin_provider_oauth_batch_import(
|
||||
"该 Provider 不是固定类型,无法使用 provider-oauth",
|
||||
));
|
||||
}
|
||||
if provider_type != "kiro"
|
||||
&& provider_type != "windsurf"
|
||||
&& admin_provider_oauth_template(&provider_type).is_none()
|
||||
{
|
||||
return Ok(build_admin_provider_oauth_backend_unavailable_response());
|
||||
}
|
||||
|
||||
let total = estimate_admin_provider_oauth_batch_import_total(
|
||||
&provider_type,
|
||||
payload.credentials.as_str(),
|
||||
|
||||
@@ -19,8 +19,10 @@ pub(super) struct AdminProviderOAuthBatchImportRequest {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct AdminProviderOAuthBatchImportEntry {
|
||||
pub parse_error: Option<String>,
|
||||
pub refresh_token: Option<String>,
|
||||
pub access_token: Option<String>,
|
||||
pub raw_credentials: Option<serde_json::Value>,
|
||||
pub expires_at: Option<u64>,
|
||||
pub account_id: Option<String>,
|
||||
pub account_user_id: Option<String>,
|
||||
@@ -141,8 +143,10 @@ fn extract_admin_provider_oauth_batch_import_entry(
|
||||
access_token.as_deref(),
|
||||
);
|
||||
Some(AdminProviderOAuthBatchImportEntry {
|
||||
parse_error: None,
|
||||
refresh_token,
|
||||
access_token,
|
||||
raw_credentials: None,
|
||||
expires_at: None,
|
||||
account_id: None,
|
||||
account_user_id: None,
|
||||
@@ -160,6 +164,8 @@ fn extract_admin_provider_oauth_batch_import_entry(
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(object) => {
|
||||
let is_grok = provider_type.trim().eq_ignore_ascii_case("grok");
|
||||
let is_windsurf = provider_type.trim().eq_ignore_ascii_case("windsurf");
|
||||
let refresh_token = coerce_admin_provider_oauth_import_str(
|
||||
object
|
||||
.get("refresh_token")
|
||||
@@ -170,12 +176,12 @@ fn extract_admin_provider_oauth_batch_import_entry(
|
||||
.get("access_token")
|
||||
.or_else(|| object.get("accessToken")),
|
||||
);
|
||||
let grok_token_alias = if provider_type.trim().eq_ignore_ascii_case("grok") {
|
||||
let grok_token_alias = if is_grok {
|
||||
object.get("token")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let grok_cookie = if provider_type.trim().eq_ignore_ascii_case("grok") {
|
||||
let grok_cookie = if is_grok {
|
||||
coerce_admin_provider_oauth_import_str(
|
||||
object.get("cookie").or_else(|| object.get("cookieHeader")),
|
||||
)
|
||||
@@ -198,9 +204,39 @@ fn extract_admin_provider_oauth_batch_import_entry(
|
||||
refresh_token.as_deref(),
|
||||
access_token.as_deref().or(session_token.as_deref()),
|
||||
);
|
||||
if refresh_token.is_none() && access_token.is_none() {
|
||||
let windsurf_api_key = is_windsurf.then(|| {
|
||||
coerce_admin_provider_oauth_import_str(
|
||||
object.get("api_key").or_else(|| object.get("apiKey")),
|
||||
)
|
||||
}).flatten();
|
||||
let windsurf_token = is_windsurf.then(|| {
|
||||
coerce_admin_provider_oauth_import_str(
|
||||
object
|
||||
.get("token")
|
||||
.or_else(|| object.get("auth_token"))
|
||||
.or_else(|| object.get("authToken")),
|
||||
)
|
||||
}).flatten();
|
||||
let windsurf_password = is_windsurf
|
||||
.then(|| coerce_admin_provider_oauth_import_str(object.get("password")))
|
||||
.flatten();
|
||||
let raw_credentials = if is_windsurf
|
||||
&& (windsurf_api_key.is_some()
|
||||
|| windsurf_token.is_some()
|
||||
|| windsurf_password.is_some())
|
||||
{
|
||||
Some(item.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if refresh_token.is_none() && access_token.is_none() && raw_credentials.is_none() {
|
||||
return None;
|
||||
}
|
||||
let refresh_token = if is_windsurf {
|
||||
refresh_token.or(windsurf_api_key).or(windsurf_token)
|
||||
} else {
|
||||
refresh_token
|
||||
};
|
||||
let expires_at =
|
||||
json_u64_value(object.get("expires_at").or_else(|| object.get("expiresAt")));
|
||||
let account_id = coerce_admin_provider_oauth_import_str(
|
||||
@@ -285,8 +321,10 @@ fn extract_admin_provider_oauth_batch_import_entry(
|
||||
.or_else(|| object.get("impersonate")),
|
||||
);
|
||||
Some(AdminProviderOAuthBatchImportEntry {
|
||||
parse_error: None,
|
||||
refresh_token,
|
||||
access_token,
|
||||
raw_credentials,
|
||||
expires_at,
|
||||
account_id,
|
||||
account_user_id,
|
||||
@@ -316,14 +354,17 @@ pub(super) fn parse_admin_provider_oauth_batch_import_entries(
|
||||
}
|
||||
|
||||
if raw.starts_with('[') {
|
||||
if let Ok(serde_json::Value::Array(items)) = serde_json::from_str::<serde_json::Value>(raw)
|
||||
{
|
||||
return items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
extract_admin_provider_oauth_batch_import_entry(provider_type, item)
|
||||
})
|
||||
.collect();
|
||||
match serde_json::from_str::<serde_json::Value>(raw) {
|
||||
Ok(serde_json::Value::Array(items)) => {
|
||||
return items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
extract_admin_provider_oauth_batch_import_entry(provider_type, item)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => return vec![parse_error_entry(format!("JSON 数组解析失败: {error}"))],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,23 +381,61 @@ pub(super) fn parse_admin_provider_oauth_batch_import_entries(
|
||||
raw.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.filter_map(|line| {
|
||||
if line.starts_with('{') {
|
||||
return serde_json::from_str::<serde_json::Value>(line)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
extract_admin_provider_oauth_batch_import_entry(provider_type, &value)
|
||||
});
|
||||
.filter_map(|token| {
|
||||
if is_json_like_batch_line(token) {
|
||||
match serde_json::from_str::<serde_json::Value>(token) {
|
||||
Ok(value @ serde_json::Value::Object(_)) => {
|
||||
return extract_admin_provider_oauth_batch_import_entry(
|
||||
provider_type,
|
||||
&value,
|
||||
);
|
||||
}
|
||||
Ok(_) => {
|
||||
return Some(parse_error_entry(
|
||||
"JSON 行必须是账号对象,不能作为 raw token 导入".to_string(),
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
return Some(parse_error_entry(format!("JSON 行解析失败: {error}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extract_admin_provider_oauth_batch_import_entry(
|
||||
provider_type,
|
||||
&serde_json::Value::String(line.to_string()),
|
||||
&serde_json::Value::String(token.to_string()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_error_entry(error: String) -> AdminProviderOAuthBatchImportEntry {
|
||||
AdminProviderOAuthBatchImportEntry {
|
||||
parse_error: Some(error),
|
||||
refresh_token: None,
|
||||
access_token: None,
|
||||
raw_credentials: None,
|
||||
expires_at: None,
|
||||
account_id: None,
|
||||
account_user_id: None,
|
||||
plan_type: None,
|
||||
pool_tier: None,
|
||||
user_id: None,
|
||||
email: None,
|
||||
account_name: None,
|
||||
sso_rw_token: None,
|
||||
cf_cookies: None,
|
||||
cf_clearance: None,
|
||||
user_agent: None,
|
||||
browser_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_json_like_batch_line(line: &str) -> bool {
|
||||
let line = line.trim_start();
|
||||
line.starts_with('{') || line.starts_with('[')
|
||||
}
|
||||
|
||||
pub(super) fn apply_admin_provider_oauth_batch_import_hints(
|
||||
provider_type: &str,
|
||||
entry: &AdminProviderOAuthBatchImportEntry,
|
||||
@@ -633,4 +712,115 @@ mod tests {
|
||||
assert_eq!(entries[0].user_id.as_deref(), Some("user-1"));
|
||||
assert_eq!(entries[0].pool_tier.as_deref(), Some("heavy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_windsurf_json_credentials_for_native_import() {
|
||||
let entries = parse_admin_provider_oauth_batch_import_entries(
|
||||
"windsurf",
|
||||
r#"[
|
||||
{"api_key":"devin-session-token$abc","email":"[email protected]"},
|
||||
{"token":"firebase-id-token","name":"Browser Login"},
|
||||
{"email":"[email protected]","password":"secret"},
|
||||
{"access_token":"devin-session-token$alias","email":"[email protected]"}
|
||||
]"#,
|
||||
);
|
||||
|
||||
assert_eq!(entries.len(), 4);
|
||||
assert_eq!(
|
||||
entries[0].refresh_token.as_deref(),
|
||||
Some("devin-session-token$abc")
|
||||
);
|
||||
assert_eq!(entries[0].email.as_deref(), Some("[email protected]"));
|
||||
assert_eq!(
|
||||
entries[0]
|
||||
.raw_credentials
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("api_key")),
|
||||
Some(&json!("devin-session-token$abc"))
|
||||
);
|
||||
assert_eq!(
|
||||
entries[1]
|
||||
.raw_credentials
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("token")),
|
||||
Some(&json!("firebase-id-token"))
|
||||
);
|
||||
assert_eq!(
|
||||
entries[2]
|
||||
.raw_credentials
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("password")),
|
||||
Some(&json!("secret"))
|
||||
);
|
||||
assert_eq!(
|
||||
entries[3].access_token.as_deref(),
|
||||
Some("devin-session-token$alias")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_windsurf_json_lines_credentials_for_native_import() {
|
||||
let entries = parse_admin_provider_oauth_batch_import_entries(
|
||||
"windsurf",
|
||||
r#"{"api_key":"devin-session-token$abc","email":"[email protected]"}
|
||||
{"token":"firebase-id-token","name":"Browser Login"}
|
||||
{"email":"[email protected]","password":"secret"}"#,
|
||||
);
|
||||
|
||||
assert_eq!(entries.len(), 3);
|
||||
assert_eq!(
|
||||
entries[0]
|
||||
.raw_credentials
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("api_key")),
|
||||
Some(&json!("devin-session-token$abc"))
|
||||
);
|
||||
assert_eq!(
|
||||
entries[1]
|
||||
.raw_credentials
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("token")),
|
||||
Some(&json!("firebase-id-token"))
|
||||
);
|
||||
assert_eq!(
|
||||
entries[2]
|
||||
.raw_credentials
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("password")),
|
||||
Some(&json!("secret"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_line_is_parse_error_not_token() {
|
||||
let entries = parse_admin_provider_oauth_batch_import_entries(
|
||||
"windsurf",
|
||||
r#"{"email":"[email protected]","password":"secret""#,
|
||||
);
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(entries[0].parse_error.is_some());
|
||||
assert!(entries[0].refresh_token.is_none());
|
||||
assert!(entries[0].access_token.is_none());
|
||||
assert!(entries[0].raw_credentials.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_like_line_after_token_is_parse_error_not_token() {
|
||||
let entries = parse_admin_provider_oauth_batch_import_entries(
|
||||
"windsurf",
|
||||
"devin-session-token$abc\n[not-json",
|
||||
);
|
||||
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert!(entries[0].parse_error.is_none());
|
||||
assert_eq!(
|
||||
entries[0].refresh_token.as_deref(),
|
||||
Some("devin-session-token$abc")
|
||||
);
|
||||
assert!(entries[1].parse_error.is_some());
|
||||
assert!(entries[1].refresh_token.is_none());
|
||||
assert!(entries[1].access_token.is_none());
|
||||
assert!(entries[1].raw_credentials.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,13 @@ pub(in super::super) async fn handle_admin_provider_oauth_start_batch_import_tas
|
||||
"该 Provider 不是固定类型,无法使用 provider-oauth",
|
||||
));
|
||||
}
|
||||
if provider_type != "kiro"
|
||||
&& provider_type != "windsurf"
|
||||
&& admin_provider_oauth_template(&provider_type).is_none()
|
||||
{
|
||||
return Ok(build_admin_provider_oauth_backend_unavailable_response());
|
||||
}
|
||||
|
||||
let total = estimate_admin_provider_oauth_batch_import_total(
|
||||
&provider_type,
|
||||
payload.credentials.as_str(),
|
||||
|
||||
+147
-3
@@ -12,6 +12,7 @@ use crate::GatewayError;
|
||||
use aether_data::repository::provider_oauth::{
|
||||
StoredAdminProviderOAuthDeviceSession, KIRO_DEVICE_AUTH_SESSION_TTL_BUFFER_SECS,
|
||||
};
|
||||
use aether_oauth::provider::{ProviderOAuthService, ProviderOAuthTransportContext};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
@@ -28,6 +29,8 @@ const KIRO_SOCIAL_MANUAL_CALLBACK_PORT: u16 = 49153;
|
||||
const KIRO_SOCIAL_ALLOWED_CALLBACK_PORTS: &[u16] = &[
|
||||
3128, 4649, 6588, 8008, 9091, 49153, 50153, 51153, 52153, 53153,
|
||||
];
|
||||
const WINDSURF_BROWSER_AUTH_EXPIRES_IN_SECS: u64 = 600;
|
||||
const WINDSURF_BROWSER_AUTH_POLL_INTERVAL_SECS: u64 = 5;
|
||||
|
||||
fn normalize_kiro_device_auth_type(raw: Option<&str>) -> String {
|
||||
match raw
|
||||
@@ -119,6 +122,26 @@ fn build_kiro_social_authorization_url(
|
||||
)
|
||||
}
|
||||
|
||||
fn build_windsurf_authorization_url(authorize_url: &str, login_option: &str) -> String {
|
||||
let login_option = login_option.trim();
|
||||
if login_option.is_empty() {
|
||||
return authorize_url.to_string();
|
||||
}
|
||||
if let Ok(mut url) = Url::parse(authorize_url) {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("login_option", login_option);
|
||||
return url.to_string();
|
||||
}
|
||||
let separator = if authorize_url.contains('?') {
|
||||
'&'
|
||||
} else {
|
||||
'?'
|
||||
};
|
||||
let mut serializer = form_urlencoded::Serializer::new(String::new());
|
||||
serializer.append_pair("login_option", login_option);
|
||||
format!("{authorize_url}{separator}{}", serializer.finish())
|
||||
}
|
||||
|
||||
pub(super) async fn handle_admin_provider_oauth_device_authorize(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
@@ -163,14 +186,14 @@ pub(super) async fn handle_admin_provider_oauth_device_authorize(
|
||||
));
|
||||
};
|
||||
let provider_type = provider.provider_type.trim().to_ascii_lowercase();
|
||||
if provider_type != "kiro" {
|
||||
if provider_type != "kiro" && provider_type != "windsurf" {
|
||||
return Ok(build_internal_control_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"设备授权仅支持 Kiro provider",
|
||||
"设备授权仅支持 Kiro / Windsurf provider",
|
||||
));
|
||||
}
|
||||
let endpoint_resolution =
|
||||
resolve_provider_oauth_runtime_endpoints(state, &provider, "kiro").await?;
|
||||
resolve_provider_oauth_runtime_endpoints(state, &provider, &provider_type).await?;
|
||||
let runtime_endpoint = endpoint_resolution.runtime_endpoint;
|
||||
let request_proxy = state
|
||||
.resolve_admin_provider_oauth_operation_proxy_snapshot(
|
||||
@@ -184,6 +207,103 @@ pub(super) async fn handle_admin_provider_oauth_device_authorize(
|
||||
)
|
||||
.await;
|
||||
|
||||
if provider_type == "windsurf" {
|
||||
let session_id = generate_provider_oauth_nonce();
|
||||
let login_option = payload
|
||||
.login_option
|
||||
.as_deref()
|
||||
.or(payload.auth_type.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("default")
|
||||
.to_ascii_lowercase();
|
||||
let ctx = ProviderOAuthTransportContext {
|
||||
provider_id: provider_id.clone(),
|
||||
provider_type: provider_type.clone(),
|
||||
endpoint_id: runtime_endpoint
|
||||
.as_ref()
|
||||
.map(|endpoint| endpoint.id.clone()),
|
||||
key_id: None,
|
||||
auth_type: Some("oauth".to_string()),
|
||||
decrypted_api_key: None,
|
||||
decrypted_auth_config: None,
|
||||
provider_config: provider.config.clone(),
|
||||
endpoint_config: runtime_endpoint
|
||||
.as_ref()
|
||||
.and_then(|endpoint| endpoint.config.clone()),
|
||||
key_config: None,
|
||||
network: aether_oauth::network::OAuthNetworkContext::provider_operation(
|
||||
request_proxy.clone(),
|
||||
),
|
||||
};
|
||||
let mut authorization = match ProviderOAuthService::with_builtin_adapters()
|
||||
.build_authorize_url(&ctx, &session_id, None)
|
||||
{
|
||||
Ok(authorization) => authorization,
|
||||
Err(error) => {
|
||||
return Ok(build_internal_control_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
format!("Windsurf 授权 URL 构建失败: {error}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
authorization.authorize_url =
|
||||
build_windsurf_authorization_url(&authorization.authorize_url, &login_option);
|
||||
let now_unix_secs = current_unix_secs();
|
||||
let session = StoredAdminProviderOAuthDeviceSession {
|
||||
provider_id: provider_id.clone(),
|
||||
region: String::new(),
|
||||
client_id: String::new(),
|
||||
client_secret: String::new(),
|
||||
device_code: String::new(),
|
||||
auth_type: Some("browser".to_string()),
|
||||
social_provider: Some(login_option.clone()),
|
||||
code_verifier: None,
|
||||
redirect_uri: Some("show-auth-token".to_string()),
|
||||
machine_id: Some(uuid::Uuid::new_v4().to_string().to_ascii_lowercase()),
|
||||
interval: WINDSURF_BROWSER_AUTH_POLL_INTERVAL_SECS,
|
||||
expires_at_unix_secs: now_unix_secs
|
||||
.saturating_add(WINDSURF_BROWSER_AUTH_EXPIRES_IN_SECS),
|
||||
status: "pending".to_string(),
|
||||
proxy_node_id: payload
|
||||
.proxy_node_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
created_at_unix_ms: now_unix_secs,
|
||||
key_id: None,
|
||||
email: None,
|
||||
replaced: false,
|
||||
error_msg: None,
|
||||
};
|
||||
if let Err(response) = state
|
||||
.save_provider_oauth_device_session(
|
||||
&session_id,
|
||||
&session,
|
||||
WINDSURF_BROWSER_AUTH_EXPIRES_IN_SECS
|
||||
.saturating_add(KIRO_DEVICE_AUTH_SESSION_TTL_BUFFER_SECS),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
return Ok(Json(json!({
|
||||
"session_id": session_id,
|
||||
"user_code": "",
|
||||
"verification_uri": "https://windsurf.com/windsurf/signin",
|
||||
"verification_uri_complete": authorization.authorize_url,
|
||||
"expires_in": WINDSURF_BROWSER_AUTH_EXPIRES_IN_SECS,
|
||||
"interval": WINDSURF_BROWSER_AUTH_POLL_INTERVAL_SECS,
|
||||
"auth_type": "browser",
|
||||
"login_option": login_option,
|
||||
"redirect_uri": "show-auth-token",
|
||||
"callback_required": true,
|
||||
}))
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let auth_type = normalize_kiro_device_auth_type(payload.auth_type.as_deref());
|
||||
if let Some(social_provider) = kiro_social_provider_id(&auth_type) {
|
||||
let redirect_uri = match normalize_kiro_social_redirect_uri(payload.redirect_uri.as_deref())
|
||||
@@ -394,3 +514,27 @@ pub(super) async fn handle_admin_provider_oauth_device_authorize(
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_windsurf_authorization_url;
|
||||
|
||||
#[test]
|
||||
fn windsurf_authorization_url_includes_login_option() {
|
||||
let url = build_windsurf_authorization_url(
|
||||
"https://windsurf.com/windsurf/signin?state=session-1",
|
||||
"github",
|
||||
);
|
||||
|
||||
let parsed = url::Url::parse(&url).expect("url should parse");
|
||||
let params = parsed
|
||||
.query_pairs()
|
||||
.map(|(key, value)| (key.to_string(), value.to_string()))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
assert_eq!(params.get("state").map(String::as_str), Some("session-1"));
|
||||
assert_eq!(
|
||||
params.get("login_option").map(String::as_str),
|
||||
Some("github")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ use aether_data::repository::provider_oauth::StoredAdminProviderOAuthDeviceSessi
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogProvider,
|
||||
};
|
||||
use aether_oauth::provider::{
|
||||
ProviderOAuthImportInput, ProviderOAuthService, ProviderOAuthTransportContext,
|
||||
};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
@@ -86,6 +89,99 @@ fn kiro_social_poll_error_response(error: impl Into<String>) -> Response<Body> {
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn windsurf_browser_poll_error_response(error: impl Into<String>) -> Response<Body> {
|
||||
Json(json!({
|
||||
"status": "error",
|
||||
"error": error.into(),
|
||||
"replaced": false,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn sanitize_windsurf_browser_poll_detail(detail: impl AsRef<str>) -> String {
|
||||
let detail = detail.as_ref().trim();
|
||||
if detail.is_empty() {
|
||||
return "-".to_string();
|
||||
}
|
||||
if contains_windsurf_sensitive_marker(detail) {
|
||||
"[REDACTED upstream error body]".to_string()
|
||||
} else {
|
||||
detail.chars().take(500).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_windsurf_browser_poll_callback_error(error: &str, description: &str) -> String {
|
||||
let error = sanitize_windsurf_browser_poll_error_code(error);
|
||||
let description = sanitize_windsurf_browser_poll_detail(description);
|
||||
format!("{error}: {description}")
|
||||
}
|
||||
|
||||
fn sanitize_windsurf_browser_poll_error_code(error: &str) -> String {
|
||||
let error = error.trim();
|
||||
if !error.is_empty()
|
||||
&& error.len() <= 80
|
||||
&& error
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
|
||||
{
|
||||
return error.to_string();
|
||||
}
|
||||
sanitize_windsurf_browser_poll_detail(error)
|
||||
}
|
||||
|
||||
fn sanitize_windsurf_browser_poll_oauth_error(error: &aether_oauth::core::OAuthError) -> String {
|
||||
match error {
|
||||
aether_oauth::core::OAuthError::InvalidRequest(_) => {
|
||||
"Windsurf token 验证失败: 请求参数无效".to_string()
|
||||
}
|
||||
aether_oauth::core::OAuthError::HttpStatus { status_code, .. } => {
|
||||
format!("Windsurf token 验证失败: HTTP {status_code}")
|
||||
}
|
||||
_ => "Windsurf token 验证失败".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_windsurf_sensitive_marker(value: &str) -> bool {
|
||||
let lowered = value.to_ascii_lowercase();
|
||||
[
|
||||
"token",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"sessiontoken",
|
||||
"firebase_id_token",
|
||||
"idtoken",
|
||||
"authorization",
|
||||
"password",
|
||||
"secret",
|
||||
"devin-session-token$",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| lowered.contains(marker))
|
||||
|| value.contains("sk-")
|
||||
}
|
||||
|
||||
fn secret_fingerprint(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
let digest = Sha256::digest(value.as_bytes());
|
||||
Some(
|
||||
digest[..8]
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
}
|
||||
|
||||
fn insert_secret_fingerprint(target: &mut serde_json::Map<String, Value>, key: &str, secret: &str) {
|
||||
if let Some(fingerprint) = secret_fingerprint(secret) {
|
||||
target.insert(key.to_string(), json!(fingerprint));
|
||||
}
|
||||
}
|
||||
|
||||
fn kiro_social_provider_from_login_option(login_option: Option<&str>) -> Option<&'static str> {
|
||||
match login_option
|
||||
.map(str::trim)
|
||||
@@ -322,8 +418,9 @@ pub(super) async fn handle_admin_provider_oauth_device_poll(
|
||||
"Provider 不存在",
|
||||
));
|
||||
};
|
||||
let provider_type = provider.provider_type.trim().to_ascii_lowercase();
|
||||
let endpoint_resolution =
|
||||
resolve_provider_oauth_runtime_endpoints(state, &provider, "kiro").await?;
|
||||
resolve_provider_oauth_runtime_endpoints(state, &provider, &provider_type).await?;
|
||||
let endpoints = endpoint_resolution.endpoints;
|
||||
let runtime_endpoint = endpoint_resolution.runtime_endpoint;
|
||||
let request_proxy = state
|
||||
@@ -338,6 +435,20 @@ pub(super) async fn handle_admin_provider_oauth_device_poll(
|
||||
)
|
||||
.await;
|
||||
|
||||
if provider_type == "windsurf" {
|
||||
return handle_admin_provider_oauth_windsurf_browser_device_poll(
|
||||
state,
|
||||
&provider,
|
||||
&endpoints,
|
||||
request_proxy,
|
||||
session_id,
|
||||
session,
|
||||
payload.callback_url.as_deref(),
|
||||
payload.token.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if kiro_device_session_is_social(&session) {
|
||||
return handle_admin_provider_oauth_kiro_social_device_poll(
|
||||
state,
|
||||
@@ -630,6 +741,276 @@ pub(super) async fn handle_admin_provider_oauth_device_poll(
|
||||
))
|
||||
}
|
||||
|
||||
fn windsurf_raw_api_key(value: &str) -> Option<&str> {
|
||||
let value = value.trim();
|
||||
if value.starts_with("devin-session-token$") || value.starts_with("sk-") {
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_admin_provider_oauth_windsurf_browser_device_poll(
|
||||
state: &AdminAppState<'_>,
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
endpoints: &[StoredProviderCatalogEndpoint],
|
||||
request_proxy: Option<ProxySnapshot>,
|
||||
session_id: &str,
|
||||
mut session: StoredAdminProviderOAuthDeviceSession,
|
||||
callback_url: Option<&str>,
|
||||
token: Option<&str>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
let callback_url = callback_url
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let token = token.map(str::trim).filter(|value| !value.is_empty());
|
||||
if callback_url.is_none() && token.is_none() {
|
||||
return Ok(Json(json!({"status": "pending", "replaced": false})).into_response());
|
||||
}
|
||||
|
||||
let mut social_provider = session
|
||||
.social_provider
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let imported_token = if let Some(callback_url) = callback_url {
|
||||
let callback_params = parse_provider_oauth_callback_params(callback_url);
|
||||
if let Some(error) = callback_params.get("error").map(String::as_str) {
|
||||
let error_description = callback_params
|
||||
.get("error_description")
|
||||
.map(String::as_str)
|
||||
.unwrap_or("用户拒绝授权");
|
||||
let sanitized_error =
|
||||
sanitize_windsurf_browser_poll_callback_error(error, error_description);
|
||||
session.status = "error".to_string();
|
||||
session.error_msg = Some(sanitized_error.clone());
|
||||
let _ = state
|
||||
.save_provider_oauth_device_session(session_id, &session, 30)
|
||||
.await;
|
||||
return Ok(attach_admin_provider_oauth_device_poll_terminal_response(
|
||||
session_id,
|
||||
"error",
|
||||
windsurf_browser_poll_error_response(sanitized_error),
|
||||
));
|
||||
}
|
||||
let Some(callback_state) = callback_params
|
||||
.get("state")
|
||||
.map(String::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Ok(windsurf_browser_poll_error_response("回调 URL 缺少 state"));
|
||||
};
|
||||
if callback_state != session_id {
|
||||
return Ok(windsurf_browser_poll_error_response(
|
||||
"回调 state 与会话不匹配",
|
||||
));
|
||||
}
|
||||
if let Some(provider) = callback_params
|
||||
.get("provider")
|
||||
.or_else(|| callback_params.get("login_option"))
|
||||
.map(String::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
social_provider = Some(provider.to_string());
|
||||
}
|
||||
let Some(callback_token) = callback_params
|
||||
.get("token")
|
||||
.or_else(|| callback_params.get("auth_token"))
|
||||
.or_else(|| callback_params.get("access_token"))
|
||||
.map(String::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Ok(windsurf_browser_poll_error_response("回调 URL 缺少 token"));
|
||||
};
|
||||
callback_token.to_string()
|
||||
} else {
|
||||
let token = token.unwrap_or_default();
|
||||
if windsurf_raw_api_key(token).is_none() {
|
||||
return Ok(windsurf_browser_poll_error_response(
|
||||
"浏览器授权请提交包含 state 的回调 URL;纯 token 请使用导入授权",
|
||||
));
|
||||
}
|
||||
token.to_string()
|
||||
};
|
||||
|
||||
let mut raw_credentials = serde_json::Map::new();
|
||||
if windsurf_raw_api_key(&imported_token).is_some() {
|
||||
raw_credentials.insert("api_key".to_string(), json!(imported_token));
|
||||
} else {
|
||||
raw_credentials.insert("token".to_string(), json!(imported_token));
|
||||
}
|
||||
if let Some(social_provider) = social_provider.as_ref() {
|
||||
raw_credentials.insert("social_provider".to_string(), json!(social_provider));
|
||||
}
|
||||
|
||||
let ctx = ProviderOAuthTransportContext {
|
||||
provider_id: provider.id.clone(),
|
||||
provider_type: provider.provider_type.clone(),
|
||||
endpoint_id: None,
|
||||
key_id: None,
|
||||
auth_type: Some("oauth".to_string()),
|
||||
decrypted_api_key: None,
|
||||
decrypted_auth_config: None,
|
||||
provider_config: provider.config.clone(),
|
||||
endpoint_config: None,
|
||||
key_config: None,
|
||||
network: aether_oauth::network::OAuthNetworkContext::provider_operation(
|
||||
request_proxy.clone(),
|
||||
),
|
||||
};
|
||||
let executor = crate::oauth::GatewayOAuthHttpExecutor::new(*state);
|
||||
let result = match ProviderOAuthService::with_builtin_adapters()
|
||||
.import_credentials(
|
||||
&executor,
|
||||
&ctx,
|
||||
ProviderOAuthImportInput {
|
||||
provider_type: provider.provider_type.clone(),
|
||||
name: None,
|
||||
refresh_token: None,
|
||||
raw_credentials: Some(Value::Object(raw_credentials)),
|
||||
network: ctx.network.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
let sanitized_error = sanitize_windsurf_browser_poll_oauth_error(&error);
|
||||
session.status = "error".to_string();
|
||||
session.error_msg = Some(sanitized_error.clone());
|
||||
let _ = state
|
||||
.save_provider_oauth_device_session(session_id, &session, 30)
|
||||
.await;
|
||||
return Ok(attach_admin_provider_oauth_device_poll_terminal_response(
|
||||
session_id,
|
||||
"error",
|
||||
windsurf_browser_poll_error_response(sanitized_error),
|
||||
));
|
||||
}
|
||||
};
|
||||
let access_token = result.token_set.access_token.trim().to_string();
|
||||
if access_token.is_empty() {
|
||||
return Ok(windsurf_browser_poll_error_response(
|
||||
"Windsurf token 验证返回缺少 apiKey/sessionToken",
|
||||
));
|
||||
}
|
||||
let mut auth_config = result.auth_config.as_object().cloned().unwrap_or_default();
|
||||
auth_config.insert("provider_type".to_string(), json!("windsurf"));
|
||||
auth_config.insert("auth_method".to_string(), json!("browser"));
|
||||
if let Some(social_provider) = social_provider.as_ref() {
|
||||
auth_config
|
||||
.entry("social_provider".to_string())
|
||||
.or_insert_with(|| json!(social_provider));
|
||||
}
|
||||
|
||||
let duplicate = match state
|
||||
.find_duplicate_provider_oauth_key(&provider.id, &auth_config, None)
|
||||
.await
|
||||
{
|
||||
Ok(duplicate) => duplicate,
|
||||
Err(detail) => {
|
||||
return Ok(Json(json!({
|
||||
"status": "error",
|
||||
"error": detail,
|
||||
"replaced": false,
|
||||
}))
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
let api_formats = provider_oauth_active_api_formats(endpoints);
|
||||
let key_proxy = provider_oauth_key_proxy_value(session.proxy_node_id.as_deref());
|
||||
let expires_at = result.token_set.expires_at_unix_secs;
|
||||
let email = auth_config
|
||||
.get("email")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let mut replaced = false;
|
||||
let persisted_key = if let Some(existing_key) = duplicate {
|
||||
replaced = true;
|
||||
match state
|
||||
.update_existing_provider_oauth_catalog_key(
|
||||
&existing_key,
|
||||
&provider.provider_type,
|
||||
&access_token,
|
||||
&auth_config,
|
||||
&api_formats,
|
||||
key_proxy.clone(),
|
||||
expires_at,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(key) => key,
|
||||
None => {
|
||||
return Ok(build_internal_control_error_response(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"provider oauth write unavailable",
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let key_name = email
|
||||
.as_deref()
|
||||
.map(|email| format!("windsurf_{email}"))
|
||||
.unwrap_or_else(|| format!("windsurf_{}", current_unix_secs()));
|
||||
match state
|
||||
.create_provider_oauth_catalog_key(
|
||||
&provider.id,
|
||||
&provider.provider_type,
|
||||
&key_name,
|
||||
&access_token,
|
||||
&auth_config,
|
||||
&api_formats,
|
||||
key_proxy,
|
||||
expires_at,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(key) => key,
|
||||
None => {
|
||||
return Ok(build_internal_control_error_response(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"provider oauth write unavailable",
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
spawn_provider_oauth_account_state_refresh_after_update(
|
||||
state.cloned_app(),
|
||||
provider.clone(),
|
||||
persisted_key.id.clone(),
|
||||
request_proxy.clone(),
|
||||
);
|
||||
|
||||
session.status = "authorized".to_string();
|
||||
session.key_id = Some(persisted_key.id.clone());
|
||||
session.email = email.clone();
|
||||
session.replaced = replaced;
|
||||
session.error_msg = None;
|
||||
let _ = state
|
||||
.save_provider_oauth_device_session(session_id, &session, 60)
|
||||
.await;
|
||||
|
||||
Ok(attach_admin_provider_oauth_device_poll_terminal_response(
|
||||
session_id,
|
||||
"authorized",
|
||||
Json(json!({
|
||||
"status": "authorized",
|
||||
"key_id": persisted_key.id,
|
||||
"email": email,
|
||||
"replaced": replaced,
|
||||
}))
|
||||
.into_response(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn handle_admin_provider_oauth_kiro_social_device_poll(
|
||||
state: &AdminAppState<'_>,
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
@@ -814,7 +1195,7 @@ async fn handle_admin_provider_oauth_kiro_social_device_poll(
|
||||
.get("idToken")
|
||||
.or_else(|| token_result.get("id_token")),
|
||||
) {
|
||||
auth_config_object.insert("id_token".to_string(), json!(id_token));
|
||||
insert_secret_fingerprint(&mut auth_config_object, "id_token_fingerprint", &id_token);
|
||||
}
|
||||
if let Some(token_type) = json_non_empty_string(
|
||||
token_result
|
||||
@@ -921,3 +1302,18 @@ async fn handle_admin_provider_oauth_kiro_social_device_poll(
|
||||
.into_response(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn windsurf_browser_poll_callback_error_redacts_sensitive_values() {
|
||||
let detail = super::sanitize_windsurf_browser_poll_callback_error(
|
||||
"access_denied",
|
||||
"bad token devin-session-token$secret and apiKey sk-secret",
|
||||
);
|
||||
|
||||
assert_eq!(detail, "access_denied: [REDACTED upstream error body]");
|
||||
assert!(!detail.contains("devin-session-token$secret"));
|
||||
assert!(!detail.contains("sk-secret"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub(super) struct AdminProviderOAuthDeviceAuthorizePayload {
|
||||
#[serde(default = "default_kiro_device_region")]
|
||||
pub(super) region: String,
|
||||
pub(super) auth_type: Option<String>,
|
||||
pub(super) login_option: Option<String>,
|
||||
pub(super) redirect_uri: Option<String>,
|
||||
pub(super) proxy_node_id: Option<String>,
|
||||
}
|
||||
@@ -20,6 +21,7 @@ pub(super) struct AdminProviderOAuthDeviceAuthorizePayload {
|
||||
pub(super) struct AdminProviderOAuthDevicePollPayload {
|
||||
pub(super) session_id: String,
|
||||
pub(super) callback_url: Option<String>,
|
||||
pub(super) token: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn attach_admin_provider_oauth_device_poll_terminal_response(
|
||||
|
||||
@@ -25,6 +25,10 @@ use crate::handlers::admin::request::{
|
||||
};
|
||||
use crate::GatewayError;
|
||||
use aether_contracts::ProxySnapshot;
|
||||
use aether_oauth::core::OAuthError;
|
||||
use aether_oauth::provider::{
|
||||
ProviderOAuthImportInput, ProviderOAuthService, ProviderOAuthTransportContext,
|
||||
};
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
@@ -39,6 +43,27 @@ struct AdminProviderOAuthSingleImportTokens {
|
||||
expires_at: Option<u64>,
|
||||
}
|
||||
|
||||
fn sanitize_windsurf_import_error(error: &OAuthError) -> String {
|
||||
match error {
|
||||
OAuthError::InvalidRequest(_) => "Windsurf 凭据验证失败: 请求参数无效".to_string(),
|
||||
OAuthError::HttpStatus { status_code, .. } => {
|
||||
format!("Windsurf 凭据验证失败: HTTP {status_code}")
|
||||
}
|
||||
_ => "Windsurf 凭据验证失败".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn import_payload_has_windsurf_credentials(
|
||||
payload: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> bool {
|
||||
import_payload_string(payload, "api_key", "apiKey").is_some()
|
||||
|| import_payload_string_any(payload, &["token", "auth_token", "authToken"]).is_some()
|
||||
|| import_payload_string(payload, "refresh_token", "refreshToken").is_some()
|
||||
|| import_payload_string(payload, "access_token", "accessToken").is_some()
|
||||
|| (import_payload_string_any(payload, &["email"]).is_some()
|
||||
&& import_payload_string_any(payload, &["password"]).is_some())
|
||||
}
|
||||
|
||||
fn import_payload_string(
|
||||
payload: &serde_json::Map<String, serde_json::Value>,
|
||||
snake_case: &str,
|
||||
@@ -266,6 +291,70 @@ async fn resolve_admin_provider_oauth_single_import_tokens(
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_admin_provider_oauth_windsurf_single_import_tokens(
|
||||
state: &AdminAppState<'_>,
|
||||
provider_type: &str,
|
||||
name: Option<String>,
|
||||
raw_payload: &serde_json::Map<String, serde_json::Value>,
|
||||
refresh_token: Option<&str>,
|
||||
request_proxy: Option<ProxySnapshot>,
|
||||
) -> Result<AdminProviderOAuthSingleImportTokens, Response<Body>> {
|
||||
let ctx = ProviderOAuthTransportContext {
|
||||
provider_id: String::new(),
|
||||
provider_type: provider_type.to_string(),
|
||||
endpoint_id: None,
|
||||
key_id: None,
|
||||
auth_type: Some("oauth".to_string()),
|
||||
decrypted_api_key: None,
|
||||
decrypted_auth_config: None,
|
||||
provider_config: None,
|
||||
endpoint_config: None,
|
||||
key_config: None,
|
||||
network: aether_oauth::network::OAuthNetworkContext::provider_operation(
|
||||
request_proxy.clone(),
|
||||
),
|
||||
};
|
||||
let executor = crate::oauth::GatewayOAuthHttpExecutor::new(*state);
|
||||
let service = ProviderOAuthService::with_builtin_adapters();
|
||||
let result = service
|
||||
.import_credentials(
|
||||
&executor,
|
||||
&ctx,
|
||||
ProviderOAuthImportInput {
|
||||
provider_type: provider_type.to_string(),
|
||||
name,
|
||||
refresh_token: refresh_token.map(ToOwned::to_owned),
|
||||
raw_credentials: Some(serde_json::Value::Object(raw_payload.clone())),
|
||||
network: ctx.network.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
build_internal_control_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
sanitize_windsurf_import_error(&error),
|
||||
)
|
||||
})?;
|
||||
let access_token = result.token_set.access_token.trim().to_string();
|
||||
if access_token.is_empty() {
|
||||
return Err(build_internal_control_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"Windsurf 凭据验证返回缺少 apiKey/sessionToken",
|
||||
));
|
||||
}
|
||||
let auth_config = result.auth_config.as_object().cloned().ok_or_else(|| {
|
||||
build_internal_control_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"Windsurf 凭据验证返回缺少 auth_config",
|
||||
)
|
||||
})?;
|
||||
Ok(AdminProviderOAuthSingleImportTokens {
|
||||
access_token,
|
||||
auth_config,
|
||||
expires_at: result.token_set.expires_at_unix_secs,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
@@ -370,19 +459,50 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
|
||||
.await;
|
||||
let key_proxy = provider_oauth_key_proxy_value(proxy_node_id.as_deref());
|
||||
|
||||
let resolved_import = match resolve_admin_provider_oauth_single_import_tokens(
|
||||
state,
|
||||
template,
|
||||
&provider_type,
|
||||
refresh_token_input.as_deref(),
|
||||
access_token_input.as_deref(),
|
||||
imported_expires_at,
|
||||
request_proxy.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(response) => return Ok(response),
|
||||
let resolved_import = if provider_type == "windsurf" {
|
||||
if !import_payload_has_windsurf_credentials(&raw_payload) {
|
||||
return Ok(build_internal_control_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"Windsurf 凭据不能为空",
|
||||
));
|
||||
}
|
||||
match resolve_admin_provider_oauth_windsurf_single_import_tokens(
|
||||
state,
|
||||
&provider_type,
|
||||
name.clone(),
|
||||
&raw_payload,
|
||||
refresh_token_input.as_deref(),
|
||||
request_proxy.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(response) => return Ok(response),
|
||||
}
|
||||
} else {
|
||||
if refresh_token_input.is_none() && access_token_input.is_none() {
|
||||
return Ok(build_internal_control_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"Refresh Token 或 Access Token 不能为空",
|
||||
));
|
||||
}
|
||||
let Some(template) = admin_provider_oauth_template(&provider_type) else {
|
||||
return Ok(build_admin_provider_oauth_backend_unavailable_response());
|
||||
};
|
||||
match resolve_admin_provider_oauth_single_import_tokens(
|
||||
state,
|
||||
template,
|
||||
&provider_type,
|
||||
refresh_token_input.as_deref(),
|
||||
access_token_input.as_deref(),
|
||||
imported_expires_at,
|
||||
request_proxy.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(response) => return Ok(response),
|
||||
}
|
||||
};
|
||||
let AdminProviderOAuthSingleImportTokens {
|
||||
access_token,
|
||||
@@ -480,3 +600,35 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::sanitize_windsurf_import_error;
|
||||
use aether_oauth::core::OAuthError;
|
||||
|
||||
#[test]
|
||||
fn windsurf_import_error_redacts_http_body() {
|
||||
let error = OAuthError::HttpStatus {
|
||||
status_code: 400,
|
||||
body_excerpt: "token=secret-token password=secret-password".to_string(),
|
||||
};
|
||||
|
||||
let detail = sanitize_windsurf_import_error(&error);
|
||||
|
||||
assert_eq!(detail, "Windsurf 凭据验证失败: HTTP 400");
|
||||
assert!(!detail.contains("secret-token"));
|
||||
assert!(!detail.contains("secret-password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_import_error_redacts_invalid_response_detail() {
|
||||
let error =
|
||||
OAuthError::invalid_response("RegisterUser failed with firebase_id_token=secret-token");
|
||||
|
||||
let detail = sanitize_windsurf_import_error(&error);
|
||||
|
||||
assert_eq!(detail, "Windsurf 凭据验证失败");
|
||||
assert!(!detail.contains("secret-token"));
|
||||
assert!(!detail.contains("firebase_id_token"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,12 @@ pub(super) async fn handle_admin_provider_oauth_start_key(
|
||||
"该 Provider 不是固定类型,无法使用 provider-oauth",
|
||||
));
|
||||
}
|
||||
if provider_type == "windsurf" {
|
||||
return Ok(build_internal_control_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"Windsurf 请使用浏览器登录或导入凭据。",
|
||||
));
|
||||
}
|
||||
let Some(template) = admin_provider_oauth_template(&provider_type) else {
|
||||
return Ok(build_internal_control_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
@@ -134,6 +140,12 @@ pub(super) async fn handle_admin_provider_oauth_start_provider(
|
||||
"Kiro 不支持 OAuth 授权,请使用导入授权。",
|
||||
));
|
||||
}
|
||||
if provider_type == "windsurf" {
|
||||
return Ok(build_internal_control_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"Windsurf 请使用浏览器登录或导入凭据。",
|
||||
));
|
||||
}
|
||||
let Some(template) = admin_provider_oauth_template(&provider_type) else {
|
||||
return Ok(build_internal_control_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
|
||||
Reference in New Issue
Block a user