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
58 changes: 58 additions & 0 deletions internal/cmd/execute_people_me_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"testing"

gapi "google.golang.org/api/googleapi"
"google.golang.org/api/option"
"google.golang.org/api/people/v1"
)
Expand Down Expand Up @@ -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)
}
}
26 changes: 25 additions & 1 deletion internal/cmd/people.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
}
155 changes: 155 additions & 0 deletions internal/cmd/people_me_fallback.go
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +32 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the active auth mode in the People API fallback

If people/me hits accessNotConfigured while the caller is using a supported non-keyring auth path (--access-token, ADC, or service-account impersonation), this helper throws those credentials away and starts a fresh keyring lookup from account. newPeopleContactsService explicitly prefers those context credentials in tokenSourceForAvailableAccountAuth, so gog whoami --access-token ... --account bob@example.com can now report Bob's stored OAuth profile instead of the identity behind the supplied token, or fail with unrelated resolve client/get token errors.

Useful? React with 👍 / 👎.

}

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
}