From b52a5cc0668f95212c844cb16c501de19d7f52f4 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Tue, 18 Nov 2025 22:35:35 +0800 Subject: [PATCH] feat(auth): add iFlow cookie-based authentication support --- cmd/server/main.go | 4 + internal/auth/iflow/iflow_auth.go | 234 ++++++++++++++++++++ internal/auth/iflow/iflow_token.go | 1 + internal/cmd/iflow_cookie.go | 111 ++++++++++ internal/runtime/executor/iflow_executor.go | 76 ++++++- sdk/cliproxy/auth/types.go | 25 ++- 6 files changed, 447 insertions(+), 4 deletions(-) create mode 100644 internal/cmd/iflow_cookie.go diff --git a/cmd/server/main.go b/cmd/server/main.go index ab9b9a35..a583399f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -59,6 +59,7 @@ func main() { var claudeLogin bool var qwenLogin bool var iflowLogin bool + var iflowCookie bool var noBrowser bool var projectID string var vertexImport string @@ -71,6 +72,7 @@ func main() { flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth") flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth") flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth") + flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie") flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth") flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)") flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path") @@ -439,6 +441,8 @@ func main() { cmd.DoQwenLogin(cfg, options) } else if iflowLogin { cmd.DoIFlowLogin(cfg, options) + } else if iflowCookie { + cmd.DoIFlowCookieAuth(cfg, options) } else { // In cloud deploy mode without config file, just wait for shutdown signals if isCloudDeploy && !configFileExists { diff --git a/internal/auth/iflow/iflow_auth.go b/internal/auth/iflow/iflow_auth.go index efac49b8..4957f519 100644 --- a/internal/auth/iflow/iflow_auth.go +++ b/internal/auth/iflow/iflow_auth.go @@ -1,6 +1,7 @@ package iflow import ( + "compress/gzip" "context" "encoding/base64" "encoding/json" @@ -23,6 +24,9 @@ const ( iFlowUserInfoEndpoint = "https://iflow.cn/api/oauth/getUserInfo" iFlowSuccessRedirectURL = "https://iflow.cn/oauth/success" + // Cookie authentication endpoints + iFlowAPIKeyEndpoint = "https://platform.iflow.cn/api/openapi/apikey" + // Client credentials provided by iFlow for the Code Assist integration. iFlowOAuthClientID = "10009311001" iFlowOAuthClientSecret = "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW" @@ -261,6 +265,7 @@ type IFlowTokenData struct { Expire string APIKey string Email string + Cookie string } // userInfoResponse represents the structure returned by the user info endpoint. @@ -274,3 +279,232 @@ type userInfoData struct { Email string `json:"email"` Phone string `json:"phone"` } + +// iFlowAPIKeyResponse represents the response from the API key endpoint +type iFlowAPIKeyResponse struct { + Success bool `json:"success"` + Code string `json:"code"` + Message string `json:"message"` + Data iFlowKeyData `json:"data"` + Extra interface{} `json:"extra"` +} + +// iFlowKeyData contains the API key information +type iFlowKeyData struct { + HasExpired bool `json:"hasExpired"` + ExpireTime string `json:"expireTime"` + Name string `json:"name"` + APIKey string `json:"apiKey"` + APIKeyMask string `json:"apiKeyMask"` +} + +// iFlowRefreshRequest represents the request body for refreshing API key +type iFlowRefreshRequest struct { + Name string `json:"name"` +} + +// AuthenticateWithCookie performs authentication using browser cookies +func (ia *IFlowAuth) AuthenticateWithCookie(ctx context.Context, cookie string) (*IFlowTokenData, error) { + if strings.TrimSpace(cookie) == "" { + return nil, fmt.Errorf("iflow cookie authentication: cookie is empty") + } + + // First, get initial API key information using GET request + keyInfo, err := ia.fetchAPIKeyInfo(ctx, cookie) + if err != nil { + return nil, fmt.Errorf("iflow cookie authentication: fetch initial API key info failed: %w", err) + } + + // Convert to token data format + data := &IFlowTokenData{ + APIKey: keyInfo.APIKey, + Expire: keyInfo.ExpireTime, + Email: keyInfo.Name, + Cookie: cookie, + } + + return data, nil +} + +// fetchAPIKeyInfo retrieves API key information using GET request with cookie +func (ia *IFlowAuth) fetchAPIKeyInfo(ctx context.Context, cookie string) (*iFlowKeyData, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, iFlowAPIKeyEndpoint, nil) + if err != nil { + return nil, fmt.Errorf("iflow cookie: create GET request failed: %w", err) + } + + // Set cookie and other headers to mimic browser + req.Header.Set("Cookie", cookie) + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + req.Header.Set("Accept-Encoding", "gzip, deflate, br") + req.Header.Set("Connection", "keep-alive") + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Site", "same-origin") + + resp, err := ia.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("iflow cookie: GET request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // Handle gzip compression + var reader io.Reader = resp.Body + if resp.Header.Get("Content-Encoding") == "gzip" { + gzipReader, err := gzip.NewReader(resp.Body) + if err != nil { + return nil, fmt.Errorf("iflow cookie: create gzip reader failed: %w", err) + } + defer func() { _ = gzipReader.Close() }() + reader = gzipReader + } + + body, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("iflow cookie: read GET response failed: %w", err) + } + + if resp.StatusCode != http.StatusOK { + log.Debugf("iflow cookie GET request failed: status=%d body=%s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("iflow cookie: GET request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var keyResp iFlowAPIKeyResponse + if err = json.Unmarshal(body, &keyResp); err != nil { + return nil, fmt.Errorf("iflow cookie: decode GET response failed: %w", err) + } + + if !keyResp.Success { + return nil, fmt.Errorf("iflow cookie: GET request not successful: %s", keyResp.Message) + } + + // Handle initial response where apiKey field might be apiKeyMask + if keyResp.Data.APIKey == "" && keyResp.Data.APIKeyMask != "" { + keyResp.Data.APIKey = keyResp.Data.APIKeyMask + } + + return &keyResp.Data, nil +} + +// RefreshAPIKey refreshes the API key using POST request +func (ia *IFlowAuth) RefreshAPIKey(ctx context.Context, cookie, name string) (*iFlowKeyData, error) { + if strings.TrimSpace(cookie) == "" { + return nil, fmt.Errorf("iflow cookie refresh: cookie is empty") + } + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("iflow cookie refresh: name is empty") + } + + // Prepare request body + refreshReq := iFlowRefreshRequest{ + Name: name, + } + + bodyBytes, err := json.Marshal(refreshReq) + if err != nil { + return nil, fmt.Errorf("iflow cookie refresh: marshal request failed: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, iFlowAPIKeyEndpoint, strings.NewReader(string(bodyBytes))) + if err != nil { + return nil, fmt.Errorf("iflow cookie refresh: create POST request failed: %w", err) + } + + // Set cookie and other headers to mimic browser + req.Header.Set("Cookie", cookie) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + req.Header.Set("Accept-Encoding", "gzip, deflate, br") + req.Header.Set("Connection", "keep-alive") + req.Header.Set("Origin", "https://platform.iflow.cn") + req.Header.Set("Referer", "https://platform.iflow.cn/") + + resp, err := ia.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("iflow cookie refresh: POST request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // Handle gzip compression + var reader io.Reader = resp.Body + if resp.Header.Get("Content-Encoding") == "gzip" { + gzipReader, err := gzip.NewReader(resp.Body) + if err != nil { + return nil, fmt.Errorf("iflow cookie refresh: create gzip reader failed: %w", err) + } + defer func() { _ = gzipReader.Close() }() + reader = gzipReader + } + + body, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("iflow cookie refresh: read POST response failed: %w", err) + } + + if resp.StatusCode != http.StatusOK { + log.Debugf("iflow cookie POST request failed: status=%d body=%s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("iflow cookie refresh: POST request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var keyResp iFlowAPIKeyResponse + if err = json.Unmarshal(body, &keyResp); err != nil { + return nil, fmt.Errorf("iflow cookie refresh: decode POST response failed: %w", err) + } + + if !keyResp.Success { + return nil, fmt.Errorf("iflow cookie refresh: POST request not successful: %s", keyResp.Message) + } + + return &keyResp.Data, nil +} + +// ShouldRefreshAPIKey checks if the API key needs to be refreshed (within 2 days of expiry) +func ShouldRefreshAPIKey(expireTime string) (bool, time.Duration, error) { + if strings.TrimSpace(expireTime) == "" { + return false, 0, fmt.Errorf("iflow cookie: expire time is empty") + } + + expire, err := time.Parse("2006-01-02 15:04", expireTime) + if err != nil { + return false, 0, fmt.Errorf("iflow cookie: parse expire time failed: %w", err) + } + + now := time.Now() + twoDaysFromNow := now.Add(48 * time.Hour) + + needsRefresh := expire.Before(twoDaysFromNow) + timeUntilExpiry := expire.Sub(now) + + return needsRefresh, timeUntilExpiry, nil +} + +// CreateCookieTokenStorage converts cookie-based token data into persistence storage +func (ia *IFlowAuth) CreateCookieTokenStorage(data *IFlowTokenData) *IFlowTokenStorage { + if data == nil { + return nil + } + + return &IFlowTokenStorage{ + APIKey: data.APIKey, + Email: data.Email, + Expire: data.Expire, + Cookie: data.Cookie, + LastRefresh: time.Now().Format(time.RFC3339), + Type: "iflow", + } +} + +// UpdateCookieTokenStorage updates the persisted token storage with refreshed API key data +func (ia *IFlowAuth) UpdateCookieTokenStorage(storage *IFlowTokenStorage, keyData *iFlowKeyData) { + if storage == nil || keyData == nil { + return + } + + storage.APIKey = keyData.APIKey + storage.Expire = keyData.ExpireTime + storage.LastRefresh = time.Now().Format(time.RFC3339) +} diff --git a/internal/auth/iflow/iflow_token.go b/internal/auth/iflow/iflow_token.go index 154ac4dd..6d2beb39 100644 --- a/internal/auth/iflow/iflow_token.go +++ b/internal/auth/iflow/iflow_token.go @@ -19,6 +19,7 @@ type IFlowTokenStorage struct { Email string `json:"email"` TokenType string `json:"token_type"` Scope string `json:"scope"` + Cookie string `json:"cookie"` Type string `json:"type"` } diff --git a/internal/cmd/iflow_cookie.go b/internal/cmd/iflow_cookie.go new file mode 100644 index 00000000..ed9c0481 --- /dev/null +++ b/internal/cmd/iflow_cookie.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) + +// DoIFlowCookieAuth performs the iFlow cookie-based authentication. +func DoIFlowCookieAuth(cfg *config.Config, options *LoginOptions) { + if options == nil { + options = &LoginOptions{} + } + + promptFn := options.Prompt + if promptFn == nil { + reader := bufio.NewReader(os.Stdin) + promptFn = func(prompt string) (string, error) { + fmt.Print(prompt) + value, err := reader.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimSpace(value), nil + } + } + + // Prompt user for cookie + cookie, err := promptForCookie(promptFn) + if err != nil { + fmt.Printf("Failed to get cookie: %v\n", err) + return + } + + // Authenticate with cookie + auth := iflow.NewIFlowAuth(cfg) + ctx := context.Background() + + tokenData, err := auth.AuthenticateWithCookie(ctx, cookie) + if err != nil { + fmt.Printf("iFlow cookie authentication failed: %v\n", err) + return + } + + // Create token storage + tokenStorage := auth.CreateCookieTokenStorage(tokenData) + + // Get auth file path using email in filename + authFilePath := getAuthFilePath(cfg, "iflow", tokenData.Email) + + // Save token to file + if err := tokenStorage.SaveTokenToFile(authFilePath); err != nil { + fmt.Printf("Failed to save authentication: %v\n", err) + return + } + + fmt.Printf("Authentication successful! API key: %s\n", tokenData.APIKey) + fmt.Printf("Expires at: %s\n", tokenData.Expire) + fmt.Printf("Authentication saved to: %s\n", authFilePath) +} + +// promptForCookie prompts the user to enter their iFlow cookie +func promptForCookie(promptFn func(string) (string, error)) (string, error) { + line, err := promptFn("Enter iFlow Cookie (from browser cookies): ") + if err != nil { + return "", fmt.Errorf("failed to read cookie: %w", err) + } + + line = strings.TrimSpace(line) + if line == "" { + return "", fmt.Errorf("cookie cannot be empty") + } + + // Clean up any extra whitespace and join multiple spaces + cookie := strings.Join(strings.Fields(line), " ") + + // Ensure it ends properly + if !strings.HasSuffix(cookie, ";") { + cookie = cookie + ";" + } + + // Ensure BXAuth is present in the cookie + if !strings.Contains(cookie, "BXAuth=") { + return "", fmt.Errorf("BXAuth field not found in cookie") + } + + return cookie, nil +} + +// getAuthFilePath returns the auth file path for the given provider and email +func getAuthFilePath(cfg *config.Config, provider, email string) string { + // Clean email to make it filename-safe + cleanEmail := strings.ReplaceAll(email, "@", "_at_") + cleanEmail = strings.ReplaceAll(cleanEmail, ".", "_") + cleanEmail = strings.ReplaceAll(cleanEmail, "-", "_") + + // Remove any remaining special characters + var result strings.Builder + for _, r := range cleanEmail { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { + result.WriteRune(r) + } + } + + return fmt.Sprintf("%s/%s-%s.json", cfg.AuthDir, provider, result.String()) +} diff --git a/internal/runtime/executor/iflow_executor.go b/internal/runtime/executor/iflow_executor.go index 85617655..0ed6c0db 100644 --- a/internal/runtime/executor/iflow_executor.go +++ b/internal/runtime/executor/iflow_executor.go @@ -242,13 +242,87 @@ func (e *IFlowExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth return cliproxyexecutor.Response{Payload: []byte(translated)}, nil } -// Refresh refreshes OAuth tokens and updates the stored API key. +// Refresh refreshes OAuth tokens or cookie-based API keys and updates the stored API key. func (e *IFlowExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { log.Debugf("iflow executor: refresh called") if auth == nil { return nil, fmt.Errorf("iflow executor: auth is nil") } + // Check if this is cookie-based authentication + var cookie string + var email string + if auth.Metadata != nil { + if v, ok := auth.Metadata["cookie"].(string); ok { + cookie = strings.TrimSpace(v) + } + if v, ok := auth.Metadata["email"].(string); ok { + email = strings.TrimSpace(v) + } + } + + // If cookie is present, use cookie-based refresh + if cookie != "" && email != "" { + return e.refreshCookieBased(ctx, auth, cookie, email) + } + + // Otherwise, use OAuth-based refresh + return e.refreshOAuthBased(ctx, auth) +} + +// refreshCookieBased refreshes API key using browser cookie +func (e *IFlowExecutor) refreshCookieBased(ctx context.Context, auth *cliproxyauth.Auth, cookie, email string) (*cliproxyauth.Auth, error) { + log.Debugf("iflow executor: checking refresh need for cookie-based API key for user: %s", email) + + // Get current expiry time from metadata + var currentExpire string + if auth.Metadata != nil { + if v, ok := auth.Metadata["expired"].(string); ok { + currentExpire = strings.TrimSpace(v) + } + } + + // Check if refresh is needed + needsRefresh, _, err := iflowauth.ShouldRefreshAPIKey(currentExpire) + if err != nil { + log.Warnf("iflow executor: failed to check refresh need: %v", err) + // If we can't check, continue with refresh anyway as a safety measure + } else if !needsRefresh { + log.Debugf("iflow executor: no refresh needed for user: %s", email) + return auth, nil + } + + log.Infof("iflow executor: refreshing cookie-based API key for user: %s", email) + + svc := iflowauth.NewIFlowAuth(e.cfg) + keyData, err := svc.RefreshAPIKey(ctx, cookie, email) + if err != nil { + log.Errorf("iflow executor: cookie-based API key refresh failed: %v", err) + return nil, err + } + + if auth.Metadata == nil { + auth.Metadata = make(map[string]any) + } + auth.Metadata["api_key"] = keyData.APIKey + auth.Metadata["expired"] = keyData.ExpireTime + auth.Metadata["type"] = "iflow" + auth.Metadata["last_refresh"] = time.Now().Format(time.RFC3339) + auth.Metadata["cookie"] = cookie + auth.Metadata["email"] = email + + log.Infof("iflow executor: cookie-based API key refreshed successfully, new expiry: %s", keyData.ExpireTime) + + if auth.Attributes == nil { + auth.Attributes = make(map[string]string) + } + auth.Attributes["api_key"] = keyData.APIKey + + return auth, nil +} + +// refreshOAuthBased refreshes tokens using OAuth refresh token +func (e *IFlowExecutor) refreshOAuthBased(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { refreshToken := "" oldAccessToken := "" if auth.Metadata != nil { diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index f9c71d56..8984554d 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -152,11 +152,29 @@ func (a *Auth) AccountInfo() (string, string) { } } } - if a.Metadata != nil { - if v, ok := a.Metadata["email"].(string); ok { - return "oauth", v + + // For iFlow provider, prioritize OAuth type if email is present + if strings.ToLower(a.Provider) == "iflow" { + if a.Metadata != nil { + if email, ok := a.Metadata["email"].(string); ok { + email = strings.TrimSpace(email) + if email != "" { + return "oauth", email + } + } } } + + // Check metadata for email first (OAuth-style auth) + if a.Metadata != nil { + if v, ok := a.Metadata["email"].(string); ok { + email := strings.TrimSpace(v) + if email != "" { + return "oauth", email + } + } + } + // Fall back to API key (API-key auth) if a.Attributes != nil { if v := a.Attributes["api_key"]; v != "" { return "api_key", v @@ -259,6 +277,7 @@ func parseTimeValue(v any) (time.Time, bool) { time.RFC3339, time.RFC3339Nano, "2006-01-02 15:04:05", + "2006-01-02 15:04", "2006-01-02T15:04:05Z07:00", } for _, layout := range layouts {