mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 13:00:52 +08:00
276 lines
8.7 KiB
Go
276 lines
8.7 KiB
Go
package iflow
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
const (
|
|
// OAuth endpoints and client metadata are derived from the reference Python implementation.
|
|
iFlowOAuthTokenEndpoint = "https://iflow.cn/oauth/token"
|
|
iFlowOAuthAuthorizeEndpoint = "https://iflow.cn/oauth"
|
|
iFlowUserInfoEndpoint = "https://iflow.cn/api/oauth/getUserInfo"
|
|
iFlowSuccessRedirectURL = "https://iflow.cn/oauth/success"
|
|
|
|
// Client credentials provided by iFlow for the Code Assist integration.
|
|
iFlowOAuthClientID = "10009311001"
|
|
iFlowOAuthClientSecret = "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW"
|
|
)
|
|
|
|
// DefaultAPIBaseURL is the canonical chat completions endpoint.
|
|
const DefaultAPIBaseURL = "https://apis.iflow.cn/v1"
|
|
|
|
// SuccessRedirectURL is exposed for consumers needing the official success page.
|
|
const SuccessRedirectURL = iFlowSuccessRedirectURL
|
|
|
|
// CallbackPort defines the local port used for OAuth callbacks.
|
|
const CallbackPort = 11451
|
|
|
|
// IFlowAuth encapsulates the HTTP client helpers for the OAuth flow.
|
|
type IFlowAuth struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewIFlowAuth constructs a new IFlowAuth with proxy-aware transport.
|
|
func NewIFlowAuth(cfg *config.Config) *IFlowAuth {
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
return &IFlowAuth{httpClient: util.SetProxy(&cfg.SDKConfig, client)}
|
|
}
|
|
|
|
// AuthorizationURL builds the authorization URL and matching redirect URI.
|
|
func (ia *IFlowAuth) AuthorizationURL(state string, port int) (authURL, redirectURI string) {
|
|
redirectURI = fmt.Sprintf("http://localhost:%d/oauth2callback", port)
|
|
values := url.Values{}
|
|
values.Set("loginMethod", "phone")
|
|
values.Set("type", "phone")
|
|
values.Set("redirect", redirectURI)
|
|
values.Set("state", state)
|
|
values.Set("client_id", iFlowOAuthClientID)
|
|
authURL = fmt.Sprintf("%s?%s", iFlowOAuthAuthorizeEndpoint, values.Encode())
|
|
return authURL, redirectURI
|
|
}
|
|
|
|
// ExchangeCodeForTokens exchanges an authorization code for access and refresh tokens.
|
|
func (ia *IFlowAuth) ExchangeCodeForTokens(ctx context.Context, code, redirectURI string) (*IFlowTokenData, error) {
|
|
form := url.Values{}
|
|
form.Set("grant_type", "authorization_code")
|
|
form.Set("code", code)
|
|
form.Set("redirect_uri", redirectURI)
|
|
form.Set("client_id", iFlowOAuthClientID)
|
|
form.Set("client_secret", iFlowOAuthClientSecret)
|
|
|
|
req, err := ia.newTokenRequest(ctx, form)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return ia.doTokenRequest(ctx, req)
|
|
}
|
|
|
|
// RefreshTokens exchanges a refresh token for a new access token.
|
|
func (ia *IFlowAuth) RefreshTokens(ctx context.Context, refreshToken string) (*IFlowTokenData, error) {
|
|
form := url.Values{}
|
|
form.Set("grant_type", "refresh_token")
|
|
form.Set("refresh_token", refreshToken)
|
|
form.Set("client_id", iFlowOAuthClientID)
|
|
form.Set("client_secret", iFlowOAuthClientSecret)
|
|
|
|
req, err := ia.newTokenRequest(ctx, form)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return ia.doTokenRequest(ctx, req)
|
|
}
|
|
|
|
func (ia *IFlowAuth) newTokenRequest(ctx context.Context, form url.Values) (*http.Request, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, iFlowOAuthTokenEndpoint, strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("iflow token: create request failed: %w", err)
|
|
}
|
|
|
|
basic := base64.StdEncoding.EncodeToString([]byte(iFlowOAuthClientID + ":" + iFlowOAuthClientSecret))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("Authorization", "Basic "+basic)
|
|
return req, nil
|
|
}
|
|
|
|
func (ia *IFlowAuth) doTokenRequest(ctx context.Context, req *http.Request) (*IFlowTokenData, error) {
|
|
resp, err := ia.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("iflow token: request failed: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("iflow token: read response failed: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.Debugf("iflow token request failed: status=%d body=%s", resp.StatusCode, string(body))
|
|
return nil, fmt.Errorf("iflow token: %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
var tokenResp IFlowTokenResponse
|
|
if err = json.Unmarshal(body, &tokenResp); err != nil {
|
|
return nil, fmt.Errorf("iflow token: decode response failed: %w", err)
|
|
}
|
|
|
|
data := &IFlowTokenData{
|
|
AccessToken: tokenResp.AccessToken,
|
|
RefreshToken: tokenResp.RefreshToken,
|
|
TokenType: tokenResp.TokenType,
|
|
Scope: tokenResp.Scope,
|
|
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
|
}
|
|
|
|
if tokenResp.AccessToken == "" {
|
|
return nil, fmt.Errorf("iflow token: missing access token in response")
|
|
}
|
|
|
|
info, errAPI := ia.FetchUserInfo(ctx, tokenResp.AccessToken)
|
|
if errAPI != nil {
|
|
return nil, fmt.Errorf("iflow token: fetch user info failed: %w", errAPI)
|
|
}
|
|
if strings.TrimSpace(info.APIKey) == "" {
|
|
return nil, fmt.Errorf("iflow token: empty api key returned")
|
|
}
|
|
email := strings.TrimSpace(info.Email)
|
|
if email == "" {
|
|
email = strings.TrimSpace(info.Phone)
|
|
}
|
|
if email == "" {
|
|
return nil, fmt.Errorf("iflow token: missing account email/phone in user info")
|
|
}
|
|
data.APIKey = info.APIKey
|
|
data.Email = email
|
|
|
|
return data, nil
|
|
}
|
|
|
|
// FetchUserInfo retrieves account metadata (including API key) for the provided access token.
|
|
func (ia *IFlowAuth) FetchUserInfo(ctx context.Context, accessToken string) (*userInfoData, error) {
|
|
if strings.TrimSpace(accessToken) == "" {
|
|
return nil, fmt.Errorf("iflow api key: access token is empty")
|
|
}
|
|
|
|
endpoint := fmt.Sprintf("%s?accessToken=%s", iFlowUserInfoEndpoint, url.QueryEscape(accessToken))
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("iflow api key: create request failed: %w", err)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := ia.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("iflow api key: request failed: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("iflow api key: read response failed: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.Debugf("iflow api key failed: status=%d body=%s", resp.StatusCode, string(body))
|
|
return nil, fmt.Errorf("iflow api key: %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
var result userInfoResponse
|
|
if err = json.Unmarshal(body, &result); err != nil {
|
|
return nil, fmt.Errorf("iflow api key: decode body failed: %w", err)
|
|
}
|
|
|
|
if !result.Success {
|
|
return nil, fmt.Errorf("iflow api key: request not successful")
|
|
}
|
|
|
|
if result.Data.APIKey == "" {
|
|
return nil, fmt.Errorf("iflow api key: missing api key in response")
|
|
}
|
|
|
|
return &result.Data, nil
|
|
}
|
|
|
|
// CreateTokenStorage converts token data into persistence storage.
|
|
func (ia *IFlowAuth) CreateTokenStorage(data *IFlowTokenData) *IFlowTokenStorage {
|
|
if data == nil {
|
|
return nil
|
|
}
|
|
return &IFlowTokenStorage{
|
|
AccessToken: data.AccessToken,
|
|
RefreshToken: data.RefreshToken,
|
|
LastRefresh: time.Now().Format(time.RFC3339),
|
|
Expire: data.Expire,
|
|
APIKey: data.APIKey,
|
|
Email: data.Email,
|
|
TokenType: data.TokenType,
|
|
Scope: data.Scope,
|
|
}
|
|
}
|
|
|
|
// UpdateTokenStorage updates the persisted token storage with latest token data.
|
|
func (ia *IFlowAuth) UpdateTokenStorage(storage *IFlowTokenStorage, data *IFlowTokenData) {
|
|
if storage == nil || data == nil {
|
|
return
|
|
}
|
|
storage.AccessToken = data.AccessToken
|
|
storage.RefreshToken = data.RefreshToken
|
|
storage.LastRefresh = time.Now().Format(time.RFC3339)
|
|
storage.Expire = data.Expire
|
|
if data.APIKey != "" {
|
|
storage.APIKey = data.APIKey
|
|
}
|
|
if data.Email != "" {
|
|
storage.Email = data.Email
|
|
}
|
|
storage.TokenType = data.TokenType
|
|
storage.Scope = data.Scope
|
|
}
|
|
|
|
// IFlowTokenResponse models the OAuth token endpoint response.
|
|
type IFlowTokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
TokenType string `json:"token_type"`
|
|
Scope string `json:"scope"`
|
|
}
|
|
|
|
// IFlowTokenData captures processed token details.
|
|
type IFlowTokenData struct {
|
|
AccessToken string
|
|
RefreshToken string
|
|
TokenType string
|
|
Scope string
|
|
Expire string
|
|
APIKey string
|
|
Email string
|
|
}
|
|
|
|
// userInfoResponse represents the structure returned by the user info endpoint.
|
|
type userInfoResponse struct {
|
|
Success bool `json:"success"`
|
|
Data userInfoData `json:"data"`
|
|
}
|
|
|
|
type userInfoData struct {
|
|
APIKey string `json:"apiKey"`
|
|
Email string `json:"email"`
|
|
Phone string `json:"phone"`
|
|
}
|