Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

### 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add the PR # here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can also remove the last bit from the changelog entry, as it's a bit too explanatory Refuses to print to a terminal to prevent accidental exposure..

- feat(auth): `auth login --sso` now requires `--token <name>` to explicitly name the stored token. This prevents accidentally overwriting tokens in multi-user SSO workflows. [#1676](https://github.com/fastly/cli/pull/1676)
- feat(auth): add `FASTLY_DISABLE_AUTH_COMMAND` env var to hide the `fastly auth` command tree from help, completions, and invocation. [#1676](https://github.com/fastly/cli/pull/1676)
- feat(auth): when `FASTLY_DISABLE_AUTH_COMMAND` is set, the `--token`/`-t` global flag is also disabled. Use `FASTLY_API_TOKEN` or stored config tokens instead. [#1676](https://github.com/fastly/cli/pull/1676)
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
46 changes: 46 additions & 0 deletions pkg/commands/auth/token.go
Original file line number Diff line number Diff line change
@@ -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
}
71 changes: 71 additions & 0 deletions pkg/commands/auth/token_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
38 changes: 38 additions & 0 deletions pkg/commands/auth/token_tty_unix_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to have a separate test file for this? I don't know that we have more then 1 test file per command in the code base.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no harm in putting them in separate files, but in this case since both files are fairly small I would probably combine them.

// 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)
}
}
3 changes: 2 additions & 1 deletion pkg/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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)
Expand Down
Loading