437 lines
13 KiB
Go
437 lines
13 KiB
Go
package windsurf
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/imroc/req/v3"
|
|
)
|
|
|
|
type AuthClient struct {
|
|
Auth1BaseURL string
|
|
SeatServiceBaseURL string
|
|
CodeiumRegisterURL string
|
|
FirebaseAPIKey string
|
|
RequestTimeout time.Duration
|
|
}
|
|
|
|
type LoginResult struct {
|
|
APIKey string `json:"api_key"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
IDToken string `json:"id_token,omitempty"`
|
|
RefreshToken string `json:"refresh_token,omitempty"`
|
|
SessionToken string `json:"session_token,omitempty"`
|
|
Auth1Token string `json:"auth1_token,omitempty"`
|
|
APIServerURL string `json:"api_server_url,omitempty"`
|
|
AuthMethod string `json:"auth_method"`
|
|
ExpiresIn int `json:"expires_in,omitempty"`
|
|
}
|
|
|
|
type RefreshResult struct {
|
|
IDToken string `json:"id_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
}
|
|
|
|
type RegisterResult struct {
|
|
APIKey string `json:"api_key"`
|
|
Name string `json:"name"`
|
|
APIServerURL string `json:"api_server_url"`
|
|
}
|
|
|
|
type AuthError struct {
|
|
Message string
|
|
IsAuthFail bool
|
|
FirebaseCode string
|
|
}
|
|
|
|
func (e *AuthError) Error() string { return e.Message }
|
|
|
|
var (
|
|
osVersions = []string{
|
|
"Windows NT 10.0; Win64; x64",
|
|
"Macintosh; Intel Mac OS X 10_15_7",
|
|
"Macintosh; Intel Mac OS X 13_4_1",
|
|
"Macintosh; Intel Mac OS X 14_2_1",
|
|
"X11; Linux x86_64",
|
|
}
|
|
chromeVersions = []string{
|
|
"120.0.0.0", "122.0.0.0", "124.0.0.0", "126.0.0.0",
|
|
"128.0.0.0", "130.0.0.0", "132.0.0.0", "134.0.0.0",
|
|
}
|
|
acceptLanguages = []string{
|
|
"en-US,en;q=0.9", "zh-CN,zh;q=0.9,en;q=0.8",
|
|
"ja,en-US;q=0.9,en;q=0.8", "de,en-US;q=0.9,en;q=0.8",
|
|
}
|
|
)
|
|
|
|
func pick(arr []string) string { return arr[rand.Intn(len(arr))] }
|
|
|
|
func generateFingerprint() http.Header {
|
|
os := pick(osVersions)
|
|
cv := pick(chromeVersions)
|
|
major := strings.Split(cv, ".")[0]
|
|
ua := fmt.Sprintf("Mozilla/5.0 (%s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36", os, cv)
|
|
|
|
h := http.Header{}
|
|
h.Set("User-Agent", ua)
|
|
h.Set("Accept-Language", pick(acceptLanguages))
|
|
h.Set("Accept", "application/json, text/plain, */*")
|
|
h.Set("Accept-Encoding", "identity")
|
|
h.Set("sec-ch-ua", fmt.Sprintf(`"Chromium";v="%s", "Google Chrome";v="%s", "Not-A.Brand";v="99"`, major, major))
|
|
h.Set("sec-ch-ua-mobile", "?0")
|
|
if strings.Contains(os, "Windows") {
|
|
h.Set("sec-ch-ua-platform", `"Windows"`)
|
|
} else if strings.Contains(os, "Mac") {
|
|
h.Set("sec-ch-ua-platform", `"macOS"`)
|
|
} else {
|
|
h.Set("sec-ch-ua-platform", `"Linux"`)
|
|
}
|
|
h.Set("Sec-Fetch-Dest", "empty")
|
|
h.Set("Sec-Fetch-Mode", "cors")
|
|
h.Set("Sec-Fetch-Site", "cross-site")
|
|
h.Set("Origin", "https://windsurf.com")
|
|
h.Set("Referer", "https://windsurf.com/")
|
|
return h
|
|
}
|
|
|
|
func newClient(timeout time.Duration, proxyURL string) *req.Client {
|
|
c := req.C().SetTimeout(timeout).ImpersonateChrome()
|
|
if proxyURL != "" {
|
|
c.SetProxyURL(proxyURL)
|
|
}
|
|
return c
|
|
}
|
|
|
|
func (a *AuthClient) Login(ctx context.Context, email, password, proxyURL string) (*LoginResult, error) {
|
|
fp := generateFingerprint()
|
|
|
|
connData, _ := a.fetchAuth1Connections(ctx, email, fp, proxyURL)
|
|
authMethod, _ := extractString(connData, "auth_method", "method")
|
|
|
|
if authMethod == "auth1" {
|
|
hasPassword, _ := extractBool(connData, "auth_method", "has_password")
|
|
if !hasPassword {
|
|
return nil, &AuthError{
|
|
Message: "该账号未设置密码登录方式",
|
|
IsAuthFail: true,
|
|
}
|
|
}
|
|
return a.loginViaAuth1(ctx, email, password, fp, proxyURL)
|
|
}
|
|
|
|
result, fbErr := a.loginViaFirebase(ctx, email, password, fp, proxyURL)
|
|
if fbErr == nil {
|
|
return result, nil
|
|
}
|
|
if ae, ok := fbErr.(*AuthError); ok && ae.IsAuthFail {
|
|
result2, a1Err := a.loginViaAuth1(ctx, email, password, fp, proxyURL)
|
|
if a1Err == nil {
|
|
return result2, nil
|
|
}
|
|
if ae2, ok2 := a1Err.(*AuthError); ok2 && ae2.IsAuthFail {
|
|
return nil, fbErr
|
|
}
|
|
return nil, a1Err
|
|
}
|
|
return nil, fbErr
|
|
}
|
|
|
|
func (a *AuthClient) fetchAuth1Connections(ctx context.Context, email string, fp http.Header, proxyURL string) (map[string]any, error) {
|
|
body := map[string]string{"product": "windsurf", "email": email}
|
|
var result map[string]any
|
|
c := newClient(a.RequestTimeout, proxyURL)
|
|
resp, err := c.R().SetContext(ctx).SetHeaders(headerMap(fp)).SetBody(body).SetSuccessResult(&result).Post(a.Auth1BaseURL + "/_devin-auth/connections")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.IsErrorState() {
|
|
return nil, fmt.Errorf("auth1 connections: status %d", resp.StatusCode)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (a *AuthClient) loginViaAuth1(ctx context.Context, email, password string, fp http.Header, proxyURL string) (*LoginResult, error) {
|
|
c := newClient(a.RequestTimeout, proxyURL)
|
|
|
|
var loginResp map[string]any
|
|
resp, err := c.R().SetContext(ctx).SetHeaders(headerMap(fp)).
|
|
SetBody(map[string]string{"email": email, "password": password}).
|
|
SetSuccessResult(&loginResp).
|
|
Post(a.Auth1BaseURL + "/_devin-auth/password/login")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("auth1 login: %w", err)
|
|
}
|
|
|
|
if resp.IsErrorState() || loginResp["detail"] != nil {
|
|
detail, _ := loginResp["detail"].(string)
|
|
return nil, classifyAuthError("Auth1 登录失败", detail)
|
|
}
|
|
|
|
auth1Token, _ := loginResp["token"].(string)
|
|
if auth1Token == "" {
|
|
return nil, fmt.Errorf("auth1 login: no token in response")
|
|
}
|
|
|
|
hdrs := headerMap(fp)
|
|
hdrs["Connect-Protocol-Version"] = "1"
|
|
|
|
var bridgeResp map[string]any
|
|
resp, err = c.R().SetContext(ctx).SetHeaders(hdrs).
|
|
SetBody(map[string]string{"auth1Token": auth1Token, "orgId": ""}).
|
|
SetSuccessResult(&bridgeResp).
|
|
Post(a.SeatServiceBaseURL + "/WindsurfPostAuth")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("windsurf post auth: %w", err)
|
|
}
|
|
if resp.IsErrorState() {
|
|
return nil, fmt.Errorf("windsurf post auth: status %d", resp.StatusCode)
|
|
}
|
|
|
|
sessionToken, _ := bridgeResp["sessionToken"].(string)
|
|
if sessionToken == "" {
|
|
return nil, fmt.Errorf("windsurf post auth: no sessionToken")
|
|
}
|
|
|
|
var ottResp map[string]any
|
|
resp, err = c.R().SetContext(ctx).SetHeaders(hdrs).
|
|
SetBody(map[string]string{"authToken": sessionToken}).
|
|
SetSuccessResult(&ottResp).
|
|
Post(a.SeatServiceBaseURL + "/GetOneTimeAuthToken")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get one-time token: %w", err)
|
|
}
|
|
if resp.IsErrorState() {
|
|
return nil, fmt.Errorf("get one-time token: status %d", resp.StatusCode)
|
|
}
|
|
|
|
oneTimeToken, _ := ottResp["authToken"].(string)
|
|
if oneTimeToken == "" {
|
|
return nil, fmt.Errorf("get one-time token: no authToken")
|
|
}
|
|
|
|
reg, err := a.RegisterWithCodeium(ctx, oneTimeToken, fp, proxyURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("codeium register (auth1): %w", err)
|
|
}
|
|
|
|
return &LoginResult{
|
|
APIKey: reg.APIKey,
|
|
Name: reg.Name,
|
|
Email: email,
|
|
APIServerURL: reg.APIServerURL,
|
|
SessionToken: sessionToken,
|
|
Auth1Token: auth1Token,
|
|
AuthMethod: "auth1",
|
|
}, nil
|
|
}
|
|
|
|
func (a *AuthClient) loginViaFirebase(ctx context.Context, email, password string, fp http.Header, proxyURL string) (*LoginResult, error) {
|
|
c := newClient(a.RequestTimeout, proxyURL)
|
|
|
|
firebaseURL := fmt.Sprintf("https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=%s", a.FirebaseAPIKey)
|
|
body := map[string]any{"email": email, "password": password, "returnSecureToken": true}
|
|
|
|
var fbResp map[string]any
|
|
resp, err := c.R().SetContext(ctx).SetHeaders(headerMap(fp)).SetBody(body).SetSuccessResult(&fbResp).Post(firebaseURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("firebase login: %w", err)
|
|
}
|
|
|
|
if errObj, ok := fbResp["error"].(map[string]any); ok {
|
|
msg, _ := errObj["message"].(string)
|
|
return nil, classifyAuthError("Firebase 登录失败", msg)
|
|
}
|
|
if resp.IsErrorState() {
|
|
return nil, fmt.Errorf("firebase login: status %d", resp.StatusCode)
|
|
}
|
|
|
|
idToken, _ := fbResp["idToken"].(string)
|
|
if idToken == "" {
|
|
return nil, fmt.Errorf("firebase login: no idToken")
|
|
}
|
|
|
|
refreshToken, _ := fbResp["refreshToken"].(string)
|
|
|
|
reg, err := a.RegisterWithCodeium(ctx, idToken, fp, proxyURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("codeium register (firebase): %w", err)
|
|
}
|
|
|
|
return &LoginResult{
|
|
APIKey: reg.APIKey,
|
|
Name: reg.Name,
|
|
Email: email,
|
|
IDToken: idToken,
|
|
RefreshToken: refreshToken,
|
|
APIServerURL: reg.APIServerURL,
|
|
AuthMethod: "firebase",
|
|
}, nil
|
|
}
|
|
|
|
func (a *AuthClient) RegisterWithCodeium(ctx context.Context, token string, fp http.Header, proxyURL string) (*RegisterResult, error) {
|
|
c := newClient(a.RequestTimeout, proxyURL)
|
|
body := map[string]string{"firebase_id_token": token}
|
|
|
|
var regResp map[string]any
|
|
resp, err := c.R().SetContext(ctx).SetHeaders(headerMap(fp)).SetBody(body).SetSuccessResult(®Resp).Post(a.CodeiumRegisterURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.IsErrorState() {
|
|
data, _ := json.Marshal(regResp)
|
|
return nil, fmt.Errorf("codeium register: status %d: %s", resp.StatusCode, string(data))
|
|
}
|
|
|
|
apiKey, _ := regResp["api_key"].(string)
|
|
if apiKey == "" {
|
|
return nil, fmt.Errorf("codeium register: no api_key in response")
|
|
}
|
|
|
|
name, _ := regResp["name"].(string)
|
|
apiServerURL, _ := regResp["api_server_url"].(string)
|
|
|
|
return &RegisterResult{APIKey: apiKey, Name: name, APIServerURL: apiServerURL}, nil
|
|
}
|
|
|
|
func (a *AuthClient) RefreshFirebaseToken(ctx context.Context, refreshToken, proxyURL string) (*RefreshResult, error) {
|
|
if refreshToken == "" {
|
|
return nil, fmt.Errorf("no refresh token available")
|
|
}
|
|
|
|
refreshURL := fmt.Sprintf("https://securetoken.googleapis.com/v1/token?key=%s", a.FirebaseAPIKey)
|
|
postBody := fmt.Sprintf("grant_type=refresh_token&refresh_token=%s", url.QueryEscape(refreshToken))
|
|
|
|
c := newClient(a.RequestTimeout, proxyURL)
|
|
var result map[string]any
|
|
resp, err := c.R().SetContext(ctx).
|
|
SetHeader("Content-Type", "application/x-www-form-urlencoded").
|
|
SetHeader("Referer", "https://windsurf.com/").
|
|
SetHeader("Origin", "https://windsurf.com").
|
|
SetBodyString(postBody).
|
|
SetSuccessResult(&result).
|
|
Post(refreshURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("firebase refresh: %w", err)
|
|
}
|
|
if resp.IsErrorState() {
|
|
if errObj, ok := result["error"].(map[string]any); ok {
|
|
msg, _ := errObj["message"].(string)
|
|
return nil, fmt.Errorf("firebase refresh: %s", msg)
|
|
}
|
|
return nil, fmt.Errorf("firebase refresh: status %d", resp.StatusCode)
|
|
}
|
|
|
|
idToken := firstString(result, "id_token", "idToken")
|
|
if idToken == "" {
|
|
return nil, fmt.Errorf("firebase refresh: no idToken in response")
|
|
}
|
|
|
|
newRefresh := firstString(result, "refresh_token", "refreshToken")
|
|
if newRefresh == "" {
|
|
newRefresh = refreshToken
|
|
}
|
|
|
|
expiresIn := 3600
|
|
if v, ok := result["expires_in"].(string); ok {
|
|
fmt.Sscanf(v, "%d", &expiresIn)
|
|
} else if v, ok := result["expiresIn"].(string); ok {
|
|
fmt.Sscanf(v, "%d", &expiresIn)
|
|
}
|
|
|
|
return &RefreshResult{IDToken: idToken, RefreshToken: newRefresh, ExpiresIn: expiresIn}, nil
|
|
}
|
|
|
|
func (a *AuthClient) ReRegisterWithCodeium(ctx context.Context, idToken, proxyURL string) (*RegisterResult, error) {
|
|
fp := generateFingerprint()
|
|
return a.RegisterWithCodeium(ctx, idToken, fp, proxyURL)
|
|
}
|
|
|
|
func classifyAuthError(prefix, detail string) *AuthError {
|
|
authFails := map[string]bool{
|
|
"EMAIL_NOT_FOUND": true,
|
|
"INVALID_PASSWORD": true,
|
|
"INVALID_LOGIN_CREDENTIALS": true,
|
|
"Invalid email or password": true,
|
|
"No password set. Please log in with Google or GitHub.": true,
|
|
"No password set": true,
|
|
}
|
|
|
|
friendly := map[string]string{
|
|
"EMAIL_NOT_FOUND": "该邮箱未注册",
|
|
"INVALID_PASSWORD": "密码错误",
|
|
"INVALID_LOGIN_CREDENTIALS": "邮箱或密码错误",
|
|
"Invalid email or password": "邮箱或密码错误",
|
|
"USER_DISABLED": "账号已被停用",
|
|
"TOO_MANY_ATTEMPTS_TRY_LATER": "尝试太多次,请稍后再试",
|
|
"INVALID_EMAIL": "邮箱格式错误",
|
|
}
|
|
|
|
msg := detail
|
|
if f, ok := friendly[detail]; ok {
|
|
msg = f
|
|
}
|
|
|
|
return &AuthError{
|
|
Message: fmt.Sprintf("%s: %s", prefix, msg),
|
|
IsAuthFail: authFails[detail],
|
|
FirebaseCode: detail,
|
|
}
|
|
}
|
|
|
|
func headerMap(h http.Header) map[string]string {
|
|
m := make(map[string]string, len(h))
|
|
for k := range h {
|
|
m[k] = h.Get(k)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func extractString(data map[string]any, keys ...string) (string, bool) {
|
|
current := data
|
|
for i, k := range keys {
|
|
if i == len(keys)-1 {
|
|
v, ok := current[k].(string)
|
|
return v, ok
|
|
}
|
|
next, ok := current[k].(map[string]any)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
current = next
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func extractBool(data map[string]any, keys ...string) (bool, bool) {
|
|
current := data
|
|
for i, k := range keys {
|
|
if i == len(keys)-1 {
|
|
v, ok := current[k].(bool)
|
|
return v, ok
|
|
}
|
|
next, ok := current[k].(map[string]any)
|
|
if !ok {
|
|
return false, false
|
|
}
|
|
current = next
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
func firstString(m map[string]any, keys ...string) string {
|
|
for _, k := range keys {
|
|
if v, ok := m[k].(string); ok && v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|