Skip to content

Commit 87e5e71

Browse files
committed
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.
1 parent cd5b9d0 commit 87e5e71

7 files changed

Lines changed: 161 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- fix(stats): `stats historical` now returns write errors instead of silently swallowing them. [#1678](https://github.com/fastly/cli/pull/1678)
99

1010
### Enhancements:
11+
- 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.
1112
- feat(stats): add `--field` flag to `stats historical` to filter to a single stats field. [#1678](https://github.com/fastly/cli/pull/1678)
1213
- feat(stats): add `stats aggregate` subcommand for cross-service aggregated stats. [#1678](https://github.com/fastly/cli/pull/1678)
1314
- feat(stats): add `stats usage` subcommand for bandwidth/request usage, with `--by-service` breakdown. [#1678](https://github.com/fastly/cli/pull/1678)

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ require (
4040

4141
require (
4242
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
43+
github.com/creack/pty v1.1.24 // indirect
4344
github.com/dnaeon/go-vcr v1.2.0 // indirect
4445
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
4546
github.com/kr/pretty v0.3.1 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ
1616
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
1717
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
1818
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
19+
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
20+
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
1921
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2022
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2123
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=

pkg/commands/auth/token.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package auth
2+
3+
import (
4+
"fmt"
5+
"io"
6+
7+
"github.com/fastly/cli/pkg/argparser"
8+
fsterr "github.com/fastly/cli/pkg/errors"
9+
"github.com/fastly/cli/pkg/global"
10+
"github.com/fastly/cli/pkg/lookup"
11+
"github.com/fastly/cli/pkg/text"
12+
)
13+
14+
// TokenCommand prints the active API token to non-terminal stdout.
15+
type TokenCommand struct {
16+
argparser.Base
17+
}
18+
19+
// NewTokenCommand returns a new command registered under the parent.
20+
func NewTokenCommand(parent argparser.Registerer, g *global.Data) *TokenCommand {
21+
var c TokenCommand
22+
c.Globals = g
23+
c.CmdClause = parent.Command("token", "Output the active API token (for use in shell substitutions)")
24+
return &c
25+
}
26+
27+
// Exec implements the command interface.
28+
func (c *TokenCommand) Exec(_ io.Reader, out io.Writer) error {
29+
if text.IsTTY(out) {
30+
return fsterr.RemediationError{
31+
Inner: fmt.Errorf("refusing to print token to a terminal"),
32+
Remediation: "Use this command in a shell substitution or pipe, e.g. $(fastly auth token).",
33+
}
34+
}
35+
36+
token, src := c.Globals.Token()
37+
if src == lookup.SourceUndefined || token == "" {
38+
return fsterr.RemediationError{
39+
Inner: fmt.Errorf("no API token configured"),
40+
Remediation: fsterr.ProfileRemediation(),
41+
}
42+
}
43+
44+
fmt.Fprint(out, token)
45+
return nil
46+
}

pkg/commands/auth/token_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package auth_test
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"testing"
7+
8+
"github.com/fastly/kingpin"
9+
10+
authcmd "github.com/fastly/cli/pkg/commands/auth"
11+
"github.com/fastly/cli/pkg/config"
12+
fsterr "github.com/fastly/cli/pkg/errors"
13+
"github.com/fastly/cli/pkg/global"
14+
)
15+
16+
func newTokenCommand(g *global.Data) *authcmd.TokenCommand {
17+
app := kingpin.New("fastly", "test")
18+
parent := app.Command("auth", "test auth")
19+
return authcmd.NewTokenCommand(parent, g)
20+
}
21+
22+
func globalDataWithToken(token string) *global.Data {
23+
return &global.Data{
24+
Config: config.File{
25+
Auth: config.Auth{
26+
Default: "user",
27+
Tokens: config.AuthTokens{
28+
"user": &config.AuthToken{
29+
Type: config.AuthTokenTypeStatic,
30+
Token: token,
31+
},
32+
},
33+
},
34+
},
35+
}
36+
}
37+
38+
func TestToken_NonTTY_Success(t *testing.T) {
39+
var buf bytes.Buffer
40+
cmd := newTokenCommand(globalDataWithToken("test-api-token-value"))
41+
err := cmd.Exec(nil, &buf)
42+
if err != nil {
43+
t.Fatalf("expected no error, got: %v", err)
44+
}
45+
if got := buf.String(); got != "test-api-token-value" {
46+
t.Errorf("expected token %q, got %q", "test-api-token-value", got)
47+
}
48+
if got := buf.Bytes(); got[len(got)-1] == '\n' {
49+
t.Error("output should not have a trailing newline")
50+
}
51+
}
52+
53+
func TestToken_NonTTY_NoToken(t *testing.T) {
54+
var buf bytes.Buffer
55+
g := &global.Data{
56+
Config: config.File{},
57+
}
58+
59+
cmd := newTokenCommand(g)
60+
err := cmd.Exec(nil, &buf)
61+
if err == nil {
62+
t.Fatal("expected error for missing token")
63+
}
64+
var re fsterr.RemediationError
65+
if !errors.As(err, &re) {
66+
t.Fatalf("expected RemediationError, got %T: %v", err, err)
67+
}
68+
if re.Inner == nil || re.Inner.Error() != "no API token configured" {
69+
t.Errorf("unexpected inner error: %v", re.Inner)
70+
}
71+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//go:build !windows
2+
3+
package auth_test
4+
5+
import (
6+
"errors"
7+
"testing"
8+
9+
"github.com/creack/pty"
10+
11+
fsterr "github.com/fastly/cli/pkg/errors"
12+
)
13+
14+
func TestToken_TTY_Refused(t *testing.T) {
15+
// Create a PTY pair so we have a writable *os.File that
16+
// term.IsTerminal recognises as a terminal. This runs reliably
17+
// on Unix CI (no /dev/tty required) and, unlike os.Stdout, never
18+
// risks leaking a token to the developer's real terminal.
19+
ptm, pts, err := pty.Open()
20+
if err != nil {
21+
t.Fatalf("failed to open pty: %v", err)
22+
}
23+
defer ptm.Close()
24+
defer pts.Close()
25+
26+
cmd := newTokenCommand(globalDataWithToken("secret-token"))
27+
err = cmd.Exec(nil, pts)
28+
if err == nil {
29+
t.Fatal("expected error when stdout is a terminal")
30+
}
31+
var re fsterr.RemediationError
32+
if !errors.As(err, &re) {
33+
t.Fatalf("expected RemediationError, got %T: %v", err, err)
34+
}
35+
if re.Inner == nil || re.Inner.Error() != "refusing to print token to a terminal" {
36+
t.Errorf("unexpected inner error: %v", re.Inner)
37+
}
38+
}

pkg/commands/commands.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,10 @@ func Define( // nolint:revive // function-length
197197
authList := authcmd.NewListCommand(authCmdRoot.CmdClause, data)
198198
authShow := authcmd.NewShowCommand(authCmdRoot.CmdClause, data)
199199
authUse := authcmd.NewUseCommand(authCmdRoot.CmdClause, data)
200+
authToken := authcmd.NewTokenCommand(authCmdRoot.CmdClause, data)
200201
authCommands = []argparser.Command{
201202
authCmdRoot, authLogin, authAdd, authDelete,
202-
authList, authShow, authUse,
203+
authList, authShow, authUse, authToken,
203204
}
204205

205206
authtokenCmdRoot := authtoken.NewRootCommand(app, data)

0 commit comments

Comments
 (0)