Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View file

@ -0,0 +1,40 @@
package claude
// PKCECodes holds PKCE verification codes for OAuth2 PKCE flow
type PKCECodes struct {
// CodeVerifier is the cryptographically random string used to correlate
// the authorization request to the token request
CodeVerifier string `json:"code_verifier"`
// CodeChallenge is the SHA256 hash of the code verifier, base64url-encoded
CodeChallenge string `json:"code_challenge"`
}
// ClaudeTokenData holds OAuth token information from Anthropic
type ClaudeTokenData struct {
// AccessToken is the OAuth2 access token for API access.
AccessToken string `json:"access_token"`
// RefreshToken is used to obtain new access tokens.
RefreshToken string `json:"refresh_token"`
// Email is the Anthropic account email.
Email string `json:"email"`
// AccountUUID identifies the Anthropic account returned by OAuth.
AccountUUID string `json:"account_uuid"`
// OrganizationUUID identifies the Anthropic organization returned by OAuth.
OrganizationUUID string `json:"organization_uuid"`
// OrganizationName is the display name returned by OAuth.
OrganizationName string `json:"organization_name"`
// Expire is the timestamp of the token expiry.
Expire string `json:"expired"`
}
// ClaudeAuthBundle aggregates authentication data after OAuth flow completion
type ClaudeAuthBundle struct {
// APIKey is the Anthropic API key obtained from token exchange.
APIKey string `json:"api_key"`
// TokenData contains the OAuth tokens from the authentication flow.
TokenData ClaudeTokenData `json:"token_data"`
// DeviceIDs contains the single device identity persisted with this credential.
DeviceIDs []string `json:"claude_device_ids"`
// LastRefresh is the timestamp of the last token refresh.
LastRefresh string `json:"last_refresh"`
}

View file

@ -0,0 +1,688 @@
// Package claude provides OAuth2 authentication functionality for Anthropic's Claude API.
// This package implements the complete OAuth2 flow with PKCE (Proof Key for Code Exchange)
// for secure authentication with Claude API, including token exchange, refresh, and storage.
package claude
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
log "github.com/sirupsen/logrus"
"golang.org/x/sync/singleflight"
)
// OAuth configuration constants for Claude/Anthropic
const (
AuthURL = "https://claude.ai/oauth/authorize"
// TokenURL is the authorization-code exchange endpoint. Claude Code 2.1.220
// posts the code exchange to platform.claude.com, not api.anthropic.com.
TokenURL = "https://platform.claude.com/v1/oauth/token"
RefreshTokenURL = "https://platform.claude.com/v1/oauth/token"
ProfileURL = "https://api.anthropic.com/api/oauth/profile"
// RolesURL is the claude_cli role endpoint the native client queries right
// after a successful token exchange, alongside the profile lookup.
RolesURL = "https://api.anthropic.com/api/oauth/claude_cli/roles"
ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
RedirectURI = "http://localhost:54545/callback"
ClaudeOAuthScope = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
claudeRefreshMinBackoff = 5 * time.Second
claudeRefreshMaxBackoff = 5 * time.Minute
claudeRefreshTimeout = 30 * time.Second
claudeRefreshHandshakeTimeout = 10 * time.Second
)
var (
claudeRefreshGroup singleflight.Group
claudeRefreshMu sync.Mutex
claudeRefreshBlock = make(map[string]time.Time)
)
type refreshHTTPError struct {
status int
message string
retryable bool
}
func (e *refreshHTTPError) Error() string {
return fmt.Sprintf("token refresh failed with status %d: %s", e.status, e.message)
}
func (e *refreshHTTPError) Retryable() bool {
return e != nil && e.retryable
}
func resetClaudeRefreshState() {
claudeRefreshMu.Lock()
defer claudeRefreshMu.Unlock()
claudeRefreshBlock = make(map[string]time.Time)
claudeRefreshGroup = singleflight.Group{}
}
func claudeRefreshBlockedUntil(refreshToken string) time.Time {
claudeRefreshMu.Lock()
defer claudeRefreshMu.Unlock()
return claudeRefreshBlock[refreshToken]
}
func setClaudeRefreshBlockedUntil(refreshToken string, until time.Time) {
claudeRefreshMu.Lock()
defer claudeRefreshMu.Unlock()
claudeRefreshBlock[refreshToken] = until
}
func clearClaudeRefreshBlockedUntil(refreshToken string) {
claudeRefreshMu.Lock()
defer claudeRefreshMu.Unlock()
delete(claudeRefreshBlock, refreshToken)
}
func clampClaudeRefreshBackoff(d time.Duration) time.Duration {
if d < claudeRefreshMinBackoff {
return claudeRefreshMinBackoff
}
if d > claudeRefreshMaxBackoff {
return claudeRefreshMaxBackoff
}
return d
}
func parseClaudeRetryAfter(resp *http.Response) time.Duration {
if resp == nil {
return claudeRefreshMinBackoff
}
if raw := strings.TrimSpace(resp.Header.Get("Retry-After")); raw != "" {
if seconds, err := time.ParseDuration(raw + "s"); err == nil {
return clampClaudeRefreshBackoff(seconds)
}
if when, err := http.ParseTime(raw); err == nil {
return clampClaudeRefreshBackoff(time.Until(when))
}
}
if raw := strings.TrimSpace(resp.Header.Get("Retry-After-Ms")); raw != "" {
if ms, err := time.ParseDuration(raw + "ms"); err == nil {
return clampClaudeRefreshBackoff(ms)
}
}
return claudeRefreshMinBackoff
}
func isClaudeRefreshRetryable(err error) bool {
var httpErr *refreshHTTPError
if errors.As(err, &httpErr) {
return httpErr.Retryable()
}
return true
}
// tokenResponse represents the response structure from Anthropic's OAuth token endpoint.
// It contains access token, refresh token, and associated user/organization information.
type tokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Organization struct {
UUID string `json:"uuid"`
Name string `json:"name"`
} `json:"organization"`
Account struct {
UUID string `json:"uuid"`
EmailAddress string `json:"email_address"`
} `json:"account"`
}
// authorizationCodeExchangeRequest is the authorization-code exchange body.
// Field order is significant: it mirrors the key order observed in native
// Claude Code 2.1.220 traffic to platform.claude.com/v1/oauth/token.
type authorizationCodeExchangeRequest struct {
GrantType string `json:"grant_type"`
Code string `json:"code"`
RedirectURI string `json:"redirect_uri"`
ClientID string `json:"client_id"`
CodeVerifier string `json:"code_verifier"`
State string `json:"state"`
}
// OAuthProfile is the account identity returned by Anthropic's OAuth profile endpoint.
type OAuthProfile struct {
Account struct {
UUID string `json:"uuid"`
Email string `json:"email"`
} `json:"account"`
Organization struct {
UUID string `json:"uuid"`
Name string `json:"name"`
} `json:"organization"`
}
// ClaudeAuth handles Anthropic OAuth2 authentication flow.
// It provides methods for generating authorization URLs, exchanging codes for tokens,
// and refreshing expired tokens using PKCE for enhanced security.
type ClaudeAuth struct {
httpClient *http.Client
}
// NewClaudeAuth creates a new Anthropic authentication service.
// It initializes the HTTP client with a custom TLS transport that uses Firefox
// fingerprint to bypass Cloudflare's TLS fingerprinting on Anthropic domains.
//
// Parameters:
// - cfg: The application configuration containing proxy settings
//
// 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(sdkCfg),
}
}
func applyClaudeOAuthAxiosHeaders(req *http.Request) {
if req == nil {
return
}
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "axios/1.15.2")
req.Header.Set("Accept-Encoding", "gzip, compress, deflate, br")
req.Header.Set("Connection", "close")
req.Close = true
}
// fetchOAuthControlPlaneJSON issues an Axios-shaped OAuth control-plane GET and
// returns the decoded response body. label names the endpoint in error text.
func (o *ClaudeAuth) fetchOAuthControlPlaneJSON(ctx context.Context, endpoint, accessToken, label string) ([]byte, error) {
if o == nil || o.httpClient == nil {
return nil, fmt.Errorf("fetch Claude OAuth %s: HTTP client is nil", label)
}
accessToken = strings.TrimSpace(accessToken)
if accessToken == "" {
return nil, fmt.Errorf("fetch Claude OAuth %s: access token is empty", label)
}
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if errRequest != nil {
return nil, fmt.Errorf("create Claude OAuth %s request: %w", label, errRequest)
}
applyClaudeOAuthAxiosHeaders(req)
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Cache-Control", "no-cache")
resp, errDo := o.httpClient.Do(req)
if errDo != nil {
return nil, fmt.Errorf("fetch Claude OAuth %s: %w", label, errDo)
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Errorf("failed to close Claude OAuth %s response body: %v", label, errClose)
}
}()
body, errRead := readClaudeOAuthResponseBody(resp)
if errRead != nil {
return nil, fmt.Errorf("read Claude OAuth %s response: %w", label, errRead)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("fetch Claude OAuth %s failed with status %d", label, resp.StatusCode)
}
return body, nil
}
// FetchOAuthProfile retrieves the account identity associated with an OAuth access token.
func (o *ClaudeAuth) FetchOAuthProfile(ctx context.Context, accessToken string) (*OAuthProfile, error) {
body, errFetch := o.fetchOAuthControlPlaneJSON(ctx, ProfileURL, accessToken, "profile")
if errFetch != nil {
return nil, errFetch
}
var profile OAuthProfile
if errUnmarshal := json.Unmarshal(body, &profile); errUnmarshal != nil {
return nil, fmt.Errorf("parse Claude OAuth profile response: %w", errUnmarshal)
}
if strings.TrimSpace(profile.Account.UUID) == "" {
return nil, fmt.Errorf("fetch Claude OAuth profile: response account UUID is empty")
}
return &profile, nil
}
// FetchOAuthRoles performs the claude_cli roles lookup the native client issues
// alongside the profile query after a token exchange. Only the request shape is
// covered by captured evidence, so the payload stays opaque and is returned raw
// instead of being decoded into a guessed structure.
func (o *ClaudeAuth) FetchOAuthRoles(ctx context.Context, accessToken string) (json.RawMessage, error) {
body, errFetch := o.fetchOAuthControlPlaneJSON(ctx, RolesURL, accessToken, "claude_cli roles")
if errFetch != nil {
return nil, errFetch
}
if !json.Valid(body) {
return nil, fmt.Errorf("parse Claude OAuth claude_cli roles response: body is not valid JSON")
}
return json.RawMessage(body), nil
}
// inspectOAuthAccount replays the login companion control-plane calls the native
// client makes within roughly 500ms of a successful token exchange: the account
// profile lookup followed by the claude_cli roles lookup. Both are advisory, so
// failures are logged and never fail the surrounding login.
func (o *ClaudeAuth) inspectOAuthAccount(ctx context.Context, accessToken string) *OAuthProfile {
profile, errProfile := o.FetchOAuthProfile(ctx, accessToken)
if errProfile != nil {
log.Warnf("fetch Claude OAuth profile after token exchange: %v", errProfile)
profile = nil
}
if _, errRoles := o.FetchOAuthRoles(ctx, accessToken); errRoles != nil {
log.Warnf("fetch Claude OAuth claude_cli roles after token exchange: %v", errRoles)
}
return profile
}
// GenerateAuthURL creates the OAuth authorization URL with PKCE.
// This method generates a secure authorization URL including PKCE challenge codes
// for the OAuth2 flow with Anthropic's API.
//
// Parameters:
// - state: A random state parameter for CSRF protection
// - pkceCodes: The PKCE codes for secure code exchange
//
// Returns:
// - string: The complete authorization URL
// - string: The state parameter for verification
// - error: An error if PKCE codes are missing or URL generation fails
func (o *ClaudeAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string, string, error) {
if pkceCodes == nil {
return "", "", fmt.Errorf("PKCE codes are required")
}
params := url.Values{
"code": {"true"},
"client_id": {ClientID},
"response_type": {"code"},
"redirect_uri": {RedirectURI},
"scope": {ClaudeOAuthScope},
"code_challenge": {pkceCodes.CodeChallenge},
"code_challenge_method": {"S256"},
"state": {state},
}
authURL := fmt.Sprintf("%s?%s", AuthURL, params.Encode())
return authURL, state, nil
}
// parseCodeAndState extracts the authorization code and state from the callback response.
// It handles the parsing of the code parameter which may contain additional fragments.
//
// Parameters:
// - code: The raw code parameter from the OAuth callback
//
// Returns:
// - parsedCode: The extracted authorization code
// - parsedState: The extracted state parameter if present
func (c *ClaudeAuth) parseCodeAndState(code string) (parsedCode, parsedState string) {
splits := strings.Split(code, "#")
parsedCode = splits[0]
if len(splits) > 1 {
parsedState = splits[1]
}
return
}
// ExchangeCodeForTokens exchanges authorization code for access tokens.
// This method implements the OAuth2 token exchange flow using PKCE for security.
// It sends the authorization code along with PKCE verifier to get access and refresh tokens.
//
// Parameters:
// - ctx: The context for the request
// - code: The authorization code received from OAuth callback
// - state: The state parameter for verification
// - pkceCodes: The PKCE codes for secure verification
//
// Returns:
// - *ClaudeAuthBundle: The complete authentication bundle with tokens
// - error: An error if token exchange fails
func (o *ClaudeAuth) ExchangeCodeForTokens(ctx context.Context, code, state string, pkceCodes *PKCECodes) (*ClaudeAuthBundle, error) {
if pkceCodes == nil {
return nil, fmt.Errorf("PKCE codes are required for token exchange")
}
newCode, newState := o.parseCodeAndState(code)
// Prepare token exchange request. The struct field order reproduces the key
// order Claude Code 2.1.220 emits on the wire; a map would be re-sorted
// alphabetically by encoding/json and change the serialized body bytes.
reqBody := authorizationCodeExchangeRequest{
GrantType: "authorization_code",
Code: newCode,
RedirectURI: RedirectURI,
ClientID: ClientID,
CodeVerifier: pkceCodes.CodeVerifier,
State: state,
}
// A state fragment appended to the callback code takes precedence.
if newState != "" {
reqBody.State = newState
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
// log.Debugf("Token exchange request: %s", string(jsonBody))
req, err := http.NewRequestWithContext(ctx, "POST", TokenURL, strings.NewReader(string(jsonBody)))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
applyClaudeOAuthAxiosHeaders(req)
resp, err := o.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("token exchange request failed: %w", err)
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Errorf("failed to close response body: %v", errClose)
}
}()
body, err := readClaudeOAuthResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read token response: %w", err)
}
// log.Debugf("Token response: %s", string(body))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body))
}
// log.Debugf("Token response: %s", string(body))
var tokenResp tokenResponse
if err = json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
deviceIDs, errDeviceIDs := GenerateDeviceIDPool()
if errDeviceIDs != nil {
return nil, errDeviceIDs
}
// Create token data.
tokenData := ClaudeTokenData{
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
Email: tokenResp.Account.EmailAddress,
AccountUUID: tokenResp.Account.UUID,
OrganizationUUID: tokenResp.Organization.UUID,
OrganizationName: tokenResp.Organization.Name,
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
}
// Replay the native login companion lookups and let the profile response win
// where it carries identity the token response omitted.
if profile := o.inspectOAuthAccount(ctx, tokenResp.AccessToken); profile != nil {
if value := strings.TrimSpace(profile.Account.UUID); value != "" {
tokenData.AccountUUID = value
}
if value := strings.TrimSpace(profile.Account.Email); value != "" {
tokenData.Email = value
}
if value := strings.TrimSpace(profile.Organization.UUID); value != "" {
tokenData.OrganizationUUID = value
}
if value := strings.TrimSpace(profile.Organization.Name); value != "" {
tokenData.OrganizationName = value
}
}
// Create auth bundle.
bundle := &ClaudeAuthBundle{
TokenData: tokenData,
DeviceIDs: deviceIDs,
LastRefresh: time.Now().Format(time.RFC3339),
}
return bundle, nil
}
// RefreshTokens refreshes the access token using the refresh token.
// This method exchanges a valid refresh token for a new access token,
// extending the user's authenticated session.
//
// Parameters:
// - ctx: The context for the request
// - refreshToken: The refresh token to use for getting new access token
//
// Returns:
// - *ClaudeTokenData: The new token data with updated access token
// - error: An error if token refresh fails
func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*ClaudeTokenData, error) {
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,
message: fmt.Sprintf("refresh temporarily blocked until %s", blockedUntil.Format(time.RFC3339)),
retryable: false,
}
}
result, err, _ := claudeRefreshGroup.Do(refreshToken, func() (interface{}, error) {
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
}
tokenData, ok := result.(*ClaudeTokenData)
if !ok || tokenData == nil {
return nil, fmt.Errorf("token refresh failed: invalid single-flight result")
}
return tokenData, nil
}
func (o *ClaudeAuth) refreshTokensSingleFlight(ctx context.Context, refreshToken string) (*ClaudeTokenData, error) {
if blockedUntil := claudeRefreshBlockedUntil(refreshToken); blockedUntil.After(time.Now()) {
return nil, &refreshHTTPError{
status: http.StatusTooManyRequests,
message: fmt.Sprintf("refresh temporarily blocked until %s", blockedUntil.Format(time.RFC3339)),
retryable: false,
}
}
reqBody := map[string]interface{}{
"client_id": ClientID,
"grant_type": "refresh_token",
"refresh_token": refreshToken,
"scope": ClaudeOAuthScope,
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", RefreshTokenURL, strings.NewReader(string(jsonBody)))
if err != nil {
return nil, fmt.Errorf("failed to create refresh request: %w", err)
}
applyClaudeOAuthAxiosHeaders(req)
resp, err := o.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("token refresh request failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
body, err := readClaudeOAuthResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read refresh response: %w", err)
}
if resp.StatusCode != http.StatusOK {
message := string(body)
if resp.StatusCode == http.StatusTooManyRequests {
retryAfter := parseClaudeRetryAfter(resp)
setClaudeRefreshBlockedUntil(refreshToken, time.Now().Add(retryAfter))
return nil, &refreshHTTPError{status: resp.StatusCode, message: message, retryable: false}
}
return nil, &refreshHTTPError{
status: resp.StatusCode,
message: message,
retryable: resp.StatusCode >= http.StatusInternalServerError,
}
}
// log.Debugf("Token response: %s", string(body))
var tokenResp tokenResponse
if err = json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
clearClaudeRefreshBlockedUntil(refreshToken)
if strings.TrimSpace(tokenResp.RefreshToken) == "" {
tokenResp.RefreshToken = refreshToken
}
tokenData := &ClaudeTokenData{
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
}
profile, errProfile := o.FetchOAuthProfile(ctx, tokenResp.AccessToken)
if errProfile != nil {
log.Warnf("fetch Claude OAuth profile after refresh: %v", errProfile)
return tokenData, nil
}
tokenData.Email = profile.Account.Email
tokenData.AccountUUID = profile.Account.UUID
tokenData.OrganizationUUID = profile.Organization.UUID
tokenData.OrganizationName = profile.Organization.Name
return tokenData, nil
}
// CreateTokenStorage creates a new ClaudeTokenStorage from auth bundle and user info.
// This method converts the authentication bundle into a token storage structure
// suitable for persistence and later use.
//
// Parameters:
// - bundle: The authentication bundle containing token data
//
// Returns:
// - *ClaudeTokenStorage: A new token storage instance
func (o *ClaudeAuth) CreateTokenStorage(bundle *ClaudeAuthBundle) *ClaudeTokenStorage {
storage := &ClaudeTokenStorage{
AccessToken: bundle.TokenData.AccessToken,
RefreshToken: bundle.TokenData.RefreshToken,
LastRefresh: bundle.LastRefresh,
Email: bundle.TokenData.Email,
AccountUUID: bundle.TokenData.AccountUUID,
OrganizationUUID: bundle.TokenData.OrganizationUUID,
OrganizationName: bundle.TokenData.OrganizationName,
DeviceIDs: append([]string(nil), bundle.DeviceIDs...),
Expire: bundle.TokenData.Expire,
}
return storage
}
// RefreshTokensWithRetry refreshes tokens with automatic retry logic.
// This method implements exponential backoff retry logic for token refresh operations,
// providing resilience against temporary network or service issues.
//
// Parameters:
// - ctx: The context for the request
// - refreshToken: The refresh token to use
// - maxRetries: The maximum number of retry attempts
//
// Returns:
// - *ClaudeTokenData: The refreshed token data
// - error: An error if all retry attempts fail
func (o *ClaudeAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*ClaudeTokenData, error) {
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
// Wait before retry
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(attempt) * time.Second):
}
}
tokenData, err := o.RefreshTokens(ctx, refreshToken)
if err == nil {
return tokenData, nil
}
lastErr = err
log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err)
if !isClaudeRefreshRetryable(err) {
break
}
}
return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
}
// UpdateTokenStorage updates an existing token storage with new token data.
// This method refreshes the token storage with newly obtained access and refresh tokens,
// updating timestamps and expiration information.
//
// Parameters:
// - storage: The existing token storage to update
// - tokenData: The new token data to apply
func (o *ClaudeAuth) UpdateTokenStorage(storage *ClaudeTokenStorage, tokenData *ClaudeTokenData) {
storage.AccessToken = tokenData.AccessToken
storage.RefreshToken = tokenData.RefreshToken
storage.LastRefresh = time.Now().Format(time.RFC3339)
if tokenData.Email != "" {
storage.Email = tokenData.Email
}
if tokenData.AccountUUID != "" {
storage.AccountUUID = tokenData.AccountUUID
}
if tokenData.OrganizationUUID != "" {
storage.OrganizationUUID = tokenData.OrganizationUUID
}
if tokenData.OrganizationName != "" {
storage.OrganizationName = tokenData.OrganizationName
}
storage.Expire = tokenData.Expire
}

