diff --git a/internal/cmd/execute_people_me_test.go b/internal/cmd/execute_people_me_test.go index 38461cc2..3aa7006c 100644 --- a/internal/cmd/execute_people_me_test.go +++ b/internal/cmd/execute_people_me_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + gapi "google.golang.org/api/googleapi" "google.golang.org/api/option" "google.golang.org/api/people/v1" ) @@ -107,3 +108,60 @@ func TestExecute_PeopleMe_Text(t *testing.T) { t.Fatalf("unexpected out=%q", out) } } + +func TestExecute_PeopleMe_FallbackWhenPeopleAPIDisabled(t *testing.T) { + origNew := newPeopleContactsService + origFallback := fallbackPeopleMeProfile + t.Cleanup(func() { + newPeopleContactsService = origNew + fallbackPeopleMeProfile = origFallback + }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(strings.Contains(r.URL.Path, "/people/me") && r.Method == http.MethodGet) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(&gapi.Error{ + Code: 403, + Message: "People API has not been used in project before or it is disabled.", + Errors: []gapi.ErrorItem{ + {Reason: "accessNotConfigured"}, + }, + }) + })) + defer srv.Close() + + svc, err := people.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newPeopleContactsService = func(context.Context, string) (*people.Service, error) { return svc, nil } + fallbackPeopleMeProfile = func(context.Context, string) (*people.Person, error) { + return &people.Person{ + ResourceName: "people/me", + Names: []*people.Name{{DisplayName: "Fallback User"}}, + EmailAddresses: []*people.EmailAddress{ + {Value: "fallback@example.com"}, + }, + }, nil + } + + out := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--account", "a@b.com", "people", "me"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + if !strings.Contains(out, "name\tFallback User") || !strings.Contains(out, "email\tfallback@example.com") { + t.Fatalf("unexpected out=%q", out) + } +} diff --git a/internal/cmd/people.go b/internal/cmd/people.go index 3d7274db..d23524b4 100644 --- a/internal/cmd/people.go +++ b/internal/cmd/people.go @@ -2,10 +2,13 @@ package cmd import ( "context" + "errors" "os" + "strings" "github.com/steipete/gogcli/internal/outfmt" "github.com/steipete/gogcli/internal/ui" + gapi "google.golang.org/api/googleapi" ) type PeopleCmd struct { @@ -33,7 +36,12 @@ func (c *PeopleMeCmd) Run(ctx context.Context, flags *RootFlags) error { PersonFields("names,emailAddresses,photos"). Do() if err != nil { - return err + if isPeopleAccessNotConfigured(err) { + person, err = fallbackPeopleMeProfile(ctx, account) + } + if err != nil { + return wrapPeopleAPIError(err) + } } if outfmt.IsJSON(ctx) { @@ -64,3 +72,19 @@ func (c *PeopleMeCmd) Run(ctx context.Context, flags *RootFlags) error { } return nil } + +func isPeopleAccessNotConfigured(err error) bool { + var apiErr *gapi.Error + if errors.As(err, &apiErr) { + if apiErr.Code == 403 { + for _, item := range apiErr.Errors { + if item.Reason == "accessNotConfigured" { + return true + } + } + } + } + errText := err.Error() + return strings.Contains(errText, "accessNotConfigured") || + strings.Contains(errText, "People API has not been used") +} diff --git a/internal/cmd/people_me_fallback.go b/internal/cmd/people_me_fallback.go new file mode 100644 index 00000000..3e780d05 --- /dev/null +++ b/internal/cmd/people_me_fallback.go @@ -0,0 +1,155 @@ +package cmd + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "google.golang.org/api/people/v1" + + "github.com/steipete/gogcli/internal/authclient" + "github.com/steipete/gogcli/internal/config" + "github.com/steipete/gogcli/internal/secrets" +) + +const googleUserinfoURL = "https://www.googleapis.com/oauth2/v2/userinfo" + +var fallbackPeopleMeProfile = fetchFallbackPeopleMeProfile + +type fallbackProfile struct { + Email string `json:"email"` + Name string `json:"name"` + Picture string `json:"picture"` +} + +func fetchFallbackPeopleMeProfile(ctx context.Context, account string) (*people.Person, error) { + client, err := authclient.ResolveClient(ctx, account) + if err != nil { + return nil, fmt.Errorf("resolve client: %w", err) + } + + creds, err := config.ReadClientCredentialsFor(client) + if err != nil { + return nil, fmt.Errorf("read credentials: %w", err) + } + + store, err := secrets.OpenDefault() + if err != nil { + return nil, fmt.Errorf("open secrets store: %w", err) + } + + tok, err := store.GetToken(client, account) + if err != nil { + return nil, fmt.Errorf("get token for %s: %w", account, err) + } + + cfg := oauth2.Config{ + ClientID: creds.ClientID, + ClientSecret: creds.ClientSecret, + Endpoint: google.Endpoint, + } + ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{Timeout: 15 * time.Second}) + + issued, err := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: tok.RefreshToken}).Token() + if err != nil { + return nil, fmt.Errorf("refresh access token: %w", err) + } + + profile := fallbackProfile{} + if raw, ok := issued.Extra("id_token").(string); ok && strings.TrimSpace(raw) != "" { + if decoded, err := profileFromIDToken(raw); err == nil { + profile = decoded + } + } + + if strings.TrimSpace(issued.AccessToken) != "" { + if remote, err := profileFromUserinfo(ctx, issued.AccessToken); err == nil { + profile = mergeFallbackProfiles(profile, remote) + } + } + + return personFromFallbackProfile(profile, account), nil +} + +func profileFromIDToken(idToken string) (fallbackProfile, error) { + parts := strings.Split(idToken, ".") + if len(parts) < 2 { + return fallbackProfile{}, fmt.Errorf("invalid id_token") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return fallbackProfile{}, fmt.Errorf("decode id_token payload: %w", err) + } + + var profile fallbackProfile + if err := json.Unmarshal(payload, &profile); err != nil { + return fallbackProfile{}, fmt.Errorf("parse id_token payload: %w", err) + } + return profile, nil +} + +func profileFromUserinfo(ctx context.Context, accessToken string) (fallbackProfile, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleUserinfoURL, nil) + if err != nil { + return fallbackProfile{}, fmt.Errorf("create userinfo request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) + if err != nil { + return fallbackProfile{}, fmt.Errorf("fetch userinfo: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fallbackProfile{}, fmt.Errorf("userinfo status: %d", resp.StatusCode) + } + + var profile fallbackProfile + if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil { + return fallbackProfile{}, fmt.Errorf("decode userinfo response: %w", err) + } + return profile, nil +} + +func mergeFallbackProfiles(base fallbackProfile, update fallbackProfile) fallbackProfile { + if strings.TrimSpace(update.Email) != "" { + base.Email = update.Email + } + if strings.TrimSpace(update.Name) != "" { + base.Name = update.Name + } + if strings.TrimSpace(update.Picture) != "" { + base.Picture = update.Picture + } + return base +} + +func personFromFallbackProfile(profile fallbackProfile, account string) *people.Person { + person := &people.Person{ResourceName: peopleMeResource} + + email := strings.TrimSpace(profile.Email) + if email == "" { + email = strings.TrimSpace(account) + } + if email != "" { + person.EmailAddresses = []*people.EmailAddress{{Value: email}} + } + + if name := strings.TrimSpace(profile.Name); name != "" { + person.Names = []*people.Name{{DisplayName: name}} + } + + if picture := strings.TrimSpace(profile.Picture); picture != "" { + person.Photos = []*people.Photo{{Url: picture}} + } + + return person +}