Skip to content
Merged
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 @@ -7,6 +7,7 @@
### Bug Fixes:

- fix(compute/deploy): remove compute trial activation code because trials no longer exist ([#1730](https://github.com/fastly/cli/pull/1730))
- fix(auth): SSO token expiration status now reflects the actual API token lifetime (~12 hours) instead of the internal JWT refresh token (~30 minutes), preventing spurious warnings and premature re-authentication [#1728](https://github.com/fastly/cli/pull/1728)

### Enhancements:

Expand Down
4 changes: 2 additions & 2 deletions pkg/app/expiry_warning_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
// expiringTokenData returns a global.Data configured with a stored token that
// expires soon. Callers can override Flags and commandName to test suppression.
func expiringTokenData(out *bytes.Buffer) *global.Data {
soon := time.Now().Add(3 * 24 * time.Hour).Format(time.RFC3339)
soon := time.Now().Add(20 * time.Minute).Format(time.RFC3339)
return &global.Data{
Output: out,
ErrOutput: out,
Expand All @@ -38,7 +38,7 @@ func expiringTokenData(out *bytes.Buffer) *global.Data {
}

func TestCheckTokenExpirationWarning(t *testing.T) {
soon := time.Now().Add(3 * 24 * time.Hour).Format(time.RFC3339)
soon := time.Now().Add(20 * time.Minute).Format(time.RFC3339)
farFuture := time.Now().Add(60 * 24 * time.Hour).Format(time.RFC3339)

tests := []struct {
Expand Down
8 changes: 7 additions & 1 deletion pkg/app/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,13 @@ func checkAndRefreshAuthSSOToken(name string, at *config.AuthToken, data *global
return false, fmt.Errorf("invalid refresh_expires_at %q: %w", at.RefreshExpiresAt, err)
}
if time.Now().After(refreshExpires) {
return true, nil // both expired, needs full re-auth
if at.APITokenExpiresAt != "" {
apiExpires, err := time.Parse(time.RFC3339, at.APITokenExpiresAt)
if err == nil && time.Now().Before(apiExpires) {
return false, nil
}
}
return true, nil
}
}

Expand Down
6 changes: 3 additions & 3 deletions pkg/app/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ func TestExecQuietSuppressesExpiryWarning(t *testing.T) {
app.Init = func(_ []string, _ io.Reader) (*global.Data, error) {
data := testutil.MockGlobalData(args, &stdout)
// Set the default token to expire soon so a warning would fire without --quiet.
data.Config.Auth.Tokens["user"].APITokenExpiresAt = time.Now().Add(3 * 24 * time.Hour).Format(time.RFC3339)
data.Config.Auth.Tokens["user"].APITokenExpiresAt = time.Now().Add(20 * time.Minute).Format(time.RFC3339)
return data, nil
}
err := app.Run(args, nil)
Expand All @@ -169,7 +169,7 @@ func TestExecConfigShowsExpiryWarning(t *testing.T) {
args := testutil.SplitArgs("config -l")
app.Init = func(_ []string, _ io.Reader) (*global.Data, error) {
data := testutil.MockGlobalData(args, &stdout)
data.Config.Auth.Tokens["user"].APITokenExpiresAt = time.Now().Add(3 * 24 * time.Hour).Format(time.RFC3339)
data.Config.Auth.Tokens["user"].APITokenExpiresAt = time.Now().Add(20 * time.Minute).Format(time.RFC3339)
return data, nil
}
err := app.Run(args, nil)
Expand Down Expand Up @@ -199,7 +199,7 @@ func TestExecJSONLeavesStdoutCleanAndWritesWarningToStderr(t *testing.T) {
data := testutil.MockGlobalData(args, &stdout)
data.ErrOutput = &stderr
data.Flags.JSON = true
data.Config.Auth.Tokens["user"].APITokenExpiresAt = time.Now().Add(3 * 24 * time.Hour).Format(time.RFC3339)
data.Config.Auth.Tokens["user"].APITokenExpiresAt = time.Now().Add(20 * time.Minute).Format(time.RFC3339)
return data, nil
}
err := app.Run(args, nil)
Expand Down
19 changes: 9 additions & 10 deletions pkg/commands/auth/expiry.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const (

const (
// expiryWarningThreshold is the warning window for API tokens and SSO refresh tokens.
expiryWarningThreshold = 7 * 24 * time.Hour
expiryWarningThreshold = 30 * time.Minute
// accessOnlyWarningThreshold is the warning window when only an access token
// expiry is available (no refresh token). This is shorter because access
// tokens are short-lived and auto-refresh.
Expand Down Expand Up @@ -70,10 +70,15 @@ func staticExpirationStatus(at *config.AuthToken, now time.Time) (ExpirationStat
return classifyExpiry(expires, now, expiryWarningThreshold), expires, nil
}

// ssoExpirationStatus handles expiration for SSO tokens. It prefers
// RefreshExpiresAt (the user-actionable deadline) and falls back to
// AccessExpiresAt when refresh info is missing or malformed.
// ssoExpirationStatus handles expiration for SSO tokens.
func ssoExpirationStatus(at *config.AuthToken, now time.Time) (ExpirationStatus, time.Time, error) {
if at.APITokenExpiresAt != "" {
expires, err := time.Parse(time.RFC3339, at.APITokenExpiresAt)
if err == nil {
return classifyExpiry(expires, now, expiryWarningThreshold), expires, nil
}
}

if at.RefreshToken != "" && at.RefreshExpiresAt == "" {
return StatusNoExpiry, time.Time{}, nil
}
Expand All @@ -83,7 +88,6 @@ func ssoExpirationStatus(at *config.AuthToken, now time.Time) (ExpirationStatus,
if err == nil {
return classifyExpiry(expires, now, expiryWarningThreshold), expires, nil
}
// Malformed refresh_expires_at; fall through to access token.
}

if at.AccessExpiresAt != "" {
Expand All @@ -94,11 +98,6 @@ func ssoExpirationStatus(at *config.AuthToken, now time.Time) (ExpirationStatus,
return StatusNoExpiry, time.Time{}, fmt.Errorf("invalid access_expires_at %q: %w", at.AccessExpiresAt, err)
}

// Neither field set.
if at.RefreshExpiresAt != "" {
// RefreshExpiresAt was set but malformed, and no AccessExpiresAt to fall back to.
return StatusNoExpiry, time.Time{}, fmt.Errorf("invalid refresh_expires_at %q", at.RefreshExpiresAt)
}
return StatusNoExpiry, time.Time{}, nil
}

Expand Down
72 changes: 66 additions & 6 deletions pkg/commands/auth/expiry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,26 @@ func TestGetExpirationStatus(t *testing.T) {
wantStatus: authcmd.StatusOK,
},
{
name: "static expiring soon (within 7 days)",
name: "static not expiring soon (3 days out)",
token: &config.AuthToken{
Type: config.AuthTokenTypeStatic,
APITokenExpiresAt: now.Add(3 * 24 * time.Hour).Format(time.RFC3339),
},
wantStatus: authcmd.StatusOK,
},
{
name: "static expiring soon (within 30 minutes)",
token: &config.AuthToken{
Type: config.AuthTokenTypeStatic,
APITokenExpiresAt: now.Add(20 * time.Minute).Format(time.RFC3339),
},
wantStatus: authcmd.StatusExpiringSoon,
},
{
name: "static expiring soon (exactly 7 days)",
name: "static expiring soon (exactly 30 minutes)",
token: &config.AuthToken{
Type: config.AuthTokenTypeStatic,
APITokenExpiresAt: now.Add(7 * 24 * time.Hour).Format(time.RFC3339),
APITokenExpiresAt: now.Add(30 * time.Minute).Format(time.RFC3339),
},
wantStatus: authcmd.StatusExpiringSoon,
},
Expand All @@ -96,6 +104,42 @@ func TestGetExpirationStatus(t *testing.T) {
},

// SSO tokens: RefreshExpiresAt primary.
{
name: "sso api_token_expires_at preferred over refresh",
token: &config.AuthToken{
Type: config.AuthTokenTypeSSO,
APITokenExpiresAt: now.Add(30 * 24 * time.Hour).Format(time.RFC3339),
RefreshExpiresAt: now.Add(25 * time.Minute).Format(time.RFC3339),
},
wantStatus: authcmd.StatusOK,
},
{
name: "sso api_token_expires_at not expiring soon (3 days out)",
token: &config.AuthToken{
Type: config.AuthTokenTypeSSO,
APITokenExpiresAt: now.Add(3 * 24 * time.Hour).Format(time.RFC3339),
RefreshExpiresAt: now.Add(25 * time.Minute).Format(time.RFC3339),
},
wantStatus: authcmd.StatusOK,
},
{
name: "sso api_token_expires_at expiring soon (within 30 minutes)",
token: &config.AuthToken{
Type: config.AuthTokenTypeSSO,
APITokenExpiresAt: now.Add(15 * time.Minute).Format(time.RFC3339),
RefreshExpiresAt: now.Add(25 * time.Minute).Format(time.RFC3339),
},
wantStatus: authcmd.StatusExpiringSoon,
},
{
name: "sso api_token_expires_at expired",
token: &config.AuthToken{
Type: config.AuthTokenTypeSSO,
APITokenExpiresAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
RefreshExpiresAt: now.Add(-2 * time.Hour).Format(time.RFC3339),
},
wantStatus: authcmd.StatusExpired,
},
{
name: "sso refresh OK",
token: &config.AuthToken{
Expand All @@ -105,11 +149,19 @@ func TestGetExpirationStatus(t *testing.T) {
wantStatus: authcmd.StatusOK,
},
{
name: "sso refresh expiring soon",
name: "sso refresh not expiring soon (3 days out)",
token: &config.AuthToken{
Type: config.AuthTokenTypeSSO,
RefreshExpiresAt: now.Add(3 * 24 * time.Hour).Format(time.RFC3339),
},
wantStatus: authcmd.StatusOK,
},
{
name: "sso refresh expiring soon (within 30 minutes)",
token: &config.AuthToken{
Type: config.AuthTokenTypeSSO,
RefreshExpiresAt: now.Add(20 * time.Minute).Format(time.RFC3339),
},
wantStatus: authcmd.StatusExpiringSoon,
},
{
Expand Down Expand Up @@ -164,7 +216,7 @@ func TestGetExpirationStatus(t *testing.T) {
RefreshExpiresAt: "garbage",
},
wantStatus: authcmd.StatusNoExpiry,
wantErr: true,
wantErr: false,
},
{
name: "sso no refresh, malformed access",
Expand Down Expand Up @@ -195,11 +247,19 @@ func TestGetExpirationStatus(t *testing.T) {

// Unknown type falls through to static-style check.
{
name: "unknown type with expiry",
name: "unknown type with expiry (not soon)",
token: &config.AuthToken{
Type: "unknown",
APITokenExpiresAt: now.Add(3 * 24 * time.Hour).Format(time.RFC3339),
},
wantStatus: authcmd.StatusOK,
},
{
name: "unknown type with expiry (within 30 minutes)",
token: &config.AuthToken{
Type: "unknown",
APITokenExpiresAt: now.Add(10 * time.Minute).Format(time.RFC3339),
},
wantStatus: authcmd.StatusExpiringSoon,
},

Expand Down
Loading