View file

@ -0,0 +1,33 @@
package claude
import (
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/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

@ -0,0 +1,540 @@
package claude
import (
"context"
"io"
"net/http"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
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)
}
}
// jsonResponse builds a canned control-plane response for the fake transport.
func jsonResponse(req *http.Request, body string) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
Request: req,
}
}
func TestExchangeCodeForTokensPersistsUpstreamAccountAndDevicePool(t *testing.T) {
auth := &ClaudeAuth{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.String() {
case TokenURL:
if req.Method != http.MethodPost {
t.Fatalf("token request = %s %s, want POST %s", req.Method, req.URL, TokenURL)
}
return jsonResponse(req, `{
"access_token":"access",
"refresh_token":"refresh",
"token_type":"Bearer",
"expires_in":3600,
"account":{"uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","email_address":"user@example.com"},
"organization":{"uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","name":"Example Org"}
}`), nil
case ProfileURL:
return jsonResponse(req, `{
"account":{"uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","email":"user@example.com"},
"organization":{"uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","name":"Example Org"}
}`), nil
case RolesURL:
return jsonResponse(req, `{"roles":[]}`), nil
default:
t.Fatalf("unexpected OAuth request URL %s", req.URL)
return nil, nil
}
}),
},
}
bundle, errExchange := auth.ExchangeCodeForTokens(context.Background(), "code", "state", &PKCECodes{CodeVerifier: "verifier"})
if errExchange != nil {
t.Fatalf("ExchangeCodeForTokens() error = %v", errExchange)
}
if bundle.TokenData.AccountUUID != "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" {
t.Fatalf("account UUID = %q, want OAuth response account", bundle.TokenData.AccountUUID)
}
if bundle.TokenData.OrganizationUUID != "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" || bundle.TokenData.OrganizationName != "Example Org" {
t.Fatalf("organization = %q/%q, want OAuth response organization", bundle.TokenData.OrganizationUUID, bundle.TokenData.OrganizationName)
}
if len(bundle.DeviceIDs) != ClaudeDevicePoolSize {
t.Fatalf("device pool length = %d, want %d", len(bundle.DeviceIDs), ClaudeDevicePoolSize)
}
storage := auth.CreateTokenStorage(bundle)
if storage.AccountUUID != bundle.TokenData.AccountUUID || storage.OrganizationUUID != bundle.TokenData.OrganizationUUID {
t.Fatalf("storage account identity = %#v, want bundle identity", storage)
}
if len(storage.DeviceIDs) != ClaudeDevicePoolSize {
t.Fatalf("storage device pool length = %d, want %d", len(storage.DeviceIDs), ClaudeDevicePoolSize)
}
}
func TestExchangeCodeForTokensUsesNative220ControlPlaneShape(t *testing.T) {
var order []string
headers := make(map[string]http.Header)
var tokenBody []byte
auth := &ClaudeAuth{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
order = append(order, req.URL.String())
headers[req.URL.String()] = req.Header.Clone()
if !req.Close {
t.Fatalf("%s request Close = false, want true", req.URL)
}
switch req.URL.String() {
case TokenURL:
if req.URL.Host != "platform.claude.com" {
t.Fatalf("exchange host = %q, want platform.claude.com", req.URL.Host)
}
body, errRead := io.ReadAll(req.Body)
if errRead != nil {
t.Fatal(errRead)
}
tokenBody = body
return jsonResponse(req, `{"access_token":"access","refresh_token":"refresh","expires_in":28800}`), nil
case ProfileURL, RolesURL:
if req.Method != http.MethodGet {
t.Fatalf("%s method = %s, want GET", req.URL, req.Method)
}
if req.URL.String() == ProfileURL {
return jsonResponse(req, `{
"account":{"uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","email":"user@example.com"},
"organization":{"uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","name":"Example Org"}
}`), nil
}
return jsonResponse(req, `{"roles":["claude_code_user"]}`), nil
default:
t.Fatalf("unexpected OAuth request URL %s", req.URL)
return nil, nil
}
}),
},
}
bundle, errExchange := auth.ExchangeCodeForTokens(t.Context(), "auth-code", "state-value", &PKCECodes{CodeVerifier: "verifier"})
if errExchange != nil {
t.Fatalf("ExchangeCodeForTokens() error = %v", errExchange)
}
wantOrder := []string{TokenURL, ProfileURL, RolesURL}
if len(order) != len(wantOrder) {
t.Fatalf("request order = %v, want %v", order, wantOrder)
}
for i, want := range wantOrder {
if order[i] != want {
t.Fatalf("request order = %v, want %v", order, wantOrder)
}
}
// Key order mirrors the captured native exchange body.
wantBody := `{"grant_type":"authorization_code","code":"auth-code","redirect_uri":"` + RedirectURI + `","client_id":"` + ClientID + `","code_verifier":"verifier","state":"state-value"}`
if got := string(tokenBody); got != wantBody {
t.Fatalf("exchange body = %q, want %q", got, wantBody)
}
wantAxios := map[string]string{
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"User-Agent": "axios/1.15.2",
"Accept-Encoding": "gzip, compress, deflate, br",
"Connection": "close",
}
for _, endpoint := range wantOrder {
for name, want := range wantAxios {
if got := headers[endpoint].Get(name); got != want {
t.Fatalf("%s %s = %q, want %q", endpoint, name, got, want)
}
}
}
if got := headers[TokenURL].Get("Authorization"); got != "" {
t.Fatalf("exchange Authorization = %q, want unset", got)
}
for _, endpoint := range []string{ProfileURL, RolesURL} {
if got := headers[endpoint].Get("Authorization"); got != "Bearer access" {
t.Fatalf("%s Authorization = %q, want the freshly exchanged bearer token", endpoint, got)
}
if got := headers[endpoint].Get("Cache-Control"); got != "no-cache" {
t.Fatalf("%s Cache-Control = %q, want no-cache", endpoint, got)
}
}
if bundle.TokenData.AccountUUID != "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" {
t.Fatalf("account UUID = %q, want the companion profile account", bundle.TokenData.AccountUUID)
}
if bundle.TokenData.OrganizationUUID != "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" || bundle.TokenData.OrganizationName != "Example Org" {
t.Fatalf("organization = %q/%q, want the companion profile organization", bundle.TokenData.OrganizationUUID, bundle.TokenData.OrganizationName)
}
if bundle.TokenData.Email != "user@example.com" {
t.Fatalf("email = %q, want the companion profile email", bundle.TokenData.Email)
}
}
func TestExchangeCodeForTokensSurvivesCompanionLookupFailure(t *testing.T) {
auth := &ClaudeAuth{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.String() == TokenURL {
return jsonResponse(req, `{
"access_token":"access",
"refresh_token":"refresh",
"expires_in":28800,
"account":{"uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","email_address":"token@example.com"},
"organization":{"uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","name":"Token Org"}
}`), nil
}
return &http.Response{
StatusCode: http.StatusServiceUnavailable,
Body: io.NopCloser(strings.NewReader(`{"error":"unavailable"}`)),
Header: make(http.Header),
Request: req,
}, nil
}),
},
}
bundle, errExchange := auth.ExchangeCodeForTokens(t.Context(), "code", "state", &PKCECodes{CodeVerifier: "verifier"})
if errExchange != nil {
t.Fatalf("companion lookup failure must not fail login, got %v", errExchange)
}
if bundle.TokenData.AccountUUID != "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" || bundle.TokenData.Email != "token@example.com" {
t.Fatalf("token-response identity must survive companion failure, got %#v", bundle.TokenData)
}
if bundle.TokenData.OrganizationName != "Token Org" {
t.Fatalf("organization = %q, want token-response organization", bundle.TokenData.OrganizationName)
}
}
func TestRefreshTokensWithRetry_429BlocksImmediateReplay(t *testing.T) {
resetClaudeRefreshState()
defer resetClaudeRefreshState()
var calls int32
auth := &ClaudeAuth{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
atomic.AddInt32(&calls, 1)
return &http.Response{
StatusCode: http.StatusTooManyRequests,
Body: io.NopCloser(strings.NewReader(`{"error":"rate_limited"}`)),
Header: http.Header{"Retry-After": []string{"60"}},
Request: req,
}, nil
}),
},
}
_, err := auth.RefreshTokensWithRetry(context.Background(), "dummy_refresh_token", 3)
if err == nil {
t.Fatalf("expected 429 refresh error")
}
if !strings.Contains(err.Error(), "status 429") {
t.Fatalf("expected status 429 in error, got %v", err)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("expected 1 refresh attempt after 429, got %d", got)
}
_, err = auth.RefreshTokensWithRetry(context.Background(), "dummy_refresh_token", 3)
if err == nil {
t.Fatalf("expected immediate blocked refresh error")
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("expected blocked retry to avoid a second refresh call, got %d attempts", got)
}
if blockedUntil := claudeRefreshBlockedUntil("dummy_refresh_token"); !blockedUntil.After(time.Now()) {
t.Fatalf("expected blocked-until timestamp to be set, got %v", blockedUntil)
}
}
func TestRefreshTokens_DeduplicatesConcurrentRefresh(t *testing.T) {
resetClaudeRefreshState()
defer resetClaudeRefreshState()
var tokenCalls int32
var profileCalls int32
started := make(chan struct{})
release := make(chan struct{})
var once sync.Once
auth := &ClaudeAuth{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.String() {
case RefreshTokenURL:
atomic.AddInt32(&tokenCalls, 1)
once.Do(func() { close(started) })
<-release
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{
"access_token":"new-access",
"refresh_token":"new-refresh",
"token_type":"Bearer",
"expires_in":3600,
"scope":"user:profile user:inference"
}`)),
Header: make(http.Header),
Request: req,
}, nil
case ProfileURL:
atomic.AddInt32(&profileCalls, 1)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{
"account":{"uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","email":"shared@example.com"},
"organization":{"uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","name":"Shared Org"}
}`)),
Header: make(http.Header),
Request: req,
}, nil
default:
t.Fatalf("unexpected OAuth request URL %s", req.URL)
return nil, nil
}
}),
},
}
results := make(chan *ClaudeTokenData, 2)
errs := make(chan error, 2)
runRefresh := func() {
td, err := auth.RefreshTokens(context.Background(), "shared-refresh-token")
results <- td
errs <- err
}
go runRefresh()
go runRefresh()
<-started
time.Sleep(20 * time.Millisecond)
if got := atomic.LoadInt32(&tokenCalls); got != 1 {
t.Fatalf("expected concurrent refresh to share a single upstream call, got %d", got)
}
close(release)
for i := 0; i < 2; i++ {
if err := <-errs; err != nil {
t.Fatalf("expected refresh to succeed, got %v", err)
}
td := <-results
if td == nil || td.AccessToken != "new-access" {
t.Fatalf("expected refreshed access token, got %#v", td)
}
if td.AccountUUID != "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" {
t.Fatalf("account UUID = %q, want OAuth response account", td.AccountUUID)
}
if td.OrganizationUUID != "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" || td.OrganizationName != "Shared Org" {
t.Fatalf("organization = %q/%q, want OAuth response organization", td.OrganizationUUID, td.OrganizationName)
}
}
if got := atomic.LoadInt32(&tokenCalls); got != 1 {
t.Fatalf("expected exactly 1 upstream refresh call, got %d", got)
}
if got := atomic.LoadInt32(&profileCalls); got != 1 {
t.Fatalf("expected exactly 1 OAuth profile call, got %d", got)
}
}
func TestRefreshTokensUsesNative220ControlPlaneShape(t *testing.T) {
resetClaudeRefreshState()
defer resetClaudeRefreshState()
const refreshToken = "placeholder-refresh"
auth := &ClaudeAuth{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.String() {
case RefreshTokenURL:
if req.Method != http.MethodPost {
t.Fatalf("refresh method = %s, want POST", req.Method)
}
body, errRead := io.ReadAll(req.Body)
if errRead != nil {
t.Fatal(errRead)
}
wantBody := `{"client_id":"` + ClientID + `","grant_type":"refresh_token","refresh_token":"` + refreshToken + `","scope":"` + ClaudeOAuthScope + `"}`
if got := string(body); got != wantBody {
t.Fatalf("refresh body = %q, want %q", got, wantBody)
}
wantHeaders := map[string]string{
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"User-Agent": "axios/1.15.2",
"Accept-Encoding": "gzip, compress, deflate, br",
"Connection": "close",
}
for name, want := range wantHeaders {
if got := req.Header.Get(name); got != want {
t.Fatalf("%s = %q, want %q", name, got, want)
}
}
if !req.Close {
t.Fatal("refresh request Close = false, want true")
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"access_token":"new-access","expires_in":3600}`)),
Header: make(http.Header),
Request: req,
}, nil
case ProfileURL:
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{
"account":{"uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","email":"shared@example.com"},
"organization":{"uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","name":"Shared Org"}
}`)),
Header: make(http.Header),
Request: req,
}, nil
default:
t.Fatalf("unexpected OAuth request URL %s", req.URL)
return nil, nil
}
}),
},
}
tokenData, errRefresh := auth.RefreshTokens(t.Context(), refreshToken)
if errRefresh != nil {
t.Fatalf("RefreshTokens() error = %v", errRefresh)
}
if tokenData.RefreshToken != refreshToken {
t.Fatalf("refresh token fallback = %q, want original placeholder", tokenData.RefreshToken)
}
if tokenData.AccountUUID == "" || tokenData.Email == "" || tokenData.OrganizationUUID == "" {
t.Fatalf("profile identity was not populated: %#v", tokenData)
}
}
func TestFetchOAuthProfile(t *testing.T) {
auth := &ClaudeAuth{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodGet || req.URL.String() != ProfileURL {
t.Fatalf("profile request = %s %s, want GET %s", req.Method, req.URL, ProfileURL)
}
if got := req.Header.Get("Authorization"); got != "Bearer test-access" {
t.Fatalf("Authorization = %q, want bearer token", got)
}
wantHeaders := map[string]string{
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"Cache-Control": "no-cache",
"User-Agent": "axios/1.15.2",
"Accept-Encoding": "gzip, compress, deflate, br",
"Connection": "close",
}
for name, want := range wantHeaders {
if got := req.Header.Get(name); got != want {
t.Fatalf("%s = %q, want %q", name, got, want)
}
}
if !req.Close {
t.Fatal("profile request Close = false, want true")
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{
"account":{"uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","email":"user@example.com"},
"organization":{"uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","name":"Example Org"}
}`)),
Header: make(http.Header),
Request: req,
}, nil
}),
},
}
profile, errProfile := auth.FetchOAuthProfile(context.Background(), "test-access")
if errProfile != nil {
t.Fatalf("FetchOAuthProfile() error = %v", errProfile)
}
if profile.Account.UUID != "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" || profile.Account.Email != "user@example.com" {
t.Fatalf("account = %#v, want upstream profile account", profile.Account)
}
if profile.Organization.UUID != "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" || profile.Organization.Name != "Example Org" {
t.Fatalf("organization = %#v, want upstream profile organization", profile.Organization)
}
}
func TestUpdateTokenStoragePreservesAccountWhenRefreshOmitsIt(t *testing.T) {
storage := &ClaudeTokenStorage{
Email: "user@example.com",
AccountUUID: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
OrganizationUUID: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
OrganizationName: "Example Org",
}
(&ClaudeAuth{}).UpdateTokenStorage(storage, &ClaudeTokenData{
AccessToken: "new-access",
RefreshToken: "new-refresh",
Expire: "2099-01-01T00:00:00Z",
})
if storage.Email != "user@example.com" {
t.Fatalf("email = %q, want preserved", storage.Email)
}
if storage.AccountUUID != "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" {
t.Fatalf("account UUID = %q, want preserved", storage.AccountUUID)
}
if storage.OrganizationUUID != "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" || storage.OrganizationName != "Example Org" {
t.Fatalf("organization = %q/%q, want preserved", storage.OrganizationUUID, storage.OrganizationName)
}
}

View file

@ -0,0 +1,167 @@
// Package claude provides authentication and token management functionality
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
// and retrieval for maintaining authenticated sessions with the Claude API.
package claude
import (
"errors"
"fmt"
"net/http"
)
// OAuthError represents an OAuth-specific error.
type OAuthError struct {
// Code is the OAuth error code.
Code string `json:"error"`
// Description is a human-readable description of the error.
Description string `json:"error_description,omitempty"`
// URI is a URI identifying a human-readable web page with information about the error.
URI string `json:"error_uri,omitempty"`
// StatusCode is the HTTP status code associated with the error.
StatusCode int `json:"-"`
}
// Error returns a string representation of the OAuth error.
func (e *OAuthError) Error() string {
if e.Description != "" {
return fmt.Sprintf("OAuth error %s: %s", e.Code, e.Description)
}
return fmt.Sprintf("OAuth error: %s", e.Code)
}
// NewOAuthError creates a new OAuth error with the specified code, description, and status code.
func NewOAuthError(code, description string, statusCode int) *OAuthError {
return &OAuthError{
Code: code,
Description: description,
StatusCode: statusCode,
}
}
// AuthenticationError represents authentication-related errors.
type AuthenticationError struct {
// Type is the type of authentication error.
Type string `json:"type"`
// Message is a human-readable message describing the error.
Message string `json:"message"`
// Code is the HTTP status code associated with the error.
Code int `json:"code"`
// Cause is the underlying error that caused this authentication error.
Cause error `json:"-"`
}
// Error returns a string representation of the authentication error.
func (e *AuthenticationError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("%s: %s (caused by: %v)", e.Type, e.Message, e.Cause)
}
return fmt.Sprintf("%s: %s", e.Type, e.Message)
}
// Common authentication error types.
var (
// ErrTokenExpired = &AuthenticationError{
// Type: "token_expired",
// Message: "Access token has expired",
// Code: http.StatusUnauthorized,
// }
// ErrInvalidState represents an error for invalid OAuth state parameter.
ErrInvalidState = &AuthenticationError{
Type: "invalid_state",
Message: "OAuth state parameter is invalid",
Code: http.StatusBadRequest,
}
// ErrCodeExchangeFailed represents an error when exchanging authorization code for tokens fails.
ErrCodeExchangeFailed = &AuthenticationError{
Type: "code_exchange_failed",
Message: "Failed to exchange authorization code for tokens",
Code: http.StatusBadRequest,
}
// ErrServerStartFailed represents an error when starting the OAuth callback server fails.
ErrServerStartFailed = &AuthenticationError{
Type: "server_start_failed",
Message: "Failed to start OAuth callback server",
Code: http.StatusInternalServerError,
}
// ErrPortInUse represents an error when the OAuth callback port is already in use.
ErrPortInUse = &AuthenticationError{
Type: "port_in_use",
Message: "OAuth callback port is already in use",
Code: 13, // Special exit code for port-in-use
}
// ErrCallbackTimeout represents an error when waiting for OAuth callback times out.
ErrCallbackTimeout = &AuthenticationError{
Type: "callback_timeout",
Message: "Timeout waiting for OAuth callback",
Code: http.StatusRequestTimeout,
}
)
// NewAuthenticationError creates a new authentication error with a cause based on a base error.
func NewAuthenticationError(baseErr *AuthenticationError, cause error) *AuthenticationError {
return &AuthenticationError{
Type: baseErr.Type,
Message: baseErr.Message,
Code: baseErr.Code,
Cause: cause,
}
}
// IsAuthenticationError checks if an error is an authentication error.
func IsAuthenticationError(err error) bool {
var authenticationError *AuthenticationError
ok := errors.As(err, &authenticationError)
return ok
}
// IsOAuthError checks if an error is an OAuth error.
func IsOAuthError(err error) bool {
var oAuthError *OAuthError
ok := errors.As(err, &oAuthError)
return ok
}
// GetUserFriendlyMessage returns a user-friendly error message based on the error type.
func GetUserFriendlyMessage(err error) string {
switch {
case IsAuthenticationError(err):
var authErr *AuthenticationError
errors.As(err, &authErr)
switch authErr.Type {
case "token_expired":
return "Your authentication has expired. Please log in again."
case "token_invalid":
return "Your authentication is invalid. Please log in again."
case "authentication_required":
return "Please log in to continue."
case "port_in_use":
return "The required port is already in use. Please close any applications using port 3000 and try again."
case "callback_timeout":
return "Authentication timed out. Please try again."
case "browser_open_failed":
return "Could not open your browser automatically. Please copy and paste the URL manually."
default:
return "Authentication failed. Please try again."
}
case IsOAuthError(err):
var oauthErr *OAuthError
errors.As(err, &oauthErr)
switch oauthErr.Code {
case "access_denied":
return "Authentication was cancelled or denied."
case "invalid_request":
return "Invalid authentication request. Please try again."
case "server_error":
return "Authentication server error. Please try again later."
default:
return fmt.Sprintf("Authentication failed: %s", oauthErr.Description)
}
default:
return "An unexpected error occurred. Please try again."
}
}

View file

@ -0,0 +1,218 @@
// Package claude provides authentication and token management functionality
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
// and retrieval for maintaining authenticated sessions with the Claude API.
package claude
// LoginSuccessHtml is the HTML template displayed to users after successful OAuth authentication.
// This template provides a user-friendly success page with options to close the window
// or navigate to the Claude platform. It includes automatic window closing functionality
// and keyboard accessibility features.
const LoginSuccessHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authentication Successful - Claude</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2310b981'%3E%3Cpath d='M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'/%3E%3C/svg%3E">
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 1rem;
}
.container {
text-align: center;
background: white;
padding: 2.5rem;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
max-width: 480px;
width: 100%;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.success-icon {
width: 64px;
height: 64px;
margin: 0 auto 1.5rem;
background: #10b981;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 2rem;
font-weight: bold;
}
h1 {
color: #1f2937;
margin-bottom: 1rem;
font-size: 1.75rem;
font-weight: 600;
}
.subtitle {
color: #6b7280;
margin-bottom: 1.5rem;
font-size: 1rem;
line-height: 1.5;
}
.setup-notice {
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 6px;
padding: 1rem;
margin: 1rem 0;
}
.setup-notice h3 {
color: #92400e;
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
.setup-notice p {
color: #92400e;
margin: 0;
font-size: 0.875rem;
}
.setup-notice a {
color: #1d4ed8;
text-decoration: none;
}
.setup-notice a:hover {
text-decoration: underline;
}
.actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 2rem;
}
.button {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
transition: all 0.2s;
cursor: pointer;
border: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.button-primary {
background: #3b82f6;
color: white;
}
.button-primary:hover {
background: #2563eb;
transform: translateY(-1px);
}
.button-secondary {
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.button-secondary:hover {
background: #e5e7eb;
}
.countdown {
color: #9ca3af;
font-size: 0.75rem;
margin-top: 1rem;
}
.footer {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
color: #9ca3af;
font-size: 0.75rem;
}
.footer a {
color: #3b82f6;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="success-icon"></div>
<h1>Authentication Successful!</h1>
<p class="subtitle">You have successfully authenticated with Claude. You can now close this window and return to your terminal to continue.</p>
{{SETUP_NOTICE}}
<div class="actions">
<button class="button button-primary" onclick="window.close()">
<span>Close Window</span>
</button>
<a href="{{PLATFORM_URL}}" target="_blank" class="button button-secondary">
<span>Open Platform</span>
<span></span>
</a>
</div>
<div class="countdown">
This window will close automatically in <span id="countdown">10</span> seconds
</div>
<div class="footer">
<p>Powered by <a href="https://chatgpt.com" target="_blank">ChatGPT</a></p>
</div>
</div>
<script>
let countdown = 10;
const countdownElement = document.getElementById('countdown');
const timer = setInterval(() => {
countdown--;
countdownElement.textContent = countdown;
if (countdown <= 0) {
clearInterval(timer);
window.close();
}
}, 1000);
// Close window when user presses Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
window.close();
}
});
// Focus the close button for keyboard accessibility
document.querySelector('.button-primary').focus();
</script>
</body>
</html>`
// SetupNoticeHtml is the HTML template for the setup notice section.
// This template is embedded within the success page to inform users about
// additional setup steps required to complete their Claude account configuration.
const SetupNoticeHtml = `
<div class="setup-notice">
<h3>Additional Setup Required</h3>
<p>To complete your setup, please visit the <a href="{{PLATFORM_URL}}" target="_blank">Claude</a> to configure your account.</p>
</div>`

View file

@ -0,0 +1,286 @@
package claude
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"sync"
)
const (
ClaudeDeviceIDsMetadataKey = "claude_device_ids"
ClaudeDevicePoolSize = 1
claudeDeviceIDByteSize = 32
)
// claudeDevicePoolMu guards every concurrent access to a Claude credential's
// Auth.Metadata map, not just the device pool. A single Auth is shared by all
// in-flight requests using that credential, and Go maps are not safe for
// concurrent read/write, so the account-profile and refresh paths have to take
// the same lock as the pool paths. Reaching into Auth.Metadata directly from a
// request path is a data race even when the keys differ.
var claudeDevicePoolMu sync.Mutex
// GenerateDeviceIDPool creates the fixed-size device pool stored with a Claude credential.
func GenerateDeviceIDPool() ([]string, error) {
deviceIDs := make([]string, 0, ClaudeDevicePoolSize)
seen := make(map[string]struct{}, ClaudeDevicePoolSize)
for len(deviceIDs) < ClaudeDevicePoolSize {
deviceID, errDeviceID := generateDeviceID()
if errDeviceID != nil {
return nil, errDeviceID
}
if _, exists := seen[deviceID]; exists {
continue
}
seen[deviceID] = struct{}{}
deviceIDs = append(deviceIDs, deviceID)
}
return deviceIDs, nil
}
func generateDeviceID() (string, error) {
data := make([]byte, claudeDeviceIDByteSize)
if _, errRead := rand.Read(data); errRead != nil {
return "", fmt.Errorf("generate Claude device ID: %w", errRead)
}
return hex.EncodeToString(data), nil
}
// NormalizeDeviceIDPool returns the first valid device ID in canonical form.
func NormalizeDeviceIDPool(raw any) []string {
var values []string
switch typed := raw.(type) {
case []string:
values = typed
case []any:
values = make([]string, 0, len(typed))
for _, value := range typed {
if text, ok := value.(string); ok {
values = append(values, text)
}
}
default:
return nil
}
deviceIDs := make([]string, 0, min(len(values), ClaudeDevicePoolSize))
seen := make(map[string]struct{}, ClaudeDevicePoolSize)
for _, value := range values {
deviceID := strings.ToLower(strings.TrimSpace(value))
if !ValidDeviceID(deviceID) {
continue
}
if _, exists := seen[deviceID]; exists {
continue
}
seen[deviceID] = struct{}{}
deviceIDs = append(deviceIDs, deviceID)
if len(deviceIDs) == ClaudeDevicePoolSize {
break
}
}
return deviceIDs
}
// HasCanonicalDeviceIDPool reports whether raw stores exactly one valid device ID.
func HasCanonicalDeviceIDPool(raw any) bool {
var values []string
switch typed := raw.(type) {
case []string:
values = typed
case []any:
values = make([]string, 0, len(typed))
for _, value := range typed {
text, ok := value.(string)
if !ok {
return false
}
values = append(values, text)
}
default:
return false
}
normalized := NormalizeDeviceIDPool(values)
return len(values) == ClaudeDevicePoolSize && len(normalized) == ClaudeDevicePoolSize && values[0] == normalized[0]
}
// EnsureDeviceIDPool repairs or creates the single-device pool in credential metadata.
func EnsureDeviceIDPool(metadata map[string]any) ([]string, bool, error) {
claudeDevicePoolMu.Lock()
defer claudeDevicePoolMu.Unlock()
return ensureDeviceIDPoolLocked(metadata)
}
// EnsureDeviceIDPoolFor lazily initializes the metadata map and then ensures the
// pool, both under the device pool lock.
//
// A single *Auth is shared by every concurrent request that selects the same
// credential, so initializing the map field outside this lock races with the
// writes below and can abort the process with "concurrent map writes". Callers
// holding a shared credential must reach the pool through this package rather
// than touching the map directly.
func EnsureDeviceIDPoolFor(metadata *map[string]any) ([]string, bool, error) {
if metadata == nil {
return nil, false, fmt.Errorf("ensure Claude device pool: metadata pointer is nil")
}
claudeDevicePoolMu.Lock()
defer claudeDevicePoolMu.Unlock()
if *metadata == nil {
*metadata = make(map[string]any)
}
return ensureDeviceIDPoolLocked(*metadata)
}
// ReadDeviceIDPool returns the stored pool value, initializing the map when
// needed, under the device pool lock. Slice values are copied so a caller can
// never mutate the stored credential identity after the lock is released.
func ReadDeviceIDPool(metadata *map[string]any) any {
if metadata == nil {
return nil
}
claudeDevicePoolMu.Lock()
defer claudeDevicePoolMu.Unlock()
if *metadata == nil {
*metadata = make(map[string]any)
return nil
}
switch stored := (*metadata)[ClaudeDeviceIDsMetadataKey].(type) {
case []string:
return append([]string(nil), stored...)
case []any:
return append([]any(nil), stored...)
default:
return stored
}
}
// StoreDeviceIDPool writes a defensive copy of deviceIDs under the device pool lock.
func StoreDeviceIDPool(metadata *map[string]any, deviceIDs []string) {
if metadata == nil {
return
}
claudeDevicePoolMu.Lock()
defer claudeDevicePoolMu.Unlock()
if *metadata == nil {
*metadata = make(map[string]any)
}
(*metadata)[ClaudeDeviceIDsMetadataKey] = append([]string(nil), deviceIDs...)
}
// ReadMetadataString reads a string-valued metadata entry under the metadata
// lock, so it cannot observe a map being concurrently written by another path.
func ReadMetadataString(metadata *map[string]any, key string) string {
if metadata == nil {
return ""
}
claudeDevicePoolMu.Lock()
defer claudeDevicePoolMu.Unlock()
if *metadata == nil {
return ""
}
value, _ := (*metadata)[key].(string)
return value
}
// StoreMetadataString writes a string-valued metadata entry under the metadata
// lock, initializing the map when needed. Empty values are skipped so callers can
// forward optional fields without erasing a previously resolved value.
func StoreMetadataString(metadata *map[string]any, key, value string) {
if metadata == nil || strings.TrimSpace(value) == "" {
return
}
claudeDevicePoolMu.Lock()
defer claudeDevicePoolMu.Unlock()
if *metadata == nil {
*metadata = make(map[string]any)
}
(*metadata)[key] = value
}
// StoreMetadataValue writes an arbitrary metadata entry under the metadata lock,
// initializing the map when needed.
func StoreMetadataValue(metadata *map[string]any, key string, value any) {
if metadata == nil {
return
}
claudeDevicePoolMu.Lock()
defer claudeDevicePoolMu.Unlock()
if *metadata == nil {
*metadata = make(map[string]any)
}
(*metadata)[key] = value
}
// EnsureMetadataMap initializes the metadata map under the metadata lock.
func EnsureMetadataMap(metadata *map[string]any) {
if metadata == nil {
return
}
claudeDevicePoolMu.Lock()
defer claudeDevicePoolMu.Unlock()
if *metadata == nil {
*metadata = make(map[string]any)
}
}
// ensureDeviceIDPoolLocked requires claudeDevicePoolMu to be held.
func ensureDeviceIDPoolLocked(metadata map[string]any) ([]string, bool, error) {
if metadata == nil {
return nil, false, fmt.Errorf("ensure Claude device pool: metadata is nil")
}
rawDeviceIDs := metadata[ClaudeDeviceIDsMetadataKey]
deviceIDs := NormalizeDeviceIDPool(rawDeviceIDs)
changed := !HasCanonicalDeviceIDPool(rawDeviceIDs)
seen := make(map[string]struct{}, ClaudeDevicePoolSize)
for _, deviceID := range deviceIDs {
seen[deviceID] = struct{}{}
}
for len(deviceIDs) < ClaudeDevicePoolSize {
deviceID, errDeviceID := generateDeviceID()
if errDeviceID != nil {
return nil, false, errDeviceID
}
if _, exists := seen[deviceID]; exists {
continue
}
seen[deviceID] = struct{}{}
deviceIDs = append(deviceIDs, deviceID)
}
if changed {
metadata[ClaudeDeviceIDsMetadataKey] = append([]string(nil), deviceIDs...)
}
return append([]string(nil), deviceIDs...), changed, nil
}
// SelectDeviceID returns the credential's sole device ID after validating the conversation session.
func SelectDeviceID(deviceIDs []string, sessionID string) (string, error) {
deviceIDs = NormalizeDeviceIDPool(deviceIDs)
if len(deviceIDs) != ClaudeDevicePoolSize {
return "", fmt.Errorf("select Claude device ID: device pool has %d entries, want %d", len(deviceIDs), ClaudeDevicePoolSize)
}
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return "", fmt.Errorf("select Claude device ID: session ID is empty")
}
return deviceIDs[0], nil
}
// ValidDeviceID reports whether a value matches Claude Code's lowercase 64-hex device format.
func ValidDeviceID(value string) bool {
if len(value) != claudeDeviceIDByteSize*2 || value != strings.ToLower(value) {
return false
}
decoded, errDecode := hex.DecodeString(value)
return errDecode == nil && len(decoded) == claudeDeviceIDByteSize
}

View file

@ -0,0 +1,195 @@
package claude
import (
"reflect"
"sync"
"testing"
)
func TestGenerateDeviceIDPool(t *testing.T) {
deviceIDs, errGenerate := GenerateDeviceIDPool()
if errGenerate != nil {
t.Fatalf("GenerateDeviceIDPool() error = %v", errGenerate)
}
if len(deviceIDs) != ClaudeDevicePoolSize {
t.Fatalf("device pool length = %d, want %d", len(deviceIDs), ClaudeDevicePoolSize)
}
seen := make(map[string]struct{}, len(deviceIDs))
for _, deviceID := range deviceIDs {
if !ValidDeviceID(deviceID) {
t.Fatalf("device ID = %q, want 64 lowercase hex", deviceID)
}
if _, exists := seen[deviceID]; exists {
t.Fatalf("duplicate device ID %q", deviceID)
}
seen[deviceID] = struct{}{}
}
}
// TestReadDeviceIDPoolReturnsDefensiveCopy pins that neither side of the device
// pool accessors hands out the live stored slice. A caller mutating a result must
// never be able to rewrite credential identity outside the device pool lock.
func TestReadDeviceIDPoolReturnsDefensiveCopy(t *testing.T) {
metadata := map[string]any{}
input := []string{"device-a", "device-b", "device-c"}
StoreDeviceIDPool(&metadata, input)
// Write side: mutating the caller's input must not affect stored state.
input[0] = "mutated-input"
stored, ok := ReadDeviceIDPool(&metadata).([]string)
if !ok {
t.Fatalf("ReadDeviceIDPool() type = %T, want []string", ReadDeviceIDPool(&metadata))
}
if stored[0] != "device-a" {
t.Fatalf("stored[0] = %q, want %q; write side is not defensive", stored[0], "device-a")
}
// Read side: mutating the returned slice must not affect stored state.
stored[0] = "hijacked-device-id"
reread, _ := ReadDeviceIDPool(&metadata).([]string)
if reread[0] != "device-a" {
t.Fatalf("stored[0] = %q after mutating the read result, want %q", reread[0], "device-a")
}
// A []any pool (as produced by JSON unmarshalling) must be copied too.
jsonMetadata := map[string]any{ClaudeDeviceIDsMetadataKey: []any{"json-a", "json-b"}}
jsonStored, ok := ReadDeviceIDPool(&jsonMetadata).([]any)
if !ok {
t.Fatalf("ReadDeviceIDPool() type = %T, want []any", ReadDeviceIDPool(&jsonMetadata))
}
jsonStored[0] = "hijacked"
jsonReread, _ := ReadDeviceIDPool(&jsonMetadata).([]any)
if jsonReread[0] != "json-a" {
t.Fatalf("stored[0] = %v after mutating the read result, want %q", jsonReread[0], "json-a")
}
}
func TestEnsureDeviceIDPoolRepairsAndStabilizesCredentialMetadata(t *testing.T) {
const first = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
metadata := map[string]any{
ClaudeDeviceIDsMetadataKey: []any{
first,
first,
"INVALID",
},
}
deviceIDs, changed, errEnsure := EnsureDeviceIDPool(metadata)
if errEnsure != nil {
t.Fatalf("EnsureDeviceIDPool() error = %v", errEnsure)
}
if !changed {
t.Fatal("EnsureDeviceIDPool() changed = false, want true")
}
if len(deviceIDs) != ClaudeDevicePoolSize || deviceIDs[0] != first {
t.Fatalf("device IDs = %#v, want repaired single-entry pool preserving first", deviceIDs)
}
second, changedAgain, errEnsureAgain := EnsureDeviceIDPool(metadata)
if errEnsureAgain != nil {
t.Fatalf("EnsureDeviceIDPool() second error = %v", errEnsureAgain)
}
if changedAgain {
t.Fatal("EnsureDeviceIDPool() second changed = true, want stable canonical pool")
}
if !reflect.DeepEqual(second, deviceIDs) {
t.Fatalf("second device IDs = %#v, want %#v", second, deviceIDs)
}
second[0] = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
stored := metadata[ClaudeDeviceIDsMetadataKey].([]string)
if stored[0] != first {
t.Fatal("returned pool aliases credential metadata")
}
}
func TestEnsureDeviceIDPoolCanonicalizesSingleDevice(t *testing.T) {
const canonical = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
metadata := map[string]any{ClaudeDeviceIDsMetadataKey: []any{" AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA "}}
deviceIDs, changed, errEnsure := EnsureDeviceIDPool(metadata)
if errEnsure != nil {
t.Fatalf("EnsureDeviceIDPool() error = %v", errEnsure)
}
if !changed || len(deviceIDs) != 1 || deviceIDs[0] != canonical {
t.Fatalf("EnsureDeviceIDPool() = %#v, changed=%v; want canonical single device", deviceIDs, changed)
}
if !HasCanonicalDeviceIDPool(metadata[ClaudeDeviceIDsMetadataKey]) {
t.Fatalf("stored device pool = %#v, want canonical", metadata[ClaudeDeviceIDsMetadataKey])
}
}
func TestEnsureDeviceIDPoolMigratesFiveSlotsToOne(t *testing.T) {
metadata := map[string]any{ClaudeDeviceIDsMetadataKey: []string{
"0000000000000000000000000000000000000000000000000000000000000000",
"1111111111111111111111111111111111111111111111111111111111111111",
"2222222222222222222222222222222222222222222222222222222222222222",
"3333333333333333333333333333333333333333333333333333333333333333",
"4444444444444444444444444444444444444444444444444444444444444444",
}}
deviceIDs, changed, errEnsure := EnsureDeviceIDPool(metadata)
if errEnsure != nil {
t.Fatalf("EnsureDeviceIDPool() error = %v", errEnsure)
}
if !changed {
t.Fatal("EnsureDeviceIDPool() changed = false, want five-slot migration")
}
want := []string{"0000000000000000000000000000000000000000000000000000000000000000"}
if !reflect.DeepEqual(deviceIDs, want) {
t.Fatalf("device IDs = %#v, want %#v", deviceIDs, want)
}
if stored, ok := metadata[ClaudeDeviceIDsMetadataKey].([]string); !ok || !reflect.DeepEqual(stored, want) {
t.Fatalf("stored device IDs = %#v, want %#v", metadata[ClaudeDeviceIDsMetadataKey], want)
}
}
func TestEnsureDeviceIDPoolConcurrentInitialization(t *testing.T) {
metadata := make(map[string]any)
const workers = 20
results := make(chan []string, workers)
errors := make(chan error, workers)
var group sync.WaitGroup
for range workers {
group.Go(func() {
deviceIDs, _, errEnsure := EnsureDeviceIDPool(metadata)
results <- deviceIDs
errors <- errEnsure
})
}
group.Wait()
close(results)
close(errors)
for errEnsure := range errors {
if errEnsure != nil {
t.Fatalf("EnsureDeviceIDPool() concurrent error = %v", errEnsure)
}
}
stored := NormalizeDeviceIDPool(metadata[ClaudeDeviceIDsMetadataKey])
if len(stored) != ClaudeDevicePoolSize {
t.Fatalf("stored device pool length = %d, want %d", len(stored), ClaudeDevicePoolSize)
}
for result := range results {
if !reflect.DeepEqual(result, stored) {
t.Fatalf("concurrent result = %#v, want %#v", result, stored)
}
}
}
func TestSelectDeviceIDUsesOneDeviceAcrossSessions(t *testing.T) {
deviceIDs := []string{
"0000000000000000000000000000000000000000000000000000000000000000",
}
first, errFirst := SelectDeviceID(deviceIDs, "11111111-2222-4333-8444-555555555555")
if errFirst != nil {
t.Fatalf("SelectDeviceID() error = %v", errFirst)
}
second, errSecond := SelectDeviceID(deviceIDs, "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee")
if errSecond != nil {
t.Fatalf("SelectDeviceID() second error = %v", errSecond)
}
if first != second || first != deviceIDs[0] {
t.Fatalf("single device selection = %q then %q, want %q", first, second, deviceIDs[0])
}
}

View file

@ -0,0 +1,72 @@
package claude
import (
"bytes"
"compress/flate"
"compress/gzip"
"compress/lzw"
"compress/zlib"
"fmt"
"io"
"net/http"
"strings"
"github.com/andybalholm/brotli"
)
func readClaudeOAuthResponseBody(resp *http.Response) ([]byte, error) {
if resp == nil || resp.Body == nil {
return nil, fmt.Errorf("read Claude OAuth response: body is nil")
}
encoded, errRead := io.ReadAll(resp.Body)
if errRead != nil {
return nil, errRead
}
encodings := strings.Split(strings.Join(resp.Header.Values("Content-Encoding"), ","), ",")
for index := len(encodings) - 1; index >= 0; index-- {
encoding := strings.ToLower(strings.TrimSpace(encodings[index]))
if encoding == "" || encoding == "identity" {
continue
}
var errDecode error
encoded, errDecode = decodeClaudeOAuthEncoding(encoded, encoding)
if errDecode != nil {
return nil, errDecode
}
}
return encoded, nil
}
func decodeClaudeOAuthEncoding(encoded []byte, encoding string) ([]byte, error) {
var reader io.ReadCloser
switch encoding {
case "gzip":
gzipReader, errGzip := gzip.NewReader(bytes.NewReader(encoded))
if errGzip != nil {
return nil, fmt.Errorf("decode Claude OAuth gzip response: %w", errGzip)
}
reader = gzipReader
case "deflate":
zlibReader, errZlib := zlib.NewReader(bytes.NewReader(encoded))
if errZlib == nil {
reader = zlibReader
} else {
reader = flate.NewReader(bytes.NewReader(encoded))
}
case "br":
reader = io.NopCloser(brotli.NewReader(bytes.NewReader(encoded)))
case "compress":
reader = lzw.NewReader(bytes.NewReader(encoded), lzw.MSB, 8)
default:
return nil, fmt.Errorf("decode Claude OAuth response: unsupported content encoding %q", encoding)
}
decoded, errDecoded := io.ReadAll(reader)
if errDecoded != nil {
_ = reader.Close()
return nil, fmt.Errorf("decode Claude OAuth %s response: %w", encoding, errDecoded)
}
if errClose := reader.Close(); errClose != nil {
return nil, fmt.Errorf("close Claude OAuth %s decoder: %w", encoding, errClose)
}
return decoded, nil
}

View file

@ -0,0 +1,108 @@
package claude
import (
"bytes"
"compress/gzip"
"io"
"net/http"
"testing"
"github.com/andybalholm/brotli"
)
func TestReadClaudeOAuthResponseBodyDecodesStackedRepeatedHeaders(t *testing.T) {
t.Parallel()
payload := []byte(`{"account":{"uuid":"test"}}`)
var gzipOutput bytes.Buffer
gzipWriter := gzip.NewWriter(&gzipOutput)
if _, errWrite := gzipWriter.Write(payload); errWrite != nil {
t.Fatal(errWrite)
}
if errClose := gzipWriter.Close(); errClose != nil {
t.Fatal(errClose)
}
var brotliOutput bytes.Buffer
brotliWriter := brotli.NewWriter(&brotliOutput)
if _, errWrite := brotliWriter.Write(gzipOutput.Bytes()); errWrite != nil {
t.Fatal(errWrite)
}
if errClose := brotliWriter.Close(); errClose != nil {
t.Fatal(errClose)
}
header := make(http.Header)
header.Add("Content-Encoding", "gzip")
header.Add("Content-Encoding", "br")
resp := &http.Response{
Header: header,
Body: io.NopCloser(bytes.NewReader(brotliOutput.Bytes())),
}
got, errRead := readClaudeOAuthResponseBody(resp)
if errRead != nil {
t.Fatal(errRead)
}
if !bytes.Equal(got, payload) {
t.Fatalf("decoded body = %q, want %q", got, payload)
}
}
func TestReadClaudeOAuthResponseBodyDecodesAdvertisedEncodings(t *testing.T) {
t.Parallel()
const payload = `{"account":{"uuid":"test"}}`
tests := []struct {
name string
encoding string
encode func(testing.TB, []byte) []byte
}{
{
name: "gzip",
encoding: "gzip",
encode: func(tb testing.TB, input []byte) []byte {
tb.Helper()
var output bytes.Buffer
writer := gzip.NewWriter(&output)
if _, errWrite := writer.Write(input); errWrite != nil {
tb.Fatal(errWrite)
}
if errClose := writer.Close(); errClose != nil {
tb.Fatal(errClose)
}
return output.Bytes()
},
},
{
name: "brotli",
encoding: "br",
encode: func(tb testing.TB, input []byte) []byte {
tb.Helper()
var output bytes.Buffer
writer := brotli.NewWriter(&output)
if _, errWrite := writer.Write(input); errWrite != nil {
tb.Fatal(errWrite)
}
if errClose := writer.Close(); errClose != nil {
tb.Fatal(errClose)
}
return output.Bytes()
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
resp := &http.Response{
Header: http.Header{"Content-Encoding": []string{test.encoding}},
Body: io.NopCloser(bytes.NewReader(test.encode(t, []byte(payload)))),
}
got, errRead := readClaudeOAuthResponseBody(resp)
if errRead != nil {
t.Fatal(errRead)
}
if string(got) != payload {
t.Fatalf("decoded body = %q, want %q", got, payload)
}
})
}
}

View file

@ -0,0 +1,320 @@
// Package claude provides authentication and token management functionality
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
// and retrieval for maintaining authenticated sessions with the Claude API.
package claude
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
// OAuthServer handles the local HTTP server for OAuth callbacks.
// It listens for the authorization code response from the OAuth provider
// and captures the necessary parameters to complete the authentication flow.
type OAuthServer struct {
// server is the underlying HTTP server instance
server *http.Server
// port is the port number on which the server listens
port int
// resultChan is a channel for sending OAuth results
resultChan chan *OAuthResult
// errorChan is a channel for sending OAuth errors
errorChan chan error
// mu is a mutex for protecting server state
mu sync.Mutex
// running indicates whether the server is currently running
running bool
}
// OAuthResult contains the result of the OAuth callback.
// It holds either the authorization code and state for successful authentication
// or an error message if the authentication failed.
type OAuthResult struct {
// Code is the authorization code received from the OAuth provider
Code string
// State is the state parameter used to prevent CSRF attacks
State string
// Error contains any error message if the OAuth flow failed
Error string
}
// NewOAuthServer creates a new OAuth callback server.
// It initializes the server with the specified port and creates channels
// for handling OAuth results and errors.
//
// Parameters:
// - port: The port number on which the server should listen
//
// Returns:
// - *OAuthServer: A new OAuthServer instance
func NewOAuthServer(port int) *OAuthServer {
return &OAuthServer{
port: port,
resultChan: make(chan *OAuthResult, 1),
errorChan: make(chan error, 1),
}
}
// Start starts the OAuth callback server.
// It sets up the HTTP handlers for the callback and success endpoints,
// and begins listening on the specified port.
//
// Returns:
// - error: An error if the server fails to start
func (s *OAuthServer) Start() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
return fmt.Errorf("server is already running")
}
// Check if port is available
if !s.isPortAvailable() {
return fmt.Errorf("port %d is already in use", s.port)
}
mux := http.NewServeMux()
mux.HandleFunc("/callback", s.handleCallback)
mux.HandleFunc("/success", s.handleSuccess)
s.server = &http.Server{
Addr: fmt.Sprintf(":%d", s.port),
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
s.running = true
// Start server in goroutine
go func() {
if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.errorChan <- fmt.Errorf("server failed to start: %w", err)
}
}()
// Give server a moment to start
time.Sleep(100 * time.Millisecond)
return nil
}
// Stop gracefully stops the OAuth callback server.
// It performs a graceful shutdown of the HTTP server with a timeout.
//
// Parameters:
// - ctx: The context for controlling the shutdown process
//
// Returns:
// - error: An error if the server fails to stop gracefully
func (s *OAuthServer) Stop(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.running || s.server == nil {
return nil
}
log.Debug("Stopping OAuth callback server")
// Create a context with timeout for shutdown
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
err := s.server.Shutdown(shutdownCtx)
s.running = false
s.server = nil
return err
}
// WaitForCallback waits for the OAuth callback with a timeout.
// It blocks until either an OAuth result is received, an error occurs,
// or the specified timeout is reached.
//
// Parameters:
// - timeout: The maximum time to wait for the callback
//
// Returns:
// - *OAuthResult: The OAuth result if successful
// - error: An error if the callback times out or an error occurs
func (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) {
select {
case result := <-s.resultChan:
return result, nil
case err := <-s.errorChan:
return nil, err
case <-time.After(timeout):
return nil, fmt.Errorf("timeout waiting for OAuth callback")
}
}
// handleCallback handles the OAuth callback endpoint.
// It extracts the authorization code and state from the callback URL,
// validates the parameters, and sends the result to the waiting channel.
//
// Parameters:
// - w: The HTTP response writer
// - r: The HTTP request
func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
log.Debug("Received OAuth callback")
// Validate request method
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract parameters
query := r.URL.Query()
code := query.Get("code")
state := query.Get("state")
errorParam := query.Get("error")
// Validate required parameters
if errorParam != "" {
log.Errorf("OAuth error received: %s", errorParam)
result := &OAuthResult{
Error: errorParam,
}
s.sendResult(result)
http.Error(w, fmt.Sprintf("OAuth error: %s", errorParam), http.StatusBadRequest)
return
}
if code == "" {
log.Error("No authorization code received")
result := &OAuthResult{
Error: "no_code",
}
s.sendResult(result)
http.Error(w, "No authorization code received", http.StatusBadRequest)
return
}
if state == "" {
log.Error("No state parameter received")
result := &OAuthResult{
Error: "no_state",
}
s.sendResult(result)
http.Error(w, "No state parameter received", http.StatusBadRequest)
return
}
// Send successful result
result := &OAuthResult{
Code: code,
State: state,
}
s.sendResult(result)
// Redirect to success page
http.Redirect(w, r, "/success", http.StatusFound)
}
// handleSuccess handles the success page endpoint.
// It serves a user-friendly HTML page indicating that authentication was successful.
//
// Parameters:
// - w: The HTTP response writer
// - r: The HTTP request
func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) {
log.Debug("Serving success page")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
// Parse query parameters for customization
query := r.URL.Query()
setupRequired := query.Get("setup_required") == "true"
platformURL := query.Get("platform_url")
if platformURL == "" {
platformURL = "https://console.anthropic.com/"
}
// Generate success page HTML with dynamic content
successHTML := s.generateSuccessHTML(setupRequired, platformURL)
_, err := w.Write([]byte(successHTML))
if err != nil {
log.Errorf("Failed to write success page: %v", err)
}
}
// generateSuccessHTML creates the HTML content for the success page.
// It customizes the page based on whether additional setup is required
// and includes a link to the platform.
//
// Parameters:
// - setupRequired: Whether additional setup is required after authentication
// - platformURL: The URL to the platform for additional setup
//
// Returns:
// - string: The HTML content for the success page
func (s *OAuthServer) generateSuccessHTML(setupRequired bool, platformURL string) string {
html := LoginSuccessHtml
// Replace platform URL placeholder
html = strings.Replace(html, "{{PLATFORM_URL}}", platformURL, -1)
// Add setup notice if required
if setupRequired {
setupNotice := strings.Replace(SetupNoticeHtml, "{{PLATFORM_URL}}", platformURL, -1)
html = strings.Replace(html, "{{SETUP_NOTICE}}", setupNotice, 1)
} else {
html = strings.Replace(html, "{{SETUP_NOTICE}}", "", 1)
}
return html
}
// sendResult sends the OAuth result to the waiting channel.
// It ensures that the result is sent without blocking the handler.
//
// Parameters:
// - result: The OAuth result to send
func (s *OAuthServer) sendResult(result *OAuthResult) {
select {
case s.resultChan <- result:
log.Debug("OAuth result sent to channel")
default:
log.Warn("OAuth result channel is full, result dropped")
}
}
// isPortAvailable checks if the specified port is available.
// It attempts to listen on the port to determine availability.
//
// Returns:
// - bool: True if the port is available, false otherwise
func (s *OAuthServer) isPortAvailable() bool {
addr := fmt.Sprintf(":%d", s.port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return false
}
defer func() {
_ = listener.Close()
}()
return true
}
// IsRunning returns whether the server is currently running.
//
// Returns:
// - bool: True if the server is running, false otherwise
func (s *OAuthServer) IsRunning() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.running
}

