Compare commits

...

4 Commits

Author SHA1 Message Date
Peter Steinberger
054573598b test(auth): fix ADC test whitespace lint (#357)
Some checks failed
ci / test (push) Has been cancelled
ci / worker (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / darwin-cgo-build (push) Has been cancelled
2026-03-08 17:16:46 +00:00
Peter Steinberger
0b31ab8d3b fix(auth): satisfy ADC lint on landed branch (#357) 2026-03-08 17:16:05 +00:00
Peter Steinberger
96a084af7f feat(auth): add ADC mode (#357) (thanks @tengis617) 2026-03-08 17:14:57 +00:00
tengis
e86e2f57e7 feat: add Application Default Credentials (ADC) auth mode
When GOG_AUTH_MODE=adc, the CLI authenticates using ambient credentials
(GKE Workload Identity, GOOGLE_APPLICATION_CREDENTIALS, or gcloud ADC)
instead of the keyring-based OAuth flow. This enables use in environments
like GKE pods where the service account accesses resources explicitly
shared with it — no domain-wide delegation or user impersonation needed.

The account email is no longer required in ADC mode; requireAccount()
returns "adc" as a placeholder when no --account/GOG_ACCOUNT is set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:13:46 +00:00
4 changed files with 116 additions and 3 deletions

View File

@ -5,6 +5,7 @@
### Added
- Admin: add Workspace Admin Directory commands for users and groups, including user list/get/create/suspend and group membership list/add/remove. (#403) — thanks @dl-alexandre.
- Auth: add `--access-token` / `GOG_ACCESS_TOKEN` for direct access-token auth in headless or CI flows, bypassing stored refresh tokens. (#419) — thanks @mmkal.
- Auth: add Application Default Credentials mode via `GOG_AUTH_MODE=adc` for Workload Identity, Cloud Run, and local `gcloud` ADC flows without stored OAuth refresh tokens. (#357) — thanks @tengis617.
- Chat: add `chat messages reactions create|list|delete` to manage emoji reactions on messages; `react` and `reaction` are aliases for the reactions command group. (#426) — thanks @fernandopps.
- Sheets: add named range management (`sheets named-ranges`) and let range-based Sheets commands accept named range names where GridRange-backed operations are needed. (#278) — thanks @TheCrazyLex.
- Sheets: add `add-tab`, `rename-tab`, and `delete-tab` commands for managing spreadsheet tabs, with delete dry-run/confirmation guardrails. (#309) — thanks @JulienMalige.

View File

@ -6,6 +6,7 @@ import (
"strings"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/secrets"
)
@ -22,6 +23,19 @@ const (
)
func requireAccount(flags *RootFlags) (string, error) {
// In ADC mode the service account authenticates as itself — no user email
// or keyring lookup is needed. We still accept --account/GOG_ACCOUNT as an
// optional label (e.g. for logging), but it is not required.
if googleapi.IsADCMode() {
if v := strings.TrimSpace(flags.Account); v != "" {
return v, nil
}
if v := strings.TrimSpace(os.Getenv("GOG_ACCOUNT")); v != "" {
return v, nil
}
return "adc", nil
}
client := config.DefaultClientName
var err error
if flags != nil {

View File

@ -6,9 +6,11 @@ import (
"fmt"
"log/slog"
"net/http"
"os"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/option"
"github.com/steipete/gogcli/internal/googleauth"
@ -28,6 +30,8 @@ const (
tokenExchangeTimeout = 30 * time.Second
)
var newADCTokenSource = google.DefaultTokenSource
func optionsForAccount(ctx context.Context, service googleauth.Service, email string) ([]option.ClientOption, error) {
scopes, err := googleauth.Scopes(service)
if err != nil {
@ -37,12 +41,36 @@ func optionsForAccount(ctx context.Context, service googleauth.Service, email st
return optionsForAccountScopes(ctx, string(service), email, scopes)
}
// IsADCMode reports whether Application Default Credentials mode is active.
// When GOG_AUTH_MODE=adc, the CLI authenticates using the ambient credentials
// (e.g. GKE Workload Identity, GOOGLE_APPLICATION_CREDENTIALS, or gcloud ADC)
// instead of the keyring-based OAuth flow. The service account accesses only
// resources explicitly shared with it — no domain-wide delegation needed.
func IsADCMode() bool {
return os.Getenv("GOG_AUTH_MODE") == "adc"
}
func optionsForAccountScopes(ctx context.Context, serviceLabel string, email string, scopes []string) ([]option.ClientOption, error) {
slog.Debug("creating client options with custom scopes", "serviceLabel", serviceLabel, "email", email)
ts, err := tokenSourceForAvailableAccountAuth(ctx, serviceLabel, email, scopes)
if err != nil {
return nil, err
var ts oauth2.TokenSource
if IsADCMode() {
slog.Debug("using Application Default Credentials (GOG_AUTH_MODE=adc)", "serviceLabel", serviceLabel)
adcTS, err := newADCTokenSource(ctx, scopes...)
if err != nil {
return nil, fmt.Errorf("ADC token source: %w", err)
}
ts = adcTS
} else {
var err error
ts, err = tokenSourceForAvailableAccountAuth(ctx, serviceLabel, email, scopes)
if err != nil {
return nil, err
}
}
baseTransport := newBaseTransport()

View File

@ -399,6 +399,76 @@ func TestOptionsForAccountScopes_ServiceAccountPreferred(t *testing.T) {
}
}
func TestIsADCMode(t *testing.T) {
t.Setenv("GOG_AUTH_MODE", "")
if IsADCMode() {
t.Fatalf("expected false when GOG_AUTH_MODE is empty")
}
t.Setenv("GOG_AUTH_MODE", "adc")
if !IsADCMode() {
t.Fatalf("expected true when GOG_AUTH_MODE=adc")
}
t.Setenv("GOG_AUTH_MODE", "oauth")
if IsADCMode() {
t.Fatalf("expected false when GOG_AUTH_MODE=oauth")
}
}
func TestOptionsForAccountScopes_ADCMode(t *testing.T) {
t.Setenv("GOG_AUTH_MODE", "adc")
origADC := newADCTokenSource
origRead := readClientCredentials
origOpen := openSecretsStore
t.Cleanup(func() {
newADCTokenSource = origADC
readClientCredentials = origRead
openSecretsStore = origOpen
})
called := false
newADCTokenSource = func(_ context.Context, scopes ...string) (oauth2.TokenSource, error) {
called = true
if len(scopes) != 1 || scopes[0] != "https://www.googleapis.com/auth/spreadsheets.readonly" {
t.Fatalf("unexpected scopes: %v", scopes)
}
return oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "adc-token"}), nil
}
// Should NOT call keyring or readClientCredentials.
readClientCredentials = func(string) (config.ClientCredentials, error) {
t.Fatalf("readClientCredentials should not be called in ADC mode")
return config.ClientCredentials{}, nil
}
openSecretsStore = func() (secrets.Store, error) {
t.Fatalf("openSecretsStore should not be called in ADC mode")
return nil, errBoom
}
opts, err := optionsForAccountScopes(context.Background(), "sheets", "adc", []string{
"https://www.googleapis.com/auth/spreadsheets.readonly",
})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !called {
t.Fatalf("expected ADC token source to be called")
}
if len(opts) == 0 {
t.Fatalf("expected client options")
}
}
func TestNewBaseTransport_RespectsProxyAndTLSMinimum(t *testing.T) {
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:8888")