Spaces:
Paused
Paused
| // Package codex provides authentication and token management for OpenAI's Codex API. | |
| // It handles the OAuth2 flow, including generating authorization URLs, exchanging | |
| // authorization codes for tokens, and refreshing expired tokens. The package also | |
| // defines data structures for storing and managing Codex authentication credentials. | |
| 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" | |
| ) | |
| // OAuth configuration constants for OpenAI Codex | |
| const ( | |
| AuthURL = "https://auth.openai.com/oauth/authorize" | |
| TokenURL = "https://auth.openai.com/oauth/token" | |
| ClientID = "app_EMoamEEZ73f0CkXaXp7hrann" | |
| RedirectURI = "http://localhost:1455/auth/callback" | |
| ) | |
| // CodexAuth handles the OpenAI OAuth2 authentication flow. | |
| // It manages the HTTP client and provides methods for generating authorization URLs, | |
| // exchanging authorization codes for tokens, and refreshing access tokens. | |
| type CodexAuth struct { | |
| httpClient *http.Client | |
| } | |
| // NewCodexAuth creates a new CodexAuth service instance. | |
| // It initializes an HTTP client with proxy settings from the provided configuration. | |
| func NewCodexAuth(cfg *config.Config) *CodexAuth { | |
| return &CodexAuth{ | |
| httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}), | |
| } | |
| } | |
| // GenerateAuthURL creates the OAuth authorization URL with PKCE (Proof Key for Code Exchange). | |
| // It constructs the URL with the necessary parameters, including the client ID, | |
| // response type, redirect URI, scopes, and PKCE challenge. | |
| 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 | |
| } | |
| // ExchangeCodeForTokens exchanges an authorization code for access and refresh tokens. | |
| // It performs an HTTP POST request to the OpenAI token endpoint with the provided | |
| // authorization code and PKCE verifier. | |
| 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") | |
| } | |
| // Prepare token exchange request | |
| 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) | |
| } | |
| // log.Debugf("Token response: %s", string(body)) | |
| if resp.StatusCode != http.StatusOK { | |
| return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body)) | |
| } | |
| // Parse token response | |
| 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) | |
| } | |
| // Extract account ID from ID token | |
| 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() | |
| } | |
| // Create token data | |
| 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), | |
| } | |
| // Create auth bundle | |
| bundle := &CodexAuthBundle{ | |
| TokenData: tokenData, | |
| LastRefresh: time.Now().Format(time.RFC3339), | |
| } | |
| return bundle, nil | |
| } | |
| // RefreshTokens refreshes an access token using a refresh token. | |
| // This method is called when an access token has expired. It makes a request to the | |
| // token endpoint to obtain a new set of tokens. | |
| 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) | |
| } | |
| // Extract account ID from ID token | |
| 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 | |
| } | |
| // CreateTokenStorage creates a new CodexTokenStorage from a CodexAuthBundle. | |
| // It populates the storage struct with token data, user information, and timestamps. | |
| 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 | |
| } | |
| // RefreshTokensWithRetry refreshes tokens with a built-in retry mechanism. | |
| // It attempts to refresh the tokens up to a specified maximum number of retries, | |
| // with an exponential backoff strategy to handle transient network errors. | |
| 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 { | |
| // Wait before retry | |
| 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) | |
| } | |
| // UpdateTokenStorage updates an existing CodexTokenStorage with new token data. | |
| // This is typically called after a successful token refresh to persist the new credentials. | |
| 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 | |
| } | |