View file

@ -0,0 +1,56 @@
// Package claude provides authentication and token management functionality
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
// and retrieval for maintaining authenticated sessions with the Claude API.
package claude
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
)
// GeneratePKCECodes generates a PKCE code verifier and challenge pair
// following RFC 7636 specifications for OAuth 2.0 PKCE extension.
// This provides additional security for the OAuth flow by ensuring that
// only the client that initiated the request can exchange the authorization code.
//
// Returns:
// - *PKCECodes: A struct containing the code verifier and challenge
// - error: An error if the generation fails, nil otherwise
func GeneratePKCECodes() (*PKCECodes, error) {
// Generate code verifier: 43-128 characters, URL-safe
codeVerifier, err := generateCodeVerifier()
if err != nil {
return nil, fmt.Errorf("failed to generate code verifier: %w", err)
}
// Generate code challenge using S256 method
codeChallenge := generateCodeChallenge(codeVerifier)
return &PKCECodes{
CodeVerifier: codeVerifier,
CodeChallenge: codeChallenge,
}, nil
}
// generateCodeVerifier creates a cryptographically random string
// of 128 characters using URL-safe base64 encoding
func generateCodeVerifier() (string, error) {
// Generate 96 random bytes (will result in 128 base64 characters)
bytes := make([]byte, 96)
_, err := rand.Read(bytes)
if err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
// Encode to URL-safe base64 without padding
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes), nil
}
// generateCodeChallenge creates a SHA256 hash of the code verifier
// and encodes it using URL-safe base64 encoding without padding
func generateCodeChallenge(codeVerifier string) string {
hash := sha256.Sum256([]byte(codeVerifier))
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])
}

