Files
Alexander 0df28e9dd8 refactor: modularize codebase — deduplicate, extract, clean up
- Unify duplicate uTLS transports into shared internal/transport package
- Extract shared version constant into internal/version
- Move LoadDefaultCredentials from config to auth (remove config→auth import)
- Deduplicate handler.go: extract telemetry/error helpers (324→268 lines)
- Break up main.go::run() into initCredential/initEmbedded
- Eliminate logging.Config duplication (use config.LoggingConfig directly)
- Extract logWriter to embedded/log.go, SSE fixtures to consts in sniff.go
- Use uTLS client for usage polling (consistent TLS fingerprint)
- Handle sjson.SetBytes errors in sanitize.go instead of silently swallowing
- Document reverse-engineered magic values in billing.go
- Unexport Credential.CooldownUntil (internal state)
- Replace hardcoded auth bypass paths with map in server.go
2026-04-15 11:01:29 +02:00

204 lines
5.0 KiB
Go

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")
}
}
}