| | |
| | |
| | |
| | |
| | package codex |
| |
|
| | import ( |
| | "context" |
| | "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 ( |
| | openaiAuthURL = "https://auth.openai.com/oauth/authorize" |
| | openaiTokenURL = "https://auth.openai.com/oauth/token" |
| | openaiClientID = "app_EMoamEEZ73f0CkXaXp7hrann" |
| | redirectURI = "http://localhost:1455/auth/callback" |
| | ) |
| |
|
| | |
| | |
| | |
| | type CodexAuth struct { |
| | httpClient *http.Client |
| | } |
| |
|
| | |
| | |
| | func NewCodexAuth(cfg *config.Config) *CodexAuth { |
| | return &CodexAuth{ |
| | httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}), |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | func (o *CodexAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string, error) { |
| | if pkceCodes == nil { |
| | return "", fmt.Errorf("PKCE codes are required") |
| | } |
| |
|
| | params := url.Values{ |
| | "client_id": {openaiClientID}, |
| | "response_type": {"code"}, |
| | "redirect_uri": {redirectURI}, |
| | "scope": {"openid email profile offline_access"}, |
| | "state": {state}, |
| | "code_challenge": {pkceCodes.CodeChallenge}, |
| | "code_challenge_method": {"S256"}, |
| | "prompt": {"login"}, |
| | "id_token_add_organizations": {"true"}, |
| | "codex_cli_simplified_flow": {"true"}, |
| | } |
| |
|
| | authURL := fmt.Sprintf("%s?%s", openaiAuthURL, params.Encode()) |
| | return authURL, nil |
| | } |
| |
|
| | |
| | |
| | |
| | func (o *CodexAuth) ExchangeCodeForTokens(ctx context.Context, code string, pkceCodes *PKCECodes) (*CodexAuthBundle, error) { |
| | if pkceCodes == nil { |
| | return nil, fmt.Errorf("PKCE codes are required for token exchange") |
| | } |
| |
|
| | |
| | data := url.Values{ |
| | "grant_type": {"authorization_code"}, |
| | "client_id": {openaiClientID}, |
| | "code": {code}, |
| | "redirect_uri": {redirectURI}, |
| | "code_verifier": {pkceCodes.CodeVerifier}, |
| | } |
| |
|
| | req, err := http.NewRequestWithContext(ctx, "POST", openaiTokenURL, strings.NewReader(data.Encode())) |
| | if err != nil { |
| | return nil, fmt.Errorf("failed to create token request: %w", err) |
| | } |
| |
|
| | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| | req.Header.Set("Accept", "application/json") |
| |
|
| | resp, err := o.httpClient.Do(req) |
| | if err != nil { |
| | return nil, fmt.Errorf("token exchange request failed: %w", err) |
| | } |
| | defer func() { |
| | _ = resp.Body.Close() |
| | }() |
| |
|
| | body, err := io.ReadAll(resp.Body) |
| | if err != nil { |
| | return nil, fmt.Errorf("failed to read token response: %w", err) |
| | } |
| | |
| |
|
| | if resp.StatusCode != http.StatusOK { |
| | return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body)) |
| | } |
| |
|
| | |
| | var tokenResp struct { |
| | AccessToken string `json:"access_token"` |
| | RefreshToken string `json:"refresh_token"` |
| | IDToken string `json:"id_token"` |
| | TokenType string `json:"token_type"` |
| | ExpiresIn int `json:"expires_in"` |
| | } |
| |
|
| | if err = json.Unmarshal(body, &tokenResp); err != nil { |
| | return nil, fmt.Errorf("failed to parse token response: %w", err) |
| | } |
| |
|
| | |
| | claims, err := ParseJWTToken(tokenResp.IDToken) |
| | if err != nil { |
| | log.Warnf("Failed to parse ID token: %v", err) |
| | } |
| |
|
| | accountID := "" |
| | email := "" |
| | if claims != nil { |
| | accountID = claims.GetAccountID() |
| | email = claims.GetUserEmail() |
| | } |
| |
|
| | |
| | tokenData := CodexTokenData{ |
| | IDToken: tokenResp.IDToken, |
| | AccessToken: tokenResp.AccessToken, |
| | RefreshToken: tokenResp.RefreshToken, |
| | AccountID: accountID, |
| | Email: email, |
| | Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339), |
| | } |
| |
|
| | |
| | bundle := &CodexAuthBundle{ |
| | TokenData: tokenData, |
| | LastRefresh: time.Now().Format(time.RFC3339), |
| | } |
| |
|
| | return bundle, nil |
| | } |
| |
|
| | |
| | |
| | |
| | func (o *CodexAuth) RefreshTokens(ctx context.Context, refreshToken string) (*CodexTokenData, error) { |
| | if refreshToken == "" { |
| | return nil, fmt.Errorf("refresh token is required") |
| | } |
| |
|
| | data := url.Values{ |
| | "client_id": {openaiClientID}, |
| | "grant_type": {"refresh_token"}, |
| | "refresh_token": {refreshToken}, |
| | "scope": {"openid profile email"}, |
| | } |
| |
|
| | req, err := http.NewRequestWithContext(ctx, "POST", openaiTokenURL, strings.NewReader(data.Encode())) |
| | if err != nil { |
| | return nil, fmt.Errorf("failed to create refresh request: %w", err) |
| | } |
| |
|
| | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| | req.Header.Set("Accept", "application/json") |
| |
|
| | resp, err := o.httpClient.Do(req) |
| | if err != nil { |
| | return nil, fmt.Errorf("token refresh request failed: %w", err) |
| | } |
| | defer func() { |
| | _ = resp.Body.Close() |
| | }() |
| |
|
| | body, err := io.ReadAll(resp.Body) |
| | if err != nil { |
| | return nil, fmt.Errorf("failed to read refresh response: %w", err) |
| | } |
| |
|
| | if resp.StatusCode != http.StatusOK { |
| | return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body)) |
| | } |
| |
|
| | var tokenResp struct { |
| | AccessToken string `json:"access_token"` |
| | RefreshToken string `json:"refresh_token"` |
| | IDToken string `json:"id_token"` |
| | TokenType string `json:"token_type"` |
| | ExpiresIn int `json:"expires_in"` |
| | } |
| |
|
| | if err = json.Unmarshal(body, &tokenResp); err != nil { |
| | return nil, fmt.Errorf("failed to parse refresh response: %w", err) |
| | } |
| |
|
| | |
| | claims, err := ParseJWTToken(tokenResp.IDToken) |
| | if err != nil { |
| | log.Warnf("Failed to parse refreshed ID token: %v", err) |
| | } |
| |
|
| | accountID := "" |
| | email := "" |
| | if claims != nil { |
| | accountID = claims.GetAccountID() |
| | email = claims.Email |
| | } |
| |
|
| | return &CodexTokenData{ |
| | IDToken: tokenResp.IDToken, |
| | AccessToken: tokenResp.AccessToken, |
| | RefreshToken: tokenResp.RefreshToken, |
| | AccountID: accountID, |
| | Email: email, |
| | Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339), |
| | }, nil |
| | } |
| |
|
| | |
| | |
| | func (o *CodexAuth) CreateTokenStorage(bundle *CodexAuthBundle) *CodexTokenStorage { |
| | storage := &CodexTokenStorage{ |
| | IDToken: bundle.TokenData.IDToken, |
| | AccessToken: bundle.TokenData.AccessToken, |
| | RefreshToken: bundle.TokenData.RefreshToken, |
| | AccountID: bundle.TokenData.AccountID, |
| | LastRefresh: bundle.LastRefresh, |
| | Email: bundle.TokenData.Email, |
| | Expire: bundle.TokenData.Expire, |
| | } |
| |
|
| | return storage |
| | } |
| |
|
| | |
| | |
| | |
| | func (o *CodexAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*CodexTokenData, error) { |
| | var lastErr error |
| |
|
| | for attempt := 0; attempt < maxRetries; attempt++ { |
| | if attempt > 0 { |
| | |
| | select { |
| | case <-ctx.Done(): |
| | return nil, ctx.Err() |
| | case <-time.After(time.Duration(attempt) * time.Second): |
| | } |
| | } |
| |
|
| | tokenData, err := o.RefreshTokens(ctx, refreshToken) |
| | if err == nil { |
| | return tokenData, nil |
| | } |
| |
|
| | lastErr = err |
| | log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err) |
| | } |
| |
|
| | return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr) |
| | } |
| |
|
| | |
| | |
| | func (o *CodexAuth) UpdateTokenStorage(storage *CodexTokenStorage, tokenData *CodexTokenData) { |
| | storage.IDToken = tokenData.IDToken |
| | storage.AccessToken = tokenData.AccessToken |
| | storage.RefreshToken = tokenData.RefreshToken |
| | storage.AccountID = tokenData.AccountID |
| | storage.LastRefresh = time.Now().Format(time.RFC3339) |
| | storage.Email = tokenData.Email |
| | storage.Expire = tokenData.Expire |
| | } |
| |
|