Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions cmd/paude-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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() {
Expand All @@ -160,7 +161,7 @@ func buildCredentialStore(domainFilter *filter.DomainFilter) (*credentials.Store
}
}

return store, tokenVendor
return store, tokenVendor, anthInjector
}

func envOr(key, fallback string) string {
Expand Down
131 changes: 131 additions & 0 deletions internal/credentials/anthropic_oauth.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
119 changes: 119 additions & 0 deletions internal/credentials/anthropic_oauth_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
34 changes: 25 additions & 9 deletions internal/credentials/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down
Loading