feat(auth): add proxy URL override support to auth constructors and executors

- Introduced `WithProxyURL` variants for `CodexAuth`, `ClaudeAuth`, `IFlowAuth`, and `DeviceFlowClient`.
- Updated executors to use proxy-aware constructors for improved configurability.
- Added unit tests to validate proxy override precedence and functionality.

Closes: #2823
This commit is contained in:
Luis Pater
2026-04-16 22:11:39 +08:00
parent 7b03f04670
commit d949921143
12 changed files with 226 additions and 9 deletions

View File

@@ -59,10 +59,30 @@ type ClaudeAuth struct {
// Returns:
// - *ClaudeAuth: A new Claude authentication service instance
func NewClaudeAuth(cfg *config.Config) *ClaudeAuth {
return NewClaudeAuthWithProxyURL(cfg, "")
}
// NewClaudeAuthWithProxyURL creates a new Anthropic authentication service with a proxy override.
// proxyURL takes precedence over cfg.ProxyURL when non-empty.
func NewClaudeAuthWithProxyURL(cfg *config.Config, proxyURL string) *ClaudeAuth {
effectiveProxyURL := strings.TrimSpace(proxyURL)
var sdkCfg *config.SDKConfig
if cfg != nil {
sdkCfgCopy := cfg.SDKConfig
if effectiveProxyURL == "" {
effectiveProxyURL = strings.TrimSpace(cfg.ProxyURL)
}
sdkCfgCopy.ProxyURL = effectiveProxyURL
sdkCfg = &sdkCfgCopy
} else if effectiveProxyURL != "" {
sdkCfgCopy := config.SDKConfig{ProxyURL: effectiveProxyURL}
sdkCfg = &sdkCfgCopy
}
// Use custom HTTP client with Firefox TLS fingerprint to bypass
// Cloudflare's bot detection on Anthropic domains
return &ClaudeAuth{
httpClient: NewAnthropicHttpClient(&cfg.SDKConfig),
httpClient: NewAnthropicHttpClient(sdkCfg),
}
}

View File

@@ -0,0 +1,33 @@
package claude
import (
"testing"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"golang.org/x/net/proxy"
)
func TestNewClaudeAuthWithProxyURL_OverrideDirectTakesPrecedence(t *testing.T) {
cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "socks5://proxy.example.com:1080"}}
auth := NewClaudeAuthWithProxyURL(cfg, "direct")
transport, ok := auth.httpClient.Transport.(*utlsRoundTripper)
if !ok || transport == nil {
t.Fatalf("expected utlsRoundTripper, got %T", auth.httpClient.Transport)
}
if transport.dialer != proxy.Direct {
t.Fatalf("expected proxy.Direct, got %T", transport.dialer)
}
}
func TestNewClaudeAuthWithProxyURL_OverrideProxyAppliedWithoutConfig(t *testing.T) {
auth := NewClaudeAuthWithProxyURL(nil, "socks5://proxy.example.com:1080")
transport, ok := auth.httpClient.Transport.(*utlsRoundTripper)
if !ok || transport == nil {
t.Fatalf("expected utlsRoundTripper, got %T", auth.httpClient.Transport)
}
if transport.dialer == proxy.Direct {
t.Fatalf("expected proxy dialer, got %T", transport.dialer)
}
}

View File

@@ -37,8 +37,23 @@ type CodexAuth struct {
// NewCodexAuth creates a new CodexAuth service instance.
// It initializes an HTTP client with proxy settings from the provided configuration.
func NewCodexAuth(cfg *config.Config) *CodexAuth {
return NewCodexAuthWithProxyURL(cfg, "")
}
// NewCodexAuthWithProxyURL creates a new CodexAuth service instance.
// proxyURL takes precedence over cfg.ProxyURL when non-empty.
func NewCodexAuthWithProxyURL(cfg *config.Config, proxyURL string) *CodexAuth {
effectiveProxyURL := strings.TrimSpace(proxyURL)
var sdkCfg config.SDKConfig
if cfg != nil {
sdkCfg = cfg.SDKConfig
if effectiveProxyURL == "" {
effectiveProxyURL = strings.TrimSpace(cfg.ProxyURL)
}
}
sdkCfg.ProxyURL = effectiveProxyURL
return &CodexAuth{
httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),
httpClient: util.SetProxy(&sdkCfg, &http.Client{}),
}
}

View File

