package auth import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "time" "github.com/rs/zerolog/log" "github.com/fujin/anthropic-proxy/internal/transport" ) const ( tokenEndpoint = "https://platform.claude.com/v1/oauth/token" clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" oauthScopes = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload" refreshLead = 5 * time.Minute refreshInterval = 30 * time.Second refreshBackoff = 5 * time.Minute ) var utlsClient = transport.NewHTTPClient(15 * time.Second) type tokenRequest struct { ClientID string `json:"client_id"` GrantType string `json:"grant_type"` RefreshToken string `json:"refresh_token"` Scope string `json:"scope"` } type tokenResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` Account struct { EmailAddress string `json:"email_address"` } `json:"account"` } func RefreshToken(ctx context.Context, cred *Credential) error { if cred.RefreshToken == "" { return fmt.Errorf("no refresh token") } reqBody, _ := json.Marshal(tokenRequest{ ClientID: clientID, GrantType: "refresh_token", RefreshToken: cred.RefreshToken, Scope: oauthScopes, }) req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, bytes.NewReader(reqBody)) if err != nil { return fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") log.Debug(). Str("url", tokenEndpoint). Str("grant_type", "refresh_token"). Str("client_id", clientID). Str("scope", oauthScopes). Msg("token refresh request") resp, err := utlsClient.Do(req) if err != nil { return fmt.Errorf("execute request: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) log.Debug(). Int("status", resp.StatusCode). Int("response_size", len(body)). Msg("token refresh response") if resp.StatusCode != http.StatusOK { return fmt.Errorf("refresh returned %d: %s", resp.StatusCode, string(body)) } var tokenResp tokenResponse if err := json.Unmarshal(body, &tokenResp); err != nil { return fmt.Errorf("decode response: %w", err) } cred.mu.Lock() cred.AccessToken = tokenResp.AccessToken if tokenResp.RefreshToken != "" { cred.RefreshToken = tokenResp.RefreshToken } cred.ExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) if tokenResp.Account.EmailAddress != "" { cred.Email = tokenResp.Account.EmailAddress } cred.mu.Unlock() return persistCredential(cred) } func persistCredential(cred *Credential) error { cred.mu.Lock() filePath := cred.FilePath accessToken := cred.AccessToken refreshToken := cred.RefreshToken expiresAt := cred.ExpiresAt cred.mu.Unlock() if filePath == "" { return nil } var doc map[string]any raw, err := os.ReadFile(filePath) if err != nil { if !os.IsNotExist(err) { return err } // File doesn't exist yet (cold start) — create from scratch if mkdirErr := os.MkdirAll(filepath.Dir(filePath), 0700); mkdirErr != nil { return fmt.Errorf("create credential dir: %w", mkdirErr) } doc = make(map[string]any) } else { if err := json.Unmarshal(raw, &doc); err != nil { return err } } oauth, _ := doc["claudeAiOauth"].(map[string]any) if oauth == nil { oauth = make(map[string]any) } oauth["accessToken"] = accessToken oauth["refreshToken"] = refreshToken oauth["expiresAt"] = expiresAt.UnixMilli() doc["claudeAiOauth"] = oauth out, _ := json.MarshalIndent(doc, "", " ") return os.WriteFile(filePath, out, 0600) } func StartBackgroundRefresh(ctx context.Context, pool *Pool) { go func() { for { select { case <-ctx.Done(): log.Info().Msg("background refresh stopped") return case <-time.After(refreshInterval): refreshExpiring(pool) } } }() } func refreshExpiring(pool *Pool) { pool.mu.Lock() creds := make([]*Credential, len(pool.creds)) copy(creds, pool.creds) pool.mu.Unlock() threshold := time.Now().Add(refreshLead) for _, cred := range creds { cred.mu.Lock() needsRefresh := !cred.ExpiresAt.IsZero() && cred.ExpiresAt.Before(threshold) hasRefresh := cred.RefreshToken != "" nextRetry := cred.nextRefreshAfter email := cred.Email expiresAt := cred.ExpiresAt cred.mu.Unlock() if !hasRefresh || !needsRefresh { continue } if !nextRetry.IsZero() && time.Now().Before(nextRetry) { continue } log.Info().Str("credential", email).Time("expires_at", expiresAt).Msg("refreshing token") ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) err := RefreshToken(ctx, cred) cancel() if err != nil { log.Error().Err(err).Str("credential", email).Msg("token refresh failed") cred.mu.Lock() cred.nextRefreshAfter = time.Now().Add(refreshBackoff) cred.mu.Unlock() } else { cred.mu.Lock() newExpiresAt := cred.ExpiresAt cred.nextRefreshAfter = time.Time{} cred.mu.Unlock() log.Info().Str("credential", email).Time("new_expiry", newExpiresAt).Msg("token refreshed") } } }