View file

@ -0,0 +1,104 @@
// Package claude provides authentication and token management functionality
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
// and retrieval for maintaining authenticated sessions with the Claude API.
package claude
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
log "github.com/sirupsen/logrus"
)
// ClaudeTokenStorage stores OAuth2 token information for Anthropic Claude API authentication.
// It maintains compatibility with the existing auth system while adding Claude-specific fields
// for managing access tokens, refresh tokens, and user account information.
type ClaudeTokenStorage struct {
// IDToken is the JWT ID token containing user claims and identity information.
IDToken string `json:"id_token"`
// AccessToken is the OAuth2 access token used for authenticating API requests.
AccessToken string `json:"access_token"`
// RefreshToken is used to obtain new access tokens when the current one expires.
RefreshToken string `json:"refresh_token"`
// LastRefresh is the timestamp of the last token refresh operation.
LastRefresh string `json:"last_refresh"`
// Email is the Anthropic account email address associated with this token.
Email string `json:"email"`
// AccountUUID identifies the Anthropic account returned by OAuth.
AccountUUID string `json:"account_uuid,omitempty"`
// OrganizationUUID identifies the Anthropic organization returned by OAuth.
OrganizationUUID string `json:"organization_uuid,omitempty"`
// OrganizationName is the display name returned by OAuth.
OrganizationName string `json:"organization_name,omitempty"`
// DeviceIDs contains the single device identity assigned to this credential.
DeviceIDs []string `json:"claude_device_ids,omitempty"`
// Type indicates the authentication provider type, always "claude" for this storage.
Type string `json:"type"`
// Expire is the timestamp when the current access token expires.
Expire string `json:"expired"`
// Metadata holds arbitrary key-value pairs injected via hooks.
// It is not exported to JSON directly to allow flattening during serialization.
Metadata map[string]any `json:"-"`
}
// SetMetadata allows external callers to inject metadata into the storage before saving.
func (ts *ClaudeTokenStorage) SetMetadata(meta map[string]any) {
ts.Metadata = meta
}
// SaveTokenToFile serializes the Claude token storage to a JSON file.
// This method creates the necessary directory structure and writes the token
// data in JSON format to the specified file path for persistent storage.
// It merges any injected metadata into the top-level JSON object.
//
// Parameters:
// - authFilePath: The full path where the token file should be saved
//
// Returns:
// - error: An error if the operation fails, nil otherwise
func (ts *ClaudeTokenStorage) SaveTokenToFile(authFilePath string) error {
misc.LogSavingCredentials(authFilePath)
ts.Type = "claude"
// Create directory structure if it doesn't exist
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
return fmt.Errorf("failed to create directory: %v", err)
}
// Merge metadata using helper
data, errMerge := misc.MergeMetadata(ts, ts.Metadata)
if errMerge != nil {
return fmt.Errorf("failed to merge metadata: %w", errMerge)
}
// Create the token file
f, err := os.Create(authFilePath)
if err != nil {
return fmt.Errorf("failed to create token file: %w", err)
}
defer func() {
if errClose := f.Close(); errClose != nil {
log.Errorf("claude token storage: close token file error: %v", errClose)
}
}()
// Encode and write the token data as JSON
if err = json.NewEncoder(f).Encode(data); err != nil {
return fmt.Errorf("failed to write token to file: %w", err)
}
return nil
}

