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>
This commit is contained in:
takashiyamaguchi 2026-04-21 00:32:03 +09:00 committed by GitHub
parent 2d0533557c
commit c73bb23fec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 373 additions and 2 deletions

View File

@ -3,6 +3,7 @@
## Unreleased
### Added
- Auth: add `auth credentials remove` to delete stored OAuth client credentials and associated refresh tokens. (#473) — thanks @yamagucci.
- Gmail: add `gmail autoreply` to reply once to matching messages, label the thread for dedupe, and optionally archive/mark read. Includes docs and regression coverage for skip/reply flows.
- Gmail: add `gmail messages search --full` to print complete message bodies instead of truncating text output. (#447) — thanks @GodsBoy.
- Drive: allow `drive share --role commenter` for comment-only sharing. (#443) — thanks @pavelzak.

View File

@ -599,6 +599,8 @@ Flag aliases:
```bash
gog auth credentials <path> # Store OAuth client credentials
gog auth credentials list # List stored OAuth client credentials
gog auth credentials remove work # Remove one OAuth client plus its tokens/domain mappings
gog auth credentials remove all # Remove all stored OAuth clients plus their tokens/domain mappings
gog --client work auth credentials <path> # Store named OAuth client credentials
gog auth add <email> # Authorize and store refresh token
gog auth add <email> --services gmail --gmail-scope readonly # Gmail read-only token

View File

@ -80,6 +80,7 @@ Implementation: `internal/ui/ui.go`.
- `gog auth credentials <credentials.json>`
- `gog --client <name> auth credentials <credentials.json>`
- `gog auth credentials list`
- `gog auth credentials remove [<client>|all]`
- Supports Googles downloaded JSON format:
- `installed.client_id/client_secret` or `web.client_id/client_secret`
@ -163,6 +164,7 @@ Flag aliases:
- `gog auth credentials <credentials.json|->`
- `gog auth credentials list`
- `gog auth credentials remove [<client>|all]`
- `gog --client <name> auth credentials <credentials.json|->`
- `gog auth add <email> [--services user|all|gmail,calendar,chat,classroom,drive,docs,slides,contacts,tasks,sheets,people,forms,appscript,ads,groups,keep,admin] [--readonly] [--drive-scope full|readonly|file] [--gmail-scope full|readonly] [--extra-scopes CSV] [--manual] [--remote] [--step 1|2] [--auth-url URL] [--listen-addr HOST[:PORT]] [--redirect-host HOST] [--timeout DURATION] [--force-consent]`
- `gog auth services [--markdown]`

View File

@ -15,8 +15,9 @@ import (
)
type AuthCredentialsCmd struct {
Set AuthCredentialsSetCmd `cmd:"" default:"withargs" help:"Store OAuth client credentials"`
List AuthCredentialsListCmd `cmd:"" name:"list" help:"List stored OAuth client credentials"`
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 {
@ -160,3 +161,202 @@ func (c *AuthCredentialsListCmd) Run(ctx context.Context, _ *RootFlags) error {
}
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
}

View File

@ -2,11 +2,15 @@ package cmd
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"github.com/99designs/keyring"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/secrets"
)
func TestExecute_AuthCredentials_JSON(t *testing.T) {
@ -150,3 +154,148 @@ func TestExecute_AuthCredentialsList_JSON(t *testing.T) {
t.Fatalf("missing expected entries: %#v", seen)
}
}
func TestExecute_AuthCredentialsRemove_RemovesCredentialTokenAndDomain(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config"))
origOpen := openSecretsStore
t.Cleanup(func() { openSecretsStore = origOpen })
store := newMemSecretsStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }
if err := config.WriteClientCredentialsFor("work", config.ClientCredentials{ClientID: "id", ClientSecret: "sec"}); err != nil {
t.Fatalf("WriteClientCredentialsFor: %v", err)
}
if err := store.SetToken("work", "A@B.COM", secrets.Token{RefreshToken: "rt"}); err != nil {
t.Fatalf("SetToken work: %v", err)
}
if err := store.SetToken(config.DefaultClientName, "default@example.com", secrets.Token{RefreshToken: "rt"}); err != nil {
t.Fatalf("SetToken default: %v", err)
}
if err := config.WriteConfig(config.File{ClientDomains: map[string]string{
"example.com": "work",
"other.com": config.DefaultClientName,
}}); err != nil {
t.Fatalf("WriteConfig: %v", err)
}
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--force", "auth", "credentials", "remove", "work"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Removed bool `json:"removed"`
Client string `json:"client"`
TokensRemoved []string `json:"tokens_removed"`
DomainsRemoved []string `json:"domains_removed"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if !parsed.Removed || parsed.Client != "work" {
t.Fatalf("unexpected remove output: %#v", parsed)
}
if len(parsed.TokensRemoved) != 1 || parsed.TokensRemoved[0] != "a@b.com" {
t.Fatalf("unexpected removed tokens: %#v", parsed.TokensRemoved)
}
if len(parsed.DomainsRemoved) != 1 || parsed.DomainsRemoved[0] != "example.com" {
t.Fatalf("unexpected removed domains: %#v", parsed.DomainsRemoved)
}
path, err := config.ClientCredentialsPathFor("work")
if err != nil {
t.Fatalf("ClientCredentialsPathFor: %v", err)
}
if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("expected work credentials removed, stat err=%v", statErr)
}
if _, tokenErr := store.GetToken("work", "a@b.com"); !errors.Is(tokenErr, keyring.ErrKeyNotFound) {
t.Fatalf("expected work token removed, err=%v", tokenErr)
}
if _, defaultTokenErr := store.GetToken(config.DefaultClientName, "default@example.com"); defaultTokenErr != nil {
t.Fatalf("expected default token retained: %v", defaultTokenErr)
}
cfg, err := config.ReadConfig()
if err != nil {
t.Fatalf("ReadConfig: %v", err)
}
if _, ok := cfg.ClientDomains["example.com"]; ok {
t.Fatalf("expected example.com mapping removed: %#v", cfg.ClientDomains)
}
if cfg.ClientDomains["other.com"] != config.DefaultClientName {
t.Fatalf("expected other.com mapping retained: %#v", cfg.ClientDomains)
}
}
func TestExecute_AuthCredentialsRemoveAll(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config"))
origOpen := openSecretsStore
t.Cleanup(func() { openSecretsStore = origOpen })
store := newMemSecretsStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }
for _, client := range []string{config.DefaultClientName, "work"} {
if err := config.WriteClientCredentialsFor(client, config.ClientCredentials{ClientID: "id-" + client, ClientSecret: "sec"}); err != nil {
t.Fatalf("WriteClientCredentialsFor(%s): %v", client, err)
}
if err := store.SetToken(client, client+"@example.com", secrets.Token{RefreshToken: "rt"}); err != nil {
t.Fatalf("SetToken(%s): %v", client, err)
}
}
if err := config.WriteConfig(config.File{ClientDomains: map[string]string{
"default.example": config.DefaultClientName,
"work.example": "work",
}}); err != nil {
t.Fatalf("WriteConfig: %v", err)
}
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--force", "auth", "credentials", "remove", "all"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Removed int `json:"removed"`
Clients []string `json:"clients"`
TokensRemoved []string `json:"tokens_removed"`
DomainsRemoved []string `json:"domains_removed"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if parsed.Removed != 2 || len(parsed.Clients) != 2 || len(parsed.TokensRemoved) != 2 || len(parsed.DomainsRemoved) != 2 {
t.Fatalf("unexpected remove-all output: %#v", parsed)
}
for _, client := range []string{config.DefaultClientName, "work"} {
path, err := config.ClientCredentialsPathFor(client)
if err != nil {
t.Fatalf("ClientCredentialsPathFor(%s): %v", client, err)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected %s credentials removed, stat err=%v", client, err)
}
if _, err := store.GetToken(client, client+"@example.com"); !errors.Is(err, keyring.ErrKeyNotFound) {
t.Fatalf("expected %s token removed, err=%v", client, err)
}
}
cfg, err := config.ReadConfig()
if err != nil {
t.Fatalf("ReadConfig: %v", err)
}
if len(cfg.ClientDomains) != 0 {
t.Fatalf("expected all domain mappings removed: %#v", cfg.ClientDomains)
}
}

View File

@ -114,6 +114,23 @@ func ReadClientCredentialsFor(client string) (ClientCredentials, error) {
return c, nil
}
func DeleteClientCredentialsFor(client string) error {
path, err := ClientCredentialsPathFor(client)
if err != nil {
return fmt.Errorf("resolve credentials path: %w", err)
}
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
return &CredentialsMissingError{Path: path, Cause: err}
}
return fmt.Errorf("delete credentials: %w", err)
}
return nil
}
func ClientCredentialsExists(client string) (bool, error) {
path, err := ClientCredentialsPathFor(client)
if err != nil {