Spaces:
Paused
Paused
| package auth | |
| import ( | |
| "context" | |
| "crypto/sha256" | |
| "encoding/hex" | |
| "fmt" | |
| "net/http" | |
| "strings" | |
| "time" | |
| "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" | |
| "github.com/router-for-me/CLIProxyAPI/v6/internal/browser" | |
| // legacy client removed | |
| "github.com/router-for-me/CLIProxyAPI/v6/internal/config" | |
| "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" | |
| "github.com/router-for-me/CLIProxyAPI/v6/internal/util" | |
| coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" | |
| log "github.com/sirupsen/logrus" | |
| ) | |
| // CodexAuthenticator implements the OAuth login flow for Codex accounts. | |
| type CodexAuthenticator struct { | |
| CallbackPort int | |
| } | |
| // NewCodexAuthenticator constructs a Codex authenticator with default settings. | |
| func NewCodexAuthenticator() *CodexAuthenticator { | |
| return &CodexAuthenticator{CallbackPort: 1455} | |
| } | |
| func (a *CodexAuthenticator) Provider() string { | |
| return "codex" | |
| } | |
| func (a *CodexAuthenticator) RefreshLead() *time.Duration { | |
| return new(5 * 24 * time.Hour) | |
| } | |
| func (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { | |
| if cfg == nil { | |
| return nil, fmt.Errorf("cliproxy auth: configuration is required") | |
| } | |
| if ctx == nil { | |
| ctx = context.Background() | |
| } | |
| if opts == nil { | |
| opts = &LoginOptions{} | |
| } | |
| callbackPort := a.CallbackPort | |
| if opts.CallbackPort > 0 { | |
| callbackPort = opts.CallbackPort | |
| } | |
| pkceCodes, err := codex.GeneratePKCECodes() | |
| if err != nil { | |
| return nil, fmt.Errorf("codex pkce generation failed: %w", err) | |
| } | |
| state, err := misc.GenerateRandomState() | |
| if err != nil { | |
| return nil, fmt.Errorf("codex state generation failed: %w", err) | |
| } | |
| oauthServer := codex.NewOAuthServer(callbackPort) | |
| if err = oauthServer.Start(); err != nil { | |
| if strings.Contains(err.Error(), "already in use") { | |
| return nil, codex.NewAuthenticationError(codex.ErrPortInUse, err) | |
| } | |
| return nil, codex.NewAuthenticationError(codex.ErrServerStartFailed, err) | |
| } | |
| defer func() { | |
| stopCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| defer cancel() | |
| if stopErr := oauthServer.Stop(stopCtx); stopErr != nil { | |
| log.Warnf("codex oauth server stop error: %v", stopErr) | |
| } | |
| }() | |
| authSvc := codex.NewCodexAuth(cfg) | |
| authURL, err := authSvc.GenerateAuthURL(state, pkceCodes) | |
| if err != nil { | |
| return nil, fmt.Errorf("codex authorization url generation failed: %w", err) | |
| } | |
| if !opts.NoBrowser { | |
| fmt.Println("Opening browser for Codex authentication") | |
| if !browser.IsAvailable() { | |
| log.Warn("No browser available; please open the URL manually") | |
| util.PrintSSHTunnelInstructions(callbackPort) | |
| fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) | |
| } else if err = browser.OpenURL(authURL); err != nil { | |
| log.Warnf("Failed to open browser automatically: %v", err) | |
| util.PrintSSHTunnelInstructions(callbackPort) | |
| fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) | |
| } | |
| } else { | |
| util.PrintSSHTunnelInstructions(callbackPort) | |
| fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) | |
| } | |
| fmt.Println("Waiting for Codex authentication callback...") | |
| callbackCh := make(chan *codex.OAuthResult, 1) | |
| callbackErrCh := make(chan error, 1) | |
| manualDescription := "" | |
| go func() { | |
| result, errWait := oauthServer.WaitForCallback(5 * time.Minute) | |
| if errWait != nil { | |
| callbackErrCh <- errWait | |
| return | |
| } | |
| callbackCh <- result | |
| }() | |
| var result *codex.OAuthResult | |
| var manualPromptTimer *time.Timer | |
| var manualPromptC <-chan time.Time | |
| if opts.Prompt != nil { | |
| manualPromptTimer = time.NewTimer(15 * time.Second) | |
| manualPromptC = manualPromptTimer.C | |
| defer manualPromptTimer.Stop() | |
| } | |
| waitForCallback: | |
| for { | |
| select { | |
| case result = <-callbackCh: | |
| break waitForCallback | |
| case err = <-callbackErrCh: | |
| if strings.Contains(err.Error(), "timeout") { | |
| return nil, codex.NewAuthenticationError(codex.ErrCallbackTimeout, err) | |
| } | |
| return nil, err | |
| case <-manualPromptC: | |
| manualPromptC = nil | |
| if manualPromptTimer != nil { | |
| manualPromptTimer.Stop() | |
| } | |
| select { | |
| case result = <-callbackCh: | |
| break waitForCallback | |
| case err = <-callbackErrCh: | |
| if strings.Contains(err.Error(), "timeout") { | |
| return nil, codex.NewAuthenticationError(codex.ErrCallbackTimeout, err) | |
| } | |
| return nil, err | |
| default: | |
| } | |
| input, errPrompt := opts.Prompt("Paste the Codex callback URL (or press Enter to keep waiting): ") | |
| if errPrompt != nil { | |
| return nil, errPrompt | |
| } | |
| parsed, errParse := misc.ParseOAuthCallback(input) | |
| if errParse != nil { | |
| return nil, errParse | |
| } | |
| if parsed == nil { | |
| continue | |
| } | |
| manualDescription = parsed.ErrorDescription | |
| result = &codex.OAuthResult{ | |
| Code: parsed.Code, | |
| State: parsed.State, | |
| Error: parsed.Error, | |
| } | |
| break waitForCallback | |
| } | |
| } | |
| if result.Error != "" { | |
| return nil, codex.NewOAuthError(result.Error, manualDescription, http.StatusBadRequest) | |
| } | |
| if result.State != state { | |
| return nil, codex.NewAuthenticationError(codex.ErrInvalidState, fmt.Errorf("state mismatch")) | |
| } | |
| log.Debug("Codex authorization code received; exchanging for tokens") | |
| authBundle, err := authSvc.ExchangeCodeForTokens(ctx, result.Code, pkceCodes) | |
| if err != nil { | |
| return nil, codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, err) | |
| } | |
| tokenStorage := authSvc.CreateTokenStorage(authBundle) | |
| if tokenStorage == nil || tokenStorage.Email == "" { | |
| return nil, fmt.Errorf("codex token storage missing account information") | |
| } | |
| planType := "" | |
| hashAccountID := "" | |
| if tokenStorage.IDToken != "" { | |
| if claims, errParse := codex.ParseJWTToken(tokenStorage.IDToken); errParse == nil && claims != nil { | |
| planType = strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType) | |
| accountID := strings.TrimSpace(claims.CodexAuthInfo.ChatgptAccountID) | |
| if accountID != "" { | |
| digest := sha256.Sum256([]byte(accountID)) | |
| hashAccountID = hex.EncodeToString(digest[:])[:8] | |
| } | |
| } | |
| } | |
| fileName := codex.CredentialFileName(tokenStorage.Email, planType, hashAccountID, true) | |
| metadata := map[string]any{ | |
| "email": tokenStorage.Email, | |
| } | |
| fmt.Println("Codex authentication successful") | |
| if authBundle.APIKey != "" { | |
| fmt.Println("Codex API key obtained and stored") | |
| } | |
| return &coreauth.Auth{ | |
| ID: fileName, | |
| Provider: a.Provider(), | |
| FileName: fileName, | |
| Storage: tokenStorage, | |
| Metadata: metadata, | |
| }, nil | |
| } | |