gogcli/internal/cmd/auth_credentials.go
takashiyamaguchi c73bb23fec
feat(auth): add credentials remove command
Adds `gog auth credentials remove [<client>|all]` with token/domain cleanup, JSON output, dry-run support, docs, and regression tests.

Local verification:
- make fmt
- make lint
- make test
- make build
- make ci

Thanks @yamagucci.

Co-authored-by: takashiyamaguchi <yama0628taka@gmail.com>
2026-04-20 16:32:03 +01:00

363 lines
9.2 KiB
Go

package cmd
import (
"context"
"fmt"
"io"
"os"
"sort"
"strings"
"github.com/steipete/gogcli/internal/authclient"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
type AuthCredentialsCmd struct {
Set AuthCredentialsSetCmd `cmd:"" default:"withargs" help:"Store OAuth client credentials"`
List AuthCredentialsListCmd `cmd:"" name:"list" help:"List stored OAuth client credentials"`
Remove AuthCredentialsRemoveCmd `cmd:"" name:"remove" help:"Remove stored OAuth client credentials"`
}
type AuthCredentialsSetCmd struct {
Path string `arg:"" name:"credentials" help:"Path to credentials.json or '-' for stdin"`
Domains string `name:"domain" help:"Comma-separated domains to map to this client (e.g. example.com)"`
}
func (c *AuthCredentialsSetCmd) Run(ctx context.Context, _ *RootFlags) error {
u := ui.FromContext(ctx)
client, err := normalizeClientForFlag(authclient.ClientOverrideFromContext(ctx))
if err != nil {
return err
}
inPath := c.Path
var b []byte
if inPath == "-" {
b, err = io.ReadAll(os.Stdin)
} else {
inPath, err = config.ExpandPath(inPath)
if err != nil {
return err
}
b, err = os.ReadFile(inPath) //nolint:gosec // user-provided path
}
if err != nil {
return err
}
creds, err := config.ParseGoogleOAuthClientJSON(b)
if err != nil {
return err
}
if err := config.WriteClientCredentialsFor(client, creds); err != nil {
return err
}
outPath, _ := config.ClientCredentialsPathFor(client)
if strings.TrimSpace(c.Domains) != "" {
cfg, err := config.ReadConfig()
if err != nil {
return err
}
for _, domain := range splitCommaList(c.Domains) {
if err := config.SetClientDomain(&cfg, domain, client); err != nil {
return err
}
}
if err := config.WriteConfig(cfg); err != nil {
return err
}
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"saved": true,
"path": outPath,
"client": client,
})
}
u.Out().Printf("path\t%s", outPath)
u.Out().Printf("client\t%s", client)
return nil
}
type AuthCredentialsListCmd struct{}
func (c *AuthCredentialsListCmd) Run(ctx context.Context, _ *RootFlags) error {
u := ui.FromContext(ctx)
cfg, err := config.ReadConfig()
if err != nil {
return err
}
creds, err := config.ListClientCredentials()
if err != nil {
return err
}
domainMap := make(map[string][]string)
for domain, client := range cfg.ClientDomains {
if strings.TrimSpace(client) == "" {
continue
}
normalizedClient, err := config.NormalizeClientNameOrDefault(client)
if err != nil {
continue
}
domainMap[normalizedClient] = append(domainMap[normalizedClient], domain)
}
type entry struct {
Client string `json:"client"`
Path string `json:"path,omitempty"`
Default bool `json:"default"`
Domains []string `json:"domains,omitempty"`
}
entries := make([]entry, 0, len(creds))
seen := make(map[string]struct{})
for _, info := range creds {
domains := domainMap[info.Client]
sort.Strings(domains)
entries = append(entries, entry{
Client: info.Client,
Path: info.Path,
Default: info.Default,
Domains: domains,
})
seen[info.Client] = struct{}{}
}
for client, domains := range domainMap {
if _, ok := seen[client]; ok {
continue
}
sort.Strings(domains)
entries = append(entries, entry{
Client: client,
Domains: domains,
})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Client < entries[j].Client })
if len(entries) == 0 {
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"clients": []entry{}})
}
u.Err().Println("No OAuth client credentials stored")
return nil
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"clients": entries})
}
w, done := tableWriter(ctx)
defer done()
_, _ = fmt.Fprintln(w, "CLIENT\tPATH\tDOMAINS")
for _, e := range entries {
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", e.Client, e.Path, strings.Join(e.Domains, ","))
}
return nil
}
type AuthCredentialsRemoveCmd struct {
Client string `arg:"" optional:"" name:"client" help:"Client name to remove (omit for default, or 'all' to remove every client)"`
}
type authCredentialsRemovalResult struct {
Client string `json:"client"`
TokensRemoved []string `json:"tokens_removed,omitempty"`
DomainsRemoved []string `json:"domains_removed,omitempty"`
}
func (c *AuthCredentialsRemoveCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
// Determine target client(s): explicit arg > --client flag > default.
target := strings.TrimSpace(c.Client)
if target == "" {
t, err := normalizeClientForFlag(authclient.ClientOverrideFromContext(ctx))
if err != nil {
return err
}
target = t
}
if strings.EqualFold(target, "all") {
return c.removeAll(ctx, flags, u)
}
client, err := config.NormalizeClientNameOrDefault(target)
if err != nil {
return err
}
if dryRunErr := dryRunExit(ctx, flags, "auth.credentials.remove", map[string]any{
"client": client,
}); dryRunErr != nil {
return dryRunErr
}
accounts, err := accountsForClient(client)
if err != nil {
return err
}
action := fmt.Sprintf("remove OAuth credentials for client %q", client)
if len(accounts) > 0 {
action += fmt.Sprintf(" and %d associated token(s) (%s)", len(accounts), strings.Join(accounts, ", "))
}
if confirmErr := confirmDestructiveChecked(ctx, flagsWithoutDryRun(flags), action); confirmErr != nil {
return confirmErr
}
if deleteErr := config.DeleteClientCredentialsFor(client); deleteErr != nil {
return deleteErr
}
tokensRemoved, err := removeTokensForClient(client, accounts)
if err != nil {
return err
}
domainsRemoved, err := removeDomainMappings(client)
if err != nil {
return err
}
return writeResult(ctx, u,
kv("removed", true),
kv("client", client),
kv("tokens_removed", tokensRemoved),
kv("domains_removed", domainsRemoved),
)
}
func (c *AuthCredentialsRemoveCmd) removeAll(ctx context.Context, flags *RootFlags, u *ui.UI) error {
creds, err := config.ListClientCredentials()
if err != nil {
return err
}
if len(creds) == 0 {
return writeResult(ctx, u, kv("removed", 0))
}
names := make([]string, 0, len(creds))
planned := make([]authCredentialsRemovalResult, 0, len(creds))
for _, info := range creds {
names = append(names, info.Client)
accounts, accountsErr := accountsForClient(info.Client)
if accountsErr != nil {
return accountsErr
}
planned = append(planned, authCredentialsRemovalResult{
Client: info.Client,
TokensRemoved: accounts,
})
}
if dryRunErr := dryRunExit(ctx, flags, "auth.credentials.remove_all", planned); dryRunErr != nil {
return dryRunErr
}
if err := confirmDestructiveChecked(ctx, flagsWithoutDryRun(flags), fmt.Sprintf("remove all OAuth credentials (%s)", strings.Join(names, ", "))); err != nil {
return err
}
var allTokens []string
var allDomains []string
for _, item := range planned {
if err := config.DeleteClientCredentialsFor(item.Client); err != nil {
return err
}
tokens, err := removeTokensForClient(item.Client, item.TokensRemoved)
if err != nil {
return err
}
allTokens = append(allTokens, tokens...)
domains, err := removeDomainMappings(item.Client)
if err != nil {
return err
}
allDomains = append(allDomains, domains...)
}
sort.Strings(allTokens)
sort.Strings(allDomains)
return writeResult(ctx, u,
kv("removed", len(creds)),
kv("clients", names),
kv("tokens_removed", allTokens),
kv("domains_removed", allDomains),
)
}
// accountsForClient returns emails that have tokens stored under the given client.
func accountsForClient(client string) ([]string, error) {
store, err := openSecretsStore()
if err != nil {
return nil, err
}
tokens, err := store.ListTokens()
if err != nil {
return nil, err
}
var emails []string
for _, tok := range tokens {
tokClient, err := config.NormalizeClientNameOrDefault(tok.Client)
if err != nil {
continue
}
if tokClient == client {
emails = append(emails, tok.Email)
}
}
sort.Strings(emails)
return emails, nil
}
// removeTokensForClient deletes tokens for the given accounts under the specified client.
func removeTokensForClient(client string, emails []string) ([]string, error) {
if len(emails) == 0 {
return nil, nil
}
store, err := openSecretsStore()
if err != nil {
return nil, err
}
var removed []string
for _, email := range emails {
if err := store.DeleteToken(client, email); err != nil {
return removed, fmt.Errorf("delete token for %s: %w", email, err)
}
removed = append(removed, email)
}
sort.Strings(removed)
return removed, nil
}
// removeDomainMappings deletes config domain entries that point to the given client.
func removeDomainMappings(client string) ([]string, error) {
cfg, err := config.ReadConfig()
if err != nil {
return nil, err
}
var removed []string
for domain, mapped := range cfg.ClientDomains {
normalized, nerr := config.NormalizeClientNameOrDefault(mapped)
if nerr != nil {
continue
}
if normalized == client {
removed = append(removed, domain)
delete(cfg.ClientDomains, domain)
}
}
if len(removed) > 0 {
sort.Strings(removed)
if err := config.WriteConfig(cfg); err != nil {
return nil, err
}
}
return removed, nil
}