mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-28 08:36:09 +08:00
Merge pull request #1683 from dusty-du/codex/device-login-flow
Add additive Codex device-code login flow
This commit is contained in:
@@ -58,6 +58,7 @@ func main() {
|
||||
// Command-line flags to control the application's behavior.
|
||||
var login bool
|
||||
var codexLogin bool
|
||||
var codexDeviceLogin bool
|
||||
var claudeLogin bool
|
||||
var qwenLogin bool
|
||||
var iflowLogin bool
|
||||
@@ -76,6 +77,7 @@ func main() {
|
||||
// Define command-line flags for different operation modes.
|
||||
flag.BoolVar(&login, "login", false, "Login Google Account")
|
||||
flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth")
|
||||
flag.BoolVar(&codexDeviceLogin, "codex-device-login", false, "Login to Codex using device code flow")
|
||||
flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth")
|
||||
flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth")
|
||||
flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
|
||||
@@ -467,6 +469,9 @@ func main() {
|
||||
} else if codexLogin {
|
||||
// Handle Codex login
|
||||
cmd.DoCodexLogin(cfg, options)
|
||||
} else if codexDeviceLogin {
|
||||
// Handle Codex device-code login
|
||||
cmd.DoCodexDeviceLogin(cfg, options)
|
||||
} else if claudeLogin {
|
||||
// Handle Claude login
|
||||
cmd.DoClaudeLogin(cfg, options)
|
||||
|
||||
@@ -71,16 +71,26 @@ func (o *CodexAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string,
|
||||
// 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) {
|
||||
return o.ExchangeCodeForTokensWithRedirect(ctx, code, RedirectURI, pkceCodes)
|
||||
}
|
||||
|
||||
// ExchangeCodeForTokensWithRedirect exchanges an authorization code for tokens using
|
||||
// a caller-provided redirect URI. This supports alternate auth flows such as device
|
||||
// login while preserving the existing token parsing and storage behavior.
|
||||
func (o *CodexAuth) ExchangeCodeForTokensWithRedirect(ctx context.Context, code, redirectURI string, pkceCodes *PKCECodes) (*CodexAuthBundle, error) {
|
||||
if pkceCodes == nil {
|
||||
return nil, fmt.Errorf("PKCE codes are required for token exchange")
|
||||
}
|
||||
if strings.TrimSpace(redirectURI) == "" {
|
||||
return nil, fmt.Errorf("redirect URI is required for token exchange")
|
||||
}
|
||||
|
||||
// Prepare token exchange request
|
||||
data := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"client_id": {ClientID},
|
||||
"code": {code},
|
||||
"redirect_uri": {RedirectURI},
|
||||
"redirect_uri": {strings.TrimSpace(redirectURI)},
|
||||
"code_verifier": {pkceCodes.CodeVerifier},
|
||||
}
|
||||
|
||||
|
||||
60
internal/cmd/openai_device_login.go
Normal file
60
internal/cmd/openai_device_login.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
codexLoginModeMetadataKey = "codex_login_mode"
|
||||
codexLoginModeDevice = "device"
|
||||
)
|
||||
|
||||
// DoCodexDeviceLogin triggers the Codex device-code flow while keeping the
|
||||
// existing codex-login OAuth callback flow intact.
|
||||
func DoCodexDeviceLogin(cfg *config.Config, options *LoginOptions) {
|
||||
if options == nil {
|
||||
options = &LoginOptions{}
|
||||
}
|
||||
|
||||
promptFn := options.Prompt
|
||||
if promptFn == nil {
|
||||
promptFn = defaultProjectPrompt()
|
||||
}
|
||||
|
||||
manager := newAuthManager()
|
||||
|
||||
authOpts := &sdkAuth.LoginOptions{
|
||||
NoBrowser: options.NoBrowser,
|
||||
CallbackPort: options.CallbackPort,
|
||||
Metadata: map[string]string{
|
||||
codexLoginModeMetadataKey: codexLoginModeDevice,
|
||||
},
|
||||
Prompt: promptFn,
|
||||
}
|
||||
|
||||
_, savedPath, err := manager.Login(context.Background(), "codex", cfg, authOpts)
|
||||
if err != nil {
|
||||
if authErr, ok := errors.AsType[*codex.AuthenticationError](err); ok {
|
||||
log.Error(codex.GetUserFriendlyMessage(authErr))
|
||||
if authErr.Type == codex.ErrPortInUse.Type {
|
||||
os.Exit(codex.ErrPortInUse.Code)
|
||||
}
|
||||
return
|
||||
}
|
||||
fmt.Printf("Codex device authentication failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if savedPath != "" {
|
||||
fmt.Printf("Authentication saved to %s\n", savedPath)
|
||||
}
|
||||
fmt.Println("Codex device authentication successful!")
|
||||
}
|
||||
@@ -2,8 +2,6 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -48,6 +46,10 @@ func (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts
|
||||
opts = &LoginOptions{}
|
||||
}
|
||||
|
||||
if shouldUseCodexDeviceFlow(opts) {
|
||||
return a.loginWithDeviceFlow(ctx, cfg, opts)
|
||||
}
|
||||
|
||||
callbackPort := a.CallbackPort
|
||||
if opts.CallbackPort > 0 {
|
||||
callbackPort = opts.CallbackPort
|
||||
@@ -186,39 +188,5 @@ waitForCallback:
|
||||
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
|
||||
return a.buildAuthRecord(authSvc, authBundle)
|
||||
}
|
||||
|
||||
291
sdk/auth/codex_device.go
Normal file
291
sdk/auth/codex_device.go
Normal file
@@ -0,0 +1,291 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user