Skip to content

Commit

Permalink
feat(profile/token): Allow user to specify how long token must be val…
Browse files Browse the repository at this point in the history
…id. (#1340)

* feat(profile/token): Allow user to specify how long token must be valid.

The default token validity period for 'fastly profile token' is 5
minutes, but users may want to ensure that the token is valid for a
longer period (or only a shorter period). This PR adds 'fastly profile
token --ttl=<period>', allowing the user to specify the validity
period in seconds, minutes, or hours (or some combination).

* Restore support for non-OIDC tokens in `fastly profile token`.

Profiles with non-OIDC tokens (tokens generated using the traditional
Fastly token-creation methods) do not contain expiration information,
so the validity-checking in the `fastly profile token` command should
not attempt to validate them.

* Emit proper suggested command when an OIDC token needs to be refreshed.

The `fastly profile update` command is not the best suggestion in this
situation; the user should directly invoke `fastly sso` instead.
  • Loading branch information
kpfleming authored Nov 11, 2024
1 parent 270451f commit 91a6e3f
Show file tree
Hide file tree
Showing 3 changed files with 94 additions and 40 deletions.
116 changes: 81 additions & 35 deletions pkg/commands/profile/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ func TestProfileToken(t *testing.T) {

scenarios := []testutil.CLIScenario{
{
Name: "validate the active profile token is displayed by default",
Name: "validate the active profile non-OIDC token is displayed by default",
Env: &testutil.EnvConfig{
Opts: &testutil.EnvOpts{
Copy: []testutil.FileIO{
Expand All @@ -421,25 +421,21 @@ func TestProfileToken(t *testing.T) {
ConfigFile: &config.File{
Profiles: config.Profiles{
"foo": &config.Profile{
Default: true,
Email: "[email protected]",
Token: "123",
RefreshTokenCreated: now.Unix(),
RefreshTokenTTL: 600,
Default: true,
Email: "[email protected]",
Token: "123",
},
"bar": &config.Profile{
Default: false,
Email: "[email protected]",
Token: "456",
RefreshTokenCreated: now.Unix(),
RefreshTokenTTL: 600,
Default: false,
Email: "[email protected]",
Token: "456",
},
},
},
WantOutput: "123",
},
{
Name: "validate token is displayed for the specified profile",
Name: "validate non-OIDC token is displayed for the specified profile",
Args: "bar", // we choose a non-default profile
Env: &testutil.EnvConfig{
Opts: &testutil.EnvOpts{
Expand All @@ -457,25 +453,21 @@ func TestProfileToken(t *testing.T) {
ConfigFile: &config.File{
Profiles: config.Profiles{
"foo": &config.Profile{
Default: true,
Email: "[email protected]",
Token: "123",
RefreshTokenCreated: now.Unix(),
RefreshTokenTTL: 600,
Default: true,
Email: "[email protected]",
Token: "123",
},
"bar": &config.Profile{
Default: false,
Email: "[email protected]",
Token: "456",
RefreshTokenCreated: now.Unix(),
RefreshTokenTTL: 600,
Default: false,
Email: "[email protected]",
Token: "456",
},
},
},
WantOutput: "456",
},
{
Name: "validate token is displayed for the specified profile using global --profile",
Name: "validate non-OIDC token is displayed for the specified profile using global --profile",
Args: "--profile bar", // we choose a non-default profile
Env: &testutil.EnvConfig{
Opts: &testutil.EnvOpts{
Expand All @@ -493,18 +485,14 @@ func TestProfileToken(t *testing.T) {
ConfigFile: &config.File{
Profiles: config.Profiles{
"foo": &config.Profile{
Default: true,
Email: "[email protected]",
Token: "123",
RefreshTokenCreated: now.Unix(),
RefreshTokenTTL: 600,
Default: true,
Email: "[email protected]",
Token: "123",
},
"bar": &config.Profile{
Default: false,
Email: "[email protected]",
Token: "456",
RefreshTokenCreated: now.Unix(),
RefreshTokenTTL: 600,
Default: false,
Email: "[email protected]",
Token: "456",
},
},
},
Expand All @@ -529,7 +517,7 @@ func TestProfileToken(t *testing.T) {
WantError: "profile 'unknown' does not exist",
},
{
Name: "validate that an expired token generates an error",
Name: "validate that an expired OIDC token generates an error",
Env: &testutil.EnvConfig{
Opts: &testutil.EnvOpts{
Copy: []testutil.FileIO{
Expand Down Expand Up @@ -557,7 +545,7 @@ func TestProfileToken(t *testing.T) {
WantError: fmt.Sprintf("the token in profile 'foo' expired at '%s'", now.Add(time.Duration(-600)*time.Second).UTC().Format(fsttime.Format)),
},
{
Name: "validate that a soon-to-expire token generates an error",
Name: "validate that a soon-to-expire OIDC token generates an error",
Env: &testutil.EnvConfig{
Opts: &testutil.EnvOpts{
Copy: []testutil.FileIO{
Expand All @@ -584,6 +572,64 @@ func TestProfileToken(t *testing.T) {
},
WantError: fmt.Sprintf("the token in profile 'foo' will expire at '%s'", now.Add(time.Duration(30)*time.Second).UTC().Format(fsttime.Format)),
},
{
Name: "validate that a soon-to-expire OIDC token with a non-default TTL does not generate an error",
Args: "--ttl 30s",
Env: &testutil.EnvConfig{
Opts: &testutil.EnvOpts{
Copy: []testutil.FileIO{
{
Src: filepath.Join("testdata", "config.toml"),
Dst: "config.toml",
},
},
},
EditScenario: func(scenario *testutil.CLIScenario, rootdir string) {
scenario.ConfigPath = filepath.Join(rootdir, "config.toml")
},
},
ConfigFile: &config.File{
Profiles: config.Profiles{
"foo": &config.Profile{
Default: true,
Email: "[email protected]",
Token: "123",
RefreshTokenCreated: now.Unix(),
RefreshTokenTTL: 60,
},
},
},
WantOutput: "123",
},
{
Name: "validate that an OIDC token with a long non-default TTL generates an error",
Args: "--ttl 1800s",
Env: &testutil.EnvConfig{
Opts: &testutil.EnvOpts{
Copy: []testutil.FileIO{
{
Src: filepath.Join("testdata", "config.toml"),
Dst: "config.toml",
},
},
},
EditScenario: func(scenario *testutil.CLIScenario, rootdir string) {
scenario.ConfigPath = filepath.Join(rootdir, "config.toml")
},
},
ConfigFile: &config.File{
Profiles: config.Profiles{
"foo": &config.Profile{
Default: true,
Email: "[email protected]",
Token: "123",
RefreshTokenCreated: now.Unix(),
RefreshTokenTTL: 1200,
},
},
},
WantError: fmt.Sprintf("the token in profile 'foo' will expire at '%s'", now.Add(time.Duration(1200)*time.Second).UTC().Format(fsttime.Format)),
},
}

testutil.RunCLIScenarios(t, []string{root.CommandName, "token"}, scenarios)
Expand Down
14 changes: 11 additions & 3 deletions pkg/commands/profile/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import (
// TokenCommand represents a Kingpin command.
type TokenCommand struct {
argparser.Base
profile string
profile string
tokenTTL time.Duration
}

// NewTokenCommand returns a new command registered in the parent.
Expand All @@ -26,6 +27,7 @@ func NewTokenCommand(parent argparser.Registerer, g *global.Data) *TokenCommand
c.Globals = g
c.CmdClause = parent.Command("token", "Print API token (defaults to the 'active' profile)")
c.CmdClause.Arg("profile", "Print API token for the named profile").Short('p').StringVar(&c.profile)
c.CmdClause.Flag("ttl", "Amount of time for which the token must be valid (in seconds 's', minutes 'm', or hours 'h')").Default(defaultTokenTTL.String()).DurationVar(&c.tokenTTL)
return &c
}

Expand All @@ -47,7 +49,7 @@ func (c *TokenCommand) Exec(_ io.Reader, out io.Writer) (err error) {

if name != "" {
if p := profile.Get(name, c.Globals.Config.Profiles); p != nil {
if err = checkTokenValidity(name, p, defaultTokenTTL); err != nil {
if err = checkTokenValidity(name, p, c.tokenTTL); err != nil {
return err
}
text.Output(out, p.Token)
Expand All @@ -62,7 +64,7 @@ func (c *TokenCommand) Exec(_ io.Reader, out io.Writer) (err error) {

// If no 'profile' arg or global --profile, then we'll use 'active' profile.
if name, p := profile.Default(c.Globals.Config.Profiles); p != nil {
if err = checkTokenValidity(name, p, defaultTokenTTL); err != nil {
if err = checkTokenValidity(name, p, c.tokenTTL); err != nil {
return err
}
text.Output(out, p.Token)
Expand All @@ -75,6 +77,12 @@ func (c *TokenCommand) Exec(_ io.Reader, out io.Writer) (err error) {
}

func checkTokenValidity(profileName string, p *config.Profile, ttl time.Duration) (err error) {
// if the token in the profile was not obtained via OIDC,
// there is no expiration information available
if p.RefreshTokenCreated == 0 {
return nil
}

var msg string
expiry := time.Unix(p.RefreshTokenCreated, 0).Add(time.Duration(p.RefreshTokenTTL) * time.Second)

Expand Down
4 changes: 2 additions & 2 deletions pkg/errors/remediation_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ var InvalidStaticConfigRemediation = strings.Join([]string{
"https://github.com/fastly/cli/issues/new?labels=bug&template=bug_report.md",
}, " ")

// TokenExpirationRemediation indicates that a stored token has expired.
// TokenExpirationRemediation indicates that a stored OIDC token has expired.
var TokenExpirationRemediation = strings.Join([]string{
"Run 'fastly --profile <NAME> update' to refresh the token.",
"Run 'fastly --profile <NAME> sso' to refresh the token.",
}, " ")

0 comments on commit 91a6e3f

Please sign in to comment.