package auth import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex" "github.com/router-for-me/CLIProxyAPI/v6/internal/browser" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "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" ) const ( codexLoginModeMetadataKey = "codex_login_mode" codexLoginModeDevice = "device" codexDeviceUserCodeURL = "https://auth.openai.com/api/accounts/deviceauth/usercode" codexDeviceTokenURL = "https://auth.openai.com/api/accounts/deviceauth/token" codexDeviceVerificationURL = "https://auth.openai.com/codex/device" codexDeviceTokenExchangeRedirectURI = "https://auth.openai.com/deviceauth/callback" codexDeviceTimeout = 15 * time.Minute codexDeviceDefaultPollIntervalSeconds = 5 ) type codexDeviceUserCodeRequest struct { ClientID string `json:"client_id"` } type codexDeviceUserCodeResponse struct { DeviceAuthID string `json:"device_auth_id"` UserCode string `json:"user_code"` UserCodeAlt string `json:"usercode"` Interval json.RawMessage `json:"interval"` } type codexDeviceTokenRequest struct { DeviceAuthID string `json:"device_auth_id"` UserCode string `json:"user_code"` } type codexDeviceTokenResponse struct { AuthorizationCode string `json:"authorization_code"` CodeVerifier string `json:"code_verifier"` CodeChallenge string `json:"code_challenge"` } func shouldUseCodexDeviceFlow(opts *LoginOptions) bool { if opts == nil || opts.Metadata == nil { return false } return strings.EqualFold(strings.TrimSpace(opts.Metadata[codexLoginModeMetadataKey]), codexLoginModeDevice) } func (a *CodexAuthenticator) loginWithDeviceFlow(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { if ctx == nil { ctx = context.Background() } httpClient := util.SetProxy(&cfg.SDKConfig, &http.Client{}) userCodeResp, err := requestCodexDeviceUserCode(ctx, httpClient) if err != nil { return nil, err } deviceCode := strings.TrimSpace(userCodeResp.UserCode) if deviceCode == "" { deviceCode = strings.TrimSpace(userCodeResp.UserCodeAlt) } deviceAuthID := strings.TrimSpace(userCodeResp.DeviceAuthID) if deviceCode == "" || deviceAuthID == "" { return nil, fmt.Errorf("codex device flow did not return required fields") } pollInterval := parseCodexDevicePollInterval(userCodeResp.Interval) fmt.Println("Starting Codex device authentication...") fmt.Printf("Codex device URL: %s\n", codexDeviceVerificationURL) fmt.Printf("Codex device code: %s\n", deviceCode) if !opts.NoBrowser { if !browser.IsAvailable() { log.Warn("No browser available; please open the device URL manually") } else if errOpen := browser.OpenURL(codexDeviceVerificationURL); errOpen != nil { log.Warnf("Failed to open browser automatically: %v", errOpen) } } tokenResp, err := pollCodexDeviceToken(ctx, httpClient, deviceAuthID, deviceCode, pollInterval) if err != nil { return nil, err } authCode := strings.TrimSpace(tokenResp.AuthorizationCode) codeVerifier := strings.TrimSpace(tokenResp.CodeVerifier) codeChallenge := strings.TrimSpace(tokenResp.CodeChallenge) if authCode == "" || codeVerifier == "" || codeChallenge == "" { return nil, fmt.Errorf("codex device flow token response missing required fields") } authSvc := codex.NewCodexAuth(cfg) authBundle, err := authSvc.ExchangeCodeForTokensWithRedirect( ctx, authCode, codexDeviceTokenExchangeRedirectURI, &codex.PKCECodes{ CodeVerifier: codeVerifier, CodeChallenge: codeChallenge, }, ) if err != nil { return nil, codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, err) } return a.buildAuthRecord(authSvc, authBundle) } func requestCodexDeviceUserCode(ctx context.Context, client *http.Client) (*codexDeviceUserCodeResponse, error) { body, err := json.Marshal(codexDeviceUserCodeRequest{ClientID: codex.ClientID}) if err != nil { return nil, fmt.Errorf("failed to encode codex device request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, codexDeviceUserCodeURL, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("failed to create codex device request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to request codex device code: %w", err) } defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read codex device code response: %w", err) } if !codexDeviceIsSuccessStatus(resp.StatusCode) { trimmed := strings.TrimSpace(string(respBody)) if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("codex device endpoint is unavailable (status %d)", resp.StatusCode) } if trimmed == "" { trimmed = "empty response body" } return nil, fmt.Errorf("codex device code request failed with status %d: %s", resp.StatusCode, trimmed) } var parsed codexDeviceUserCodeResponse if err := json.Unmarshal(respBody, &parsed); err != nil { return nil, fmt.Errorf("failed to decode codex device code response: %w", err) } return &parsed, nil } func pollCodexDeviceToken(ctx context.Context, client *http.Client, deviceAuthID, userCode string, interval time.Duration) (*codexDeviceTokenResponse, error) { deadline := time.Now().Add(codexDeviceTimeout) for { if time.Now().After(deadline) { return nil, fmt.Errorf("codex device authentication timed out after 15 minutes") } body, err := json.Marshal(codexDeviceTokenRequest{ DeviceAuthID: deviceAuthID, UserCode: userCode, }) if err != nil { return nil, fmt.Errorf("failed to encode codex device poll request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, codexDeviceTokenURL, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("failed to create codex device poll request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to poll codex device token: %w", err) } respBody, readErr := io.ReadAll(resp.Body) _ = resp.Body.Close() if readErr != nil { return nil, fmt.Errorf("failed to read codex device poll response: %w", readErr) } switch { case codexDeviceIsSuccessStatus(resp.StatusCode): var parsed codexDeviceTokenResponse if err := json.Unmarshal(respBody, &parsed); err != nil { return nil, fmt.Errorf("failed to decode codex device token response: %w", err) } return &parsed, nil case resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound: select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(interval): continue } default: trimmed := strings.TrimSpace(string(respBody)) if trimmed == "" { trimmed = "empty response body" } return nil, fmt.Errorf("codex device token polling failed with status %d: %s", resp.StatusCode, trimmed) } } } func parseCodexDevicePollInterval(raw json.RawMessage) time.Duration { defaultInterval := time.Duration(codexDeviceDefaultPollIntervalSeconds) * time.Second if len(raw) == 0 { return defaultInterval } var asString string if err := json.Unmarshal(raw, &asString); err == nil { if seconds, convErr := strconv.Atoi(strings.TrimSpace(asString)); convErr == nil && seconds > 0 { return time.Duration(seconds) * time.Second } } var asInt int if err := json.Unmarshal(raw, &asInt); err == nil && asInt > 0 { return time.Duration(asInt) * time.Second } return defaultInterval } func codexDeviceIsSuccessStatus(code int) bool { return code >= 200 && code < 300 } func (a *CodexAuthenticator) buildAuthRecord(authSvc *codex.CodexAuth, authBundle *codex.CodexAuthBundle) (*coreauth.Auth, error) { 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 }