View file

@ -0,0 +1,59 @@
package claude
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestSaveTokenToFile_PreservesCustomMetadata(t *testing.T) {
tempDir := t.TempDir()
authFilePath := filepath.Join(tempDir, "claude-test.json")
storage := &ClaudeTokenStorage{
Type: "claude",
Email: "user@example.com",
AccessToken: "new-claude-access",
RefreshToken: "new-claude-refresh",
Expire: "2026-12-31T23:59:59Z",
LastRefresh: "2026-04-14T12:00:00Z",
}
storage.SetMetadata(map[string]any{
"disabled": false,
"prefix": "claude-prefix",
"note": "claude custom note",
"proxy_url": "http://proxy:8080",
"weight": float64(5),
})
if errSave := storage.SaveTokenToFile(authFilePath); errSave != nil {
t.Fatalf("SaveTokenToFile() error = %v", errSave)
}
savedRaw, errRead := os.ReadFile(authFilePath)
if errRead != nil {
t.Fatalf("os.ReadFile error = %v", errRead)
}
var saved map[string]any
if errUnmarshal := json.Unmarshal(savedRaw, &saved); errUnmarshal != nil {
t.Fatalf("json.Unmarshal error = %v", errUnmarshal)
}
if saved["access_token"] != "new-claude-access" {
t.Errorf("access_token = %v, want new-claude-access", saved["access_token"])
}
if saved["prefix"] != "claude-prefix" {
t.Errorf("prefix = %v, want claude-prefix", saved["prefix"])
}
if saved["note"] != "claude custom note" {
t.Errorf("note = %v, want claude custom note", saved["note"])
}
if saved["proxy_url"] != "http://proxy:8080" {
t.Errorf("proxy_url = %v, want http://proxy:8080", saved["proxy_url"])
}
if saved["weight"] != float64(5) {
t.Errorf("weight = %v, want 5", saved["weight"])
}
}

