diff --git a/CHANGELOG.md b/CHANGELOG.md index a31289b..c902a25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index a5fc31b..6407400 100644 --- a/README.md +++ b/README.md @@ -599,6 +599,8 @@ Flag aliases: ```bash gog auth credentials # 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 # Store named OAuth client credentials gog auth add # Authorize and store refresh token gog auth add --services gmail --gmail-scope readonly # Gmail read-only token diff --git a/docs/spec.md b/docs/spec.md index a8a06ad..8c4b03d 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -80,6 +80,7 @@ Implementation: `internal/ui/ui.go`. - `gog auth credentials ` - `gog --client auth credentials ` - `gog auth credentials list` + - `gog auth credentials remove [|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 ` - `gog auth credentials list` +- `gog auth credentials remove [|all]` - `gog --client auth credentials ` - `gog auth add [--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]` diff --git a/internal/cmd/auth_credentials.go b/internal/cmd/auth_credentials.go index 61d5235..ef29eb6 100644 --- a/internal/cmd/auth_credentials.go +++ b/internal/cmd/auth_credentials.go @@ -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 +} diff --git a/internal/cmd/execute_auth_credentials_test.go b/internal/cmd/execute_auth_credentials_test.go index 5e3be5c..1df6592 100644 --- a/internal/cmd/execute_auth_credentials_test.go +++ b/internal/cmd/execute_auth_credentials_test.go @@ -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) + } +} diff --git a/internal/config/credentials.go b/internal/config/credentials.go index a82db04..aeae277 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -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 {