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`:
- `api_keys` — key(s) your clients use to authenticate with the proxy
- `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)
## Build and run
-1
View File
@@ -1,7 +1,6 @@
port: 8082
api_keys:
- "your-proxy-api-key"
auth_dir: ""
claude_credentials: "~/.claude/.credentials.json"
claude_binary: "claude"
+13 -57
View File
@@ -10,7 +10,6 @@ import (
"net"
"net/http"
"os"
"strings"
"sync"
"time"
@@ -19,9 +18,9 @@ import (
)
const (
cliProxyTokenEndpoint = "https://api.anthropic.com/v1/oauth/token"
nativeTokenEndpoint = "https://platform.claude.com/v1/oauth/token"
clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
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
@@ -34,6 +33,7 @@ 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 {
@@ -50,23 +50,18 @@ func RefreshToken(ctx context.Context, cred *Credential) error {
return fmt.Errorf("no refresh token")
}
endpoint := cliProxyTokenEndpoint
if cred.ID == "claude-native" {
endpoint = nativeTokenEndpoint
}
reqBody, _ := json.Marshal(tokenRequest{
ClientID: clientID,
GrantType: "refresh_token",
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 {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := utlsClient.Do(req)
if err != nil {
@@ -100,59 +95,36 @@ func RefreshToken(ctx context.Context, cred *Credential) error {
func persistCredential(cred *Credential) error {
cred.mu.Lock()
id := cred.ID
filePath := cred.FilePath
accessToken := cred.AccessToken
refreshToken := cred.RefreshToken
expiresAt := cred.ExpiresAt
email := cred.Email
cred.mu.Unlock()
if filePath == "" {
return nil
}
if id == "claude-native" {
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)
raw, err := os.ReadFile(filePath)
if err != nil {
return err
}
var doc map[string]interface{}
var doc map[string]any
if err := json.Unmarshal(raw, &doc); err != nil {
return err
}
oauth, _ := doc["claudeAiOauth"].(map[string]interface{})
oauth, _ := doc["claudeAiOauth"].(map[string]any)
if oauth == nil {
oauth = make(map[string]interface{})
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(path, out, 0600)
return os.WriteFile(filePath, out, 0600)
}
// Chrome TLS HTTP client for refresh requests (same as proxy transport).
func newUTLSClient() *http.Client {
return &http.Client{
Timeout: 15 * time.Second,
@@ -214,7 +186,6 @@ func (t *utlsRefreshTransport) RoundTrip(req *http.Request) (*http.Response, err
return h2Conn.RoundTrip(req)
}
// StartBackgroundRefresh runs a goroutine that checks and refreshes tokens periodically.
func StartBackgroundRefresh(ctx context.Context, pool *Pool) {
go func() {
for {
@@ -223,13 +194,13 @@ func StartBackgroundRefresh(ctx context.Context, pool *Pool) {
log.Printf("background refresh stopped")
return
case <-time.After(refreshInterval):
refreshAll(pool)
refreshExpiring(pool)
}
}
}()
}
func refreshAll(pool *Pool) {
func refreshExpiring(pool *Pool) {
pool.mu.Lock()
creds := make([]*Credential, len(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) {
cred.mu.Lock()
defer cred.mu.Unlock()
cred.CooldownUntil = time.Time{}
cred.ClearCooldown()
}
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.
type Credential struct {
ID string
Email string
AccessToken string
RefreshToken string
ExpiresAt time.Time
FilePath string
CooldownUntil time.Time
nextRefreshAfter time.Time
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)
ID string
Email string
AccessToken string
RefreshToken string
ExpiresAt time.Time
FilePath string
CooldownUntil time.Time
nextRefreshAfter time.Time
mu sync.Mutex
}
// 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)
}
// 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.
func (c *Credential) Token() string {
c.mu.Lock()
+15 -82
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/fujin/anthropic-proxy/internal/auth"
@@ -12,18 +11,17 @@ import (
)
type Config struct {
Port int `yaml:"port"`
APIKeys []string `yaml:"api_keys"`
AuthDir string `yaml:"auth_dir"`
ClaudeCredentials string `yaml:"claude_credentials"`
ClaudeBinary string `yaml:"claude_binary"`
Sanitize SanitizeConfig `yaml:"sanitize"`
Port int `yaml:"port"`
APIKeys []string `yaml:"api_keys"`
ClaudeCredentials string `yaml:"claude_credentials"`
ClaudeBinary string `yaml:"claude_binary"`
Sanitize SanitizeConfig `yaml:"sanitize"`
}
type SanitizeConfig struct {
Tools []RenameRule `yaml:"tools"`
System []ReplaceRule `yaml:"system"`
Body []ReplaceRule `yaml:"body"`
Tools []RenameRule `yaml:"tools"`
System []ReplaceRule `yaml:"system"`
Body []ReplaceRule `yaml:"body"`
}
type RenameRule struct {
@@ -36,14 +34,6 @@ type ReplaceRule struct {
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 {
ClaudeAiOauth struct {
AccessToken string `json:"accessToken"`
@@ -68,28 +58,19 @@ func Load(path string) (*Config, error) {
}
func LoadCredentials(cfg *Config) ([]*auth.Credential, error) {
var creds []*auth.Credential
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.ClaudeCredentials == "" {
return nil, fmt.Errorf("claude_credentials not set")
}
if cfg.AuthDir != "" {
dirCreds, err := loadAuthDir(cfg.AuthDir)
if err != nil {
return nil, fmt.Errorf("load auth dir: %w", err)
}
creds = append(creds, dirCreds...)
cred, err := loadCredentials(cfg.ClaudeCredentials)
if err != nil {
return nil, err
}
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)
if err != nil {
return nil, err
@@ -114,51 +95,3 @@ func loadClaudeCredentials(path string) (*auth.Credential, error) {
FilePath: path,
}, 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"
"sync"
"time"
"github.com/tidwall/gjson"
)
// 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 {
// Capture raw headers preserving original casing and order.
// Capture raw headers preserving original casing.
var headers [][2]string
for i := 0; i < len(r.Header); i++ {
for name, vals := range r.Header {
if skipHeaders[strings.ToLower(name)] {
continue
}
for _, v := range vals {
headers = append(headers, [2]string{name, v})
}
for name, vals := range r.Header {
if skipHeaders[strings.ToLower(name)] {
continue
}
for _, v := range vals {
headers = append(headers, [2]string{name, v})
}
break
}
// 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{
Headers: deduped,
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("/reload", s.handleReload())
engine.POST("/debug/refresh", handleDebugRefresh(pool))
engine.GET("/healthz", func(c *gin.Context) {
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{} {
keySet := make(map[string]struct{}, len(apiKeys))
for _, k := range apiKeys {