From de375a6889aca7edc4a055d4952baaecaaf6e01f Mon Sep 17 00:00:00 2001 From: apstndb <803393+apstndb@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:04:30 +0900 Subject: [PATCH 1/4] Add opt-in ADC reauth preflight before query execution Detect invalid_grant with error_subtype invalid_rapt or rapt_required on a token fetch before creating the Spanner client. --reauth=off (default) only appends a google-auth-style hint. --reauth=auto may run gcloud application-default login once when user ADC is applicable, then reload credentials. Never replay SQL, DML, or already-started RPCs. Skip automatic login when emulator/GAC/CLOUDSDK_CONFIG is set, ADC is not a writable authorized_user file, stdin/stderr are not terminals, gcloud is missing, or tests inject client options. Login is human-paced and does not consume --timeout. Validation: go build, package tests, go test -run Reauth|Auth, full emulator suite, and golangci-lint with GOTOOLCHAIN=go1.25.13. --- README.md | 30 ++ go.mod | 7 +- go.sum | 8 +- main.go | 23 +- reauth.go | 392 ++++++++++++++++++ reauth_test.go | 864 +++++++++++++++++++++++++++++++++++++++ reauth_transport_test.go | 63 +++ 7 files changed, 1376 insertions(+), 11 deletions(-) create mode 100644 reauth.go create mode 100644 reauth_test.go create mode 100644 reauth_transport_test.go diff --git a/README.md b/README.md index faebaee..abb4a92 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,10 @@ Flags: --enable-partitioned-dml Execute DML statement using Partitioned DML --timeout=10m Maximum time to wait for the SQL query to complete + --reauth="off" When auto, run gcloud application-default + login once if local user ADC needs + reauthentication; off only prints a hint + ($EXECSPANSQL_REAUTH). --try-partition-query (Experimental) Check whether the query can be executed as partition query or not @@ -339,6 +343,32 @@ Note: `--log-grpc=payload` can log request and response payloads (including boun Use `--priority=high`, `--priority=medium`, `--priority=low`, or `--priority=unspecified` to set the Spanner execute-SQL request priority. Omitting the flag keeps the unspecified priority. The setting applies to JSON/YAML output (including eager and lazy jq input), CSV, ordinary DML, and Partitioned DML; it does not change read-write transaction commit priority. +### Reauthentication + +Google Workspace session policies can invalidate a user Application Default Credentials refresh token (`invalid_grant` with `error_subtype` `invalid_rapt` or `rapt_required`). execspansql never replays SQL after a Spanner RPC has started. + +`--reauth=off` (default, also `EXECSPANSQL_REAUTH`) leaves client construction unchanged. If a classified reauth error or a gRPC `Unauthenticated` message containing `invalid_rapt` / `rapt_required` is reported, the process exits non-zero with: + +``` +Reauthentication is needed. Please run 'gcloud auth application-default login' to reauthenticate. +``` + +`--reauth=auto` is explicit consent for one interactive `gcloud auth application-default login` **before** the Spanner client is created. `auto` means continue as whoever completes that login; the principal is not compared with the previous ADC identity. The login budget is exactly one attempt per process. After a successful login the well-known ADC file is re-read, must still be `authorized_user`, and a token is fetched again. A quota project change is reported on stderr and does not fail the command. + +Automatic login runs only when all of the following hold: + +* `SPANNER_EMULATOR_HOST` is unset (the emulator does not use these credentials) +* `GOOGLE_APPLICATION_CREDENTIALS` is unset (gcloud writes the well-known file, not that path) +* `CLOUDSDK_CONFIG` is unset (gcloud honors it; the Go auth library does not, so the login result would not be picked up) +* the well-known ADC file exists, is writable, and has `"type": "authorized_user"` +* stdin and stderr are terminals (stdout may be a pipe) +* `gcloud` is on `PATH` +* no test/emulator client options that bypass ADC were injected + +`--timeout` applies only to query execution, not to the login. A reauth failure during a long-running statement (after the preflight) is reported with the hint rather than retried. DML is never replayed. + +The Go client always reads `$HOME/.config/gcloud/application_default_credentials.json` (`%APPDATA%\gcloud\...` on Windows). If `CLOUDSDK_CONFIG` is set, automatic login is skipped and the hint names that variable. + ### (Experimental) `--try-partition-query` Check whether the query can be executed as partition query or not. diff --git a/go.mod b/go.mod index c422244..dafa7eb 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/apstndb/execspansql go 1.25.0 require ( + cloud.google.com/go/auth v0.20.0 cloud.google.com/go/spanner v1.90.0 github.com/alecthomas/kong v1.15.0 github.com/apstndb/gsqlutils v0.0.0-20260502161854-d7d6011a36e0 @@ -19,6 +20,8 @@ require ( go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/sdk v1.44.0 go.uber.org/zap v1.27.0 + golang.org/x/oauth2 v0.36.0 + golang.org/x/term v0.45.0 google.golang.org/api v0.280.0 google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.11 @@ -27,7 +30,6 @@ require ( require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.11.0 // indirect @@ -112,9 +114,8 @@ require ( go.uber.org/multierr v1.10.0 // indirect golang.org/x/crypto v0.51.0 // indirect golang.org/x/net v0.55.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.45.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect diff --git a/go.sum b/go.sum index 7799610..2b43113 100644 --- a/go.sum +++ b/go.sum @@ -352,10 +352,10 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= diff --git a/main.go b/main.go index b7caa4f..e8c3ea5 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "fmt" "log" "os" + "os/signal" "encoding/json" @@ -72,6 +73,7 @@ type opts struct { TraceOTLPEndpoint string `name:"experimental-trace-otlp-endpoint" default:"localhost:4317" help:"OTLP/gRPC endpoint used with --experimental-trace-otlp."` EnablePartitionedDML bool `name:"enable-partitioned-dml" help:"Execute DML statement using Partitioned DML"` Timeout time.Duration `name:"timeout" default:"10m" help:"Maximum time to wait for the SQL query to complete"` + Reauth string `name:"reauth" enum:"off,auto" default:"off" env:"EXECSPANSQL_REAUTH" help:"When auto, run gcloud application-default login once if local user ADC needs reauthentication; off only prints a hint."` TryPartitionQuery bool `name:"try-partition-query" help:"(Experimental) Check whether the query can be executed as partition query or not"` TimestampBound struct { Strong bool `name:"strong" xor:"timestamp" help:"Perform a strong query."` @@ -406,14 +408,17 @@ func _main() error { } // runCLI accepts client options so transport tests can inspect outgoing RPCs. -func runCLI(clientOptions ...option.ClientOption) error { +// Non-empty clientOptions skip the ADC reauth preflight (tests inject insecure +// dial options that bypass application-default credentials). +func runCLI(clientOptions ...option.ClientOption) (err error) { o, err := processFlags() if err != nil { os.Exit(1) } + defer func() { err = wrapWithHint(err) }() - ctx, cancel := context.WithTimeout(context.Background(), o.Timeout) - defer cancel() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() jqMode, err := jqresult.ParseInputMode(o.JqInputMode) if err != nil { @@ -462,6 +467,16 @@ func runCLI(clientOptions ...option.ClientOption) error { return err } + authOpts, err := maybeAuthPreflight(ctx, o, clientOptions, newReauthHooks()) + if err != nil { + return err + } + + // Query execution timeout starts after authentication. Login is + // human-paced and must not consume --timeout. + ctx, cancel := context.WithTimeout(ctx, o.Timeout) + defer cancel() + ctx, tp, err := enableTracing(ctx, o) if err != nil { return err @@ -474,7 +489,7 @@ func runCLI(clientOptions ...option.ClientOption) error { }() } - client, err := newClient(ctx, o.Project, o.Instance, o.Database, o.DatabaseRole, string(o.LogGrpc), tracingEnabled(o), clientOptions...) + client, err := newClient(ctx, o.Project, o.Instance, o.Database, o.DatabaseRole, string(o.LogGrpc), tracingEnabled(o), append(clientOptions, authOpts...)...) if err != nil { return err } diff --git a/reauth.go b/reauth.go new file mode 100644 index 0000000..93e75b2 --- /dev/null +++ b/reauth.go @@ -0,0 +1,392 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "cloud.google.com/go/auth" + "cloud.google.com/go/auth/credentials" + apiv1 "cloud.google.com/go/spanner/apiv1" + "golang.org/x/oauth2" + "golang.org/x/term" + "google.golang.org/api/option" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + reauthModeOff = "off" + reauthModeAuto = "auto" + + adcFileName = "application_default_credentials.json" + + reauthHintText = "Reauthentication is needed. Please run 'gcloud auth application-default login' to reauthenticate." + + gcloudADCLoginNotice = "Reauthentication is needed; updating Application Default Credentials via gcloud auth application-default login." +) + +const ( + envSpannerEmulatorHost = "SPANNER_EMULATOR_HOST" + envAppCredentials = "GOOGLE_APPLICATION_CREDENTIALS" + envCloudSDKConfig = "CLOUDSDK_CONFIG" + envSSHConnection = "SSH_CONNECTION" + envSSHTTY = "SSH_TTY" + envDisplay = "DISPLAY" +) + +// reauthClass distinguishes a typed token error (login-eligible) from a +// message-only match that may only receive a hint. +type reauthClass int + +const ( + reauthClassNone reauthClass = iota + reauthClassTyped + reauthClassHint +) + +var errLoginBudgetSpent = errors.New("reauth login budget already spent") + +type adcSnapshot struct { + Type string + Writable bool + Contents []byte +} + +// reauthHooks are the injectable seams for the ADC preflight. Production +// wiring uses productionReauthHooks. Tests replace individual fields. +type reauthHooks struct { + detect func(ctx context.Context) (*auth.Credentials, error) + fetchToken func(ctx context.Context, creds *auth.Credentials) error + login func(ctx context.Context) error + getenv func(key string) string + lookPath func(file string) (string, error) + isTerminal func(fd int) bool + stdinFD int + stderrFD int + wellKnownPath func() string + inspectADC func(path string) (adcSnapshot, error) + stderr io.Writer + + mu sync.Mutex + loginUsed bool +} + +func productionReauthHooks() *reauthHooks { + return &reauthHooks{ + detect: detectDefaultCredentials, + fetchToken: func(ctx context.Context, creds *auth.Credentials) error { + if creds == nil { + return errors.New("no credentials") + } + _, err := creds.Token(ctx) + return err + }, + login: func(ctx context.Context) error { + return runGcloudADCLogin(ctx, os.Getenv, exec.LookPath) + }, + getenv: os.Getenv, + lookPath: exec.LookPath, + isTerminal: term.IsTerminal, + stdinFD: int(os.Stdin.Fd()), + stderrFD: int(os.Stderr.Fd()), + wellKnownPath: wellKnownADCPath, + inspectADC: inspectADCFile, + stderr: os.Stderr, + } +} + +var newReauthHooks = productionReauthHooks + +func detectDefaultCredentials(context.Context) (*auth.Credentials, error) { + return credentials.DetectDefault(&credentials.DetectOptions{ + Scopes: apiv1.DefaultAuthScopes(), + }) +} + +// wellKnownADCPath is the path the Go auth library reads. It ignores +// CLOUDSDK_CONFIG; gcloud honors that variable, which is why a set +// CLOUDSDK_CONFIG makes automatic login inapplicable. +func wellKnownADCPath() string { + if runtime.GOOS == "windows" { + return filepath.Join(os.Getenv("APPDATA"), "gcloud", adcFileName) + } + return filepath.Join(os.Getenv("HOME"), ".config", "gcloud", adcFileName) +} + +func inspectADCFile(path string) (adcSnapshot, error) { + data, err := os.ReadFile(path) + if err != nil { + return adcSnapshot{}, err + } + snap := adcSnapshot{ + Type: credentialJSONType(data), + Contents: data, + } + f, err := os.OpenFile(path, os.O_WRONLY, 0) + if err != nil { + return snap, nil + } + snap.Writable = true + _ = f.Close() + return snap, nil +} + +func credentialJSONType(raw []byte) string { + var parsed struct { + Type string `json:"type"` + } + if json.Unmarshal(raw, &parsed) != nil { + return "" + } + return parsed.Type +} + +func isAuthorizedUserType(typ string) bool { + return typ == string(credentials.AuthorizedUser) +} + +func classifyReauthError(err error) reauthClass { + if err == nil { + return reauthClassNone + } + if isTypedReauthError(err) { + return reauthClassTyped + } + if hasReauthSubstring(err.Error()) { + return reauthClassHint + } + return reauthClassNone +} + +// isReauthError reports whether err is a typed reauthentication failure +// (invalid_grant with error_subtype invalid_rapt or rapt_required). +// Message-only matches are not login-eligible. +func isReauthError(err error) bool { + return classifyReauthError(err) == reauthClassTyped +} + +func isTypedReauthError(err error) bool { + var ae *auth.Error + if errors.As(err, &ae) { + return bodyIsRAPTGrant(ae.Body) + } + var re *oauth2.RetrieveError + if errors.As(err, &re) { + return retrieveErrorIsRAPTGrant(re) + } + return false +} + +func retrieveErrorIsRAPTGrant(re *oauth2.RetrieveError) bool { + code, subtype, parsed := parseOAuthErrorBody(re.Body) + if re.ErrorCode != "" { + code = re.ErrorCode + } + if !parsed && re.ErrorCode == "" { + return false + } + return isRAPTGrant(code, subtype) +} + +func bodyIsRAPTGrant(body []byte) bool { + code, subtype, ok := parseOAuthErrorBody(body) + if !ok { + return false + } + return isRAPTGrant(code, subtype) +} + +func parseOAuthErrorBody(body []byte) (code, subtype string, ok bool) { + var parsed struct { + Error string `json:"error"` + ErrorSubtype string `json:"error_subtype"` + } + if json.Unmarshal(body, &parsed) != nil { + return "", "", false + } + return parsed.Error, parsed.ErrorSubtype, true +} + +func isRAPTGrant(code, subtype string) bool { + if code != "invalid_grant" { + return false + } + return subtype == "invalid_rapt" || subtype == "rapt_required" +} + +func hasReauthSubstring(s string) bool { + return strings.Contains(s, "invalid_rapt") || strings.Contains(s, "rapt_required") +} + +func needsReauthHint(err error) bool { + if err == nil { + return false + } + if isReauthError(err) { + return true + } + if st, ok := status.FromError(err); ok && st.Code() == codes.Unauthenticated && hasReauthSubstring(st.Message()) { + return true + } + return hasReauthSubstring(err.Error()) +} + +func wrapWithHint(err error) error { + if err == nil { + return nil + } + if !needsReauthHint(err) { + return err + } + if strings.Contains(err.Error(), reauthHintText) { + return err + } + var b strings.Builder + b.WriteString(reauthHintText) + if os.Getenv(envAppCredentials) != "" { + b.WriteString("\nGOOGLE_APPLICATION_CREDENTIALS is set; gcloud writes the well-known ADC file, not that path.") + } + if os.Getenv(envCloudSDKConfig) != "" { + b.WriteString("\nCLOUDSDK_CONFIG is set; the Go auth library does not read that config directory.") + } + return fmt.Errorf("%w\n%s", err, b.String()) +} + +func reauthApplicable(o opts, injectedClientOptions []option.ClientOption, h *reauthHooks) bool { + if o.Reauth != reauthModeAuto { + return false + } + if len(injectedClientOptions) > 0 { + return false + } + if h.getenv(envSpannerEmulatorHost) != "" { + return false + } + if h.getenv(envAppCredentials) != "" { + return false + } + if h.getenv(envCloudSDKConfig) != "" { + return false + } + if !h.isTerminal(h.stdinFD) || !h.isTerminal(h.stderrFD) { + return false + } + if _, err := h.lookPath("gcloud"); err != nil { + return false + } + snap, err := h.inspectADC(h.wellKnownPath()) + if err != nil { + return false + } + return snap.Writable && isAuthorizedUserType(snap.Type) +} + +func useNoLaunchBrowser(getenv func(string) string) bool { + if getenv(envSSHConnection) != "" || getenv(envSSHTTY) != "" { + return true + } + return runtime.GOOS == "linux" && getenv(envDisplay) == "" +} + +func gcloudADCLoginArgs(getenv func(string) string) []string { + args := []string{"auth", "application-default", "login"} + if useNoLaunchBrowser(getenv) { + args = append(args, "--no-launch-browser") + } + return args +} + +// runGcloudADCLogin runs `gcloud auth application-default login` with a +// fixed argument vector. Stdout is attached to stderr so query output is +// not mixed with gcloud's instructions. Tokens and ADC JSON are not logged. +func runGcloudADCLogin(ctx context.Context, getenv func(string) string, lookPath func(string) (string, error)) error { + bin, err := lookPath("gcloud") + if err != nil { + return err + } + cmd := exec.CommandContext(ctx, bin, gcloudADCLoginArgs(getenv)...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func (h *reauthHooks) tryLogin(ctx context.Context) error { + h.mu.Lock() + defer h.mu.Unlock() + if h.loginUsed { + return errLoginBudgetSpent + } + h.loginUsed = true + _, _ = fmt.Fprintln(h.stderr, gcloudADCLoginNotice) + return h.login(ctx) +} + +func quotaProjectID(ctx context.Context, creds *auth.Credentials) string { + if creds == nil { + return "" + } + id, err := creds.QuotaProjectID(ctx) + if err != nil { + return "" + } + return id +} + +func maybeAuthPreflight(ctx context.Context, o opts, injectedClientOptions []option.ClientOption, h *reauthHooks) ([]option.ClientOption, error) { + if o.Reauth != reauthModeAuto || len(injectedClientOptions) > 0 || !reauthApplicable(o, injectedClientOptions, h) { + return nil, nil + } + return runAuthPreflight(ctx, h) +} + +func runAuthPreflight(ctx context.Context, h *reauthHooks) ([]option.ClientOption, error) { + creds, err := h.detect(ctx) + if err == nil { + err = h.fetchToken(ctx, creds) + } + if !isReauthError(err) { + if err != nil { + return nil, err + } + return []option.ClientOption{option.WithAuthCredentials(creds)}, nil + } + orig := err + before, _ := h.inspectADC(h.wellKnownPath()) + quotaBefore := quotaProjectID(ctx, creds) + if err := h.tryLogin(ctx); err != nil { + if !errors.Is(err, errLoginBudgetSpent) { + _, _ = fmt.Fprintln(h.stderr, "gcloud auth application-default login failed") + } + return nil, orig + } + after, inspErr := h.inspectADC(h.wellKnownPath()) + if inspErr != nil || !isAuthorizedUserType(after.Type) || bytes.Equal(after.Contents, before.Contents) { + return nil, orig + } + creds, err = h.detect(ctx) + if err != nil { + return nil, orig + } + if !isAuthorizedUserType(credentialJSONType(creds.JSON())) { + return nil, orig + } + if err := h.fetchToken(ctx, creds); err != nil { + return nil, orig + } + quotaAfter := quotaProjectID(ctx, creds) + if quotaBefore != quotaAfter { + _, _ = fmt.Fprintf(h.stderr, "warning: quota project ID changed from %q to %q after reauthentication\n", quotaBefore, quotaAfter) + } + return []option.ClientOption{option.WithAuthCredentials(creds)}, nil +} diff --git a/reauth_test.go b/reauth_test.go new file mode 100644 index 0000000..296429e --- /dev/null +++ b/reauth_test.go @@ -0,0 +1,864 @@ +package main + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "cloud.google.com/go/auth" + "cloud.google.com/go/auth/credentials" + "github.com/alecthomas/kong" + "golang.org/x/oauth2" + "google.golang.org/api/option" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var ( + raptJSON = []byte(`{"error":"invalid_grant","error_description":"reauth related error (invalid_rapt)","error_uri":"https://support.google.com/a/answer/9368756","error_subtype":"invalid_rapt"}`) + raptRequiredJSON = []byte(`{"error":"invalid_grant","error_subtype":"rapt_required"}`) + revokedJSON = []byte(`{"error":"invalid_grant","error_description":"Token has been expired or revoked."}`) +) + +func newAuthErr(body []byte) *auth.Error { + // auth.Error.Error() dereferences Response when the unexported code is empty. + return &auth.Error{ + Response: &http.Response{StatusCode: http.StatusBadRequest}, + Body: body, + } +} + +func TestClassifyReauthError(t *testing.T) { + t.Parallel() + + grpcRAPT := status.Error(codes.Unauthenticated, `transport: per-RPC creds failed due to error: auth: "invalid_grant" "reauth related error (invalid_rapt)"`) + + tests := []struct { + name string + err error + want reauthClass + }{ + {name: "nil", want: reauthClassNone}, + {name: "auth_invalid_rapt", err: newAuthErr(raptJSON), want: reauthClassTyped}, + {name: "auth_rapt_required", err: newAuthErr(raptRequiredJSON), want: reauthClassTyped}, + {name: "auth_revoked_invalid_grant", err: newAuthErr(revokedJSON), want: reauthClassNone}, + {name: "auth_non_json_body", err: newAuthErr([]byte("not json")), want: reauthClassNone}, + {name: "auth_wrapped_invalid_rapt", err: fmtWrap(newAuthErr(raptJSON)), want: reauthClassTyped}, + { + name: "oauth2_invalid_rapt", + err: &oauth2.RetrieveError{ErrorCode: "invalid_grant", Body: raptJSON}, + want: reauthClassTyped, + }, + { + name: "oauth2_rapt_required", + err: &oauth2.RetrieveError{ErrorCode: "invalid_grant", Body: raptRequiredJSON}, + want: reauthClassTyped, + }, + { + name: "oauth2_revoked_invalid_grant", + err: &oauth2.RetrieveError{ + ErrorCode: "invalid_grant", + ErrorDescription: "Token has been expired or revoked.", + Body: revokedJSON, + }, + want: reauthClassNone, + }, + { + name: "oauth2_non_json_body", + err: &oauth2.RetrieveError{ErrorCode: "invalid_grant", Body: []byte("not json")}, + want: reauthClassNone, + }, + { + name: "oauth2_body_only_invalid_rapt", + err: &oauth2.RetrieveError{Body: raptJSON}, + want: reauthClassTyped, + }, + {name: "grpc_unauthenticated_rapt_hint_only", err: grpcRAPT, want: reauthClassHint}, + {name: "unrelated", err: errors.New("connection refused"), want: reauthClassNone}, + {name: "permission_denied", err: status.Error(codes.PermissionDenied, "denied"), want: reauthClassNone}, + {name: "unauthenticated_without_rapt", err: status.Error(codes.Unauthenticated, "missing credentials"), want: reauthClassNone}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifyReauthError(tt.err) + if got != tt.want { + t.Fatalf("classifyReauthError() = %v, want %v (err=%v)", got, tt.want, tt.err) + } + if tt.want == reauthClassTyped && !isReauthError(tt.err) { + t.Fatal("isReauthError() = false, want true for typed reauth") + } + if tt.want != reauthClassTyped && isReauthError(tt.err) { + t.Fatal("isReauthError() = true, want false") + } + }) + } +} + +func fmtWrap(err error) error { + return errors.Join(err) +} + +func TestWrapWithHint(t *testing.T) { + t.Setenv(envAppCredentials, "") + t.Setenv(envCloudSDKConfig, "") + + typed := newAuthErr(raptJSON) + hinted := wrapWithHint(typed) + if hinted == nil || !strings.Contains(hinted.Error(), reauthHintText) { + t.Fatalf("typed wrap = %v, want hint", hinted) + } + if !errors.Is(hinted, typed) { + t.Fatal("wrapWithHint should wrap the original typed error") + } + + grpcErr := status.Error(codes.Unauthenticated, `transport: per-RPC creds failed due to error: auth: "invalid_grant" "reauth related error (invalid_rapt)"`) + got := wrapWithHint(grpcErr) + if got == nil || !strings.Contains(got.Error(), reauthHintText) { + t.Fatalf("unauthenticated wrap = %v, want hint", got) + } + + unrelated := errors.New("boom") + if wrapWithHint(unrelated) != unrelated { + t.Fatalf("unrelated error was rewritten: %v", wrapWithHint(unrelated)) + } + + already := wrapWithHint(typed) + if wrapWithHint(already) != already && strings.Count(wrapWithHint(already).Error(), reauthHintText) != 1 { + t.Fatal("hint was applied more than once") + } +} + +func TestWrapWithHintMentionsEnv(t *testing.T) { + t.Setenv(envCloudSDKConfig, "") + t.Setenv(envAppCredentials, "/tmp/creds.json") + got := wrapWithHint(newAuthErr(raptJSON)).Error() + if !strings.Contains(got, "GOOGLE_APPLICATION_CREDENTIALS") { + t.Fatalf("hint = %q, want GOOGLE_APPLICATION_CREDENTIALS", got) + } + + t.Setenv(envAppCredentials, "") + t.Setenv(envCloudSDKConfig, "/tmp/gcloud-config") + got = wrapWithHint(newAuthErr(raptJSON)).Error() + if !strings.Contains(got, "CLOUDSDK_CONFIG") { + t.Fatalf("hint = %q, want CLOUDSDK_CONFIG", got) + } +} + +func TestProcessFlagsReauth(t *testing.T) { + oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + + base := []string{"execspansql", "database", "--project", "p", "--instance", "i", "--sql", "SELECT 1"} + tests := []struct { + name string + env string + args []string + want string + wantErr string + }{ + {name: "default_off", args: base, want: reauthModeOff}, + {name: "flag_auto", args: append(append([]string{}, base...), "--reauth", "auto"), want: reauthModeAuto}, + {name: "flag_off", args: append(append([]string{}, base...), "--reauth", "off"), want: reauthModeOff}, + {name: "env_auto", env: reauthModeAuto, args: base, want: reauthModeAuto}, + {name: "invalid", args: append(append([]string{}, base...), "--reauth", "prompt"), wantErr: "--reauth must be one of"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != "" { + t.Setenv("EXECSPANSQL_REAUTH", tt.env) + } else { + t.Setenv("EXECSPANSQL_REAUTH", "") + _ = os.Unsetenv("EXECSPANSQL_REAUTH") + } + os.Args = tt.args + got, err := processFlags() + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("processFlags() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if got.Reauth != tt.want { + t.Fatalf("Reauth = %q, want %q", got.Reauth, tt.want) + } + }) + } +} + +func TestGcloudADCLoginArgs(t *testing.T) { + t.Parallel() + + plain := gcloudADCLoginArgs(func(k string) string { + if k == envDisplay { + return ":0" + } + return "" + }) + if want := []string{"auth", "application-default", "login"}; !equalStrings(plain, want) { + t.Fatalf("args = %v, want %v", plain, want) + } + ssh := gcloudADCLoginArgs(func(k string) string { + if k == envSSHConnection { + return "1 2 3 4" + } + return "" + }) + if want := []string{"auth", "application-default", "login", "--no-launch-browser"}; !equalStrings(ssh, want) { + t.Fatalf("ssh args = %v, want %v", ssh, want) + } +} + +func TestReauthApplicability(t *testing.T) { + authorized := adcSnapshot{ + Type: string(credentials.AuthorizedUser), + Writable: true, + Contents: []byte(`{"type":"authorized_user"}`), + } + base := func() *reauthHooks { + return &reauthHooks{ + getenv: func(string) string { return "" }, + lookPath: func(string) (string, error) { return "/usr/bin/gcloud", nil }, + isTerminal: func(int) bool { return true }, + stdinFD: 1, + stderrFD: 2, + wellKnownPath: func() string { return "/tmp/adc.json" }, + inspectADC: func(string) (adcSnapshot, error) { return authorized, nil }, + } + } + + tests := []struct { + name string + o opts + injected []option.ClientOption + hooks func() *reauthHooks + want bool + }{ + {name: "all_hold", o: opts{Reauth: reauthModeAuto}, hooks: base, want: true}, + {name: "reauth_off", o: opts{Reauth: reauthModeOff}, hooks: base}, + { + name: "injected_client_options", + o: opts{Reauth: reauthModeAuto}, + injected: []option.ClientOption{option.WithoutAuthentication()}, + hooks: base, + }, + { + name: "emulator_host", + o: opts{Reauth: reauthModeAuto}, + hooks: func() *reauthHooks { + h := base() + h.getenv = getenvMap(map[string]string{envSpannerEmulatorHost: "localhost:9010"}) + return h + }, + }, + { + name: "application_credentials", + o: opts{Reauth: reauthModeAuto}, + hooks: func() *reauthHooks { + h := base() + h.getenv = getenvMap(map[string]string{envAppCredentials: "/tmp/sa.json"}) + return h + }, + }, + { + name: "cloudsdk_config", + o: opts{Reauth: reauthModeAuto}, + hooks: func() *reauthHooks { + h := base() + h.getenv = getenvMap(map[string]string{envCloudSDKConfig: "/tmp/gcloud"}) + return h + }, + }, + { + name: "missing_file", + o: opts{Reauth: reauthModeAuto}, + hooks: func() *reauthHooks { + h := base() + h.inspectADC = func(string) (adcSnapshot, error) { return adcSnapshot{}, os.ErrNotExist } + return h + }, + }, + { + name: "unwritable_file", + o: opts{Reauth: reauthModeAuto}, + hooks: func() *reauthHooks { + h := base() + h.inspectADC = func(string) (adcSnapshot, error) { + s := authorized + s.Writable = false + return s, nil + } + return h + }, + }, + { + name: "service_account_type", + o: opts{Reauth: reauthModeAuto}, + hooks: func() *reauthHooks { + h := base() + h.inspectADC = func(string) (adcSnapshot, error) { + return adcSnapshot{Type: string(credentials.ServiceAccount), Writable: true, Contents: []byte(`{"type":"service_account"}`)}, nil + } + return h + }, + }, + { + name: "non_tty_stdin", + o: opts{Reauth: reauthModeAuto}, + hooks: func() *reauthHooks { + h := base() + h.isTerminal = func(fd int) bool { return fd != h.stdinFD } + return h + }, + }, + { + name: "non_tty_stderr", + o: opts{Reauth: reauthModeAuto}, + hooks: func() *reauthHooks { + h := base() + h.isTerminal = func(fd int) bool { return fd != h.stderrFD } + return h + }, + }, + { + name: "gcloud_missing", + o: opts{Reauth: reauthModeAuto}, + hooks: func() *reauthHooks { + h := base() + h.lookPath = func(string) (string, error) { return "", os.ErrNotExist } + return h + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := reauthApplicable(tt.o, tt.injected, tt.hooks()) + if got != tt.want { + t.Fatalf("reauthApplicable() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestReauthApplicabilityEnvAndHome(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv(envSpannerEmulatorHost, "") + t.Setenv(envAppCredentials, "") + t.Setenv(envCloudSDKConfig, "") + + adcDir := filepath.Join(home, ".config", "gcloud") + if err := os.MkdirAll(adcDir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(adcDir, adcFileName) + if err := os.WriteFile(path, []byte(`{"type":"authorized_user"}`), 0o600); err != nil { + t.Fatal(err) + } + + hooks := func() *reauthHooks { + h := productionReauthHooks() + h.getenv = os.Getenv + h.isTerminal = func(int) bool { return true } + h.lookPath = func(string) (string, error) { return "/usr/bin/gcloud", nil } + return h + } + o := opts{Reauth: reauthModeAuto} + if !reauthApplicable(o, nil, hooks()) { + t.Fatal("want applicable with temp HOME ADC") + } + if wellKnownADCPath() != path { + t.Fatalf("wellKnownADCPath() = %q, want %q", wellKnownADCPath(), path) + } + + t.Run("emulator", func(t *testing.T) { + t.Setenv(envSpannerEmulatorHost, "localhost:9010") + if reauthApplicable(o, nil, hooks()) { + t.Fatal("SPANNER_EMULATOR_HOST should disable auto reauth") + } + }) + t.Run("gac", func(t *testing.T) { + t.Setenv(envAppCredentials, filepath.Join(home, "sa.json")) + if reauthApplicable(o, nil, hooks()) { + t.Fatal("GOOGLE_APPLICATION_CREDENTIALS should disable auto reauth") + } + }) + t.Run("cloudsdk_config", func(t *testing.T) { + t.Setenv(envCloudSDKConfig, filepath.Join(home, "gcloud-config")) + if reauthApplicable(o, nil, hooks()) { + t.Fatal("CLOUDSDK_CONFIG should disable auto reauth") + } + }) + t.Run("missing_file", func(t *testing.T) { + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if reauthApplicable(o, nil, hooks()) { + t.Fatal("missing ADC file should disable auto reauth") + } + }) + t.Run("wrong_type", func(t *testing.T) { + if err := os.WriteFile(path, []byte(`{"type":"service_account"}`), 0o600); err != nil { + t.Fatal(err) + } + if reauthApplicable(o, nil, hooks()) { + t.Fatal("non-authorized_user ADC should disable auto reauth") + } + }) + t.Run("unwritable", func(t *testing.T) { + if err := os.WriteFile(path, []byte(`{"type":"authorized_user"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o400); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(path, 0o600) }) + if reauthApplicable(o, nil, hooks()) { + t.Fatal("unwritable ADC file should disable auto reauth") + } + }) +} + +func TestReauthPreflight(t *testing.T) { + raptErr := newAuthErr(raptJSON) + auto := opts{Reauth: reauthModeAuto} + off := opts{Reauth: reauthModeOff} + + t.Run("off_no_detection", func(t *testing.T) { + h, loginCalls, detectCalls := newPreflightHooks(t, raptErr) + opts, err := runPreflight(context.Background(), off, nil, h) + if err != nil { + t.Fatal(err) + } + if opts != nil { + t.Fatalf("client options = %v, want nil", opts) + } + if *detectCalls != 0 || *loginCalls != 0 { + t.Fatalf("detect=%d login=%d, want 0, 0", *detectCalls, *loginCalls) + } + }) + + t.Run("reauth_off_hint_only", func(t *testing.T) { + h, loginCalls, _ := newPreflightHooks(t, raptErr) + _, err := runPreflight(context.Background(), off, nil, h) + if err != nil { + t.Fatal(err) + } + if *loginCalls != 0 { + t.Fatalf("login calls = %d, want 0", *loginCalls) + } + hinted := wrapWithHint(raptErr) + if !strings.Contains(hinted.Error(), reauthHintText) { + t.Fatalf("hint = %v", hinted) + } + }) + + t.Run("auto_success_no_login", func(t *testing.T) { + h, loginCalls, detectCalls := newPreflightHooks(t, nil) + opts, err := runPreflight(context.Background(), auto, nil, h) + if err != nil { + t.Fatal(err) + } + if len(opts) != 1 { + t.Fatalf("client options len = %d, want 1", len(opts)) + } + if *loginCalls != 0 || *detectCalls != 1 { + t.Fatalf("detect=%d login=%d, want 1, 0", *detectCalls, *loginCalls) + } + }) + + t.Run("auto_reauth_one_login", func(t *testing.T) { + h, loginCalls, detectCalls, stderr := newMutablePreflightHooks(t, raptErr) + h.login = func(context.Context) error { + *loginCalls++ + h.inspectADC = func(string) (adcSnapshot, error) { + return adcSnapshot{ + Type: string(credentials.AuthorizedUser), + Writable: true, + Contents: []byte(`{"type":"authorized_user","refresh_token":"new"}`), + }, nil + } + return nil + } + fetch := 0 + h.fetchToken = func(context.Context, *auth.Credentials) error { + fetch++ + if fetch == 1 { + return raptErr + } + return nil + } + opts, err := runPreflight(context.Background(), auto, nil, h) + if err != nil { + t.Fatal(err) + } + if len(opts) != 1 { + t.Fatalf("client options len = %d, want 1", len(opts)) + } + if *loginCalls != 1 { + t.Fatalf("login calls = %d, want 1", *loginCalls) + } + if *detectCalls != 2 { + t.Fatalf("detect calls = %d, want 2", *detectCalls) + } + if fetch != 2 { + t.Fatalf("fetch calls = %d, want 2", fetch) + } + if !strings.Contains(stderr.String(), gcloudADCLoginNotice) { + t.Fatalf("stderr = %q, want login notice", stderr.String()) + } + }) + + t.Run("login_fails", func(t *testing.T) { + h, loginCalls, detectCalls := newPreflightHooks(t, raptErr) + h.login = func(context.Context) error { + *loginCalls++ + return errors.New("gcloud failed") + } + h.fetchToken = func(context.Context, *auth.Credentials) error { return raptErr } + _, err := runPreflight(context.Background(), auto, nil, h) + if err == nil || !strings.Contains(err.Error(), reauthHintText) { + t.Fatalf("error = %v, want original plus hint", err) + } + if !errors.Is(err, raptErr) { + t.Fatalf("error = %v, want original typed error", err) + } + if *loginCalls != 1 || *detectCalls != 1 { + t.Fatalf("detect=%d login=%d, want 1, 1 (no second login)", *detectCalls, *loginCalls) + } + }) + + t.Run("reloaded_unchanged", func(t *testing.T) { + h, loginCalls, detectCalls := newPreflightHooks(t, raptErr) + h.login = func(context.Context) error { *loginCalls++; return nil } + h.fetchToken = func(context.Context, *auth.Credentials) error { return raptErr } + _, err := runPreflight(context.Background(), auto, nil, h) + if err == nil || !errors.Is(err, raptErr) || !strings.Contains(err.Error(), reauthHintText) { + t.Fatalf("error = %v, want original plus hint", err) + } + if *loginCalls != 1 || *detectCalls != 1 { + t.Fatalf("detect=%d login=%d, want no loop", *detectCalls, *loginCalls) + } + }) + + t.Run("reloaded_wrong_type", func(t *testing.T) { + h, loginCalls, detectCalls := newPreflightHooks(t, raptErr) + h.login = func(context.Context) error { + *loginCalls++ + h.inspectADC = func(string) (adcSnapshot, error) { + return adcSnapshot{Type: string(credentials.ServiceAccount), Writable: true, Contents: []byte(`{"type":"service_account"}`)}, nil + } + return nil + } + h.fetchToken = func(context.Context, *auth.Credentials) error { return raptErr } + _, err := runPreflight(context.Background(), auto, nil, h) + if err == nil || !errors.Is(err, raptErr) { + t.Fatalf("error = %v, want original", err) + } + if *loginCalls != 1 || *detectCalls != 1 { + t.Fatalf("detect=%d login=%d, want no loop", *detectCalls, *loginCalls) + } + }) + + t.Run("reloaded_unreadable", func(t *testing.T) { + h, loginCalls, detectCalls := newPreflightHooks(t, raptErr) + h.login = func(context.Context) error { + *loginCalls++ + h.inspectADC = func(string) (adcSnapshot, error) { return adcSnapshot{}, errors.New("unreadable") } + return nil + } + h.fetchToken = func(context.Context, *auth.Credentials) error { return raptErr } + _, err := runPreflight(context.Background(), auto, nil, h) + if err == nil || !errors.Is(err, raptErr) { + t.Fatalf("error = %v, want original", err) + } + if *loginCalls != 1 || *detectCalls != 1 { + t.Fatalf("detect=%d login=%d, want no loop", *detectCalls, *loginCalls) + } + }) + + t.Run("second_fetch_fails", func(t *testing.T) { + h, loginCalls, detectCalls := newPreflightHooks(t, raptErr) + h.login = func(context.Context) error { + *loginCalls++ + h.inspectADC = func(string) (adcSnapshot, error) { + return adcSnapshot{ + Type: string(credentials.AuthorizedUser), + Writable: true, + Contents: []byte(`{"type":"authorized_user","refresh_token":"new"}`), + }, nil + } + return nil + } + h.fetchToken = func(context.Context, *auth.Credentials) error { return raptErr } + _, err := runPreflight(context.Background(), auto, nil, h) + if err == nil || !errors.Is(err, raptErr) || !strings.Contains(err.Error(), reauthHintText) { + t.Fatalf("error = %v, want original plus hint", err) + } + if *loginCalls != 1 || *detectCalls != 2 { + t.Fatalf("detect=%d login=%d, want 2, 1", *detectCalls, *loginCalls) + } + }) + + t.Run("budget_already_spent", func(t *testing.T) { + h, loginCalls, _ := newPreflightHooks(t, raptErr) + h.loginUsed = true + h.fetchToken = func(context.Context, *auth.Credentials) error { return raptErr } + _, err := runPreflight(context.Background(), auto, nil, h) + if err == nil || !errors.Is(err, raptErr) || !strings.Contains(err.Error(), reauthHintText) { + t.Fatalf("error = %v, want original plus hint", err) + } + if *loginCalls != 0 { + t.Fatalf("login calls = %d, want 0", *loginCalls) + } + }) + + t.Run("quota_project_changed", func(t *testing.T) { + h, _, _, stderr := newMutablePreflightHooks(t, raptErr) + firstJSON := []byte(`{"type":"authorized_user","quota_project_id":"old"}`) + secondJSON := []byte(`{"type":"authorized_user","quota_project_id":"new","refresh_token":"new"}`) + detect := 0 + h.detect = func(context.Context) (*auth.Credentials, error) { + detect++ + if detect == 1 { + return credsWithQuota("old", firstJSON), nil + } + return credsWithQuota("new", secondJSON), nil + } + h.login = func(context.Context) error { + h.inspectADC = func(string) (adcSnapshot, error) { + return adcSnapshot{Type: string(credentials.AuthorizedUser), Writable: true, Contents: secondJSON}, nil + } + return nil + } + fetch := 0 + h.fetchToken = func(context.Context, *auth.Credentials) error { + fetch++ + if fetch == 1 { + return raptErr + } + return nil + } + _, err := runPreflight(context.Background(), auto, nil, h) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stderr.String(), "old") || !strings.Contains(stderr.String(), "new") { + t.Fatalf("stderr = %q, want quota project difference", stderr.String()) + } + }) + + t.Run("injected_options_skip_detect", func(t *testing.T) { + h, loginCalls, detectCalls := newPreflightHooks(t, raptErr) + _, err := runPreflight(context.Background(), auto, []option.ClientOption{option.WithoutAuthentication()}, h) + if err != nil { + t.Fatal(err) + } + if *detectCalls != 0 || *loginCalls != 0 { + t.Fatalf("detect=%d login=%d, want skipped", *detectCalls, *loginCalls) + } + }) +} + +func runPreflight(ctx context.Context, o opts, injected []option.ClientOption, h *reauthHooks) ([]option.ClientOption, error) { + opts, err := maybeAuthPreflight(ctx, o, injected, h) + return opts, wrapWithHint(err) +} + +func newPreflightHooks(t *testing.T, tokenErr error) (*reauthHooks, *int, *int) { + t.Helper() + h, loginCalls, detectCalls, _ := newMutablePreflightHooks(t, tokenErr) + return h, loginCalls, detectCalls +} + +func newMutablePreflightHooks(t *testing.T, tokenErr error) (*reauthHooks, *int, *int, *bytes.Buffer) { + t.Helper() + var stderr bytes.Buffer + var loginCalls, detectCalls int + snap := adcSnapshot{ + Type: string(credentials.AuthorizedUser), + Writable: true, + Contents: []byte(`{"type":"authorized_user","refresh_token":"old"}`), + } + h := &reauthHooks{ + getenv: func(string) string { return "" }, + lookPath: func(string) (string, error) { return "/usr/bin/gcloud", nil }, + isTerminal: func(int) bool { return true }, + wellKnownPath: func() string { return "/tmp/adc.json" }, + inspectADC: func(string) (adcSnapshot, error) { return snap, nil }, + stderr: &stderr, + detect: func(context.Context) (*auth.Credentials, error) { + detectCalls++ + return credsWithQuota("proj", snap.Contents), nil + }, + fetchToken: func(context.Context, *auth.Credentials) error { return tokenErr }, + login: func(context.Context) error { + loginCalls++ + t.Fatal("unexpected login") + return nil + }, + } + return h, &loginCalls, &detectCalls, &stderr +} + +func credsWithQuota(quota string, raw []byte) *auth.Credentials { + return auth.NewCredentials(&auth.CredentialsOptions{ + TokenProvider: tokenStub{tok: &auth.Token{Value: "ya29.fake"}}, + JSON: raw, + QuotaProjectIDProvider: auth.CredentialsPropertyFunc(func(context.Context) (string, error) { + return quota, nil + }), + }) +} + +type tokenStub struct { + tok *auth.Token + err error +} + +func (s tokenStub) Token(context.Context) (*auth.Token, error) { + return s.tok, s.err +} + +func getenvMap(m map[string]string) func(string) string { + return func(k string) string { return m[k] } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestReauthKongEnum(t *testing.T) { + t.Parallel() + var o opts + parser, err := kong.New(&o, kong.Name("execspansql")) + if err != nil { + t.Fatal(err) + } + _, err = parser.Parse([]string{"db", "--project", "p", "--instance", "i", "--sql", "SELECT 1", "--reauth", "auto"}) + if err != nil { + t.Fatal(err) + } + if o.Reauth != reauthModeAuto { + t.Fatalf("Reauth = %q, want %q", o.Reauth, reauthModeAuto) + } +} + +func TestInspectADCFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, adcFileName) + if _, err := inspectADCFile(path); err == nil { + t.Fatal("missing file should error") + } + if err := os.WriteFile(path, []byte(`{"type":"authorized_user"}`), 0o600); err != nil { + t.Fatal(err) + } + snap, err := inspectADCFile(path) + if err != nil { + t.Fatal(err) + } + if snap.Type != string(credentials.AuthorizedUser) || !snap.Writable { + t.Fatalf("snapshot = %+v", snap) + } + if err := os.Chmod(path, 0o400); err != nil { + t.Fatal(err) + } + snap, err = inspectADCFile(path) + if err != nil { + t.Fatal(err) + } + if snap.Writable { + t.Fatal("want unwritable") + } +} + +func TestRunGcloudADCLogin(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake gcloud script is a POSIX shell script") + } + dir := t.TempDir() + argvPath := filepath.Join(dir, "argv") + script := "#!/bin/sh\n" + + "printf '%s\\n' \"$@\" > " + shellQuote(argvPath) + "\n" + + "echo gcloud-login-stdout\n" + gcloudPath := filepath.Join(dir, "gcloud") + if err := os.WriteFile(gcloudPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + t.Setenv(envSSHConnection, "203.0.113.1 60000 203.0.113.2 22") + t.Setenv(envSSHTTY, "") + + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + stderrR, stderrW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + oldStdout, oldStderr := os.Stdout, os.Stderr + os.Stdout, os.Stderr = stdoutW, stderrW + defer func() { + os.Stdout, os.Stderr = oldStdout, oldStderr + }() + + var stdoutBuf, stderrBuf bytes.Buffer + stdoutDone := make(chan struct{}) + stderrDone := make(chan struct{}) + go func() { _, _ = io.Copy(&stdoutBuf, stdoutR); close(stdoutDone) }() + go func() { _, _ = io.Copy(&stderrBuf, stderrR); close(stderrDone) }() + + runErr := runGcloudADCLogin(context.Background(), os.Getenv, exec.LookPath) + if err := stdoutW.Close(); err != nil { + t.Fatal(err) + } + if err := stderrW.Close(); err != nil { + t.Fatal(err) + } + <-stdoutDone + <-stderrDone + os.Stdout, os.Stderr = oldStdout, oldStderr + + if runErr != nil { + t.Fatalf("runGcloudADCLogin() = %v", runErr) + } + gotArgv, err := os.ReadFile(argvPath) + if err != nil { + t.Fatal(err) + } + wantArgv := "auth\napplication-default\nlogin\n--no-launch-browser\n" + if string(gotArgv) != wantArgv { + t.Fatalf("argv = %q, want %q", gotArgv, wantArgv) + } + if strings.Contains(stdoutBuf.String(), "gcloud-login-stdout") { + t.Fatalf("gcloud stdout leaked to process stdout: %q", stdoutBuf.String()) + } + if !strings.Contains(stderrBuf.String(), "gcloud-login-stdout") { + t.Fatalf("stderr = %q, want gcloud-login-stdout", stderrBuf.String()) + } +} + +func shellQuote(path string) string { + return "'" + strings.ReplaceAll(path, "'", `'"'"'`) + "'" +} diff --git a/reauth_transport_test.go b/reauth_transport_test.go new file mode 100644 index 0000000..7eb39ec --- /dev/null +++ b/reauth_transport_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + + "cloud.google.com/go/auth" + sppb "cloud.google.com/go/spanner/apiv1/spannerpb" + "github.com/apstndb/execspansql/internal/grpctest" + "github.com/apstndb/spanemuboost" + "google.golang.org/api/option" +) + +func TestReauthAutoSkipsPreflightWithInjectedClientOptions(t *testing.T) { + env, err := spanemuboost.RunEmulatorWithClients(context.Background()) + if err != nil { + t.Fatal(err) + } + defer env.Close() //nolint:errcheck + t.Setenv("SPANNER_EMULATOR_HOST", env.Emulator().URI()) + + orig := newReauthHooks + t.Cleanup(func() { newReauthHooks = orig }) + var detectCalls int + newReauthHooks = func() *reauthHooks { + h := orig() + h.detect = func(context.Context) (*auth.Credentials, error) { + detectCalls++ + return nil, errors.New("DetectDefault must not run when client options are injected") + } + return h + } + + var executeSQL int + dialOptions := grpctest.Inspect(func(_ string, req any) error { + if _, ok := req.(*sppb.ExecuteSqlRequest); ok { + executeSQL++ + } + return nil + }) + + args := []string{ + env.DatabaseID, "--project", env.ProjectID, "--instance", env.InstanceID, + "--sql", "SELECT 1", "--timeout", "30s", "--reauth", "auto", + } + out, err := captureStdout(t, func() error { + return runMain(t, args, option.WithGRPCDialOption(dialOptions[0]), option.WithGRPCDialOption(dialOptions[1])) + }) + if err != nil { + t.Fatal(err) + } + if detectCalls != 0 { + t.Fatalf("detect calls = %d, want 0", detectCalls) + } + if executeSQL != 1 { + t.Fatalf("ExecuteSql count = %d, want 1 (unchanged from a single SELECT)", executeSQL) + } + if !strings.Contains(out, "1") { + t.Fatalf("stdout = %q, want query result", out) + } +} From f5a91d82ca0843fc42b606c3c6e3d96cec78dd85 Mon Sep 17 00:00:00 2001 From: apstndb <803393+apstndb@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:28:33 +0900 Subject: [PATCH 2/4] Restore default SIGINT handling after the first interrupt signal.NotifyContext keeps capturing SIGINT until stop() is called, so a second Ctrl-C would be swallowed while a cancelled login or query is still unwinding. Call stop() as soon as ctx is cancelled so the second interrupt terminates the process as before. --- main.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/main.go b/main.go index e8c3ea5..f719b4b 100644 --- a/main.go +++ b/main.go @@ -417,8 +417,15 @@ func runCLI(clientOptions ...option.ClientOption) (err error) { } defer func() { err = wrapWithHint(err) }() + // The first interrupt cancels ctx so an in-progress gcloud login or query + // unwinds cleanly; stop() then restores default signal handling so a + // second interrupt still terminates the process if shutdown hangs. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() + go func() { + <-ctx.Done() + stop() + }() jqMode, err := jqresult.ParseInputMode(o.JqInputMode) if err != nil { From 570b6c4962bc8e8b80df2ae878adf388e4fdc83d Mon Sep 17 00:00:00 2001 From: apstndb <803393+apstndb@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:13:49 +0900 Subject: [PATCH 3/4] Document why gcloud.cmd launches without an explicit interpreter A review suggested that passing the resolved gcloud.cmd path to exec.CommandContext cannot start the batch wrapper on Windows. It can: CreateProcess launches .cmd/.bat files through cmd.exe implicitly, which is why exec.LookPath resolves PATHEXT batch extensions and why os/exec documents cmd.exe quoting as a caveat rather than a limitation. Record that reasoning at the call site, together with why the cmd.exe unquoting differences are irrelevant for this fixed argument vector, so future reviews do not repeat the concern. Windows runtime behavior remains unverified in CI. --- reauth.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/reauth.go b/reauth.go index 93e75b2..98f97a4 100644 --- a/reauth.go +++ b/reauth.go @@ -309,6 +309,14 @@ func gcloudADCLoginArgs(getenv func(string) string) []string { // runGcloudADCLogin runs `gcloud auth application-default login` with a // fixed argument vector. Stdout is attached to stderr so query output is // not mixed with gcloud's instructions. Tokens and ADC JSON are not logged. +// +// On Windows the SDK installs gcloud as gcloud.cmd. exec.LookPath resolves +// it through PATHEXT and CreateProcess launches batch files through cmd.exe +// implicitly, so no explicit interpreter is needed (this is the same +// mechanism os/exec documents under its cmd.exe quoting caveat). The cmd.exe +// unquoting differences do not matter here because every argument is a fixed +// literal without spaces or metacharacters; only the resolved path may +// contain spaces, and Go quotes argv[0] like any other argument. func runGcloudADCLogin(ctx context.Context, getenv func(string) string, lookPath func(string) (string, error)) error { bin, err := lookPath("gcloud") if err != nil { From c9932d83b43aeb3639e334066f38fcd5fb41d161 Mon Sep 17 00:00:00 2001 From: apstndb <803393+apstndb@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:45:55 +0900 Subject: [PATCH 4/4] Freeze query parameters before the reauth preflight mergedParams and GenerateParams ran after maybeAuthPreflight, so a parameter file edited while the user completed a browser login could change the executed statement. Build the spanner.Statement (SQL and parameters) before any interactive step, as the design note's frozen execution spec requires. Behavior is unchanged when --reauth=off. --- main.go | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/main.go b/main.go index f719b4b..1cc9e7c 100644 --- a/main.go +++ b/main.go @@ -474,6 +474,18 @@ func runCLI(clientOptions ...option.ClientOption) (err error) { return err } + // Freeze the statement (SQL and parameters) before any interactive step so + // a parameter file edited during a browser login cannot change what runs. + paramStrMap, err := o.mergedParams() + if err != nil { + return err + } + paramMap, err := params.GenerateParams(paramStrMap, mode == sppb.ExecuteSqlRequest_PLAN) + if err != nil { + return err + } + stmt := spanner.Statement{SQL: query, Params: paramMap} + authOpts, err := maybeAuthPreflight(ctx, o, clientOptions, newReauthHooks()) if err != nil { return err @@ -502,17 +514,6 @@ func runCLI(clientOptions ...option.ClientOption) (err error) { } defer client.Close() - paramStrMap, err := o.mergedParams() - if err != nil { - return err - } - paramMap, err := params.GenerateParams(paramStrMap, mode == sppb.ExecuteSqlRequest_PLAN) - if err != nil { - return err - } - - stmt := spanner.Statement{SQL: query, Params: paramMap} - if o.TryPartitionQuery { bt, err := client.BatchReadOnlyTransaction(ctx, tb) if err != nil {