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:
parent
2d0533557c
commit
c73bb23fec
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 Google’s 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]`
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user