diff --git a/internal/auth/claude/anthropic_auth.go b/internal/auth/claude/anthropic_auth.go index d7ca1542..111af75d 100644 --- a/internal/auth/claude/anthropic_auth.go +++ b/internal/auth/claude/anthropic_auth.go @@ -27,8 +27,10 @@ const ( ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" RedirectURI = "http://localhost:54545/callback" - claudeRefreshMinBackoff = 5 * time.Second - claudeRefreshMaxBackoff = 5 * time.Minute + claudeRefreshMinBackoff = 5 * time.Second + claudeRefreshMaxBackoff = 5 * time.Minute + claudeRefreshTimeout = 30 * time.Second + claudeRefreshHandshakeTimeout = 10 * time.Second ) var ( @@ -331,6 +333,9 @@ func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*C if refreshToken == "" { return nil, fmt.Errorf("refresh token is required") } + if ctx == nil { + ctx = context.Background() + } if blockedUntil := claudeRefreshBlockedUntil(refreshToken); blockedUntil.After(time.Now()) { return nil, &refreshHTTPError{ status: http.StatusTooManyRequests, @@ -340,7 +345,10 @@ func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*C } result, err, _ := claudeRefreshGroup.Do(refreshToken, func() (interface{}, error) { - return o.refreshTokensSingleFlight(context.WithoutCancel(ctx), refreshToken) + refreshCtx, cancelRefresh := context.WithTimeout(context.WithoutCancel(ctx), claudeRefreshTimeout) + defer cancelRefresh() + refreshCtx = context.WithValue(refreshCtx, claudeRefreshHandshakeTimeoutContextKey{}, claudeRefreshHandshakeTimeout) + return o.refreshTokensSingleFlight(refreshCtx, refreshToken) }) if err != nil { return nil, err diff --git a/internal/auth/claude/anthropic_auth_test.go b/internal/auth/claude/anthropic_auth_test.go index 0b14d083..2aead804 100644 --- a/internal/auth/claude/anthropic_auth_test.go +++ b/internal/auth/claude/anthropic_auth_test.go @@ -17,6 +17,49 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } +func TestNewAnthropicHttpClientDoesNotSetRequestTimeout(t *testing.T) { + if got := NewAnthropicHttpClient(nil).Timeout; got != 0 { + t.Fatalf("HTTP client timeout = %s, want zero", got) + } +} + +func TestRefreshTokens_UsesIndependentTimeout(t *testing.T) { + resetClaudeRefreshState() + defer resetClaudeRefreshState() + + callerCtx, cancelCaller := context.WithCancel(context.Background()) + cancelCaller() + var requestDeadline time.Time + auth := &ClaudeAuth{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + var ok bool + requestDeadline, ok = req.Context().Deadline() + if !ok { + t.Fatal("refresh request has no deadline") + } + if errContext := req.Context().Err(); errContext != nil { + t.Fatalf("refresh request context is already done: %v", errContext) + } + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(`{"error":"probe"}`)), + Header: make(http.Header), + Request: req, + }, nil + }), + }, + } + + _, err := auth.RefreshTokens(callerCtx, "independent-timeout-token") + if err == nil { + t.Fatal("expected refresh error") + } + if requestDeadline.IsZero() || !requestDeadline.After(time.Now()) { + t.Fatalf("refresh deadline = %v, want a future deadline", requestDeadline) + } +} + func TestRefreshTokensWithRetry_429BlocksImmediateReplay(t *testing.T) { resetClaudeRefreshState() defer resetClaudeRefreshState() diff --git a/internal/auth/claude/utls_transport.go b/internal/auth/claude/utls_transport.go index bb82e7dd..79543ee3 100644 --- a/internal/auth/claude/utls_transport.go +++ b/internal/auth/claude/utls_transport.go @@ -3,9 +3,11 @@ package claude import ( + "fmt" "net/http" "strings" "sync" + "time" tls "github.com/refraction-networking/utls" "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" @@ -15,6 +17,8 @@ import ( "golang.org/x/net/proxy" ) +type claudeRefreshHandshakeTimeoutContextKey struct{} + // utlsRoundTripper implements http.RoundTripper using utls with Chrome fingerprint // to bypass Cloudflare's TLS fingerprinting on Anthropic domains. type utlsRoundTripper struct { @@ -50,7 +54,7 @@ func newUtlsRoundTripper(cfg *config.SDKConfig) *utlsRoundTripper { // getOrCreateConnection gets an existing connection or creates a new one. // It uses a per-host locking mechanism to prevent multiple goroutines from // creating connections to the same host simultaneously. -func (t *utlsRoundTripper) getOrCreateConnection(host, addr string) (*http2.ClientConn, error) { +func (t *utlsRoundTripper) getOrCreateConnection(host, addr string, handshakeTimeout time.Duration) (*http2.ClientConn, error) { t.mu.Lock() // Check if connection exists and is usable @@ -77,7 +81,7 @@ func (t *utlsRoundTripper) getOrCreateConnection(host, addr string) (*http2.Clie t.mu.Unlock() // Create connection outside the lock - h2Conn, err := t.createConnection(host, addr) + h2Conn, err := t.createConnection(host, addr, handshakeTimeout) t.mu.Lock() defer t.mu.Unlock() @@ -98,25 +102,38 @@ func (t *utlsRoundTripper) getOrCreateConnection(host, addr string) (*http2.Clie // createConnection creates a new HTTP/2 connection with Chrome TLS fingerprint. // Chrome's TLS fingerprint is closer to Node.js/OpenSSL (which real Claude Code uses) // than Firefox, reducing the mismatch between TLS layer and HTTP headers. -func (t *utlsRoundTripper) createConnection(host, addr string) (*http2.ClientConn, error) { - conn, err := t.dialer.Dial("tcp", addr) - if err != nil { - return nil, err +func (t *utlsRoundTripper) createConnection(host, addr string, handshakeTimeout time.Duration) (*http2.ClientConn, error) { + conn, errDial := t.dialer.Dial("tcp", addr) + if errDial != nil { + return nil, errDial + } + + if handshakeTimeout > 0 { + if errSetDeadline := conn.SetDeadline(time.Now().Add(handshakeTimeout)); errSetDeadline != nil { + _ = conn.Close() + return nil, fmt.Errorf("failed to set TLS handshake deadline: %w", errSetDeadline) + } } tlsConfig := &tls.Config{ServerName: host} tlsConn := tls.UClient(conn, tlsConfig, tls.HelloChrome_Auto) - if err := tlsConn.Handshake(); err != nil { - conn.Close() - return nil, err + if errHandshake := tlsConn.Handshake(); errHandshake != nil { + _ = conn.Close() + return nil, errHandshake + } + if handshakeTimeout > 0 { + if errClearDeadline := conn.SetDeadline(time.Time{}); errClearDeadline != nil { + _ = conn.Close() + return nil, fmt.Errorf("failed to clear TLS handshake deadline: %w", errClearDeadline) + } } tr := &http2.Transport{} - h2Conn, err := tr.NewClientConn(tlsConn) - if err != nil { - tlsConn.Close() - return nil, err + h2Conn, errClientConn := tr.NewClientConn(tlsConn) + if errClientConn != nil { + _ = tlsConn.Close() + return nil, errClientConn } return h2Conn, nil @@ -133,7 +150,8 @@ func (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) // Get hostname without port for TLS ServerName hostname := req.URL.Hostname() - h2Conn, err := t.getOrCreateConnection(hostname, addr) + handshakeTimeout, _ := req.Context().Value(claudeRefreshHandshakeTimeoutContextKey{}).(time.Duration) + h2Conn, err := t.getOrCreateConnection(hostname, addr, handshakeTimeout) if err != nil { return nil, err } diff --git a/internal/auth/claude/utls_transport_test.go b/internal/auth/claude/utls_transport_test.go new file mode 100644 index 00000000..d262f563 --- /dev/null +++ b/internal/auth/claude/utls_transport_test.go @@ -0,0 +1,39 @@ +package claude + +import ( + "errors" + "net" + "testing" + "time" +) + +type claudeTestDialer struct { + conn net.Conn +} + +func (d claudeTestDialer) Dial(_, _ string) (net.Conn, error) { + return d.conn, nil +} + +func TestUtlsRoundTripperBoundsTLSHandshake(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer func() { + if errClose := serverConn.Close(); errClose != nil { + t.Errorf("server connection close returned error: %v", errClose) + } + }() + + transport := &utlsRoundTripper{dialer: claudeTestDialer{conn: clientConn}} + startedAt := time.Now() + _, err := transport.createConnection("example.com", "unused", 20*time.Millisecond) + if err == nil { + t.Fatal("expected TLS handshake timeout") + } + var netErr net.Error + if !errors.As(err, &netErr) || !netErr.Timeout() { + t.Fatalf("error = %v, want timeout error", err) + } + if elapsed := time.Since(startedAt); elapsed > time.Second { + t.Fatalf("TLS handshake took %s, want less than one second", elapsed) + } +} diff --git a/internal/auth/codex/openai_auth.go b/internal/auth/codex/openai_auth.go index 040703c2..2c1eac08 100644 --- a/internal/auth/codex/openai_auth.go +++ b/internal/auth/codex/openai_auth.go @@ -22,10 +22,11 @@ import ( // OAuth configuration constants for OpenAI Codex const ( - AuthURL = "https://auth.openai.com/oauth/authorize" - TokenURL = "https://auth.openai.com/oauth/token" - ClientID = "app_EMoamEEZ73f0CkXaXp7hrann" - RedirectURI = "http://localhost:1455/auth/callback" + AuthURL = "https://auth.openai.com/oauth/authorize" + TokenURL = "https://auth.openai.com/oauth/token" + ClientID = "app_EMoamEEZ73f0CkXaXp7hrann" + RedirectURI = "http://localhost:1455/auth/callback" + codexRefreshTimeout = 30 * time.Second ) // CodexAuth handles the OpenAI OAuth2 authentication flow. @@ -195,7 +196,9 @@ func (o *CodexAuth) RefreshTokens(ctx context.Context, refreshToken string) (*Co } result, err, _ := codexRefreshGroup.Do(refreshToken, func() (interface{}, error) { - return o.refreshTokensSingleFlight(context.WithoutCancel(ctx), refreshToken) + refreshCtx, cancelRefresh := context.WithTimeout(context.WithoutCancel(ctx), codexRefreshTimeout) + defer cancelRefresh() + return o.refreshTokensSingleFlight(refreshCtx, refreshToken) }) if err != nil { return nil, err diff --git a/internal/auth/codex/openai_auth_test.go b/internal/auth/codex/openai_auth_test.go index 20a02fd7..55942c7b 100644 --- a/internal/auth/codex/openai_auth_test.go +++ b/internal/auth/codex/openai_auth_test.go @@ -20,6 +20,49 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } +func TestNewCodexAuthDoesNotSetRequestTimeout(t *testing.T) { + if got := NewCodexAuth(nil).httpClient.Timeout; got != 0 { + t.Fatalf("HTTP client timeout = %s, want zero", got) + } +} + +func TestRefreshTokens_UsesIndependentTimeout(t *testing.T) { + resetCodexRefreshGroupForTest() + defer resetCodexRefreshGroupForTest() + + callerCtx, cancelCaller := context.WithCancel(context.Background()) + cancelCaller() + var requestDeadline time.Time + auth := &CodexAuth{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + var ok bool + requestDeadline, ok = req.Context().Deadline() + if !ok { + t.Fatal("refresh request has no deadline") + } + if errContext := req.Context().Err(); errContext != nil { + t.Fatalf("refresh request context is already done: %v", errContext) + } + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(`{"error":"probe"}`)), + Header: make(http.Header), + Request: req, + }, nil + }), + }, + } + + _, err := auth.RefreshTokens(callerCtx, "independent-timeout-token") + if err == nil { + t.Fatal("expected refresh error") + } + if requestDeadline.IsZero() || !requestDeadline.After(time.Now()) { + t.Fatalf("refresh deadline = %v, want a future deadline", requestDeadline) + } +} + func resetCodexRefreshGroupForTest() { codexRefreshGroup = singleflight.Group{} }