@@ -7,6 +7,8 @@ import (
"strings"
"sync/atomic"
"testing"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -42,3 +44,37 @@ func TestRefreshTokensWithRetry_NonRetryableOnlyAttemptsOnce(t *testing.T) {
t.Fatalf("expected 1 refresh attempt, got %d", got)
}
}
func TestNewCodexAuthWithProxyURL_OverrideDirectDisablesProxy(t *testing.T) {
cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://proxy.example.com:8080"}}
auth := NewCodexAuthWithProxyURL(cfg, "direct")
transport, ok := auth.httpClient.Transport.(*http.Transport)
if !ok || transport == nil {
t.Fatalf("expected http.Transport, got %T", auth.httpClient.Transport)
}
if transport.Proxy != nil {
t.Fatal("expected direct transport to disable proxy function")
}
}
func TestNewCodexAuthWithProxyURL_OverrideProxyTakesPrecedence(t *testing.T) {
cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://global.example.com:8080"}}
auth := NewCodexAuthWithProxyURL(cfg, "http://override.example.com:8081")
transport, ok := auth.httpClient.Transport.(*http.Transport)
if !ok || transport == nil {
t.Fatalf("expected http.Transport, got %T", auth.httpClient.Transport)
}
req, errReq := http.NewRequest(http.MethodGet, "https://example.com", nil)
if errReq != nil {
t.Fatalf("new request: %v", errReq)
}
proxyURL, errProxy := transport.Proxy(req)
if errProxy != nil {
t.Fatalf("proxy func: %v", errProxy)
}
if proxyURL == nil || proxyURL.String() != "http://override.example.com:8081" {
t.Fatalf("proxy URL = %v, want http://override.example.com:8081", proxyURL)
}
}

View File

