From db34713b623d86791a97c4eb7f84a90841fdc3e5 Mon Sep 17 00:00:00 2001 From: Mitsuyuki Osabe <24588751+carrotRakko@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:44:20 +0000 Subject: [PATCH 1/4] feat: support GOG_SA_KEY_PATH env var for explicit SA key file path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When GOG_SA_KEY_PATH is set, tokenSourceForServiceAccountScopes reads the SA key from the specified path instead of deriving it from the account email. This decouples the account email (used as the DWD impersonation subject) from the key file location, enabling proxy wrappers to pass a user email as the account while pointing to a single SA key file. ✍️ Author: Claude Code with @carrotRakko (AI-written, human-approved) --- internal/googleapi/service_account.go | 17 ++++++++ internal/googleapi/service_account_test.go | 48 ++++++++++++++++++++++ 2 files changed, 65 insertions(+) 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..732b7ab3 100644 --- a/internal/googleapi/service_account_test.go +++ b/internal/googleapi/service_account_test.go @@ -7,6 +7,8 @@ import ( "crypto/x509" "encoding/pem" "fmt" + "os" + "path/filepath" "testing" ) @@ -67,6 +69,52 @@ 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, 0600); 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) From 0ce67780ccdfb19e70f99734112fb7ae38af81fa Mon Sep 17 00:00:00 2001 From: Mitsuyuki Osabe <24588751+carrotRakko@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:50:53 +0000 Subject: [PATCH 2/4] style: use octal prefix 0o600 (gofumpt) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✍️ Author: Claude Code with @carrotRakko (AI-written, human-approved) --- internal/googleapi/service_account_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/googleapi/service_account_test.go b/internal/googleapi/service_account_test.go index 732b7ab3..982023fa 100644 --- a/internal/googleapi/service_account_test.go +++ b/internal/googleapi/service_account_test.go @@ -77,7 +77,7 @@ func TestTokenSourceForServiceAccountScopes_GOG_SA_KEY_PATH(t *testing.T) { // Write SA key to a temp file. dir := t.TempDir() keyPath := filepath.Join(dir, "sa-key.json") - if err := os.WriteFile(keyPath, keyJSON, 0600); err != nil { + if err := os.WriteFile(keyPath, keyJSON, 0o600); err != nil { t.Fatalf("write key file: %v", err) } From 361aa3648be4eabf70b69c4ebcaa29b67336c6ef Mon Sep 17 00:00:00 2001 From: Mitsuyuki Osabe <24588751+carrotRakko@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:51:53 +0000 Subject: [PATCH 3/4] style: fix lint issues (wsl whitespace + unparam nolint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✍️ Author: Claude Code with @carrotRakko (AI-written, human-approved) --- internal/googleapi/service_account_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/googleapi/service_account_test.go b/internal/googleapi/service_account_test.go index 982023fa..37b43891 100644 --- a/internal/googleapi/service_account_test.go +++ b/internal/googleapi/service_account_test.go @@ -12,7 +12,7 @@ import ( "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) @@ -72,11 +72,14 @@ 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) } @@ -89,15 +92,19 @@ func TestTokenSourceForServiceAccountScopes_GOG_SA_KEY_PATH(t *testing.T) { 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) } From d0f63a60efc795e5b41d59834f6f414e0e4e0c0b Mon Sep 17 00:00:00 2001 From: Mitsuyuki Osabe <24588751+carrotRakko@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:52:01 +0000 Subject: [PATCH 4/4] style: gofumpt auto-fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✍️ Author: Claude Code with @carrotRakko (AI-written, human-approved) --- internal/googleapi/service_account_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/googleapi/service_account_test.go b/internal/googleapi/service_account_test.go index 37b43891..836c8d3a 100644 --- a/internal/googleapi/service_account_test.go +++ b/internal/googleapi/service_account_test.go @@ -92,7 +92,6 @@ func TestTokenSourceForServiceAccountScopes_GOG_SA_KEY_PATH(t *testing.T) { context.Background(), userEmail, []string{"https://www.googleapis.com/auth/calendar"}, ) - if err != nil { t.Fatalf("unexpected error: %v", err) }