From d02ae505e78d7e650dd46bbb3ad2aa0e1f028593 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Mon, 6 Apr 2026 10:21:00 +0200 Subject: [PATCH 1/5] Add 'auth revoke' subcommand for token revocation This adds a new `auth revoke` subcommand to revoke tokens, which was available in the legacy `auth-token` command but was not ported to the new `auth` command. The subcommand is intentionally distinct from `auth delete` to make it clear that this revokes tokens instead of just removing them from the local store. A token (not just an ID) can be passed directly, which likely matches the most common use case for revocation (deleting leaked credentials). Previously, it was possible but a little bit counterintuitive. The command supports five identification modes: - `--current` (the authenticating token) - `--name` (stored token) - `--token-value` (raw string or stdin) - `--id` (token ID), and - `--file` (bulk IDs). --- pkg/commands/auth/revoke.go | 452 +++++++++++++++++++++++++++++++ pkg/commands/auth/revoke_test.go | 451 ++++++++++++++++++++++++++++++ pkg/commands/commands.go | 3 +- 3 files changed, 905 insertions(+), 1 deletion(-) create mode 100644 pkg/commands/auth/revoke.go create mode 100644 pkg/commands/auth/revoke_test.go diff --git a/pkg/commands/auth/revoke.go b/pkg/commands/auth/revoke.go new file mode 100644 index 000000000..0c317816e --- /dev/null +++ b/pkg/commands/auth/revoke.go @@ -0,0 +1,452 @@ +package auth + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/fastly/go-fastly/v13/fastly" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/config" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +var errCancelled = errors.New("cancelled") + +// RevokeCommand revokes a token via the API and removes it from local config. +type RevokeCommand struct { + argparser.Base + current bool + name string + tokenValue string + id string + file string +} + +func NewRevokeCommand(parent argparser.Registerer, g *global.Data) *RevokeCommand { + var c RevokeCommand + c.Globals = g + c.CmdClause = parent.Command("revoke", "Revoke a token via the API and remove it from local config") + c.CmdClause.Flag("current", "Revoke the token used to authenticate the current request").BoolVar(&c.current) + c.CmdClause.Flag("name", "Name of a locally stored token to revoke").StringVar(&c.name) + c.CmdClause.Flag("token-value", "Raw API token string to revoke (pass '-' to read from stdin)").StringVar(&c.tokenValue) + c.CmdClause.Flag("id", "Alphanumeric string identifying a token to revoke").StringVar(&c.id) + c.CmdClause.Flag("file", "Path to a newline-delimited file of token IDs to revoke in bulk").StringVar(&c.file) + return &c +} + +func (c *RevokeCommand) Exec(in io.Reader, out io.Writer) error { + if err := c.validateFlags(); err != nil { + return err + } + + switch { + case c.current: + return c.revokeCurrent(in, out) + case c.name != "": + return c.revokeByName(in, out) + case c.tokenValue != "": + return c.revokeByTokenValue(in, out) + case c.id != "": + return c.revokeByID(out) + case c.file != "": + return c.revokeByFile(out) + } + + return nil +} + +func (c *RevokeCommand) validateFlags() error { + count := 0 + if c.current { + count++ + } + if c.name != "" { + count++ + } + if c.tokenValue != "" { + count++ + } + if c.id != "" { + count++ + } + if c.file != "" { + count++ + } + if count == 0 { + return fmt.Errorf("error parsing arguments: must provide one of --current, --name, --token-value, --id, or --file") + } + if count > 1 { + return fmt.Errorf("error parsing arguments: only one of --current, --name, --token-value, --id, or --file may be used") + } + return nil +} + +func (c *RevokeCommand) revokeCurrent(in io.Reader, out io.Writer) error { + tok, _ := c.Globals.Token() + + names := findLocalTokensByValue(&c.Globals.Config, tok) + if err := c.confirmDefaultRevocation(names, in, out); err != nil { + if errors.Is(err, errCancelled) { + return nil + } + return err + } + + client, err := c.authClient() + if err != nil { + return err + } + + err = client.DeleteTokenSelf(context.TODO()) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Revoked current token") + return c.removeLocalTokens(names, out) +} + +func (c *RevokeCommand) revokeByName(in io.Reader, out io.Writer) error { + entry := c.Globals.Config.GetAuthToken(c.name) + if entry == nil { + return fmt.Errorf("token %q not found", c.name) + } + + if err := c.confirmDefaultRevocation([]string{c.name}, in, out); err != nil { + if errors.Is(err, errCancelled) { + return nil + } + return err + } + + client, err := c.buildClient(entry.Token) + if err != nil { + return err + } + + err = client.DeleteTokenSelf(context.TODO()) + if err != nil { + if isSelfAlreadyGone(err) { + text.Warning(out, "Token was already revoked remotely\n") + } else { + c.Globals.ErrLog.Add(err) + return err + } + } else { + text.Success(out, "Revoked token %q", c.name) + } + + names := []string{c.name} + for _, n := range findLocalTokensByValue(&c.Globals.Config, entry.Token) { + if n != c.name { + names = append(names, n) + } + } + return c.removeLocalTokens(names, out) +} + +func (c *RevokeCommand) revokeByTokenValue(in io.Reader, out io.Writer) error { + raw, err := readTokenValue(c.tokenValue, in) + if err != nil { + return err + } + + names := findLocalTokensByValue(&c.Globals.Config, raw) + if err := c.confirmDefaultRevocation(names, in, out); err != nil { + if errors.Is(err, errCancelled) { + return nil + } + return err + } + + client, err := c.buildClient(raw) + if err != nil { + return err + } + + err = client.DeleteTokenSelf(context.TODO()) + if err != nil { + if isSelfAlreadyGone(err) { + text.Warning(out, "Token was already revoked remotely\n") + } else { + c.Globals.ErrLog.Add(err) + return err + } + } else { + text.Success(out, "Revoked token") + } + + if len(names) == 0 { + text.Info(out, "No matching local token entry found\n") + return nil + } + return c.removeLocalTokens(names, out) +} + +func (c *RevokeCommand) revokeByID(out io.Writer) error { + client, err := c.authClient() + if err != nil { + return err + } + + err = client.DeleteToken(context.TODO(), &fastly.DeleteTokenInput{ + TokenID: c.id, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Revoked token '%s'", c.id) + names := findLocalTokensByID(&c.Globals.Config, c.id) + if len(names) == 0 { + text.Info(out, "No local token entry with matching API token ID found; local cleanup skipped\n") + return nil + } + return c.removeLocalTokens(names, out) +} + +func (c *RevokeCommand) revokeByFile(out io.Writer) error { + ids, err := readTokenIDFile(c.file) + if err != nil { + return err + } + + client, err := c.authClient() + if err != nil { + return err + } + + tokens := make([]*fastly.BatchToken, len(ids)) + for i, id := range ids { + tokens[i] = &fastly.BatchToken{ID: id} + } + + err = client.BatchDeleteTokens(context.TODO(), &fastly.BatchDeleteTokensInput{ + Tokens: tokens, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Revoked %d token(s)", len(ids)) + if c.Globals.Verbose() { + tbl := text.NewTable(out) + tbl.AddHeader("TOKEN ID") + for _, id := range ids { + tbl.AddLine(id) + } + tbl.Print() + } + + var names []string + for _, id := range ids { + names = append(names, findLocalTokensByID(&c.Globals.Config, id)...) + } + if len(names) == 0 { + text.Info(out, "No local token entries with matching API token IDs found; local cleanup skipped\n") + return nil + } + return c.removeLocalTokens(names, out) +} + +func (c *RevokeCommand) authClient() (api.Interface, error) { + tok, _ := c.Globals.Token() + if tok == "" { + return nil, fsterr.RemediationError{ + Inner: fmt.Errorf("no token available for authentication"), + Remediation: fsterr.AuthRemediation(), + } + } + return c.buildClient(tok) +} + +func (c *RevokeCommand) buildClient(token string) (api.Interface, error) { + endpoint, _ := c.Globals.APIEndpoint() + client, err := c.Globals.APIClientFactory(token, endpoint, c.Globals.Flags.Debug) + if err != nil { + return nil, fsterr.RemediationError{ + Inner: fmt.Errorf("error creating API client: %w", err), + Remediation: "Check your network connection and API endpoint configuration.", + } + } + return client, nil +} + +func (c *RevokeCommand) confirmDefaultRevocation(names []string, in io.Reader, out io.Writer) error { + def := c.Globals.Config.Auth.Default + if def == "" { + return nil + } + + isDefault := false + for _, n := range names { + if n == def { + isDefault = true + break + } + } + if !isDefault { + return nil + } + + if c.Globals.Flags.AutoYes || c.Globals.Flags.NonInteractive { + return nil + } + + text.Warning(out, "%q is your current default token. Revoking it will invalidate it remotely and remove it from local config.", def) + cont, err := text.AskYesNo(out, "Are you sure? [y/N]: ", in) + if err != nil { + return err + } + if !cont { + return errCancelled + } + return nil +} + +func isSelfAlreadyGone(err error) bool { + var httpErr *fastly.HTTPError + if errors.As(err, &httpErr) { + return httpErr.StatusCode == http.StatusUnauthorized || httpErr.StatusCode == http.StatusNotFound + } + return false +} + +func readTokenValue(flag string, in io.Reader) (string, error) { + if flag == "-" { + const maxTokenSize = 4096 + b, err := io.ReadAll(io.LimitReader(in, maxTokenSize+1)) + if err != nil { + return "", fsterr.RemediationError{ + Inner: fmt.Errorf("failed to read token from stdin: %w", err), + Remediation: "Pipe a token value, e.g.: echo $TOKEN | fastly auth revoke --token-value=-", + } + } + if len(b) > maxTokenSize { + return "", fsterr.RemediationError{ + Inner: fmt.Errorf("stdin input exceeds %d bytes", maxTokenSize), + Remediation: "Pipe a single token value, not a file. Example: echo $TOKEN | fastly auth revoke --token-value=-", + } + } + val := strings.TrimSpace(string(b)) + if val == "" { + return "", fsterr.RemediationError{ + Inner: fmt.Errorf("no token provided on stdin"), + Remediation: "Pipe a token value, e.g.: echo $TOKEN | fastly auth revoke --token-value=-", + } + } + return val, nil + } + + return flag, nil +} + +func readTokenIDFile(path string) ([]string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return nil, fsterr.RemediationError{ + Inner: fmt.Errorf("invalid file path %q: %w", path, err), + Remediation: "Check the file path and try again.", + } + } + + f, err := os.Open(abs) // #nosec + if err != nil { + return nil, fsterr.RemediationError{ + Inner: fmt.Errorf("failed to open %q: %w", abs, err), + Remediation: "Check the file path and permissions, then try again.", + } + } + defer f.Close() // #nosec + + var ids []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" { + ids = append(ids, line) + } + } + if err := scanner.Err(); err != nil { + return nil, fsterr.RemediationError{ + Inner: fmt.Errorf("error reading %q: %w", abs, err), + Remediation: "Check the file for encoding issues or try recreating it.", + } + } + if len(ids) == 0 { + return nil, fsterr.RemediationError{ + Inner: fmt.Errorf("file %q contains no token IDs", abs), + Remediation: "The file should contain one token ID per line.", + } + } + return ids, nil +} + +func findLocalTokensByValue(cfg *config.File, raw string) []string { + var names []string + for name, entry := range cfg.Auth.Tokens { + if entry.Token == raw { + names = append(names, name) + } + } + return names +} + +func findLocalTokensByID(cfg *config.File, id string) []string { + var names []string + for name, entry := range cfg.Auth.Tokens { + if entry.APITokenID == id { + names = append(names, name) + } + } + return names +} + +func (c *RevokeCommand) removeLocalTokens(names []string, out io.Writer) error { + if len(names) == 0 { + return nil + } + + originalDefault := c.Globals.Config.Auth.Default + removedDefault := false + for _, name := range names { + if name == originalDefault { + removedDefault = true + } + c.Globals.Config.DeleteAuthToken(name) + } + + if err := c.Globals.Config.Write(c.Globals.ConfigPath); err != nil { + return fsterr.RemediationError{ + Inner: fmt.Errorf("token(s) revoked remotely but failed to update local config: %w", err), + Remediation: fmt.Sprintf("Check file permissions on %s. The local config may be stale; use 'fastly auth delete' to clean up manually.", c.Globals.ConfigPath), + } + } + + for _, name := range names { + text.Info(out, "Removed local token entry %q\n", name) + } + + if removedDefault { + if c.Globals.Config.Auth.Default != "" { + text.Info(out, "Default token reassigned to %q\n", c.Globals.Config.Auth.Default) + } else { + text.Warning(out, "No default token configured; use 'fastly auth use ' to set one\n") + } + } + return nil +} diff --git a/pkg/commands/auth/revoke_test.go b/pkg/commands/auth/revoke_test.go new file mode 100644 index 000000000..68ad96c8d --- /dev/null +++ b/pkg/commands/auth/revoke_test.go @@ -0,0 +1,451 @@ +package auth_test + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/fastly/go-fastly/v13/fastly" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/threadsafe" +) + +func TestAuthRevoke(t *testing.T) { + deleteTokenSelfOK := func(_ context.Context) error { return nil } + deleteTokenOK := func(_ context.Context, _ *fastly.DeleteTokenInput) error { return nil } + batchDeleteTokensOK := func(_ context.Context, _ *fastly.BatchDeleteTokensInput) error { return nil } + + deleteTokenSelf401 := func(_ context.Context) error { + return &fastly.HTTPError{StatusCode: http.StatusUnauthorized} + } + deleteTokenSelf500 := func(_ context.Context) error { + return &fastly.HTTPError{StatusCode: http.StatusInternalServerError} + } + + twoTokenConfig := &config.File{ + Auth: config.Auth{ + Default: "primary", + Tokens: config.AuthTokens{ + "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-primary", APITokenID: "id-primary"}, + "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-secondary", APITokenID: "id-secondary"}, + }, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "no flags provided", + Args: "revoke", + WantError: "must provide one of", + }, + { + Name: "multiple flags provided", + Args: "revoke --current --name foo", + WantError: "only one of", + }, + + // --current + { + Name: "revoke current token", + Args: "revoke --current --token tok-stored", + API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, + ConfigFile: &config.File{ + Auth: config.Auth{ + Default: "mytoken", + Tokens: config.AuthTokens{ + "mytoken": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-stored"}, + }, + }, + }, + Stdin: []string{"y"}, + WantOutputs: []string{"Revoked current token", `Removed local token entry "mytoken"`}, + }, + { + Name: "revoke current default declined is clean exit", + Args: "revoke --current --token tok-stored", + API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, + ConfigFile: &config.File{ + Auth: config.Auth{ + Default: "mytoken", + Tokens: config.AuthTokens{ + "mytoken": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-stored"}, + }, + }, + }, + Stdin: []string{"n"}, + WantOutput: "current default token", + DontWantOutput: "Revoked", + Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { + t.Helper() + if opts.Config.GetAuthToken("mytoken") == nil { + t.Error("expected token to still exist after decline") + } + }, + }, + { + Name: "revoke current skips prompt with --auto-yes", + Args: "revoke --current --token 123 -y", + API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, + DontWantOutput: "Are you sure", + WantOutput: "Revoked current token", + }, + { + Name: "revoke current with --token flag (unstored token)", + Args: "revoke --current --token raw-ephemeral-token", + API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, + WantOutputs: []string{"Revoked current token"}, + DontWantOutput: "Removed local", + }, + + // --name + { + Name: "revoke by name success", + Args: "revoke --name secondary", + API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, + ConfigFile: &config.File{ + Auth: config.Auth{ + Default: "primary", + Tokens: config.AuthTokens{ + "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-primary"}, + "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-secondary"}, + }, + }, + }, + WantOutputs: []string{`Revoked token "secondary"`, `Removed local token entry "secondary"`}, + Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { + t.Helper() + if opts.Config.GetAuthToken("secondary") != nil { + t.Error("expected secondary token to be removed") + } + if opts.Config.GetAuthToken("primary") == nil { + t.Error("expected primary token to still exist") + } + }, + }, + { + Name: "revoke by name not found", + Args: "revoke --name ghost", + WantError: `token "ghost" not found`, + }, + { + Name: "revoke by name remote 401 still cleans up locally", + Args: "revoke --name secondary", + API: &mock.API{DeleteTokenSelfFn: deleteTokenSelf401}, + ConfigFile: &config.File{ + Auth: config.Auth{ + Default: "primary", + Tokens: config.AuthTokens{ + "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-primary"}, + "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-secondary"}, + }, + }, + }, + WantOutputs: []string{"already revoked", `Removed local token entry "secondary"`}, + Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { + t.Helper() + if opts.Config.GetAuthToken("secondary") != nil { + t.Error("expected secondary token to be removed after 401") + } + }, + }, + { + Name: "revoke by name remote 5xx does not clean up locally", + Args: "revoke --name secondary", + API: &mock.API{DeleteTokenSelfFn: deleteTokenSelf500}, + ConfigFile: &config.File{ + Auth: config.Auth{ + Default: "primary", + Tokens: config.AuthTokens{ + "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-primary"}, + "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-secondary"}, + }, + }, + }, + WantError: "500", + DontWantOutput: "Removed", + Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { + t.Helper() + if opts.Config.GetAuthToken("secondary") == nil { + t.Error("expected secondary token to still exist after 5xx") + } + }, + }, + { + Name: "revoke by name default token reassigns", + Args: "revoke --name primary -y", + API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, + ConfigFile: &config.File{ + Auth: config.Auth{ + Default: "primary", + Tokens: config.AuthTokens{ + "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-primary"}, + "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-secondary"}, + }, + }, + }, + WantOutputs: []string{`Removed local token entry "primary"`, "Default token reassigned"}, + Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { + t.Helper() + if opts.Config.Auth.Default == "primary" { + t.Error("expected default to no longer be primary") + } + if opts.Config.Auth.Default == "" { + t.Error("expected default to be reassigned") + } + }, + }, + + // --token-value + { + Name: "revoke by token value success with local match", + Args: "revoke --token-value tok-secondary", + API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, + ConfigFile: &config.File{ + Auth: config.Auth{ + Default: "primary", + Tokens: config.AuthTokens{ + "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-primary"}, + "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-secondary"}, + }, + }, + }, + WantOutputs: []string{"Revoked token", `Removed local token entry "secondary"`}, + }, + { + Name: "revoke by token value no local match", + Args: "revoke --token-value tok-unknown", + API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, + WantOutputs: []string{"Revoked token", "No matching local token entry found"}, + }, + { + Name: "revoke by token value from stdin", + Args: "revoke --token-value=-", + API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, + ConfigFile: &config.File{ + Auth: config.Auth{ + Default: "other", + Tokens: config.AuthTokens{ + "other": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "other-tok"}, + "target": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-from-stdin"}, + }, + }, + }, + Stdin: []string{"tok-from-stdin"}, + WantOutputs: []string{"Revoked token", `Removed local token entry "target"`}, + }, + { + Name: "revoke by token value rejects oversized stdin", + Args: "revoke --token-value=-", + Stdin: []string{strings.Repeat("x", 5000)}, + WantError: "exceeds 4096 bytes", + WantRemediation: "single token value", + }, + { + Name: "revoke by token value removes duplicate local entries", + Args: "revoke --token-value shared-tok", + API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, + ConfigFile: &config.File{ + Auth: config.Auth{ + Default: "alias1", + Tokens: config.AuthTokens{ + "alias1": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "shared-tok"}, + "alias2": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "shared-tok"}, + "other": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "other-tok"}, + }, + }, + }, + Stdin: []string{"y"}, + Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { + t.Helper() + if opts.Config.GetAuthToken("alias1") != nil { + t.Error("expected alias1 to be removed") + } + if opts.Config.GetAuthToken("alias2") != nil { + t.Error("expected alias2 to be removed") + } + if opts.Config.GetAuthToken("other") == nil { + t.Error("expected other token to still exist") + } + }, + }, + + { + Name: "revoke by token value confirms when revoking default", + Args: "revoke --token-value tok-default", + API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, + ConfigFile: &config.File{ + Auth: config.Auth{ + Default: "mydefault", + Tokens: config.AuthTokens{ + "mydefault": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-default"}, + }, + }, + }, + Stdin: []string{"n"}, + WantOutput: "current default token", + DontWantOutput: "Revoked", + }, + + // --id + { + Name: "revoke by ID with local match", + Args: "revoke --id id-secondary --token 123", + API: &mock.API{DeleteTokenFn: deleteTokenOK}, + ConfigFile: func() *config.File { + c := *twoTokenConfig + c.Auth.Tokens = make(config.AuthTokens) + for k, v := range twoTokenConfig.Auth.Tokens { + cp := *v + c.Auth.Tokens[k] = &cp + } + return &c + }(), + WantOutputs: []string{"Revoked token 'id-secondary'", `Removed local token entry "secondary"`}, + }, + { + Name: "revoke by ID no local match warns", + Args: "revoke --id id-unknown --token 123", + API: &mock.API{DeleteTokenFn: deleteTokenOK}, + ConfigFile: func() *config.File { + c := *twoTokenConfig + c.Auth.Tokens = make(config.AuthTokens) + for k, v := range twoTokenConfig.Auth.Tokens { + cp := *v + c.Auth.Tokens[k] = &cp + } + return &c + }(), + WantOutputs: []string{"Revoked token 'id-unknown'", "local cleanup skipped"}, + }, + { + Name: "revoke by ID API 401 returns error without local cleanup", + Args: "revoke --id some-id --token 123", + API: &mock.API{ + DeleteTokenFn: func(_ context.Context, _ *fastly.DeleteTokenInput) error { + return &fastly.HTTPError{StatusCode: http.StatusUnauthorized} + }, + }, + ConfigFile: &config.File{ + Auth: config.Auth{ + Default: "stored", + Tokens: config.AuthTokens{ + "stored": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok", APITokenID: "some-id"}, + }, + }, + }, + WantError: "401", + DontWantOutput: "Removed", + Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { + t.Helper() + if opts.Config.GetAuthToken("stored") == nil { + t.Error("expected token to still exist after 401 on --id path") + } + }, + }, + { + Name: "revoke by ID legacy token without APITokenID", + Args: "revoke --id id-legacy --token 123", + API: &mock.API{DeleteTokenFn: deleteTokenOK}, + ConfigFile: &config.File{ + Auth: config.Auth{ + Default: "legacy", + Tokens: config.AuthTokens{ + "legacy": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-legacy"}, + }, + }, + }, + WantOutputs: []string{"Revoked token 'id-legacy'", "local cleanup skipped"}, + Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { + t.Helper() + if opts.Config.GetAuthToken("legacy") == nil { + t.Error("expected legacy token to still exist (no APITokenID)") + } + }, + }, + + // --file + { + Name: "revoke by file success", + Args: fmt.Sprintf("revoke --file %s --token 123", writeTokenIDFile(t, "id-1\nid-2\n")), + API: &mock.API{BatchDeleteTokensFn: batchDeleteTokensOK}, + ConfigFile: &config.File{ + Auth: config.Auth{ + Default: "tok1", + Tokens: config.AuthTokens{ + "tok1": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "t1", APITokenID: "id-1"}, + "tok2": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "t2", APITokenID: "id-2"}, + "other": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "t3", APITokenID: "id-other"}, + }, + }, + }, + WantOutputs: []string{"Revoked 2 token(s)", "Removed local token entry"}, + Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { + t.Helper() + if opts.Config.GetAuthToken("tok1") != nil { + t.Error("expected tok1 to be removed") + } + if opts.Config.GetAuthToken("tok2") != nil { + t.Error("expected tok2 to be removed") + } + if opts.Config.GetAuthToken("other") == nil { + t.Error("expected other to still exist") + } + }, + }, + { + Name: "revoke by file unreadable", + Args: "revoke --file /nonexistent/path/tokens.txt --token 123", + WantError: "failed to open", + WantRemediation: "file path and permissions", + }, + { + Name: "revoke by file empty", + Args: fmt.Sprintf("revoke --file %s --token 123", writeTokenIDFile(t, "\n\n")), + WantError: "contains no token IDs", + WantRemediation: "one token ID per line", + }, + + // API client factory failure + { + Name: "API client factory failure on --name", + Args: "revoke --name secondary", + Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { + opts.APIClientFactory = func(_, _ string, _ bool) (api.Interface, error) { + return nil, fmt.Errorf("connection refused") + } + }, + ConfigFile: &config.File{ + Auth: config.Auth{ + Default: "primary", + Tokens: config.AuthTokens{ + "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-primary"}, + "secondary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-secondary"}, + }, + }, + }, + WantError: "connection refused", + WantRemediation: "network connection", + }, + } + + testutil.RunCLIScenarios(t, []string{"auth"}, scenarios) +} + +func writeTokenIDFile(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "token-ids.txt") + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return p +} diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index 4d3cc16ff..c83ba22d9 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -201,9 +201,10 @@ func Define( // nolint:revive // function-length authList := authcmd.NewListCommand(authCmdRoot.CmdClause, data) authShow := authcmd.NewShowCommand(authCmdRoot.CmdClause, data) authUse := authcmd.NewUseCommand(authCmdRoot.CmdClause, data) + authRevoke := authcmd.NewRevokeCommand(authCmdRoot.CmdClause, data) authCommands = []argparser.Command{ authCmdRoot, authLogin, authAdd, authDelete, - authList, authShow, authUse, + authList, authShow, authUse, authRevoke, } authtokenCmdRoot := authtoken.NewRootCommand(app, data) From 7cbea8ed874ba372b6e3de49ff2739bccd1de13a Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Tue, 7 Apr 2026 09:04:54 +0200 Subject: [PATCH 2/5] Update CHANGELOG.md for the new auth revoke subcommand --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6179aebf..25dbdd044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Enhancements: +- feat(auth): add `auth revoke` subcommand for revoking API tokens via `--current`, `--name`, `--token-value`, `--id`, or `--file` (bulk) + ### Dependencies: ## [v14.2.0](https://github.com/fastly/cli/releases/tag/v14.2.0) (2026-03-24) From fbda0f2827fe4a4b7e039136926ce7d7c31dd0d9 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Tue, 7 Apr 2026 16:45:17 +0200 Subject: [PATCH 3/5] Add link to the PR --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 654446ae5..54db18117 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ### Enhancements: -- feat(auth): add `auth revoke` subcommand for revoking API tokens via `--current`, `--name`, `--token-value`, `--id`, or `--file` (bulk) +- feat(auth): add `auth revoke` subcommand for revoking API tokens via `--current`, `--name`, `--token-value`, `--id`, or `--file` (bulk) [#1717](https://github.com/fastly/cli/pull/1717) ### Dependencies: - build(deps): `github.com/andybalholm/brotli` from 1.2.0 to 1.2.1 ([#1716](https://github.com/fastly/cli/pull/1716)) From eb200af734b0dcd7b50e81b4d194f6461962092d Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Wed, 8 Apr 2026 17:03:12 +0200 Subject: [PATCH 4/5] Add godoc comment to errCancelled in auth revoke --- pkg/commands/auth/revoke.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/commands/auth/revoke.go b/pkg/commands/auth/revoke.go index 0c317816e..e35bb77ee 100644 --- a/pkg/commands/auth/revoke.go +++ b/pkg/commands/auth/revoke.go @@ -21,6 +21,8 @@ import ( "github.com/fastly/cli/pkg/text" ) +// errCancelled is returned when a user declines a confirmation prompt. +// It signals intentional cancellation, not a failure condition. var errCancelled = errors.New("cancelled") // RevokeCommand revokes a token via the API and removes it from local config. From f2015ecf12be26f77c478a5a909a09cdbf115f99 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Wed, 8 Apr 2026 17:06:19 +0200 Subject: [PATCH 5/5] revokeByID now handles 404 like other paths --- pkg/commands/auth/revoke.go | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/pkg/commands/auth/revoke.go b/pkg/commands/auth/revoke.go index e35bb77ee..468a982f3 100644 --- a/pkg/commands/auth/revoke.go +++ b/pkg/commands/auth/revoke.go @@ -207,11 +207,15 @@ func (c *RevokeCommand) revokeByID(out io.Writer) error { TokenID: c.id, }) if err != nil { - c.Globals.ErrLog.Add(err) - return err + if isAlreadyGone(err) { + text.Warning(out, "Token was already revoked remotely\n") + } else { + c.Globals.ErrLog.Add(err) + return err + } + } else { + text.Success(out, "Revoked token '%s'", c.id) } - - text.Success(out, "Revoked token '%s'", c.id) names := findLocalTokensByID(&c.Globals.Config, c.id) if len(names) == 0 { text.Info(out, "No local token entry with matching API token ID found; local cleanup skipped\n") @@ -320,12 +324,14 @@ func (c *RevokeCommand) confirmDefaultRevocation(names []string, in io.Reader, o return nil } +func isAlreadyGone(err error) bool { + var httpErr *fastly.HTTPError + return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound +} + func isSelfAlreadyGone(err error) bool { var httpErr *fastly.HTTPError - if errors.As(err, &httpErr) { - return httpErr.StatusCode == http.StatusUnauthorized || httpErr.StatusCode == http.StatusNotFound - } - return false + return isAlreadyGone(err) || (errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusUnauthorized) } func readTokenValue(flag string, in io.Reader) (string, error) {