@@ -48,8 +48,23 @@ type IFlowAuth struct {
// NewIFlowAuth constructs a new IFlowAuth with proxy-aware transport.
func NewIFlowAuth(cfg *config.Config) *IFlowAuth {
return NewIFlowAuthWithProxyURL(cfg, "")
}
// NewIFlowAuthWithProxyURL constructs a new IFlowAuth with a proxy override.
// proxyURL takes precedence over cfg.ProxyURL when non-empty.
func NewIFlowAuthWithProxyURL(cfg *config.Config, proxyURL string) *IFlowAuth {
client := &http.Client{Timeout: 30 * time.Second}
return &IFlowAuth{httpClient: util.SetProxy(&cfg.SDKConfig, client)}
effectiveProxyURL := strings.TrimSpace(proxyURL)
var sdkCfg config.SDKConfig
if cfg != nil {
sdkCfg = cfg.SDKConfig
if effectiveProxyURL == "" {
effectiveProxyURL = strings.TrimSpace(cfg.ProxyURL)
}
}
sdkCfg.ProxyURL = effectiveProxyURL
return &IFlowAuth{httpClient: util.SetProxy(&sdkCfg, client)}
}
// AuthorizationURL builds the authorization URL and matching redirect URI.

View File

@@ -0,0 +1,42 @@
package iflow
import (
"net/http"
"testing"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
)
func TestNewIFlowAuthWithProxyURL_OverrideDirectDisablesProxy(t *testing.T) {
cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://proxy.example.com:8080"}}
auth := NewIFlowAuthWithProxyURL(cfg, "direct")
transport, ok := auth.httpClient.Transport.(*http.Transport)
if !ok || transport == nil {
t.Fatalf("expected http.Transport, got %T", auth.httpClient.Transport)
}
if transport.Proxy != nil {
t.Fatal("expected direct transport to disable proxy function")
}
}
func TestNewIFlowAuthWithProxyURL_OverrideProxyTakesPrecedence(t *testing.T) {
cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://global.example.com:8080"}}
auth := NewIFlowAuthWithProxyURL(cfg, "http://override.example.com:8081")
transport, ok := auth.httpClient.Transport.(*http.Transport)
if !ok || transport == nil {
t.Fatalf("expected http.Transport, got %T", auth.httpClient.Transport)
}
req, errReq := http.NewRequest(http.MethodGet, "https://example.com", nil)
if errReq != nil {
t.Fatalf("new request: %v", errReq)
}
proxyURL, errProxy := transport.Proxy(req)
if errProxy != nil {
t.Fatalf("proxy func: %v", errProxy)
}
if proxyURL == nil || proxyURL.String() != "http://override.example.com:8081" {
t.Fatalf("proxy URL = %v, want http://override.example.com:8081", proxyURL)
}
}

View File

@@ -102,10 +102,24 @@ func NewDeviceFlowClient(cfg *config.Config) *DeviceFlowClient {
// NewDeviceFlowClientWithDeviceID creates a new device flow client with the specified device ID.
func NewDeviceFlowClientWithDeviceID(cfg *config.Config, deviceID string) *DeviceFlowClient {
return NewDeviceFlowClientWithDeviceIDAndProxyURL(cfg, deviceID, "")
}
// NewDeviceFlowClientWithDeviceIDAndProxyURL creates a new device flow client with a proxy override.
// proxyURL takes precedence over cfg.ProxyURL when non-empty.
func NewDeviceFlowClientWithDeviceIDAndProxyURL(cfg *config.Config, deviceID string, proxyURL string) *DeviceFlowClient {
client := &http.Client{Timeout: 30 * time.Second}
effectiveProxyURL := strings.TrimSpace(proxyURL)
var sdkCfg config.SDKConfig
if cfg != nil {
client = util.SetProxy(&cfg.SDKConfig, client)
sdkCfg = cfg.SDKConfig
if effectiveProxyURL == "" {
effectiveProxyURL = strings.TrimSpace(cfg.ProxyURL)
}
}
sdkCfg.ProxyURL = effectiveProxyURL
client = util.SetProxy(&sdkCfg, client)
resolvedDeviceID := strings.TrimSpace(deviceID)
if resolvedDeviceID == "" {
resolvedDeviceID = getOrCreateDeviceID()

View File

@@ -0,0 +1,42 @@
package kimi
import (
"net/http"
"testing"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
)
func TestNewDeviceFlowClientWithDeviceIDAndProxyURL_OverrideDirectDisablesProxy(t *testing.T) {
cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://proxy.example.com:8080"}}
client := NewDeviceFlowClientWithDeviceIDAndProxyURL(cfg, "device-1", "direct")
transport, ok := client.httpClient.Transport.(*http.Transport)
if !ok || transport == nil {
t.Fatalf("expected http.Transport, got %T", client.httpClient.Transport)
}
if transport.Proxy != nil {
t.Fatal("expected direct transport to disable proxy function")
}
}
func TestNewDeviceFlowClientWithDeviceIDAndProxyURL_OverrideProxyTakesPrecedence(t *testing.T) {
cfg := &config.Config{SDKConfig: config.SDKConfig{ProxyURL: "http://global.example.com:8080"}}
client := NewDeviceFlowClientWithDeviceIDAndProxyURL(cfg, "device-1", "http://override.example.com:8081")
transport, ok := client.httpClient.Transport.(*http.Transport)
if !ok || transport == nil {
t.Fatalf("expected http.Transport, got %T", client.httpClient.Transport)
}
req, errReq := http.NewRequest(http.MethodGet, "https://example.com", nil)
if errReq != nil {
t.Fatalf("new request: %v", errReq)
}
proxyURL, errProxy := transport.Proxy(req)
if errProxy != nil {
t.Fatalf("proxy func: %v", errProxy)
}
if proxyURL == nil || proxyURL.String() != "http://override.example.com:8081" {
t.Fatalf("proxy URL = %v, want http://override.example.com:8081", proxyURL)
}
}

View File

@@ -659,7 +659,7 @@ func (e *ClaudeExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (
if refreshToken == "" {
return auth, nil
}
svc := claudeauth.NewClaudeAuth(e.cfg)
svc := claudeauth.NewClaudeAuthWithProxyURL(e.cfg, auth.ProxyURL)
td, err := svc.RefreshTokens(ctx, refreshToken)
if err != nil {
return nil, err

View File

@@ -612,7 +612,7 @@ func (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*
if refreshToken == "" {
return auth, nil
}
svc := codexauth.NewCodexAuth(e.cfg)
svc := codexauth.NewCodexAuthWithProxyURL(e.cfg, auth.ProxyURL)
td, err := svc.RefreshTokensWithRetry(ctx, refreshToken, 3)
if err != nil {
return nil, err

View File

@@ -381,7 +381,7 @@ func (e *IFlowExecutor) refreshCookieBased(ctx context.Context, auth *cliproxyau
log.Infof("iflow executor: refreshing cookie-based API key for user: %s", email)
svc := iflowauth.NewIFlowAuth(e.cfg)
svc := iflowauth.NewIFlowAuthWithProxyURL(e.cfg, auth.ProxyURL)
keyData, err := svc.RefreshAPIKey(ctx, cookie, email)
if err != nil {
log.Errorf("iflow executor: cookie-based API key refresh failed: %v", err)
@@ -429,7 +429,7 @@ func (e *IFlowExecutor) refreshOAuthBased(ctx context.Context, auth *cliproxyaut
log.Debugf("iflow executor: refreshing access token, old: %s", util.HideAPIKey(oldAccessToken))
}
svc := iflowauth.NewIFlowAuth(e.cfg)
svc := iflowauth.NewIFlowAuthWithProxyURL(e.cfg, auth.ProxyURL)
tokenData, err := svc.RefreshTokens(ctx, refreshToken)
if err != nil {
log.Errorf("iflow executor: token refresh failed: %v", err)

View File

@@ -472,7 +472,7 @@ func (e *KimiExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*c
return auth, nil
}
client := kimiauth.NewDeviceFlowClientWithDeviceID(e.cfg, resolveKimiDeviceID(auth))
client := kimiauth.NewDeviceFlowClientWithDeviceIDAndProxyURL(e.cfg, resolveKimiDeviceID(auth), auth.ProxyURL)
td, err := client.RefreshToken(ctx, refreshToken)
if err != nil {
return nil, err