View file

@ -0,0 +1,254 @@
package claude
import (
"context"
"fmt"
"net"
"net/http"
"strings"
"time"
tls "github.com/refraction-networking/utls"
internalcache "github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v7/internal/httpwire"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
log "github.com/sirupsen/logrus"
"golang.org/x/net/proxy"
)
type claudeRefreshHandshakeTimeoutContextKey struct{}
var claudeOAuthRefreshHeaderOrder = []string{
"Accept",
"Content-Type",
"User-Agent",
"Content-Length",
"Accept-Encoding",
"Host",
"Connection",
}
// claudeOAuthInspectHeaderOrder is the order the native client emits for the
// authenticated Axios GET lookups on the OAuth control plane, covering both the
// account profile and the claude_cli roles companion request.
var claudeOAuthInspectHeaderOrder = []string{
"Accept",
"Content-Type",
"Authorization",
"Cache-Control",
"User-Agent",
"Accept-Encoding",
"Host",
"Connection",
}
// claudeOAuthInspectTargets are the authenticated control-plane GET paths that
// use claudeOAuthInspectHeaderOrder.
var claudeOAuthInspectTargets = []string{
"/api/oauth/profile",
"/api/oauth/claude_cli/roles",
}
func claudeOAuthRequestHeaderOrder(method, requestTarget string) []string {
if method == http.MethodGet {
for _, target := range claudeOAuthInspectTargets {
if strings.HasPrefix(requestTarget, target) {
return claudeOAuthInspectHeaderOrder
}
}
}
return claudeOAuthRefreshHeaderOrder
}
// claudeOAuthSessionCacheCapacity bounds one proxy's TLS session cache. The
// OAuth control plane only talks to platform.claude.com and api.anthropic.com,
// so a small cache covers every reachable server.
const (
claudeOAuthSessionCacheCapacity = 8
claudeOAuthProxySessionCacheCapacity = 64
)
// claudeOAuthSessionCaches keys one session cache per effective proxy URL.
//
// ClaudeAuth is constructed per operation (every refresh and every executor
// profile check builds a new one), so a cache owned by the round tripper would
// always start empty and never resume. Keying on the proxy instead matches the
// inference plane, where the whole round tripper is cached per proxy, and keeps
// resumption from crossing proxy boundaries. TLS sessions are scoped to a
// server rather than a credential, and connections are already pooled per proxy
// on the inference plane, so this adds no new cross-credential linkage.
var claudeOAuthSessionCaches = internalcache.NewBoundedLRU[string, tls.ClientSessionCache](
claudeOAuthProxySessionCacheCapacity,
nil,
)
func claudeOAuthSessionCache(proxyURL string) tls.ClientSessionCache {
return claudeOAuthSessionCaches.GetOrAdd(proxyURL, func() tls.ClientSessionCache {
return tls.NewLRUClientSessionCache(claudeOAuthSessionCacheCapacity)
})
}
// newClaudeOAuthTLSConfig builds the uTLS config for one control-plane dial.
//
// OmitEmptyPsk keeps the pre_shared_key extension silent until a session is
// actually cached, so the first ClientHello is byte-identical to the captured
// native handshake. PreferSkipResumptionOnNilExtension is defense in depth: for
// HelloCustom specs uTLS panics when it wants to resume but the spec lacks the
// matching extension, and this degrades that into a skipped resumption.
func newClaudeOAuthTLSConfig(host string, sessionCache tls.ClientSessionCache) *tls.Config {
return &tls.Config{
ServerName: host,
ClientSessionCache: sessionCache,
OmitEmptyPsk: true,
PreferSkipResumptionOnNilExtension: true,
}
}
// claudeOAuthTLSClientHelloSpec reproduces the compact Node/OpenSSL profile
// Claude Code 2.1.220 uses for Axios OAuth control-plane requests. Unlike the
// inference profile, it advertises no ALPN extension and therefore uses
// HTTP/1.1 without negotiating a protocol.
func claudeOAuthTLSClientHelloSpec() *tls.ClientHelloSpec {
return &tls.ClientHelloSpec{
TLSVersMin: tls.VersionTLS12,
TLSVersMax: tls.VersionTLS13,
CompressionMethods: []uint8{0},
CipherSuites: []uint16{
tls.TLS_AES_128_GCM_SHA256,
tls.TLS_AES_256_GCM_SHA384,
tls.TLS_CHACHA20_POLY1305_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_RSA_WITH_AES_128_CBC_SHA,
tls.TLS_RSA_WITH_AES_256_CBC_SHA,
},
Extensions: []tls.TLSExtension{
&tls.SNIExtension{},
&tls.ExtendedMasterSecretExtension{},
&tls.RenegotiationInfoExtension{Renegotiation: tls.RenegotiateOnceAsClient},
&tls.SupportedCurvesExtension{Curves: []tls.CurveID{tls.X25519, tls.CurveP256, tls.CurveP384}},
&tls.SupportedPointsExtension{SupportedPoints: []byte{0}},
&tls.SessionTicketExtension{},
&tls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []tls.SignatureScheme{
tls.ECDSAWithP256AndSHA256,
tls.PSSWithSHA256,
tls.PKCS1WithSHA256,
tls.ECDSAWithP384AndSHA384,
tls.PSSWithSHA384,
tls.PKCS1WithSHA384,
tls.PSSWithSHA512,
tls.PKCS1WithSHA512,
tls.PKCS1WithSHA1,
}},
&tls.KeyShareExtension{KeyShares: []tls.KeyShare{{Group: tls.X25519}}},
&tls.PSKKeyExchangeModesExtension{Modes: []uint8{tls.PskModeDHE}},
&tls.SupportedVersionsExtension{Versions: []uint16{tls.VersionTLS13, tls.VersionTLS12}},
// pre_shared_key MUST be the final extension (RFC 8446 4.2.11). It
// contributes zero bytes until a cached session exists.
&tls.UtlsPreSharedKeyExtension{},
},
}
}
// utlsRoundTripper uses Claude Code's OAuth control-plane TLS and HTTP/1.1
// profile while retaining net/http proxy, cancellation, response parsing and
// connection lifecycle semantics.
type utlsRoundTripper struct {
dialer proxy.Dialer
// sessionCache is shared by every transport built for the same proxy, so
// short-lived ClaudeAuth instances can still resume, while resumption never
// crosses proxy boundaries.
sessionCache tls.ClientSessionCache
transport *http.Transport
}
func newUtlsRoundTripper(cfg *config.SDKConfig) *utlsRoundTripper {
var dialer proxy.Dialer = proxy.Direct
var proxyURL string
if cfg != nil {
proxyURL = cfg.ProxyURL
proxyDialer, mode, errBuild := proxyutil.BuildDialer(cfg.ProxyURL)
if errBuild != nil {
log.Errorf("failed to configure proxy dialer for %q: %v", proxyutil.Redact(cfg.ProxyURL), errBuild)
} else if mode != proxyutil.ModeInherit && proxyDialer != nil {
dialer = proxyDialer
}
}
roundTripper := &utlsRoundTripper{
dialer: dialer,
sessionCache: claudeOAuthSessionCache(proxyURL),
}
roundTripper.transport = &http.Transport{
ForceAttemptHTTP2: false,
DialTLSContext: roundTripper.dialTLSContext,
}
return roundTripper
}
func (t *utlsRoundTripper) dialTLSContext(ctx context.Context, network, addr string) (net.Conn, error) {
var (
conn net.Conn
err error
)
if contextDialer, ok := t.dialer.(proxy.ContextDialer); ok {
conn, err = contextDialer.DialContext(ctx, network, addr)
} else {
conn, err = t.dialer.Dial(network, addr)
}
if err != nil {
return nil, fmt.Errorf("claude oauth tls: dial upstream: %w", err)
}
host, _, errSplit := net.SplitHostPort(addr)
if errSplit != nil {
if errClose := conn.Close(); errClose != nil {
log.Debugf("claude oauth tls: close failed connection: %v", errClose)
}
return nil, fmt.Errorf("claude oauth tls: split upstream address: %w", errSplit)
}
tlsConn := tls.UClient(conn, newClaudeOAuthTLSConfig(host, t.sessionCache), tls.HelloCustom)
if errPreset := tlsConn.ApplyPreset(claudeOAuthTLSClientHelloSpec()); errPreset != nil {
if errClose := tlsConn.Close(); errClose != nil {
log.Debugf("claude oauth tls: close connection after preset failure: %v", errClose)
}
return nil, fmt.Errorf("claude oauth tls: apply ClientHello: %w", errPreset)
}
handshakeCtx := ctx
if handshakeTimeout, _ := ctx.Value(claudeRefreshHandshakeTimeoutContextKey{}).(time.Duration); handshakeTimeout > 0 {
var cancelHandshake context.CancelFunc
handshakeCtx, cancelHandshake = context.WithTimeout(ctx, handshakeTimeout)
defer cancelHandshake()
}
if errHandshake := tlsConn.HandshakeContext(handshakeCtx); errHandshake != nil {
if errClose := tlsConn.Close(); errClose != nil {
log.Debugf("claude oauth tls: close connection after handshake failure: %v", errClose)
}
return nil, fmt.Errorf("claude oauth tls: handshake upstream: %w", errHandshake)
}
return httpwire.NewOrderedRequestConn(tlsConn, claudeOAuthRequestHeaderOrder), nil
}
func (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return t.transport.RoundTrip(req)
}
func (t *utlsRoundTripper) CloseIdleConnections() {
t.transport.CloseIdleConnections()
}
func NewAnthropicHttpClient(cfg *config.SDKConfig) *http.Client {
return &http.Client{Transport: newUtlsRoundTripper(cfg)}
}

