Compare commits

..

2 Commits

Author SHA1 Message Date
Alexander 17cde479c3 Remove dead code, secure debug endpoints, fix encapsulation 2026-04-10 13:07:26 +02:00
Alexander 4abd4e68dc Fixes, readme
Drop cli-proxy-api token handling, use only native Claude credentials.
Simplify refresh to single endpoint (platform.claude.com) with scope.
Add debug/refresh and debug/shutdown endpoints. Graceful shutdown.
2026-04-10 12:56:42 +02:00
8 changed files with 105 additions and 188 deletions
-1
View File
@@ -18,7 +18,6 @@ cp config.example.yaml config.yaml
Edit `config.yaml`: Edit `config.yaml`:
- `api_keys` — key(s) your clients use to authenticate with the proxy - `api_keys` — key(s) your clients use to authenticate with the proxy
- `claude_credentials` — path to your Claude credentials file - `claude_credentials` — path to your Claude credentials file
- `auth_dir` — optional, directory with additional OAuth credential JSON files
- `claude_binary` — path to `claude` binary (used on startup to capture request fingerprint) - `claude_binary` — path to `claude` binary (used on startup to capture request fingerprint)
## Build and run ## Build and run
-1
View File
@@ -1,7 +1,6 @@
port: 8082 port: 8082
api_keys: api_keys:
- "your-proxy-api-key" - "your-proxy-api-key"
auth_dir: ""
claude_credentials: "~/.claude/.credentials.json" claude_credentials: "~/.claude/.credentials.json"
claude_binary: "claude" claude_binary: "claude"
+13 -57
View File
@@ -10,7 +10,6 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"strings"
"sync" "sync"
"time" "time"
@@ -19,9 +18,9 @@ import (
) )
const ( const (
cliProxyTokenEndpoint = "https://api.anthropic.com/v1/oauth/token" tokenEndpoint = "https://platform.claude.com/v1/oauth/token"
nativeTokenEndpoint = "https://platform.claude.com/v1/oauth/token" clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
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 refreshLead = 5 * time.Minute
refreshInterval = 30 * time.Second refreshInterval = 30 * time.Second
@@ -34,6 +33,7 @@ type tokenRequest struct {
ClientID string `json:"client_id"` ClientID string `json:"client_id"`
GrantType string `json:"grant_type"` GrantType string `json:"grant_type"`
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
} }
type tokenResponse struct { type tokenResponse struct {
@@ -50,23 +50,18 @@ func RefreshToken(ctx context.Context, cred *Credential) error {
return fmt.Errorf("no refresh token") return fmt.Errorf("no refresh token")
} }
endpoint := cliProxyTokenEndpoint
if cred.ID == "claude-native" {
endpoint = nativeTokenEndpoint
}
reqBody, _ := json.Marshal(tokenRequest{ reqBody, _ := json.Marshal(tokenRequest{
ClientID: clientID, ClientID: clientID,
GrantType: "refresh_token", GrantType: "refresh_token",
RefreshToken: cred.RefreshToken, RefreshToken: cred.RefreshToken,
Scope: oauthScopes,
}) })
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(reqBody)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, bytes.NewReader(reqBody))
if err != nil { if err != nil {
return fmt.Errorf("create request: %w", err) return fmt.Errorf("create request: %w", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := utlsClient.Do(req) resp, err := utlsClient.Do(req)
if err != nil { if err != nil {
@@ -100,59 +95,36 @@ func RefreshToken(ctx context.Context, cred *Credential) error {
func persistCredential(cred *Credential) error { func persistCredential(cred *Credential) error {
cred.mu.Lock() cred.mu.Lock()
id := cred.ID
filePath := cred.FilePath filePath := cred.FilePath
accessToken := cred.AccessToken accessToken := cred.AccessToken
refreshToken := cred.RefreshToken refreshToken := cred.RefreshToken
expiresAt := cred.ExpiresAt expiresAt := cred.ExpiresAt
email := cred.Email
cred.mu.Unlock() cred.mu.Unlock()
if filePath == "" { if filePath == "" {
return nil return nil
} }
if id == "claude-native" { raw, err := os.ReadFile(filePath)
return persistNativeCredential(filePath, accessToken, refreshToken, expiresAt)
}
return persistCliProxyCredential(filePath, accessToken, refreshToken, expiresAt, email)
}
func persistCliProxyCredential(path, accessToken, refreshToken string, expiresAt time.Time, email string) error {
data := map[string]string{
"access_token": accessToken,
"refresh_token": refreshToken,
"email": email,
"expired": expiresAt.Format(time.RFC3339),
"type": "claude",
"last_refresh": time.Now().Format(time.RFC3339),
}
out, _ := json.MarshalIndent(data, "", " ")
return os.WriteFile(path, out, 0600)
}
func persistNativeCredential(path, accessToken, refreshToken string, expiresAt time.Time) error {
raw, err := os.ReadFile(path)
if err != nil { if err != nil {
return err return err
} }
var doc map[string]interface{} var doc map[string]any
if err := json.Unmarshal(raw, &doc); err != nil { if err := json.Unmarshal(raw, &doc); err != nil {
return err return err
} }
oauth, _ := doc["claudeAiOauth"].(map[string]interface{}) oauth, _ := doc["claudeAiOauth"].(map[string]any)
if oauth == nil { if oauth == nil {
oauth = make(map[string]interface{}) oauth = make(map[string]any)
} }
oauth["accessToken"] = accessToken oauth["accessToken"] = accessToken
oauth["refreshToken"] = refreshToken oauth["refreshToken"] = refreshToken
oauth["expiresAt"] = expiresAt.UnixMilli() oauth["expiresAt"] = expiresAt.UnixMilli()
doc["claudeAiOauth"] = oauth doc["claudeAiOauth"] = oauth
out, _ := json.MarshalIndent(doc, "", " ") out, _ := json.MarshalIndent(doc, "", " ")
return os.WriteFile(path, out, 0600) return os.WriteFile(filePath, out, 0600)
} }
// Chrome TLS HTTP client for refresh requests (same as proxy transport).
func newUTLSClient() *http.Client { func newUTLSClient() *http.Client {
return &http.Client{ return &http.Client{
Timeout: 15 * time.Second, Timeout: 15 * time.Second,
@@ -214,7 +186,6 @@ func (t *utlsRefreshTransport) RoundTrip(req *http.Request) (*http.Response, err
return h2Conn.RoundTrip(req) return h2Conn.RoundTrip(req)
} }
// StartBackgroundRefresh runs a goroutine that checks and refreshes tokens periodically.
func StartBackgroundRefresh(ctx context.Context, pool *Pool) { func StartBackgroundRefresh(ctx context.Context, pool *Pool) {
go func() { go func() {
for { for {
@@ -223,13 +194,13 @@ func StartBackgroundRefresh(ctx context.Context, pool *Pool) {
log.Printf("background refresh stopped") log.Printf("background refresh stopped")
return return
case <-time.After(refreshInterval): case <-time.After(refreshInterval):
refreshAll(pool) refreshExpiring(pool)
} }
} }
}() }()
} }
func refreshAll(pool *Pool) { func refreshExpiring(pool *Pool) {
pool.mu.Lock() pool.mu.Lock()
creds := make([]*Credential, len(pool.creds)) creds := make([]*Credential, len(pool.creds))
copy(creds, pool.creds) copy(creds, pool.creds)
@@ -269,18 +240,3 @@ func refreshAll(pool *Pool) {
} }
} }
} }
// NeedsRefresh checks if a credential needs refresh within the lead time.
func NeedsRefresh(cred *Credential) bool {
cred.mu.Lock()
defer cred.mu.Unlock()
if cred.ExpiresAt.IsZero() || cred.RefreshToken == "" {
return false
}
return time.Until(cred.ExpiresAt) <= refreshLead
}
// IsNativeCredential checks if the credential is from ~/.claude/.credentials.json.
func IsNativeCredential(cred *Credential) bool {
return cred.ID == "claude-native" || strings.Contains(cred.FilePath, ".credentials.json")
}
+46 -4
View File
@@ -48,11 +48,53 @@ func (p *Pool) MarkFailure(cred *Credential, statusCode int) {
} }
func (p *Pool) MarkSuccess(cred *Credential) { func (p *Pool) MarkSuccess(cred *Credential) {
cred.mu.Lock() cred.ClearCooldown()
defer cred.mu.Unlock()
cred.CooldownUntil = time.Time{}
} }
func (p *Pool) RefreshExpiring(ctx context.Context) { func (p *Pool) RefreshExpiring(ctx context.Context) {
refreshAll(p) refreshExpiring(p)
}
func (p *Pool) RefreshAll(ctx context.Context) []map[string]string {
p.mu.Lock()
creds := make([]*Credential, len(p.creds))
copy(creds, p.creds)
p.mu.Unlock()
var results []map[string]string
for _, cred := range creds {
cred.mu.Lock()
id := cred.ID
email := cred.Email
oldExpiry := cred.ExpiresAt
hasRefresh := cred.RefreshToken != ""
cred.mu.Unlock()
r := map[string]string{
"id": id,
"email": email,
"old_expiry": oldExpiry.Format(time.RFC3339),
}
if !hasRefresh {
r["status"] = "skipped"
r["reason"] = "no refresh token"
results = append(results, r)
continue
}
err := RefreshToken(ctx, cred)
if err != nil {
r["status"] = "error"
r["error"] = err.Error()
} else {
cred.mu.Lock()
r["status"] = "ok"
r["new_expiry"] = cred.ExpiresAt.Format(time.RFC3339)
r["new_token_prefix"] = cred.AccessToken[:20] + "..."
cred.mu.Unlock()
}
results = append(results, r)
}
return results
} }
+16 -16
View File
@@ -7,22 +7,15 @@ import (
// Credential represents an Anthropic API credential loaded from a JSON file. // Credential represents an Anthropic API credential loaded from a JSON file.
type Credential struct { type Credential struct {
ID string ID string
Email string Email string
AccessToken string AccessToken string
RefreshToken string RefreshToken string
ExpiresAt time.Time ExpiresAt time.Time
FilePath string FilePath string
CooldownUntil time.Time CooldownUntil time.Time
nextRefreshAfter time.Time nextRefreshAfter time.Time
mu sync.Mutex mu sync.Mutex
}
// IsExpired returns true if the credential's access token has expired.
func (c *Credential) IsExpired() bool {
c.mu.Lock()
defer c.mu.Unlock()
return time.Now().After(c.ExpiresAt)
} }
// IsOnCooldown returns true if the credential is currently on cooldown. // IsOnCooldown returns true if the credential is currently on cooldown.
@@ -39,6 +32,13 @@ func (c *Credential) SetCooldown(duration time.Duration) {
c.CooldownUntil = time.Now().Add(duration) c.CooldownUntil = time.Now().Add(duration)
} }
// ClearCooldown removes any active cooldown on the credential.
func (c *Credential) ClearCooldown() {
c.mu.Lock()
defer c.mu.Unlock()
c.CooldownUntil = time.Time{}
}
// Token returns the current access token. // Token returns the current access token.
func (c *Credential) Token() string { func (c *Credential) Token() string {
c.mu.Lock() c.mu.Lock()
+15 -82
View File
@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"time" "time"
"github.com/fujin/anthropic-proxy/internal/auth" "github.com/fujin/anthropic-proxy/internal/auth"
@@ -12,18 +11,17 @@ import (
) )
type Config struct { type Config struct {
Port int `yaml:"port"` Port int `yaml:"port"`
APIKeys []string `yaml:"api_keys"` APIKeys []string `yaml:"api_keys"`
AuthDir string `yaml:"auth_dir"` ClaudeCredentials string `yaml:"claude_credentials"`
ClaudeCredentials string `yaml:"claude_credentials"` ClaudeBinary string `yaml:"claude_binary"`
ClaudeBinary string `yaml:"claude_binary"` Sanitize SanitizeConfig `yaml:"sanitize"`
Sanitize SanitizeConfig `yaml:"sanitize"`
} }
type SanitizeConfig struct { type SanitizeConfig struct {
Tools []RenameRule `yaml:"tools"` Tools []RenameRule `yaml:"tools"`
System []ReplaceRule `yaml:"system"` System []ReplaceRule `yaml:"system"`
Body []ReplaceRule `yaml:"body"` Body []ReplaceRule `yaml:"body"`
} }
type RenameRule struct { type RenameRule struct {
@@ -36,14 +34,6 @@ type ReplaceRule struct {
Replace string `yaml:"replace"` Replace string `yaml:"replace"`
} }
type authFileJSON struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
Email string `json:"email"`
Expired string `json:"expired"`
Type string `json:"type"`
}
type claudeCredentialsJSON struct { type claudeCredentialsJSON struct {
ClaudeAiOauth struct { ClaudeAiOauth struct {
AccessToken string `json:"accessToken"` AccessToken string `json:"accessToken"`
@@ -68,28 +58,19 @@ func Load(path string) (*Config, error) {
} }
func LoadCredentials(cfg *Config) ([]*auth.Credential, error) { func LoadCredentials(cfg *Config) ([]*auth.Credential, error) {
var creds []*auth.Credential if cfg.ClaudeCredentials == "" {
return nil, fmt.Errorf("claude_credentials not set")
if cfg.ClaudeCredentials != "" {
cred, err := loadClaudeCredentials(cfg.ClaudeCredentials)
if err != nil {
return nil, fmt.Errorf("load claude credentials: %w", err)
}
creds = append(creds, cred)
} }
if cfg.AuthDir != "" { cred, err := loadCredentials(cfg.ClaudeCredentials)
dirCreds, err := loadAuthDir(cfg.AuthDir) if err != nil {
if err != nil { return nil, err
return nil, fmt.Errorf("load auth dir: %w", err)
}
creds = append(creds, dirCreds...)
} }
return creds, nil return []*auth.Credential{cred}, nil
} }
func loadClaudeCredentials(path string) (*auth.Credential, error) { func loadCredentials(path string) (*auth.Credential, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -114,51 +95,3 @@ func loadClaudeCredentials(path string) (*auth.Credential, error) {
FilePath: path, FilePath: path,
}, nil }, nil
} }
func loadAuthDir(authDir string) ([]*auth.Credential, error) {
pattern := filepath.Join(authDir, "*.json")
files, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("glob auth files: %w", err)
}
var creds []*auth.Credential
for _, f := range files {
cred, err := loadAuthFile(f)
if err != nil {
return nil, fmt.Errorf("load auth file %s: %w", f, err)
}
creds = append(creds, cred)
}
return creds, nil
}
func loadAuthFile(path string) (*auth.Credential, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var af authFileJSON
if err := json.Unmarshal(data, &af); err != nil {
return nil, err
}
expiresAt, err := time.Parse(time.RFC3339, af.Expired)
if err != nil {
expiresAt, err = time.Parse("2006-01-02T15:04:05", af.Expired)
if err != nil {
expiresAt = time.Now()
}
}
return &auth.Credential{
ID: filepath.Base(path),
Email: af.Email,
AccessToken: af.AccessToken,
RefreshToken: af.RefreshToken,
ExpiresAt: expiresAt,
FilePath: path,
}, nil
}
+7 -27
View File
@@ -10,8 +10,6 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/tidwall/gjson"
) )
// SniffedProfile holds everything captured from a real Claude Code request. // SniffedProfile holds everything captured from a real Claude Code request.
@@ -124,18 +122,15 @@ func SniffClaudeCode(claudeBinary string) (*SniffedProfile, error) {
} }
func extractProfile(r *http.Request, body []byte) *SniffedProfile { func extractProfile(r *http.Request, body []byte) *SniffedProfile {
// Capture raw headers preserving original casing and order. // Capture raw headers preserving original casing.
var headers [][2]string var headers [][2]string
for i := 0; i < len(r.Header); i++ { for name, vals := range r.Header {
for name, vals := range r.Header { if skipHeaders[strings.ToLower(name)] {
if skipHeaders[strings.ToLower(name)] { continue
continue }
} for _, v := range vals {
for _, v := range vals { headers = append(headers, [2]string{name, v})
headers = append(headers, [2]string{name, v})
}
} }
break
} }
// Deduplicate and strip subscription-specific betas. // Deduplicate and strip subscription-specific betas.
@@ -170,21 +165,6 @@ func extractProfile(r *http.Request, body []byte) *SniffedProfile {
} }
} }
// Extract the system prompt template from the body (everything except the billing header block).
// The billing header is the first system block starting with "x-anthropic-billing-header:".
systemBlocks := gjson.GetBytes(body, "system")
var templateSystem []string
if systemBlocks.IsArray() {
for _, block := range systemBlocks.Array() {
text := block.Get("text").String()
if strings.HasPrefix(text, "x-anthropic-billing-header:") {
continue
}
templateSystem = append(templateSystem, block.Raw)
}
}
_ = templateSystem // stored in body for now
return &SniffedProfile{ return &SniffedProfile{
Headers: deduped, Headers: deduped,
Body: body, Body: body,
+8
View File
@@ -45,6 +45,7 @@ func New(cfg *config.Config, pool *auth.Pool, profile *proxy.SniffedProfile) *Se
engine.POST("/messages", handler) engine.POST("/messages", handler)
engine.POST("/reload", s.handleReload()) engine.POST("/reload", s.handleReload())
engine.POST("/debug/refresh", handleDebugRefresh(pool))
engine.GET("/healthz", func(c *gin.Context) { engine.GET("/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"}) c.JSON(http.StatusOK, gin.H{"status": "ok"})
}) })
@@ -97,6 +98,13 @@ func (s *Server) handleReload() gin.HandlerFunc {
} }
} }
func handleDebugRefresh(pool *auth.Pool) gin.HandlerFunc {
return func(c *gin.Context) {
results := pool.RefreshAll(c.Request.Context())
c.JSON(http.StatusOK, results)
}
}
func makeKeySet(apiKeys []string) map[string]struct{} { func makeKeySet(apiKeys []string) map[string]struct{} {
keySet := make(map[string]struct{}, len(apiKeys)) keySet := make(map[string]struct{}, len(apiKeys))
for _, k := range apiKeys { for _, k := range apiKeys {