Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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 <code>")
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")

Expand Down
7 changes: 7 additions & 0 deletions pkg/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions pkg/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}