View file

@ -0,0 +1,284 @@
package claude
import (
"context"
"crypto/md5"
"encoding/binary"
"encoding/hex"
"errors"
"io"
"net"
"reflect"
"strconv"
"strings"
"testing"
"time"
tls "github.com/refraction-networking/utls"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
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}}
ctx := context.WithValue(context.Background(), claudeRefreshHandshakeTimeoutContextKey{}, 20*time.Millisecond)
startedAt := time.Now()
_, err := transport.dialTLSContext(ctx, "tcp", "example.com:443")
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)
}
}
func TestClaudeOAuthTLSClientHelloSpecMatchesNative220Capture(t *testing.T) {
t.Parallel()
const wantJA3 = "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49161-49171-49162-49172-156-157-47-53,0-23-65281-10-11-35-13-51-45-43,29-23-24,0"
const wantJA3MD5 = "203503b7023848ab87b9836c336b8e81"
wantCipherSuites := []uint16{4865, 4866, 4867, 49195, 49199, 49196, 49200, 52393, 52392, 49161, 49171, 49162, 49172, 156, 157, 47, 53}
wantExtensions := []uint16{0, 23, 65281, 10, 11, 35, 13, 51, 45, 43}
spec := claudeOAuthTLSClientHelloSpec()
if !reflect.DeepEqual(spec.CipherSuites, wantCipherSuites) {
t.Fatalf("cipher suites = %v, want %v", spec.CipherSuites, wantCipherSuites)
}
extensionTypes := claudeOAuthExtensionTypes(t, spec.Extensions)
if !reflect.DeepEqual(extensionTypes, wantExtensions) {
t.Fatalf("extension types = %v, want %v", extensionTypes, wantExtensions)
}
curves := spec.Extensions[3].(*tls.SupportedCurvesExtension).Curves
points := spec.Extensions[4].(*tls.SupportedPointsExtension).SupportedPoints
actualJA3 := "771," + joinClaudeOAuthUint16(spec.CipherSuites) + "," + joinClaudeOAuthUint16(extensionTypes) + "," + joinClaudeOAuthCurves(curves) + "," + joinClaudeOAuthUint8(points)
if actualJA3 != wantJA3 {
t.Fatalf("JA3 = %q, want %q", actualJA3, wantJA3)
}
if strings.Contains(actualJA3, "-16-") {
t.Fatal("OAuth JA3 unexpectedly contains ALPN extension 16")
}
hash := md5.Sum([]byte(actualJA3)) // #nosec G401 -- JA3 requires MD5.
if got := hex.EncodeToString(hash[:]); got != wantJA3MD5 {
t.Fatalf("JA3 MD5 = %s, want %s", got, wantJA3MD5)
}
record := captureClaudeOAuthClientHello(t)
if got := len(record) - 9; got != 245 {
t.Fatalf("ClientHello length = %d, want 245", got)
}
}
func TestClaudeOAuthTLSResumptionIsWireSafe(t *testing.T) {
t.Parallel()
// RFC 8446 4.2.11 requires pre_shared_key to be the final extension.
spec := claudeOAuthTLSClientHelloSpec()
last := spec.Extensions[len(spec.Extensions)-1]
if _, ok := last.(*tls.UtlsPreSharedKeyExtension); !ok {
t.Fatalf("last OAuth extension = %T, want *tls.UtlsPreSharedKeyExtension", last)
}
// Without OmitEmptyPsk uTLS refuses to marshal an empty PSK, and without
// PreferSkipResumptionOnNilExtension a HelloCustom resumption attempt panics.
cfg := newClaudeOAuthTLSConfig("api.anthropic.com", tls.NewLRUClientSessionCache(claudeOAuthSessionCacheCapacity))
if cfg.ServerName != "api.anthropic.com" {
t.Fatalf("ServerName = %q, want api.anthropic.com", cfg.ServerName)
}
if cfg.ClientSessionCache == nil {
t.Fatal("ClientSessionCache = nil, want a session cache so resumption is possible")
}
if !cfg.OmitEmptyPsk {
t.Fatal("OmitEmptyPsk = false, want true so an unresumed ClientHello stays byte-identical")
}
if !cfg.PreferSkipResumptionOnNilExtension {
t.Fatal("PreferSkipResumptionOnNilExtension = false, want true to avoid a HelloCustom resumption panic")
}
// ClaudeAuth is rebuilt for every refresh and every executor profile check, so
// the cache must be keyed on the proxy rather than owned by the transport;
// otherwise every dial starts with an empty cache and never resumes.
first := newUtlsRoundTripper(&sdkconfig.SDKConfig{ProxyURL: "http://127.0.0.1:9"})
second := newUtlsRoundTripper(&sdkconfig.SDKConfig{ProxyURL: "http://127.0.0.1:9"})
if first.sessionCache == nil || second.sessionCache == nil {
t.Fatal("round tripper session cache = nil, want a shared per-proxy cache")
}
if first.sessionCache != second.sessionCache {
t.Fatal("same-proxy transports have different session caches, so resumption can never hit")
}
// Resumption must not cross proxy boundaries.
other := newUtlsRoundTripper(&sdkconfig.SDKConfig{ProxyURL: "http://127.0.0.1:10"})
if first.sessionCache == other.sessionCache {
t.Fatal("different proxies share a session cache, want per-proxy isolation")
}
// Same check through the real entry point: two ClaudeAuth values built the way
// refresh and the executor profile check build them must still share a cache.
cacheOf := func(service *ClaudeAuth) tls.ClientSessionCache {
t.Helper()
transport, ok := service.httpClient.Transport.(*utlsRoundTripper)
if !ok {
t.Fatalf("ClaudeAuth transport type = %T, want *utlsRoundTripper", service.httpClient.Transport)
}
return transport.sessionCache
}
if cacheOf(NewClaudeAuthWithProxyURL(nil, "http://127.0.0.1:11")) != cacheOf(NewClaudeAuthWithProxyURL(nil, "http://127.0.0.1:11")) {
t.Fatal("per-operation ClaudeAuth instances do not share a session cache, so refresh can never resume")
}
}
func TestClaudeOAuthSessionCacheBoundsProxyCardinality(t *testing.T) {
firstProxy := "http://127.0.0.1:31000"
first := claudeOAuthSessionCache(firstProxy)
for index := 1; index <= claudeOAuthProxySessionCacheCapacity; index++ {
claudeOAuthSessionCache("http://127.0.0.1:" + strconv.Itoa(31000+index))
}
if got := claudeOAuthSessionCaches.Len(); got > claudeOAuthProxySessionCacheCapacity {
t.Fatalf("OAuth session caches = %d, want at most %d", got, claudeOAuthProxySessionCacheCapacity)
}
if recreated := claudeOAuthSessionCache(firstProxy); recreated == first {
t.Fatal("least recently used OAuth proxy session cache was not evicted")
}
}
func TestClaudeOAuthRequestHeaderOrderMatchesNative220Capture(t *testing.T) {
t.Parallel()
wantRefresh := []string{"Accept", "Content-Type", "User-Agent", "Content-Length", "Accept-Encoding", "Host", "Connection"}
wantProfile := []string{"Accept", "Content-Type", "Authorization", "Cache-Control", "User-Agent", "Accept-Encoding", "Host", "Connection"}
if got := claudeOAuthRequestHeaderOrder("POST", "/v1/oauth/token"); !reflect.DeepEqual(got, wantRefresh) {
t.Fatalf("refresh header order = %v, want %v", got, wantRefresh)
}
if got := claudeOAuthRequestHeaderOrder("GET", "/api/oauth/profile"); !reflect.DeepEqual(got, wantProfile) {
t.Fatalf("profile header order = %v, want %v", got, wantProfile)
}
// The claude_cli roles companion lookup uses the same authenticated Axios GET shape.
if got := claudeOAuthRequestHeaderOrder("GET", "/api/oauth/claude_cli/roles"); !reflect.DeepEqual(got, wantProfile) {
t.Fatalf("roles header order = %v, want %v", got, wantProfile)
}
// The authorization-code exchange is a POST and keeps the JSON-body order.
if got := claudeOAuthRequestHeaderOrder("POST", "/api/oauth/profile"); !reflect.DeepEqual(got, wantRefresh) {
t.Fatalf("non-GET profile target header order = %v, want %v", got, wantRefresh)
}
}
func claudeOAuthExtensionTypes(t *testing.T, extensions []tls.TLSExtension) []uint16 {
t.Helper()
result := make([]uint16, 0, len(extensions))
for _, extension := range extensions {
switch extension.(type) {
case *tls.SNIExtension:
result = append(result, 0)
case *tls.ExtendedMasterSecretExtension:
result = append(result, 23)
case *tls.RenegotiationInfoExtension:
result = append(result, 65281)
case *tls.SupportedCurvesExtension:
result = append(result, 10)
case *tls.SupportedPointsExtension:
result = append(result, 11)
case *tls.SessionTicketExtension:
result = append(result, 35)
case *tls.SignatureAlgorithmsExtension:
result = append(result, 13)
case *tls.KeyShareExtension:
result = append(result, 51)
case *tls.PSKKeyExchangeModesExtension:
result = append(result, 45)
case *tls.SupportedVersionsExtension:
result = append(result, 43)
case *tls.UtlsPreSharedKeyExtension:
// pre_shared_key contributes zero bytes until a session is cached, so
// it never appears in the fresh ClientHello the native capture covers
// and must stay out of the JA3 extension list. The record length
// assertion in the caller proves the byte neutrality.
continue
default:
t.Fatalf("unexpected OAuth TLS extension %T", extension)
}
}
return result
}
func joinClaudeOAuthUint16(values []uint16) string {
parts := make([]string, len(values))
for index, value := range values {
parts[index] = strconv.Itoa(int(value))
}
return strings.Join(parts, "-")
}
func joinClaudeOAuthCurves(values []tls.CurveID) string {
parts := make([]string, len(values))
for index, value := range values {
parts[index] = strconv.Itoa(int(value))
}
return strings.Join(parts, "-")
}
func joinClaudeOAuthUint8(values []uint8) string {
parts := make([]string, len(values))
for index, value := range values {
parts[index] = strconv.Itoa(int(value))
}
return strings.Join(parts, "-")
}
func captureClaudeOAuthClientHello(t *testing.T) []byte {
t.Helper()
clientConn, serverConn := net.Pipe()
t.Cleanup(func() {
if errClose := clientConn.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
t.Errorf("close client connection: %v", errClose)
}
if errClose := serverConn.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
t.Errorf("close server connection: %v", errClose)
}
})
// Use the production config so the captured bytes reflect the real dial path.
cfg := newClaudeOAuthTLSConfig("api.anthropic.com", tls.NewLRUClientSessionCache(claudeOAuthSessionCacheCapacity))
tlsConn := tls.UClient(clientConn, cfg, tls.HelloCustom)
if errPreset := tlsConn.ApplyPreset(claudeOAuthTLSClientHelloSpec()); errPreset != nil {
t.Fatal(errPreset)
}
handshakeDone := make(chan error, 1)
go func() { handshakeDone <- tlsConn.Handshake() }()
if errDeadline := serverConn.SetReadDeadline(time.Now().Add(5 * time.Second)); errDeadline != nil {
t.Fatal(errDeadline)
}
header := make([]byte, 5)
if _, errRead := io.ReadFull(serverConn, header); errRead != nil {
t.Fatal(errRead)
}
payload := make([]byte, int(binary.BigEndian.Uint16(header[3:5])))
if _, errRead := io.ReadFull(serverConn, payload); errRead != nil {
t.Fatal(errRead)
}
if errClose := serverConn.Close(); errClose != nil {
t.Fatal(errClose)
}
select {
case <-handshakeDone:
case <-time.After(5 * time.Second):
t.Fatal("OAuth uTLS handshake did not exit")
}
return append(header, payload...)
}