diff --git a/CLAUDE.md b/CLAUDE.md index 8ec31ab..4036c80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,8 @@ The agent container is the threat actor. It can make arbitrary HTTP requests thr | `OPENAI_API_KEY` | -> `Authorization: Bearer` for `*.openai.com` | | | `CURSOR_API_KEY` | -> `Authorization: Bearer` for `*.cursor.com`, `*.cursorapi.com` | | | `GH_TOKEN` | -> `Authorization: Bearer` for `github.com`, `*.githubusercontent.com` | | -| `GOOGLE_APPLICATION_CREDENTIALS` | Path to gcloud ADC JSON | | +| `GCP_ADC_JSON` | Inline gcloud ADC JSON content (preferred over file path) | | +| `GOOGLE_APPLICATION_CREDENTIALS` | Path to gcloud ADC JSON (fallback if `GCP_ADC_JSON` unset) | | ## Credential Routing Table diff --git a/internal/credentials/config.go b/internal/credentials/config.go index 0923b3f..5649ac2 100644 --- a/internal/credentials/config.go +++ b/internal/credentials/config.go @@ -97,9 +97,16 @@ func BuildFromConfig(cfg *CredentialConfig) (*Store, *TokenVendor, map[string][] hasCredentials := false domainMap := make(map[string][]string) + gcpADCJSON := os.Getenv("GCP_ADC_JSON") + for _, entry := range cfg.Credentials { value := os.Getenv(entry.EnvVar) - if value == "" { + + // For gcloud entries, GCP_ADC_JSON takes precedence over the file path + // and allows processing even if GOOGLE_APPLICATION_CREDENTIALS is unset. + if entry.InjectorType == "gcloud" && gcpADCJSON == "" && value == "" { + continue + } else if value == "" && entry.InjectorType != "gcloud" { continue } @@ -115,9 +122,19 @@ func BuildFromConfig(cfg *CredentialConfig) (*Store, *TokenVendor, map[string][] Key: value, } case "gcloud": - gcloudInjector := NewGCloudInjector(value) + var gcloudInjector *GCloudInjector + if gcpADCJSON != "" { + gcloudInjector = NewGCloudInjectorFromJSON([]byte(gcpADCJSON)) + log.Println("Using GCP_ADC_JSON env var for gcloud ADC credentials") + } else { + gcloudInjector = NewGCloudInjector(value) + } if !gcloudInjector.Available() { - log.Printf("WARN: %s=%s but ADC not loadable", entry.EnvVar, value) + if gcpADCJSON != "" { + log.Printf("WARN: GCP_ADC_JSON set but ADC not loadable") + } else { + log.Printf("WARN: %s=%s but ADC not loadable", entry.EnvVar, value) + } continue } injector = gcloudInjector @@ -135,7 +152,6 @@ func BuildFromConfig(cfg *CredentialConfig) (*Store, *TokenVendor, map[string][] store.AddRoute(route) } - // Log the credential route domainDesc := formatDomains(entry.Domains) log.Printf("Credential route: %s -> %s", domainDesc, injectorDescription(entry)) hasCredentials = true diff --git a/internal/credentials/config_test.go b/internal/credentials/config_test.go index dc5609a..a3378d7 100644 --- a/internal/credentials/config_test.go +++ b/internal/credentials/config_test.go @@ -344,6 +344,115 @@ func TestBuildFromConfig_MultipleEntries(t *testing.T) { } } +func TestBuildFromConfig_GCloudFromJSON(t *testing.T) { + // GCP_ADC_JSON alone (without GOOGLE_APPLICATION_CREDENTIALS) should work. + t.Setenv("GCP_ADC_JSON", `{"type":"authorized_user","client_id":"test","client_secret":"test","refresh_token":"test"}`) + os.Unsetenv("GOOGLE_APPLICATION_CREDENTIALS") + + cfg := &CredentialConfig{ + Credentials: []CredentialEntry{ + { + EnvVar: "GOOGLE_APPLICATION_CREDENTIALS", + InjectorType: "gcloud", + Domains: []string{".googleapis.com"}, + }, + }, + } + + store, tokenVendor, domainMap := BuildFromConfig(cfg) + + if tokenVendor == nil { + t.Fatal("tokenVendor should not be nil when GCP_ADC_JSON is set with valid JSON") + } + if _, ok := domainMap["GOOGLE_APPLICATION_CREDENTIALS"]; !ok { + t.Error("domain map should contain GOOGLE_APPLICATION_CREDENTIALS entry") + } + + req := &http.Request{ + URL: &url.URL{Host: "storage.googleapis.com"}, + Header: make(http.Header), + } + if !store.InjectCredentials(req) { + t.Error("should match storage.googleapis.com with gcloud injector from JSON") + } +} + +func TestBuildFromConfig_GCloudJSONPreferredOverFile(t *testing.T) { + // GCP_ADC_JSON should be preferred even when GOOGLE_APPLICATION_CREDENTIALS + // points to a nonexistent file (proving the file is never read). + t.Setenv("GCP_ADC_JSON", `{"type":"authorized_user","client_id":"test","client_secret":"test","refresh_token":"test"}`) + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/nonexistent/path/adc.json") + + cfg := &CredentialConfig{ + Credentials: []CredentialEntry{ + { + EnvVar: "GOOGLE_APPLICATION_CREDENTIALS", + InjectorType: "gcloud", + Domains: []string{".googleapis.com"}, + }, + }, + } + + 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") + } + + req := &http.Request{ + URL: &url.URL{Host: "storage.googleapis.com"}, + Header: make(http.Header), + } + if !store.InjectCredentials(req) { + t.Error("should match storage.googleapis.com with gcloud injector from JSON") + } +} + +func TestBuildFromConfig_GCloudFallbackToFile(t *testing.T) { + // When GCP_ADC_JSON is not set, fall back to GOOGLE_APPLICATION_CREDENTIALS file path. + os.Unsetenv("GCP_ADC_JSON") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/nonexistent/path/adc.json") + + cfg := &CredentialConfig{ + Credentials: []CredentialEntry{ + { + EnvVar: "GOOGLE_APPLICATION_CREDENTIALS", + InjectorType: "gcloud", + Domains: []string{".googleapis.com"}, + }, + }, + } + + // File doesn't exist, so gcloud should not be available + _, tokenVendor, _ := BuildFromConfig(cfg) + if tokenVendor != nil { + t.Error("tokenVendor should be nil when ADC file doesn't exist and GCP_ADC_JSON is unset") + } +} + +func TestBuildFromConfig_GCloudSkippedWhenBothUnset(t *testing.T) { + os.Unsetenv("GCP_ADC_JSON") + os.Unsetenv("GOOGLE_APPLICATION_CREDENTIALS") + + cfg := &CredentialConfig{ + Credentials: []CredentialEntry{ + { + EnvVar: "GOOGLE_APPLICATION_CREDENTIALS", + InjectorType: "gcloud", + Domains: []string{".googleapis.com"}, + }, + }, + } + + _, tokenVendor, domainMap := BuildFromConfig(cfg) + if tokenVendor != nil { + t.Error("tokenVendor should be nil when both GCP_ADC_JSON and GOOGLE_APPLICATION_CREDENTIALS are unset") + } + if _, ok := domainMap["GOOGLE_APPLICATION_CREDENTIALS"]; ok { + t.Error("domain map should not contain entry when both env vars are unset") + } +} + func TestBuildFromConfig_ExactAndSuffixDomains(t *testing.T) { t.Setenv("TEST_MIX_KEY", "mix-token") diff --git a/internal/credentials/gcloud.go b/internal/credentials/gcloud.go index a0a4d8b..8599493 100644 --- a/internal/credentials/gcloud.go +++ b/internal/credentials/gcloud.go @@ -20,6 +20,7 @@ type GCloudInjector struct { initOnce sync.Once initErr error adcPath string + adcJSON []byte scopes []string } @@ -32,12 +33,28 @@ func NewGCloudInjector(adcPath string) *GCloudInjector { } } +// NewGCloudInjectorFromJSON creates an injector from raw ADC JSON content. +// This is preferred over NewGCloudInjector when credentials are passed +// via environment variable rather than mounted as a file. +func NewGCloudInjectorFromJSON(data []byte) *GCloudInjector { + return &GCloudInjector{ + adcJSON: data, + scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, + } +} + func (g *GCloudInjector) init() error { g.initOnce.Do(func() { - data, err := os.ReadFile(g.adcPath) - if err != nil { - g.initErr = fmt.Errorf("read ADC file %s: %w", g.adcPath, err) - return + var data []byte + if len(g.adcJSON) > 0 { + data = g.adcJSON + } else { + var err error + data, err = os.ReadFile(g.adcPath) + if err != nil { + g.initErr = fmt.Errorf("read ADC file %s: %w", g.adcPath, err) + return + } } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -76,8 +93,11 @@ func (g *GCloudInjector) Inject(req *http.Request) { req.Header.Set("Authorization", "Bearer "+token.AccessToken) } -// Available returns true if the ADC file exists and can be loaded. +// Available returns true if ADC credentials can be loaded (from JSON or file). func (g *GCloudInjector) Available() bool { + if len(g.adcJSON) > 0 { + return g.init() == nil + } if _, err := os.Stat(g.adcPath); err != nil { return false }