diff --git a/cmd/auth.go b/cmd/auth.go index f66522c..8a81cb2 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -78,7 +78,10 @@ Non-interactive (for headless agents / hosts without a browser): Scopes can be specified either with --scopes (a comma-separated list of scope suffixes — see 'ghealth schema scopes') or with --scopes-preset (readonly | all | -category names like 'sleep,activity_and_fitness').`, +category names like 'sleep,activity_and_fitness'). The 'all' preset covers only +health-data scopes: cloud-platform is excluded because data-plane endpoints +reject tokens that carry it. Webhooks need a separate login, ideally in a +dedicated config dir — see 'ghealth webhooks --help'.`, RunE: runAuthLogin, } @@ -147,7 +150,7 @@ func init() { authLoginCmd.Flags().BoolVar(&authLoginNonInteractive, "non-interactive", false, "Print an auth URL as JSON and save pending state; complete with --complete ") authLoginCmd.Flags().StringVar(&authLoginComplete, "complete", "", "Exchange the given authorization code (paired with a prior --non-interactive call)") authLoginCmd.Flags().StringVar(&authLoginScopes, "scopes", "", "Comma-separated scope suffixes (see 'ghealth schema scopes')") - authLoginCmd.Flags().StringVar(&authLoginScopesPreset, "scopes-preset", "", "Scope preset: readonly | all | comma-separated category names") + authLoginCmd.Flags().StringVar(&authLoginScopesPreset, "scopes-preset", "", "Scope preset: readonly | all (health-data scopes only) | comma-separated category names") authImportCmd.Flags().StringVar(&authImportFile, "file", "", "Read credentials JSON from this file instead of stdin") diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index eb399cd..a754d86 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -197,8 +197,15 @@ func ScopePreset(name string) ([]string, error) { } return out, nil case "all": + // cloud-platform is a webhook-admin scope that the data-plane + // endpoints reject ("Request contains disallowed OAuth scope(s)"), + // so "all" covers only health-data scopes. Webhook credentials need + // their own login with --scopes cloud-platform (see 'ghealth webhooks'). out := make([]string, 0, len(AllScopes)) for _, s := range AllScopes { + if s.Suffix == "cloud-platform" { + continue + } out = append(out, s.Suffix) } return out, nil diff --git a/pkg/auth/auth_test.go b/pkg/auth/auth_test.go new file mode 100644 index 0000000..b1f1a5c --- /dev/null +++ b/pkg/auth/auth_test.go @@ -0,0 +1,92 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "slices" + "testing" +) + +func allScopeSuffixes() []string { + out := make([]string, 0, len(AllScopes)) + for _, s := range AllScopes { + out = append(out, s.Suffix) + } + return out +} + +func TestScopePresetAllExcludesCloudPlatform(t *testing.T) { + scopes, err := ScopePreset("all") + if err != nil { + t.Fatalf("ScopePreset(all) returned error: %v", err) + } + if slices.Contains(scopes, "cloud-platform") { + t.Errorf("ScopePreset(all) includes cloud-platform; data-plane endpoints reject tokens carrying it") + } + // Every non-webhook scope must still be present. + for _, s := range AllScopes { + if s.Suffix == "cloud-platform" { + continue + } + if !slices.Contains(scopes, s.Suffix) { + t.Errorf("ScopePreset(all) is missing health-data scope %q", s.Suffix) + } + } +} + +func TestScopePresetReadonlyExcludesCloudPlatform(t *testing.T) { + scopes, err := ScopePreset("readonly") + if err != nil { + t.Fatalf("ScopePreset(readonly) returned error: %v", err) + } + if slices.Contains(scopes, "cloud-platform") { + t.Errorf("ScopePreset(readonly) includes cloud-platform") + } + if !slices.Contains(scopes, "activity_and_fitness.readonly") { + t.Errorf("ScopePreset(readonly) is missing activity_and_fitness.readonly") + } +} + +func TestScopePresetCategoryExpansionStillWorks(t *testing.T) { + scopes, err := ScopePreset("sleep,activity_and_fitness") + if err != nil { + t.Fatalf("ScopePreset(sleep,activity_and_fitness) returned error: %v", err) + } + want := []string{"sleep.readonly", "activity_and_fitness.readonly"} + for _, w := range want { + if !slices.Contains(scopes, w) { + t.Errorf("category expansion missing %q; got %v", w, scopes) + } + } +} + +func TestScopePresetUnknownNameErrors(t *testing.T) { + if _, err := ScopePreset("no-such-preset"); err == nil { + t.Fatal("expected error for unknown preset name, got nil") + } +} + +func TestScopePresetAllCoversEveryNonWebhookSuffix(t *testing.T) { + // Guard against a future scope being added to AllScopes but silently + // dropped from the "all" expansion. + all, err := ScopePreset("all") + if err != nil { + t.Fatalf("ScopePreset(all) returned error: %v", err) + } + total := len(allScopeSuffixes()) - 1 // minus cloud-platform + if len(all) != total { + t.Errorf("ScopePreset(all) returned %d scopes, want %d", len(all), total) + } +}