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 { d := 5 * 24 * time.Hour return &d} 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}
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 {
d := 5 * 24 * time.Hour
return &d
}
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
}