mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
fix(provider): 对齐 Windsurf PostAuth 登录链路
This commit is contained in:
@@ -49,10 +49,32 @@ fn sanitize_windsurf_import_error(error: &OAuthError) -> String {
|
||||
OAuthError::HttpStatus { status_code, .. } => {
|
||||
format!("Windsurf 凭据验证失败: HTTP {status_code}")
|
||||
}
|
||||
OAuthError::InvalidResponse(detail) => sanitize_windsurf_invalid_response_detail(detail)
|
||||
.unwrap_or_else(|| "Windsurf 凭据验证失败".to_string()),
|
||||
_ => "Windsurf 凭据验证失败".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_windsurf_invalid_response_detail(detail: &str) -> Option<String> {
|
||||
let detail = detail.trim();
|
||||
if detail.eq_ignore_ascii_case("Auth1 response is not json") {
|
||||
return Some("Windsurf 凭据验证失败: Auth1 响应无法解析".to_string());
|
||||
}
|
||||
if detail.eq_ignore_ascii_case("Auth1 response missing token") {
|
||||
return Some("Windsurf 凭据验证失败: Auth1 响应缺少 token".to_string());
|
||||
}
|
||||
if detail.contains("WindsurfPostAuth response missing sessionToken")
|
||||
|| detail.contains("WindsurfPostAuth response is not json")
|
||||
|| (detail.contains("WindsurfPostAuth failed") && detail.contains("missing sessionToken"))
|
||||
{
|
||||
return Some("Windsurf 凭据验证失败: PostAuth 未返回 sessionToken".to_string());
|
||||
}
|
||||
if detail.contains("WindsurfPostAuth failed") {
|
||||
return Some("Windsurf 凭据验证失败: PostAuth 失败".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn import_payload_has_windsurf_credentials(
|
||||
payload: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> bool {
|
||||
@@ -631,4 +653,16 @@ mod tests {
|
||||
assert!(!detail.contains("secret-token"));
|
||||
assert!(!detail.contains("firebase_id_token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_import_error_keeps_safe_post_auth_stage() {
|
||||
let error = OAuthError::invalid_response("WindsurfPostAuth response missing sessionToken");
|
||||
|
||||
let detail = sanitize_windsurf_import_error(&error);
|
||||
|
||||
assert_eq!(
|
||||
detail,
|
||||
"Windsurf 凭据验证失败: PostAuth 未返回 sessionToken"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ impl WindsurfProviderOAuthAdapter {
|
||||
request_id: "provider-oauth:windsurf-auth1-login".to_string(),
|
||||
method: reqwest::Method::POST,
|
||||
url: AUTH1_PASSWORD_LOGIN_URL.to_string(),
|
||||
headers: json_headers(),
|
||||
headers: windsurf_browser_json_headers(),
|
||||
content_type: Some("application/json".to_string()),
|
||||
json_body: Some(json!({ "email": email, "password": password })),
|
||||
body_bytes: None,
|
||||
@@ -255,12 +255,11 @@ impl WindsurfProviderOAuthAdapter {
|
||||
.await;
|
||||
match response {
|
||||
Ok(response) if (200..300).contains(&response.status_code) => {
|
||||
let payload = response
|
||||
.json_body
|
||||
.or_else(|| serde_json::from_str::<Value>(&response.body_text).ok())
|
||||
.ok_or_else(|| {
|
||||
OAuthError::invalid_response("WindsurfPostAuth response is not json")
|
||||
})?;
|
||||
let payload = parse_windsurf_post_auth_payload(&response).ok_or_else(|| {
|
||||
OAuthError::invalid_response(
|
||||
"WindsurfPostAuth response missing sessionToken",
|
||||
)
|
||||
})?;
|
||||
if let Some(session_token) = string_any(&payload, &["sessionToken"]) {
|
||||
let mut auth_config = Map::new();
|
||||
auth_config
|
||||
@@ -542,6 +541,36 @@ fn json_headers() -> BTreeMap<String, String> {
|
||||
])
|
||||
}
|
||||
|
||||
fn windsurf_browser_json_headers() -> BTreeMap<String, String> {
|
||||
let mut headers = windsurf_browser_headers();
|
||||
headers.insert("content-type".to_string(), "application/json".to_string());
|
||||
headers.insert(
|
||||
"accept".to_string(),
|
||||
"application/json, text/plain, */*".to_string(),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
fn windsurf_browser_headers() -> BTreeMap<String, String> {
|
||||
BTreeMap::from([
|
||||
(
|
||||
"user-agent".to_string(),
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36".to_string(),
|
||||
),
|
||||
("accept".to_string(), "application/json, text/plain, */*".to_string()),
|
||||
("accept-language".to_string(), "en-US,en;q=0.9".to_string()),
|
||||
("accept-encoding".to_string(), "identity".to_string()),
|
||||
("origin".to_string(), "https://windsurf.com".to_string()),
|
||||
("referer".to_string(), "https://windsurf.com/".to_string()),
|
||||
("sec-ch-ua".to_string(), "\"Chromium\";v=\"134\", \"Google Chrome\";v=\"134\", \"Not.A/Brand\";v=\"99\"".to_string()),
|
||||
("sec-ch-ua-mobile".to_string(), "?0".to_string()),
|
||||
("sec-ch-ua-platform".to_string(), "\"macOS\"".to_string()),
|
||||
("sec-fetch-dest".to_string(), "empty".to_string()),
|
||||
("sec-fetch-mode".to_string(), "cors".to_string()),
|
||||
("sec-fetch-site".to_string(), "cross-site".to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
fn json_connect_headers() -> BTreeMap<String, String> {
|
||||
let mut headers = json_headers();
|
||||
headers.insert("connect-protocol-version".to_string(), "1".to_string());
|
||||
@@ -549,16 +578,59 @@ fn json_connect_headers() -> BTreeMap<String, String> {
|
||||
}
|
||||
|
||||
fn proto_headers() -> BTreeMap<String, String> {
|
||||
BTreeMap::from([
|
||||
("content-type".to_string(), "application/proto".to_string()),
|
||||
("accept".to_string(), "application/json".to_string()),
|
||||
("connect-protocol-version".to_string(), "1".to_string()),
|
||||
(
|
||||
"referer".to_string(),
|
||||
"https://windsurf.com/account/login".to_string(),
|
||||
),
|
||||
("user-agent".to_string(), "windsurf/1.9600.41".to_string()),
|
||||
])
|
||||
let mut headers = windsurf_browser_headers();
|
||||
headers.insert("content-type".to_string(), "application/proto".to_string());
|
||||
headers.insert("content-length".to_string(), "0".to_string());
|
||||
headers.insert("connect-protocol-version".to_string(), "1".to_string());
|
||||
headers.insert(
|
||||
"referer".to_string(),
|
||||
"https://windsurf.com/account/login".to_string(),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
fn parse_windsurf_post_auth_payload(response: &crate::network::OAuthHttpResponse) -> Option<Value> {
|
||||
let payload = response
|
||||
.json_body
|
||||
.clone()
|
||||
.or_else(|| serde_json::from_str::<Value>(&response.body_text).ok());
|
||||
if payload
|
||||
.as_ref()
|
||||
.and_then(|value| string_any(value, &["sessionToken"]))
|
||||
.is_some()
|
||||
{
|
||||
return payload;
|
||||
}
|
||||
let session_token = extract_windsurf_session_token_from_text(&response.body_text)?;
|
||||
let account_id = extract_windsurf_raw_match(&response.body_text, "account-");
|
||||
let primary_org_id = extract_windsurf_raw_match(&response.body_text, "org-");
|
||||
let mut payload = Map::new();
|
||||
payload.insert("sessionToken".to_string(), json!(session_token));
|
||||
if let Some(account_id) = account_id {
|
||||
payload.insert("accountId".to_string(), json!(account_id));
|
||||
}
|
||||
if let Some(primary_org_id) = primary_org_id {
|
||||
payload.insert("primaryOrgId".to_string(), json!(primary_org_id));
|
||||
}
|
||||
Some(Value::Object(payload))
|
||||
}
|
||||
|
||||
fn extract_windsurf_session_token_from_text(value: &str) -> Option<String> {
|
||||
let start = value.find("devin-session-token$")?;
|
||||
let token = value[start..]
|
||||
.chars()
|
||||
.take_while(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '$' | '.' | '_' | '-'))
|
||||
.collect::<String>();
|
||||
(token.len() > "devin-session-token$".len()).then_some(token)
|
||||
}
|
||||
|
||||
fn extract_windsurf_raw_match(value: &str, prefix: &str) -> Option<String> {
|
||||
let start = value.find(prefix)?;
|
||||
let matched = value[start..]
|
||||
.chars()
|
||||
.take_while(|ch| ch.is_ascii_hexdigit() || *ch == '-')
|
||||
.collect::<String>();
|
||||
(matched.len() > prefix.len()).then_some(matched)
|
||||
}
|
||||
|
||||
fn copy_optional_string(
|
||||
@@ -697,6 +769,7 @@ mod tests {
|
||||
#[derive(Default)]
|
||||
struct RecordingExecutor {
|
||||
requests: Arc<Mutex<Vec<OAuthHttpRequest>>>,
|
||||
raw_post_auth_body: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -732,6 +805,13 @@ mod tests {
|
||||
});
|
||||
}
|
||||
if request.url == WINDSURF_POST_AUTH_URL {
|
||||
if let Some(body_text) = self.raw_post_auth_body.clone() {
|
||||
return Ok(OAuthHttpResponse {
|
||||
status_code: 200,
|
||||
body_text,
|
||||
json_body: None,
|
||||
});
|
||||
}
|
||||
return Ok(OAuthHttpResponse {
|
||||
status_code: 200,
|
||||
body_text: r#"{"sessionToken":"devin-session-token$password","accountId":"acct-password","primaryOrgId":"org-password","planName":"Pro"}"#.to_string(),
|
||||
@@ -908,6 +988,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imports_email_password_from_raw_post_auth_body() {
|
||||
let executor = RecordingExecutor {
|
||||
raw_post_auth_body: Some(
|
||||
"\u{0}\u{8}devin-session-token$raw-password-token\u{0}".to_string(),
|
||||
),
|
||||
..RecordingExecutor::default()
|
||||
};
|
||||
let adapter = WindsurfProviderOAuthAdapter;
|
||||
let result = adapter
|
||||
.import_credentials(
|
||||
&executor,
|
||||
&ctx(),
|
||||
ProviderOAuthImportInput {
|
||||
provider_type: "windsurf".to_string(),
|
||||
name: None,
|
||||
refresh_token: None,
|
||||
raw_credentials: Some(json!({
|
||||
"email": "alice@example.com",
|
||||
"password": "secret-password"
|
||||
})),
|
||||
network: crate::network::OAuthNetworkContext::provider_operation(None),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("raw post auth body should import");
|
||||
|
||||
assert_eq!(
|
||||
result.token_set.access_token,
|
||||
"devin-session-token$raw-password-token"
|
||||
);
|
||||
assert_eq!(result.auth_config["auth_method"], json!("email_password"));
|
||||
assert_eq!(result.auth_config["email"], json!("alice@example.com"));
|
||||
assert!(result.auth_config.get("password").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imports_session_token_from_token_field_without_register_user() {
|
||||
let executor = RecordingExecutor::default();
|
||||
|
||||
Reference in New Issue
Block a user