| |
| |
| |
| |
| 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 ( |
| AuthURL = "https://auth.openai.com/oauth/authorize" |
| TokenURL = "https://auth.openai.com/oauth/token" |
| ClientID = "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": {ClientID}, |
| "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", AuthURL, 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": {ClientID}, |
| "code": {code}, |
| "redirect_uri": {RedirectURI}, |
| "code_verifier": {pkceCodes.CodeVerifier}, |
| } |
|
|
| req, err := http.NewRequestWithContext(ctx, "POST", TokenURL, 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": {ClientID}, |
| "grant_type": {"refresh_token"}, |
| "refresh_token": {refreshToken}, |
| "scope": {"openid profile email"}, |
| } |
|
|
| req, err := http.NewRequestWithContext(ctx, "POST", TokenURL, 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 |
| } |
|
|