From 87e5e71c39399cc3042232d2eccbafd8e82e2642 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Mon, 16 Mar 2026 13:40:51 +0100 Subject: [PATCH] Add auth token subcommand to output active API token This is meant to be used for shell substitution. The command refuses to output the token when stdout is a terminal. When piped or captured, it prints the raw token with no trailing newline. --- CHANGELOG.md | 1 + go.mod | 1 + go.sum | 2 + pkg/commands/auth/token.go | 46 +++++++++++++++ pkg/commands/auth/token_test.go | 71 ++++++++++++++++++++++++ pkg/commands/auth/token_tty_unix_test.go | 38 +++++++++++++ pkg/commands/commands.go | 3 +- 7 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 pkg/commands/auth/token.go create mode 100644 pkg/commands/auth/token_test.go create mode 100644 pkg/commands/auth/token_tty_unix_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f33df03e..fb6370c1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - fix(stats): `stats historical` now returns write errors instead of silently swallowing them. [#1678](https://github.com/fastly/cli/pull/1678) ### Enhancements: +- feat(auth): add `auth token` subcommand to output the active API token for use in shell substitutions (e.g. `$(fastly auth token)`). Refuses to print to a terminal to prevent accidental exposure. - feat(stats): add `--field` flag to `stats historical` to filter to a single stats field. [#1678](https://github.com/fastly/cli/pull/1678) - feat(stats): add `stats aggregate` subcommand for cross-service aggregated stats. [#1678](https://github.com/fastly/cli/pull/1678) - feat(stats): add `stats usage` subcommand for bandwidth/request usage, with `--by-service` breakdown. [#1678](https://github.com/fastly/cli/pull/1678) diff --git a/go.mod b/go.mod index a4a6c1767..d08846815 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/dnaeon/go-vcr v1.2.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/kr/pretty v0.3.1 // indirect diff --git a/go.sum b/go.sum index 148fb8ab5..3c1d2e1dd 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= diff --git a/pkg/commands/auth/token.go b/pkg/commands/auth/token.go new file mode 100644 index 000000000..281dd7ecf --- /dev/null +++ b/pkg/commands/auth/token.go @@ -0,0 +1,46 @@ +package auth + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/lookup" + "github.com/fastly/cli/pkg/text" +) + +// TokenCommand prints the active API token to non-terminal stdout. +type TokenCommand struct { + argparser.Base +} + +// NewTokenCommand returns a new command registered under the parent. +func NewTokenCommand(parent argparser.Registerer, g *global.Data) *TokenCommand { + var c TokenCommand + c.Globals = g + c.CmdClause = parent.Command("token", "Output the active API token (for use in shell substitutions)") + return &c +} + +// Exec implements the command interface. +func (c *TokenCommand) Exec(_ io.Reader, out io.Writer) error { + if text.IsTTY(out) { + return fsterr.RemediationError{ + Inner: fmt.Errorf("refusing to print token to a terminal"), + Remediation: "Use this command in a shell substitution or pipe, e.g. $(fastly auth token).", + } + } + + token, src := c.Globals.Token() + if src == lookup.SourceUndefined || token == "" { + return fsterr.RemediationError{ + Inner: fmt.Errorf("no API token configured"), + Remediation: fsterr.ProfileRemediation(), + } + } + + fmt.Fprint(out, token) + return nil +} diff --git a/pkg/commands/auth/token_test.go b/pkg/commands/auth/token_test.go new file mode 100644 index 000000000..adb704423 --- /dev/null +++ b/pkg/commands/auth/token_test.go @@ -0,0 +1,71 @@ +package auth_test + +import ( + "bytes" + "errors" + "testing" + + "github.com/fastly/kingpin" + + authcmd "github.com/fastly/cli/pkg/commands/auth" + "github.com/fastly/cli/pkg/config" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +func newTokenCommand(g *global.Data) *authcmd.TokenCommand { + app := kingpin.New("fastly", "test") + parent := app.Command("auth", "test auth") + return authcmd.NewTokenCommand(parent, g) +} + +func globalDataWithToken(token string) *global.Data { + return &global.Data{ + Config: config.File{ + Auth: config.Auth{ + Default: "user", + Tokens: config.AuthTokens{ + "user": &config.AuthToken{ + Type: config.AuthTokenTypeStatic, + Token: token, + }, + }, + }, + }, + } +} + +func TestToken_NonTTY_Success(t *testing.T) { + var buf bytes.Buffer + cmd := newTokenCommand(globalDataWithToken("test-api-token-value")) + err := cmd.Exec(nil, &buf) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if got := buf.String(); got != "test-api-token-value" { + t.Errorf("expected token %q, got %q", "test-api-token-value", got) + } + if got := buf.Bytes(); got[len(got)-1] == '\n' { + t.Error("output should not have a trailing newline") + } +} + +func TestToken_NonTTY_NoToken(t *testing.T) { + var buf bytes.Buffer + g := &global.Data{ + Config: config.File{}, + } + + cmd := newTokenCommand(g) + err := cmd.Exec(nil, &buf) + if err == nil { + t.Fatal("expected error for missing token") + } + var re fsterr.RemediationError + if !errors.As(err, &re) { + t.Fatalf("expected RemediationError, got %T: %v", err, err) + } + if re.Inner == nil || re.Inner.Error() != "no API token configured" { + t.Errorf("unexpected inner error: %v", re.Inner) + } +} diff --git a/pkg/commands/auth/token_tty_unix_test.go b/pkg/commands/auth/token_tty_unix_test.go new file mode 100644 index 000000000..1438ad93a --- /dev/null +++ b/pkg/commands/auth/token_tty_unix_test.go @@ -0,0 +1,38 @@ +//go:build !windows + +package auth_test + +import ( + "errors" + "testing" + + "github.com/creack/pty" + + fsterr "github.com/fastly/cli/pkg/errors" +) + +func TestToken_TTY_Refused(t *testing.T) { + // Create a PTY pair so we have a writable *os.File that + // term.IsTerminal recognises as a terminal. This runs reliably + // on Unix CI (no /dev/tty required) and, unlike os.Stdout, never + // risks leaking a token to the developer's real terminal. + ptm, pts, err := pty.Open() + if err != nil { + t.Fatalf("failed to open pty: %v", err) + } + defer ptm.Close() + defer pts.Close() + + cmd := newTokenCommand(globalDataWithToken("secret-token")) + err = cmd.Exec(nil, pts) + if err == nil { + t.Fatal("expected error when stdout is a terminal") + } + var re fsterr.RemediationError + if !errors.As(err, &re) { + t.Fatalf("expected RemediationError, got %T: %v", err, err) + } + if re.Inner == nil || re.Inner.Error() != "refusing to print token to a terminal" { + t.Errorf("unexpected inner error: %v", re.Inner) + } +} diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index 0f8aacd14..db5991fab 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -197,9 +197,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) + authToken := authcmd.NewTokenCommand(authCmdRoot.CmdClause, data) authCommands = []argparser.Command{ authCmdRoot, authLogin, authAdd, authDelete, - authList, authShow, authUse, + authList, authShow, authUse, authToken, } authtokenCmdRoot := authtoken.NewRootCommand(app, data)