From dd3fe44352a2f514f3f15789b87c05124a69907a Mon Sep 17 00:00:00 2001 From: Trevor Vaughan Date: Sun, 7 Jun 2026 22:27:22 -0400 Subject: [PATCH] feat(creds): inject Claude subscription OAuth at the proxy edge Let a containerized Claude Code session use a Claude subscription (OAuth) without the real token ever reaching the agent, the same edge-injection model already used for gcloud ADC. - Add AnthropicOAuthInjector: always overrides Authorization: Bearer for api.anthropic.com and .claude.ai from a creds file mounted into the proxy. - Piggyback Claude Code's own OAuth refresh inside the proxy instead of refreshing on a timer: rewrite the agent's refresh request with the real token, capture the rotated tokens upstream, hand the agent a dummy response. - Wire a new "anthropic_oauth" injector type via ANTHROPIC_OAUTH_CREDS_FILE, inert when the var is unset (same as the gcloud entry). - Net effect: the agent only ever holds dummy bearer tokens; the real credential lives solely in the proxy. Details: Injector (internal/credentials/anthropic_oauth.go): - Lazy-loads the creds file (~/.claude/.credentials.json shape) under a mutex; the injector itself never calls the OAuth endpoint. - CurrentRefreshToken exposes the real refresh token to the intercept layer. - UpdateFromRefresh records rotated tokens and writes them back atomically (temp file + rename, 0600), preserving scopes and subscriptionType. Keeps the existing refresh token when the response omits a rotated one. Refresh piggyback (internal/proxy/proxy.go, oauth_refresh.go): - Request hook rewrites only genuine grant_type=refresh_token bodies. An authorization_code grant or invalid JSON passes through untouched and is not marked for response interception, which closes a bug where a non-refresh 200 body got replaced with a dummy. - Response hook captures the rotated tokens (when access_token is non-empty) and swaps in a dummy body that mirrors expires_in so the agent's local expiry stays in sync. Non-200 responses pass through so the agent sees the real failure. Token vending (internal/credentials/token_vending.go): - Add IsAnthropicTokenExchange for console.anthropic.com and platform.claude.com (/v1/oauth/token, /api/oauth/token), matching host with or without the :443 suffix. Correct two misleading comments. Logging (internal/proxy/redact.go): - logRefreshDiag is the single named site for refresh logging. It records host and path only, never token material, bodies, or Authorization headers. Config wiring (config.go, credentials.json, main.go): - BuildFromConfig returns the injector as a 4th value; main threads it into proxy.Config.AnthropicInjector to activate the refresh hooks in production. - Default routing table gains the ANTHROPIC_OAUTH_CREDS_FILE entry pointing at api.anthropic.com and .claude.ai. Tests: - Cover injection/override, rotation with field preservation, the refresh-only-on-refresh-grant contract, a guard that no token string ever reaches the log, config wiring (non-nil with creds, nil without), and an end-to-end refresh integration. Security note: the injected token is scoped to Anthropic endpoints and is meant to serve Claude Code over this transport only, not for independent token use. Assisted-By: Claude Opus 4.8 (1M context) --- cmd/paude-proxy/main.go | 29 ++-- internal/credentials/anthropic_oauth.go | 131 +++++++++++++++++ internal/credentials/anthropic_oauth_test.go | 119 ++++++++++++++++ internal/credentials/config.go | 34 +++-- internal/credentials/config_test.go | 134 ++++++++++++++++-- internal/credentials/credentials.json | 5 + internal/credentials/token_vending.go | 29 +++- internal/credentials/token_vending_test.go | 38 +++++ internal/proxy/oauth_refresh.go | 51 +++++++ .../proxy/oauth_refresh_integration_test.go | 129 +++++++++++++++++ internal/proxy/oauth_refresh_test.go | 109 ++++++++++++++ internal/proxy/proxy.go | 74 ++++++++++ internal/proxy/redact.go | 10 ++ internal/proxy/redact_test.go | 26 ++++ 14 files changed, 879 insertions(+), 39 deletions(-) create mode 100644 internal/credentials/anthropic_oauth.go create mode 100644 internal/credentials/anthropic_oauth_test.go create mode 100644 internal/proxy/oauth_refresh.go create mode 100644 internal/proxy/oauth_refresh_integration_test.go create mode 100644 internal/proxy/oauth_refresh_test.go create mode 100644 internal/proxy/redact.go create mode 100644 internal/proxy/redact_test.go diff --git a/cmd/paude-proxy/main.go b/cmd/paude-proxy/main.go index 1803fdc..1c10696 100644 --- a/cmd/paude-proxy/main.go +++ b/cmd/paude-proxy/main.go @@ -84,23 +84,24 @@ func main() { defer blockedLogger.Close() log.Printf("Blocked request log: %s", blockedLogPath) - // Credential store and token vendor - credStore, tokenVendor := buildCredentialStore(domainFilter) + // Credential store, token vendor, and Anthropic OAuth injector + credStore, tokenVendor, anthInjector := buildCredentialStore(domainFilter) // Start background hostname re-resolution (no-op if no hostnames configured) clientFilter.StartResolving() // Create and start proxy srv := proxy.New(proxy.Config{ - ListenAddr: listenAddr, - CA: ca, - DomainFilter: domainFilter, - CredStore: credStore, - TokenVendor: tokenVendor, - PortFilter: portFilter, - BlockedLogger: blockedLogger, - Verbose: verbose, - ClientFilter: clientFilter, + ListenAddr: listenAddr, + CA: ca, + DomainFilter: domainFilter, + CredStore: credStore, + TokenVendor: tokenVendor, + PortFilter: portFilter, + BlockedLogger: blockedLogger, + Verbose: verbose, + ClientFilter: clientFilter, + AnthropicInjector: anthInjector, }) // Graceful shutdown @@ -127,7 +128,7 @@ func main() { log.Println("Stopped") } -func buildCredentialStore(domainFilter *filter.DomainFilter) (*credentials.Store, *credentials.TokenVendor) { +func buildCredentialStore(domainFilter *filter.DomainFilter) (*credentials.Store, *credentials.TokenVendor, *credentials.AnthropicOAuthInjector) { var cfg *credentials.CredentialConfig var err error @@ -143,7 +144,7 @@ func buildCredentialStore(domainFilter *filter.DomainFilter) (*credentials.Store log.Fatalf("Failed to load credential config: %v", err) } - store, tokenVendor, domainMap := credentials.BuildFromConfig(cfg) + store, tokenVendor, domainMap, anthInjector := credentials.BuildFromConfig(cfg) // Validate: warn if credentials are configured but their domains aren't allowed if !domainFilter.AllowAll() { @@ -160,7 +161,7 @@ func buildCredentialStore(domainFilter *filter.DomainFilter) (*credentials.Store } } - return store, tokenVendor + return store, tokenVendor, anthInjector } func envOr(key, fallback string) string { diff --git a/internal/credentials/anthropic_oauth.go b/internal/credentials/anthropic_oauth.go new file mode 100644 index 0000000..453e1b2 --- /dev/null +++ b/internal/credentials/anthropic_oauth.go @@ -0,0 +1,131 @@ +package credentials + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "sync" + "time" +) + +// AnthropicOAuthInjector injects an Authorization: Bearer header using the +// Claude subscription OAuth access token from the credentials file. It no +// longer self-refreshes — token rotation is handled externally via +// UpdateFromRefresh (called by the refresh-intercept layer). Always overrides +// the Authorization header on matching requests. +type AnthropicOAuthInjector struct { + credsPath string + + mu sync.Mutex + full credentialsFile // cached full struct; patched on refresh to preserve scopes etc. + access string + refresh string + expiresAt time.Time + loaded bool +} + +// credentialsFile mirrors ~/.claude/.credentials.json. +type credentialsFile struct { + ClaudeAiOauth struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt int64 `json:"expiresAt"` // unix millis + Scopes []string `json:"scopes,omitempty"` + SubscriptionType string `json:"subscriptionType,omitempty"` + } `json:"claudeAiOauth"` +} + +// NewAnthropicOAuthInjector reads credentials from credsPath on first use. +func NewAnthropicOAuthInjector(credsPath string) *AnthropicOAuthInjector { + return &AnthropicOAuthInjector{credsPath: credsPath} +} + +func (a *AnthropicOAuthInjector) load() error { + if a.loaded { + return nil + } + data, err := os.ReadFile(a.credsPath) + if err != nil { + return fmt.Errorf("read anthropic creds %s: %w", a.credsPath, err) + } + var cf credentialsFile + if err := json.Unmarshal(data, &cf); err != nil { + return fmt.Errorf("parse anthropic creds: %w", err) + } + a.full = cf + a.access = cf.ClaudeAiOauth.AccessToken + a.refresh = cf.ClaudeAiOauth.RefreshToken + a.expiresAt = time.UnixMilli(cf.ClaudeAiOauth.ExpiresAt) + a.loaded = true + return nil +} + +func (a *AnthropicOAuthInjector) persistLocked() error { + // Patch only the token fields; leave scopes, subscriptionType, and any + // other fields in a.full intact to avoid erasing them on write. + a.full.ClaudeAiOauth.AccessToken = a.access + a.full.ClaudeAiOauth.RefreshToken = a.refresh + a.full.ClaudeAiOauth.ExpiresAt = a.expiresAt.UnixMilli() + data, err := json.Marshal(&a.full) + if err != nil { + return err + } + tmp := a.credsPath + ".tmp" + if err := os.WriteFile(tmp, data, 0600); err != nil { + return err + } + return os.Rename(tmp, a.credsPath) +} + +// Inject sets Authorization: Bearer with the cached access token. Always overrides. +func (a *AnthropicOAuthInjector) Inject(req *http.Request) bool { + if req == nil { + log.Printf("DEFENSIVE_CHECK: AnthropicOAuthInjector.Inject called with nil request") + return false + } + a.mu.Lock() + defer a.mu.Unlock() + if err := a.load(); err != nil { + log.Printf("ERROR anthropic creds load: %v", err) + return false + } + req.Header.Set("Authorization", "Bearer "+a.access) + return true +} + +// Available returns true if the credentials file can be loaded. As a side +// effect it primes the lazy load (sets loaded=true), so a subsequent Inject() +// call reuses the cached credentials without re-reading the file. +func (a *AnthropicOAuthInjector) Available() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.load() == nil +} + +// CurrentRefreshToken returns the real refresh token (for the refresh intercept). +func (a *AnthropicOAuthInjector) CurrentRefreshToken() string { + a.mu.Lock() + defer a.mu.Unlock() + if err := a.load(); err != nil { + log.Printf("ERROR anthropic creds load: %v", err) + return "" + } + return a.refresh +} + +// UpdateFromRefresh records rotated tokens (from an intercepted CC refresh) and +// persists them back to the creds file, preserving non-token fields. +func (a *AnthropicOAuthInjector) UpdateFromRefresh(access, refresh string, expiresIn int) { + a.mu.Lock() + defer a.mu.Unlock() + a.access = access + if refresh != "" { + a.refresh = refresh + } + a.expiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) + if err := a.persistLocked(); err != nil { + log.Printf("ERROR anthropic creds persist: %v", err) + } +} diff --git a/internal/credentials/anthropic_oauth_test.go b/internal/credentials/anthropic_oauth_test.go new file mode 100644 index 0000000..a423ec5 --- /dev/null +++ b/internal/credentials/anthropic_oauth_test.go @@ -0,0 +1,119 @@ +package credentials + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeCreds(t *testing.T, dir string, access, refresh string, expiresAtMs int64) string { + t.Helper() + p := filepath.Join(dir, ".credentials.json") + // Construct JSON manually to avoid encoding/json import just for this helper. + body := `{"claudeAiOauth":{"accessToken":"` + access + `","refreshToken":"` + refresh + `","expiresAt":` + itoa(expiresAtMs) + `}}` + if err := os.WriteFile(p, []byte(body), 0600); err != nil { + t.Fatal(err) + } + return p +} + +// itoa converts an int64 to its decimal string representation without importing strconv. +func itoa(n int64) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + buf := make([]byte, 0, 20) + for n > 0 { + buf = append([]byte{byte('0' + n%10)}, buf...) + n /= 10 + } + if neg { + buf = append([]byte{'-'}, buf...) + } + return string(buf) +} + +func TestAnthropicInject_OverridesAuthHeader(t *testing.T) { + dir := t.TempDir() + p := writeCreds(t, dir, "sk-ant-oat01-current", "sk-ant-ort01-r", 4102444800000) + inj := NewAnthropicOAuthInjector(p) + req, _ := http.NewRequest("GET", "https://api.anthropic.com/v1/messages", nil) + req.Header.Set("Authorization", "Bearer dummy") + if !inj.Inject(req) { + t.Fatal("Inject returned false") + } + if got := req.Header.Get("Authorization"); got != "Bearer sk-ant-oat01-current" { + t.Fatalf("auth header = %q, want overridden current token", got) + } +} + +// TestAnthropicInject_MissingCredsFile guards that a non-existent credentials +// file causes both Available and Inject to report false. +func TestAnthropicInject_MissingCredsFile(t *testing.T) { + p := filepath.Join(t.TempDir(), "nonexistent.json") + inj := NewAnthropicOAuthInjector(p) + + if inj.Available() { + t.Error("Available() returned true for missing file; want false") + } + req, _ := http.NewRequest("GET", "https://api.anthropic.com/v1/messages", nil) + if inj.Inject(req) { + t.Error("Inject returned true for missing file; want false") + } +} + +// TestAnthropicInject_MalformedCredsJSON guards that unparseable JSON in the +// credentials file causes both Available and Inject to return false. +func TestAnthropicInject_MalformedCredsJSON(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, ".credentials.json") + if err := os.WriteFile(p, []byte("not json{"), 0600); err != nil { + t.Fatal(err) + } + inj := NewAnthropicOAuthInjector(p) + + if inj.Available() { + t.Error("Available() returned true for malformed JSON; want false") + } + req, _ := http.NewRequest("GET", "https://api.anthropic.com/v1/messages", nil) + if inj.Inject(req) { + t.Error("Inject returned true for malformed JSON; want false") + } +} + +func TestAnthropic_CurrentRefreshToken(t *testing.T) { + dir := t.TempDir() + p := writeCreds(t, dir, "sk-ant-oat01-a", "sk-ant-ort01-r", 4102444800000) + inj := NewAnthropicOAuthInjector(p) + if got := inj.CurrentRefreshToken(); got != "sk-ant-ort01-r" { + t.Fatalf("CurrentRefreshToken=%q", got) + } +} + +func TestAnthropic_UpdateFromRefresh_PersistsAndPreserves(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, ".credentials.json") + if err := os.WriteFile(p, []byte(`{"claudeAiOauth":{"accessToken":"old","refreshToken":"oldr","expiresAt":1,"scopes":["user:inference"],"subscriptionType":"max"}}`), 0600); err != nil { + t.Fatal(err) + } + inj := NewAnthropicOAuthInjector(p) + _ = inj.Available() + inj.UpdateFromRefresh("sk-ant-oat01-new", "sk-ant-ort01-new", 28800) + req, _ := http.NewRequest("GET", "https://api.anthropic.com/v1/messages", nil) + if !inj.Inject(req) || req.Header.Get("Authorization") != "Bearer sk-ant-oat01-new" { + t.Fatalf("inject after update: %q", req.Header.Get("Authorization")) + } + data, _ := os.ReadFile(p) + s := string(data) + for _, want := range []string{"sk-ant-oat01-new", "sk-ant-ort01-new", "user:inference", "max"} { + if !strings.Contains(s, want) { + t.Errorf("persisted file missing %q: %s", want, s) + } + } +} diff --git a/internal/credentials/config.go b/internal/credentials/config.go index 5649ac2..c330d52 100644 --- a/internal/credentials/config.go +++ b/internal/credentials/config.go @@ -23,7 +23,7 @@ type CredentialEntry struct { // EnvVar is the environment variable name to read the credential from. EnvVar string `json:"env_var"` - // InjectorType is one of: "bearer", "api_key", "gcloud". + // InjectorType is one of: "bearer", "api_key", "gcloud", "anthropic_oauth". InjectorType string `json:"injector"` // Params holds injector-specific parameters (e.g., "header_name" for api_key). @@ -36,9 +36,10 @@ type CredentialEntry struct { } var validInjectorTypes = map[string]bool{ - "bearer": true, - "api_key": true, - "gcloud": true, + "bearer": true, + "api_key": true, + "gcloud": true, + "anthropic_oauth": true, } // ParseConfig parses and validates a credential config from JSON bytes. @@ -53,7 +54,7 @@ func ParseConfig(data []byte) (*CredentialConfig, error) { return nil, fmt.Errorf("credential entry %d: env_var is required", i) } if !validInjectorTypes[entry.InjectorType] { - return nil, fmt.Errorf("credential entry %d (%s): invalid injector type %q (valid: bearer, api_key, gcloud)", i, entry.EnvVar, entry.InjectorType) + return nil, fmt.Errorf("credential entry %d (%s): invalid injector type %q (valid: bearer, api_key, gcloud, anthropic_oauth)", i, entry.EnvVar, entry.InjectorType) } if len(entry.Domains) == 0 { return nil, fmt.Errorf("credential entry %d (%s): at least one domain is required", i, entry.EnvVar) @@ -89,11 +90,13 @@ func LoadDefaultConfig() (*CredentialConfig, error) { // BuildFromConfig creates a credential Store and optional TokenVendor from // a parsed config. It reads credential values from environment variables. -// Returns the store, token vendor (nil if no gcloud entry), and a map of -// env var names to their domain lists (for domain filter validation). -func BuildFromConfig(cfg *CredentialConfig) (*Store, *TokenVendor, map[string][]string) { +// Returns the store, token vendor (nil if no gcloud or anthropic_oauth entry), +// a map of env var names to their domain lists (for domain filter validation), +// and the AnthropicOAuthInjector (nil if no anthropic_oauth entry was active). +func BuildFromConfig(cfg *CredentialConfig) (*Store, *TokenVendor, map[string][]string, *AnthropicOAuthInjector) { store := NewStore() var tokenVendor *TokenVendor + var anthropicInjector *AnthropicOAuthInjector hasCredentials := false domainMap := make(map[string][]string) @@ -140,6 +143,17 @@ func BuildFromConfig(cfg *CredentialConfig) (*Store, *TokenVendor, map[string][] injector = gcloudInjector tokenVendor = NewTokenVendor() log.Println("Token vendor: ENABLED (returns dummy tokens for oauth2.googleapis.com/token)") + case "anthropic_oauth": + // value is the path to the mounted credentials file. + inj := NewAnthropicOAuthInjector(value) + if !inj.Available() { + log.Printf("WARN: %s=%s but Anthropic OAuth creds not loadable", entry.EnvVar, value) + continue + } + injector = inj + anthropicInjector = inj + tokenVendor = NewTokenVendor() + log.Println("Anthropic OAuth: ENABLED") } for _, domain := range entry.Domains { @@ -161,7 +175,7 @@ func BuildFromConfig(cfg *CredentialConfig) (*Store, *TokenVendor, map[string][] log.Println("No credential routes configured") } - return store, tokenVendor, domainMap + return store, tokenVendor, domainMap, anthropicInjector } func formatDomains(domains []string) string { @@ -184,6 +198,8 @@ func injectorDescription(entry CredentialEntry) string { return entry.Params["header_name"] case "gcloud": return "gcloud ADC Bearer token" + case "anthropic_oauth": + return "Anthropic OAuth Bearer token" default: return entry.InjectorType } diff --git a/internal/credentials/config_test.go b/internal/credentials/config_test.go index 3f223a7..2b19710 100644 --- a/internal/credentials/config_test.go +++ b/internal/credentials/config_test.go @@ -116,8 +116,8 @@ func TestLoadDefaultConfig(t *testing.T) { if err != nil { t.Fatalf("default config should be valid: %v", err) } - if len(cfg.Credentials) != 5 { - t.Errorf("default config has %d entries, want 5", len(cfg.Credentials)) + if len(cfg.Credentials) != 6 { + t.Errorf("default config has %d entries, want 6", len(cfg.Credentials)) } // Verify the expected entries are present @@ -125,7 +125,7 @@ func TestLoadDefaultConfig(t *testing.T) { for _, entry := range cfg.Credentials { envVars[entry.EnvVar] = true } - for _, expected := range []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "CURSOR_API_KEY", "GH_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS"} { + for _, expected := range []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "CURSOR_API_KEY", "GH_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS", "ANTHROPIC_OAUTH_CREDS_FILE"} { if !envVars[expected] { t.Errorf("default config missing entry for %s", expected) } @@ -200,11 +200,14 @@ func TestBuildFromConfig_Bearer(t *testing.T) { }, } - store, tokenVendor, domainMap := BuildFromConfig(cfg) + store, tokenVendor, domainMap, anthInjector := BuildFromConfig(cfg) if tokenVendor != nil { t.Error("tokenVendor should be nil without gcloud entry") } + if anthInjector != nil { + t.Error("anthInjector should be nil without anthropic_oauth entry") + } if domains, ok := domainMap["TEST_BEARER_KEY"]; !ok { t.Error("domain map missing TEST_BEARER_KEY") @@ -238,7 +241,7 @@ func TestBuildFromConfig_APIKey(t *testing.T) { }, } - store, _, _ := BuildFromConfig(cfg) + store, _, _, _ := BuildFromConfig(cfg) req := &http.Request{ URL: &url.URL{Host: "api.anthropic.com"}, @@ -265,7 +268,7 @@ func TestBuildFromConfig_GitHubBearer(t *testing.T) { }, } - store, _, _ := BuildFromConfig(cfg) + store, _, _, _ := BuildFromConfig(cfg) req := &http.Request{ URL: &url.URL{Host: "api.github.com"}, @@ -312,7 +315,7 @@ func TestBuildFromConfig_MissingEnvVarSkipped(t *testing.T) { }, } - store, _, domainMap := BuildFromConfig(cfg) + store, _, domainMap, _ := BuildFromConfig(cfg) if _, ok := domainMap["DEFINITELY_NOT_SET_12345"]; ok { t.Error("domain map should not contain entry for unset env var") @@ -346,7 +349,7 @@ func TestBuildFromConfig_MultipleEntries(t *testing.T) { }, } - store, _, domainMap := BuildFromConfig(cfg) + store, _, domainMap, _ := BuildFromConfig(cfg) if len(domainMap) != 2 { t.Errorf("domain map has %d entries, want 2", len(domainMap)) @@ -392,7 +395,7 @@ func TestBuildFromConfig_GCloudFromJSON(t *testing.T) { }, } - store, tokenVendor, domainMap := BuildFromConfig(cfg) + store, tokenVendor, domainMap, _ := BuildFromConfig(cfg) if tokenVendor == nil { t.Fatal("tokenVendor should not be nil when GCP_ADC_JSON is set with valid JSON") @@ -426,7 +429,7 @@ func TestBuildFromConfig_GCloudJSONPreferredOverFile(t *testing.T) { }, } - store, tokenVendor, _ := BuildFromConfig(cfg) + store, tokenVendor, _, _ := BuildFromConfig(cfg) if tokenVendor == nil { t.Fatal("tokenVendor should not be nil — GCP_ADC_JSON should be used instead of the nonexistent file") @@ -457,7 +460,7 @@ func TestBuildFromConfig_GCloudFallbackToFile(t *testing.T) { } // File doesn't exist, so gcloud should not be available - _, tokenVendor, _ := BuildFromConfig(cfg) + _, tokenVendor, _, _ := BuildFromConfig(cfg) if tokenVendor != nil { t.Error("tokenVendor should be nil when ADC file doesn't exist and GCP_ADC_JSON is unset") } @@ -477,7 +480,7 @@ func TestBuildFromConfig_GCloudSkippedWhenBothUnset(t *testing.T) { }, } - _, tokenVendor, domainMap := BuildFromConfig(cfg) + _, tokenVendor, domainMap, _ := BuildFromConfig(cfg) if tokenVendor != nil { t.Error("tokenVendor should be nil when both GCP_ADC_JSON and GOOGLE_APPLICATION_CREDENTIALS are unset") } @@ -486,6 +489,111 @@ func TestBuildFromConfig_GCloudSkippedWhenBothUnset(t *testing.T) { } } +func TestAnthropicOAuthConfig_ParseAccepted(t *testing.T) { + data := []byte(`{ + "credentials": [ + {"env_var": "ANTHROPIC_OAUTH_CREDS_FILE", "injector": "anthropic_oauth", "domains": ["api.anthropic.com", ".claude.ai"]} + ] + }`) + + cfg, err := ParseConfig(data) + if err != nil { + t.Fatalf("ParseConfig should accept anthropic_oauth entry: %v", err) + } + if len(cfg.Credentials) != 1 { + t.Fatalf("got %d entries, want 1", len(cfg.Credentials)) + } + if cfg.Credentials[0].InjectorType != "anthropic_oauth" { + t.Errorf("injector = %q, want %q", cfg.Credentials[0].InjectorType, "anthropic_oauth") + } +} + +func TestAnthropicOAuthConfig_BuildWithCredsFile(t *testing.T) { + // Write a minimal credentials file that AnthropicOAuthInjector can load. + dir := t.TempDir() + credsPath := filepath.Join(dir, ".credentials.json") + credsJSON := []byte(`{"claudeAiOauth":{"accessToken":"sk-ant-oat01-x","refreshToken":"sk-ant-ort01-y","expiresAt":4102444800000}}`) + if err := os.WriteFile(credsPath, credsJSON, 0600); err != nil { + t.Fatal(err) + } + + t.Setenv("ANTHROPIC_OAUTH_CREDS_FILE", credsPath) + + cfg := &CredentialConfig{ + Credentials: []CredentialEntry{ + { + EnvVar: "ANTHROPIC_OAUTH_CREDS_FILE", + InjectorType: "anthropic_oauth", + Domains: []string{"api.anthropic.com", ".claude.ai"}, + }, + }, + } + + store, tokenVendor, domainMap, anthInjector := BuildFromConfig(cfg) + + if tokenVendor == nil { + t.Error("tokenVendor should not be nil when anthropic_oauth creds are available") + } + if anthInjector == nil { + t.Error("anthInjector should not be nil when anthropic_oauth creds are available") + } + if _, ok := domainMap["ANTHROPIC_OAUTH_CREDS_FILE"]; !ok { + t.Error("domain map should contain ANTHROPIC_OAUTH_CREDS_FILE entry") + } + + // api.anthropic.com (exact) should match. + req := &http.Request{ + URL: &url.URL{Host: "api.anthropic.com"}, + Header: make(http.Header), + } + if matched, _ := store.InjectCredentials(req); !matched { + t.Error("store should route api.anthropic.com with anthropic_oauth injector") + } + + // sub.claude.ai (suffix) should match. + req2 := &http.Request{ + URL: &url.URL{Host: "sub.claude.ai"}, + Header: make(http.Header), + } + if matched, _ := store.InjectCredentials(req2); !matched { + t.Error("store should route sub.claude.ai with anthropic_oauth injector (suffix .claude.ai)") + } +} + +func TestAnthropicOAuthConfig_SkippedWhenEnvVarUnset(t *testing.T) { + t.Setenv("ANTHROPIC_OAUTH_CREDS_FILE", "") + + cfg := &CredentialConfig{ + Credentials: []CredentialEntry{ + { + EnvVar: "ANTHROPIC_OAUTH_CREDS_FILE", + InjectorType: "anthropic_oauth", + Domains: []string{"api.anthropic.com", ".claude.ai"}, + }, + }, + } + + store, tokenVendor, domainMap, anthInjector := BuildFromConfig(cfg) + + if tokenVendor != nil { + t.Error("tokenVendor should be nil when ANTHROPIC_OAUTH_CREDS_FILE is unset") + } + if anthInjector != nil { + t.Error("anthInjector should be nil when ANTHROPIC_OAUTH_CREDS_FILE is unset") + } + if _, ok := domainMap["ANTHROPIC_OAUTH_CREDS_FILE"]; ok { + t.Error("domain map should not contain entry when env var is unset") + } + + req := &http.Request{ + URL: &url.URL{Host: "api.anthropic.com"}, + Header: make(http.Header), + } + if matched, _ := store.InjectCredentials(req); matched { + t.Error("store should not route api.anthropic.com when env var is unset") + } +} + func TestBuildFromConfig_ExactAndSuffixDomains(t *testing.T) { t.Setenv("TEST_MIX_KEY", "mix-token") @@ -499,7 +607,7 @@ func TestBuildFromConfig_ExactAndSuffixDomains(t *testing.T) { }, } - store, _, _ := BuildFromConfig(cfg) + store, _, _, _ := BuildFromConfig(cfg) // Exact match req1 := &http.Request{ diff --git a/internal/credentials/credentials.json b/internal/credentials/credentials.json index 2a75eb7..d5b2905 100644 --- a/internal/credentials/credentials.json +++ b/internal/credentials/credentials.json @@ -27,6 +27,11 @@ "env_var": "GOOGLE_APPLICATION_CREDENTIALS", "injector": "gcloud", "domains": [".googleapis.com"] + }, + { + "env_var": "ANTHROPIC_OAUTH_CREDS_FILE", + "injector": "anthropic_oauth", + "domains": ["api.anthropic.com", ".claude.ai"] } ] } diff --git a/internal/credentials/token_vending.go b/internal/credentials/token_vending.go index 77181a0..cae8d1d 100644 --- a/internal/credentials/token_vending.go +++ b/internal/credentials/token_vending.go @@ -45,8 +45,9 @@ type tokenResponse struct { TokenType string `json:"token_type"` } -// IsTokenExchange returns true if the request is an OAuth2 token exchange -// to Google's token endpoint. +// IsTokenExchange returns true if the request is an OAuth2 token exchange to +// Google's token endpoint. Anthropic's token endpoint is handled separately by +// IsAnthropicTokenExchange and the refresh-intercept layer. func IsTokenExchange(req *http.Request) bool { if req == nil || req.URL == nil { return false @@ -56,13 +57,35 @@ func IsTokenExchange(req *http.Request) bool { if host == "" { host = req.Host } - // Strip port for comparison + // Match host with or without the default :443 port suffix. if host == "oauth2.googleapis.com" || host == "oauth2.googleapis.com:443" { return req.Method == http.MethodPost && req.URL.Path == "/token" } return false } +// IsAnthropicTokenExchange returns true if the request is an OAuth2 token +// exchange to Anthropic's token endpoint. The refresh-intercept layer uses this +// to piggyback on Claude Code's native refresh: it rewrites the request body +// with the real refresh token and replaces the response body with a dummy. +func IsAnthropicTokenExchange(req *http.Request) bool { + if req == nil || req.URL == nil { + return false + } + + host := req.URL.Host + if host == "" { + host = req.Host + } + // Match host with or without the default :443 port suffix. + if host == "console.anthropic.com" || host == "console.anthropic.com:443" || + host == "platform.claude.com" || host == "platform.claude.com:443" { + return req.Method == http.MethodPost && + (req.URL.Path == "/v1/oauth/token" || req.URL.Path == "/api/oauth/token") + } + return false +} + // HandleTokenExchange responds to an OAuth2 token exchange request with // a dummy access token. The real token injection happens later via the // GCloudInjector when the agent makes API calls to *.googleapis.com. diff --git a/internal/credentials/token_vending_test.go b/internal/credentials/token_vending_test.go index 9fd8ae9..9381f99 100644 --- a/internal/credentials/token_vending_test.go +++ b/internal/credentials/token_vending_test.go @@ -67,6 +67,44 @@ func TestHandleTokenExchange_NilURL(t *testing.T) { } } +func TestIsAnthropicTokenExchange(t *testing.T) { + for _, host := range []string{"console.anthropic.com", "platform.claude.com"} { + for _, path := range []string{"/v1/oauth/token", "/api/oauth/token"} { + req, _ := http.NewRequest("POST", "https://"+host+path, nil) + if !IsAnthropicTokenExchange(req) { + t.Errorf("IsAnthropicTokenExchange should match %s%s", host, path) + } + } + } + // non-token path on the same host must NOT match + req, _ := http.NewRequest("GET", "https://api.anthropic.com/v1/messages", nil) + if IsAnthropicTokenExchange(req) { + t.Error("IsAnthropicTokenExchange should not match api.anthropic.com messages") + } + // wrong method must NOT match + req2, _ := http.NewRequest("GET", "https://console.anthropic.com/v1/oauth/token", nil) + if IsAnthropicTokenExchange(req2) { + t.Error("IsAnthropicTokenExchange should not match GET to the token endpoint") + } + // nil request / nil URL must NOT match + if IsAnthropicTokenExchange(nil) { + t.Error("IsAnthropicTokenExchange should return false for nil request") + } + if IsAnthropicTokenExchange(&http.Request{Method: http.MethodPost, URL: nil}) { + t.Error("IsAnthropicTokenExchange should return false for nil URL") + } +} + +func TestIsTokenExchange_AnthropicNotMatched(t *testing.T) { + // IsTokenExchange now matches Google only; Anthropic hosts must be false. + for _, host := range []string{"console.anthropic.com", "platform.claude.com"} { + req, _ := http.NewRequest("POST", "https://"+host+"/v1/oauth/token", nil) + if IsTokenExchange(req) { + t.Errorf("IsTokenExchange should no longer match Anthropic host %s", host) + } + } +} + func TestHandleTokenExchange_ValidRequest(t *testing.T) { tv := NewTokenVendor() diff --git a/internal/proxy/oauth_refresh.go b/internal/proxy/oauth_refresh.go new file mode 100644 index 0000000..26a285b --- /dev/null +++ b/internal/proxy/oauth_refresh.go @@ -0,0 +1,51 @@ +package proxy + +import "encoding/json" + +const dummyAccessToken = "sk-ant-oat01-paude-proxy-managed" +const dummyRefreshToken = "sk-ant-ort01-paude-proxy-managed" + +// rewriteRefreshBody replaces refresh_token with realRefresh in a +// grant_type=refresh_token JSON body, preserving all other fields (client_id, +// etc.). Returns (newBody, true) when it was a refresh_token grant, else (orig, false). +func rewriteRefreshBody(body []byte, realRefresh string) ([]byte, bool) { + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + return body, false + } + if m["grant_type"] != "refresh_token" { + return body, false + } + m["refresh_token"] = realRefresh + out, err := json.Marshal(m) + if err != nil { + return body, false + } + return out, true +} + +// parseRefreshResponse extracts tokens from an upstream refresh response. +func parseRefreshResponse(body []byte) (access, refresh string, expiresIn int, err error) { + var r struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + } + if err = json.Unmarshal(body, &r); err != nil { + return "", "", 0, err + } + return r.AccessToken, r.RefreshToken, r.ExpiresIn, nil +} + +// buildDummyRefreshResponseBody returns the dummy token response handed to the +// agent, with expires_in mirroring the real value so the agent's local expiry +// stays in sync with the real token. +func buildDummyRefreshResponseBody(expiresIn int) []byte { + b, _ := json.Marshal(map[string]any{ + "access_token": dummyAccessToken, + "refresh_token": dummyRefreshToken, + "token_type": "Bearer", + "expires_in": expiresIn, + }) + return b +} diff --git a/internal/proxy/oauth_refresh_integration_test.go b/internal/proxy/oauth_refresh_integration_test.go new file mode 100644 index 0000000..ce6d1c6 --- /dev/null +++ b/internal/proxy/oauth_refresh_integration_test.go @@ -0,0 +1,129 @@ +package proxy + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/bbrowning/paude-proxy/internal/credentials" +) + +// TestOAuthRefresh_PiggybackEndToEnd exercises the full refresh-piggyback data +// flow that the goproxy request/response hooks implement, against a real +// AnthropicOAuthInjector (backed by a temp creds file) and a mock upstream that +// behaves like Anthropic's token endpoint. +// +// Integration-test approach: the goproxy MITM TLS machinery is not stood up +// here. Instead this test drives the exact request-rewrite then +// response-capture sequence the two hooks perform (rewriteRefreshBody -> +// forward upstream -> parseRefreshResponse/UpdateFromRefresh -> +// buildDummyRefreshResponseBody), wired to the production injector and a real +// httptest upstream. The individual pure functions are covered by +// oauth_refresh_test.go; this test proves they compose correctly with the +// injector and that the creds file ends up holding the rotated real tokens. +func TestOAuthRefresh_PiggybackEndToEnd(t *testing.T) { + dir := t.TempDir() + credsPath := filepath.Join(dir, ".credentials.json") + // Seed the creds file with the REAL refresh token the proxy will swap in. + seed := `{"claudeAiOauth":{"accessToken":"old-access","refreshToken":"real-refresh-token","expiresAt":0,"scopes":["user:inference"],"subscriptionType":"pro"}}` + if err := os.WriteFile(credsPath, []byte(seed), 0600); err != nil { + t.Fatalf("seed creds: %v", err) + } + injector := credentials.NewAnthropicOAuthInjector(credsPath) + + // Mock upstream: captures the forwarded refresh_token, returns rotated tokens. + var forwardedRefresh string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var m map[string]any + _ = json.Unmarshal(body, &m) + if rt, ok := m["refresh_token"].(string); ok { + forwardedRefresh = rt + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"new-real-access","refresh_token":"new-real-refresh","token_type":"Bearer","expires_in":3600}`)) + })) + defer upstream.Close() + + // --- Request-hook logic: the agent POSTs a refresh with the DUMMY token. --- + agentBody := []byte(`{"grant_type":"refresh_token","refresh_token":"` + dummyRefreshToken + `","client_id":"cc-client-id"}`) + newBody, ok := rewriteRefreshBody(agentBody, injector.CurrentRefreshToken()) + if !ok { + t.Fatal("rewriteRefreshBody should report a refresh_token grant") + } + + // Forward the rewritten request to the mock upstream (stand-in for goproxy's transport). + resp, err := http.Post(upstream.URL+"/v1/oauth/token", "application/json", bytes.NewReader(newBody)) + if err != nil { + t.Fatalf("forward upstream: %v", err) + } + defer resp.Body.Close() + + // Assertion (a): the forwarded refresh carried the REAL refresh token, + // preserving client_id, and NOT the dummy. + if forwardedRefresh != "real-refresh-token" { + t.Errorf("upstream saw refresh_token=%q, want real-refresh-token", forwardedRefresh) + } + var sent map[string]any + _ = json.Unmarshal(newBody, &sent) + if sent["client_id"] != "cc-client-id" { + t.Errorf("client_id not preserved through rewrite: %v", sent["client_id"]) + } + + // --- Response-hook logic: capture rotated tokens, hand the agent a dummy. --- + upstreamBody, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read upstream body: %v", err) + } + access, refresh, expiresIn, perr := parseRefreshResponse(upstreamBody) + if perr != nil { + t.Fatalf("parseRefreshResponse: %v", perr) + } + if access != "" { + injector.UpdateFromRefresh(access, refresh, expiresIn) + } + agentFacing := buildDummyRefreshResponseBody(expiresIn) + + // Assertion (b): the agent-facing response is the DUMMY (no real token leaks). + var agentResp map[string]any + if err := json.Unmarshal(agentFacing, &agentResp); err != nil { + t.Fatalf("unmarshal agent-facing body: %v", err) + } + if agentResp["access_token"] != dummyAccessToken { + t.Errorf("agent-facing access_token = %v, want dummy %q", agentResp["access_token"], dummyAccessToken) + } + if agentResp["refresh_token"] != dummyRefreshToken { + t.Errorf("agent-facing refresh_token = %v, want dummy %q", agentResp["refresh_token"], dummyRefreshToken) + } + if bytes.Contains(agentFacing, []byte("new-real-access")) || bytes.Contains(agentFacing, []byte("new-real-refresh")) { + t.Error("agent-facing response leaked a real token") + } + if agentResp["expires_in"] != float64(3600) { + t.Errorf("agent-facing expires_in = %v, want 3600 (mirrored)", agentResp["expires_in"]) + } + + // Assertion (c): the injector now serves the new REAL refresh token, and the + // creds file on disk holds the rotated real tokens. + if got := injector.CurrentRefreshToken(); got != "new-real-refresh" { + t.Errorf("injector refresh token = %q, want new-real-refresh", got) + } + persisted, err := os.ReadFile(credsPath) + if err != nil { + t.Fatalf("read persisted creds: %v", err) + } + if !bytes.Contains(persisted, []byte("new-real-access")) { + t.Errorf("creds file missing rotated access token: %s", persisted) + } + if !bytes.Contains(persisted, []byte("new-real-refresh")) { + t.Errorf("creds file missing rotated refresh token: %s", persisted) + } + // Non-token fields must be preserved on persist. + if !bytes.Contains(persisted, []byte("user:inference")) { + t.Errorf("creds file dropped scopes: %s", persisted) + } +} diff --git a/internal/proxy/oauth_refresh_test.go b/internal/proxy/oauth_refresh_test.go new file mode 100644 index 0000000..8193fc7 --- /dev/null +++ b/internal/proxy/oauth_refresh_test.go @@ -0,0 +1,109 @@ +package proxy + +import ( + "encoding/json" + "testing" +) + +func TestRewriteRefreshBody_SwapsRefreshTokenPreservesClientID(t *testing.T) { + orig := []byte(`{"grant_type":"refresh_token","refresh_token":"dummy","client_id":"abc-123"}`) + out, ok := rewriteRefreshBody(orig, "real-refresh-token") + if !ok { + t.Fatal("expected ok=true for refresh_token grant") + } + var m map[string]any + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("unmarshal rewritten body: %v", err) + } + if m["refresh_token"] != "real-refresh-token" { + t.Errorf("refresh_token = %v, want real-refresh-token", m["refresh_token"]) + } + if m["client_id"] != "abc-123" { + t.Errorf("client_id = %v, want abc-123 (must be preserved)", m["client_id"]) + } + if m["grant_type"] != "refresh_token" { + t.Errorf("grant_type = %v, want refresh_token (must be preserved)", m["grant_type"]) + } +} + +func TestRewriteRefreshBody_IgnoresNonRefresh(t *testing.T) { + orig := []byte(`{"grant_type":"authorization_code","code":"xyz"}`) + out, ok := rewriteRefreshBody(orig, "real-refresh-token") + if ok { + t.Error("expected ok=false for non-refresh grant") + } + if string(out) != string(orig) { + t.Errorf("body should be unchanged, got %q", out) + } + + // Invalid JSON also returns ok=false and the original body. + bad := []byte(`not json`) + out2, ok2 := rewriteRefreshBody(bad, "real-refresh-token") + if ok2 { + t.Error("expected ok=false for invalid JSON") + } + if string(out2) != string(bad) { + t.Errorf("invalid-JSON body should be returned unchanged, got %q", out2) + } +} + +// TestRewriteRefreshBody_AuthCodeGrantNotRewritten documents the contract that +// the request hook relies on: authorization_code grants must return ok=false so +// the hook does NOT set the anthropicRefreshMarker and does NOT intercept the +// response (which would replace a non-refresh response body with a dummy). +func TestRewriteRefreshBody_AuthCodeGrantNotRewritten(t *testing.T) { + in := []byte(`{"grant_type":"authorization_code","code":"abc","client_id":"cc"}`) + out, ok := rewriteRefreshBody(in, "sk-ant-ort01-REAL") + if ok { + t.Fatal("authorization_code grant must not be treated as a refresh") + } + if string(out) != string(in) { + t.Fatalf("body must be unchanged: %s", out) + } +} + +func TestParseRefreshResponse(t *testing.T) { + body := []byte(`{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600,"token_type":"Bearer"}`) + access, refresh, expiresIn, err := parseRefreshResponse(body) + if err != nil { + t.Fatalf("parseRefreshResponse: %v", err) + } + if access != "new-access" { + t.Errorf("access = %q, want new-access", access) + } + if refresh != "new-refresh" { + t.Errorf("refresh = %q, want new-refresh", refresh) + } + if expiresIn != 3600 { + t.Errorf("expiresIn = %d, want 3600", expiresIn) + } + + if _, _, _, err := parseRefreshResponse([]byte(`not json`)); err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestBuildDummyRefreshResponseBody(t *testing.T) { + out := buildDummyRefreshResponseBody(1234) + var r struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(out, &r); err != nil { + t.Fatalf("unmarshal dummy body: %v", err) + } + if r.AccessToken != dummyAccessToken { + t.Errorf("access_token = %q, want %q", r.AccessToken, dummyAccessToken) + } + if r.RefreshToken != dummyRefreshToken { + t.Errorf("refresh_token = %q, want %q", r.RefreshToken, dummyRefreshToken) + } + if r.TokenType != "Bearer" { + t.Errorf("token_type = %q, want Bearer", r.TokenType) + } + if r.ExpiresIn != 1234 { + t.Errorf("expires_in = %d, want 1234 (must mirror real value)", r.ExpiresIn) + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 6500b51..41a4733 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -1,9 +1,11 @@ package proxy import ( + "bytes" "crypto/tls" "crypto/x509" "fmt" + "io" "log" "net" "net/http" @@ -20,6 +22,12 @@ import ( "github.com/bbrowning/paude-proxy/internal/filter" ) +// anthropicRefreshMarker is stored in goproxy's ctx.UserData by the request +// handler to flag that the matching response is an intercepted Anthropic OAuth +// refresh, so the response handler knows to capture the real tokens and swap in +// a dummy body. +type anthropicRefreshMarker struct{} + // PortFilter controls which ports are allowed for HTTP and CONNECT requests. type PortFilter struct { SafePorts map[int]bool // Allowed for HTTP requests @@ -307,6 +315,12 @@ type Config struct { Verbose bool ClientFilter *ClientFilter // If non-nil, only listed IPs/CIDRs can connect UpstreamCAs *x509.CertPool // If non-nil, used as root CAs for upstream TLS verification (for testing) + + // AnthropicInjector, if non-nil, enables piggybacking on Claude Code's + // native OAuth refresh: the proxy rewrites the agent's refresh request with + // the real refresh token, captures the rotated tokens from the upstream + // response, and hands the agent a dummy response. + AnthropicInjector *credentials.AnthropicOAuthInjector } // New creates a configured goproxy server. @@ -468,6 +482,33 @@ func New(cfg Config) *http.Server { ) } + // Piggyback on Claude Code's native OAuth refresh: rewrite the + // agent's refresh request with the real refresh token and forward + // it upstream. The response handler captures the rotated tokens and + // returns a dummy body to the agent. + if cfg.AnthropicInjector != nil && credentials.IsAnthropicTokenExchange(req) { + body, err := io.ReadAll(req.Body) + _ = req.Body.Close() + if err != nil { + return req, goproxy.NewResponse(ctx.Req, goproxy.ContentTypeText, http.StatusBadGateway, rejectMsg) + } + newBody, ok := rewriteRefreshBody(body, cfg.AnthropicInjector.CurrentRefreshToken()) + if !ok { + // Not a refresh_token grant — forward unchanged, do NOT intercept the response. + req.Body = io.NopCloser(bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + req.Header.Set("Content-Length", strconv.Itoa(len(body))) + return req, nil + } + req.Body = io.NopCloser(bytes.NewReader(newBody)) + req.ContentLength = int64(len(newBody)) + req.Header.Set("Content-Length", strconv.Itoa(len(newBody))) + // Log endpoint only — NEVER the body or any token. + logRefreshDiag(stripPort(req.URL.Host), req.URL.Path) + ctx.UserData = anthropicRefreshMarker{} + return req, nil + } + // Intercept OAuth2 token exchange requests. if cfg.TokenVendor != nil && credentials.IsTokenExchange(req) { if resp := cfg.TokenVendor.HandleTokenExchange(req); resp != nil { @@ -496,6 +537,39 @@ func New(cfg Config) *http.Server { }, ) + // Capture the upstream response of an intercepted Anthropic OAuth refresh: + // record the rotated real tokens and replace the body with a dummy so the + // agent only ever stores dummies. + proxy.OnResponse().DoFunc(func(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response { + if resp == nil { + return resp + } + if _, ok := ctx.UserData.(anthropicRefreshMarker); !ok { + return resp + } + if resp.StatusCode != http.StatusOK { + // Sanitized passthrough: the agent sees the real failure. No token + // rewrite, no token logged. + return resp + } + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + return resp + } + access, refresh, expiresIn, perr := parseRefreshResponse(body) + // UpdateFromRefresh keeps the existing refresh token when the response + // omits a rotated one (its own refresh != "" guard); we require access != "". + if perr == nil && access != "" && cfg.AnthropicInjector != nil { + cfg.AnthropicInjector.UpdateFromRefresh(access, refresh, expiresIn) + } + dummy := buildDummyRefreshResponseBody(expiresIn) + resp.Body = io.NopCloser(bytes.NewReader(dummy)) + resp.ContentLength = int64(len(dummy)) + resp.Header.Set("Content-Length", strconv.Itoa(len(dummy))) + return resp + }) + return &http.Server{ Addr: cfg.ListenAddr, Handler: proxy, diff --git a/internal/proxy/redact.go b/internal/proxy/redact.go new file mode 100644 index 0000000..252cf31 --- /dev/null +++ b/internal/proxy/redact.go @@ -0,0 +1,10 @@ +package proxy + +import "log" + +// logRefreshDiag logs only the endpoint of an intercepted OAuth refresh — never +// token values, request/response bodies, or Authorization headers. The proxy +// must never write credentials to logs. +func logRefreshDiag(host, path string) { + log.Printf("OAUTH_REFRESH host=%s path=%s (rewritten; tokens redacted)", host, path) +} diff --git a/internal/proxy/redact_test.go b/internal/proxy/redact_test.go new file mode 100644 index 0000000..973d883 --- /dev/null +++ b/internal/proxy/redact_test.go @@ -0,0 +1,26 @@ +package proxy + +import ( + "bytes" + "log" + "strings" + "testing" +) + +func TestLogRefreshDiag_NoTokens(t *testing.T) { + var buf bytes.Buffer + old := log.Writer() + log.SetOutput(&buf) + defer log.SetOutput(old) + + logRefreshDiag("console.anthropic.com", "/v1/oauth/token") + out := buf.String() + if !strings.Contains(out, "console.anthropic.com") || !strings.Contains(out, "/v1/oauth/token") { + t.Fatalf("expected endpoint in log: %s", out) + } + for _, tok := range []string{"sk-ant-oat01-", "sk-ant-ort01-", "refresh_token", "access_token", "Bearer "} { + if strings.Contains(out, tok) { + t.Fatalf("token-related material leaked to log: %q in %s", tok, out) + } + } +}