mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-28 10:16:07 +08:00
292 lines
9.1 KiB
Go
292 lines
9.1 KiB
Go
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
|
|
}
|