diff --git a/internal/googleapi/service_account.go b/internal/googleapi/service_account.go index 34de1143..33e2e6b9 100644 --- a/internal/googleapi/service_account.go +++ b/internal/googleapi/service_account.go @@ -32,6 +32,23 @@ var newServiceAccountTokenSource = func(ctx context.Context, keyJSON []byte, sub } func tokenSourceForServiceAccountScopes(ctx context.Context, email string, scopes []string) (oauth2.TokenSource, string, bool, error) { + // GOG_SA_KEY_PATH allows callers (e.g. proxy wrappers) to specify + // the SA key file directly, decoupling the account email (used as + // the impersonation subject) from the key file location. + if envPath := os.Getenv("GOG_SA_KEY_PATH"); envPath != "" { + data, readErr := os.ReadFile(envPath) //nolint:gosec // caller-provided path + if readErr != nil { + return nil, "", false, fmt.Errorf("read service account key from GOG_SA_KEY_PATH: %w", readErr) + } + + ts, tokenErr := newServiceAccountTokenSource(ctx, data, email, scopes) + if tokenErr != nil { + return nil, "", false, tokenErr + } + + return ts, envPath, true, nil + } + saPath, err := config.ServiceAccountPath(email) if err != nil { return nil, "", false, fmt.Errorf("service account path: %w", err) diff --git a/internal/googleapi/service_account_test.go b/internal/googleapi/service_account_test.go index 62884b19..836c8d3a 100644 --- a/internal/googleapi/service_account_test.go +++ b/internal/googleapi/service_account_test.go @@ -7,10 +7,12 @@ import ( "crypto/x509" "encoding/pem" "fmt" + "os" + "path/filepath" "testing" ) -func generateTestSAKeyJSON(t *testing.T, clientEmail string) []byte { +func generateTestSAKeyJSON(t *testing.T, clientEmail string) []byte { //nolint:unparam // test helper; callers always use saEmail but the parameter keeps intent clear t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) @@ -67,6 +69,58 @@ func TestNewServiceAccountTokenSource_Impersonation(t *testing.T) { } } +func TestTokenSourceForServiceAccountScopes_GOG_SA_KEY_PATH(t *testing.T) { + const saEmail = "sa@test-project.iam.gserviceaccount.com" + const userEmail = "user@example.com" + + keyJSON := generateTestSAKeyJSON(t, saEmail) + + // Write SA key to a temp file. + dir := t.TempDir() + + keyPath := filepath.Join(dir, "sa-key.json") + + if err := os.WriteFile(keyPath, keyJSON, 0o600); err != nil { + t.Fatalf("write key file: %v", err) + } + + // Set GOG_SA_KEY_PATH so tokenSourceForServiceAccountScopes reads + // the key from this path instead of deriving it from the email. + t.Setenv("GOG_SA_KEY_PATH", keyPath) + + ts, path, ok, err := tokenSourceForServiceAccountScopes( + context.Background(), userEmail, + []string{"https://www.googleapis.com/auth/calendar"}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !ok { + t.Fatal("expected ok=true") + } + + if ts == nil { + t.Fatal("expected non-nil token source") + } + + if path != keyPath { + t.Fatalf("expected path=%q, got %q", keyPath, path) + } +} + +func TestTokenSourceForServiceAccountScopes_GOG_SA_KEY_PATH_NotFound(t *testing.T) { + t.Setenv("GOG_SA_KEY_PATH", "/nonexistent/sa-key.json") + + _, _, _, err := tokenSourceForServiceAccountScopes( + context.Background(), "user@example.com", + []string{"https://www.googleapis.com/auth/calendar"}, + ) + if err == nil { + t.Fatal("expected error for nonexistent key path") + } +} + func TestNewServiceAccountTokenSource_EmptySubject(t *testing.T) { const saEmail = "sa@test-project.iam.gserviceaccount.com" keyJSON := generateTestSAKeyJSON(t, saEmail)