Skip to content

Commit 5a396de

Browse files
session timeout-improved (#4645)
Adds a 30-day session timeout to flyctl so long dormant users don't hit issues with expired/invalid tokens. Sessions that haven't been refreshed in 30 days will prompt for re-login. Changes Core Implementation Config tracking: Added LastLogin timestamp to ~/.fly/config.yml to track when users last authenticated Session validation: Enhanced RequireSession preparer to check session age and prompt for re-login after 30 days Login timestamp persistence: Save LastLogin timestamp when users authenticate via fly auth login CI/CD Compatibility Environment token bypass: Skip session timeout validation when FLY_ACCESS_TOKEN or FLY_API_TOKEN environment variables are set This ensures automated pipelines using token-based authentication continue working without session expiration User Experience Graceful expiry handling: Shows friendly "Welcome back!" message for expired sessions Three expiry scenarios: not_authenticated: No valid token present no_timestamp: User has old config without LastLogin (pre-feature) expired: Token older than 30 days Migration Existing users with tokens but no LastLogin timestamp will be prompted to re-login once, after which the timestamp will be tracked going forward.
1 parent 4993942 commit 5a396de

File tree

5 files changed

+132
-47
lines changed

5 files changed

+132
-47
lines changed

internal/command/auth/webauth/webauth.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ func SaveToken(ctx context.Context, token string) error {
3131
return err
3232
}
3333

34+
// Record the login timestamp
35+
if err := config.SetLastLogin(state.ConfigFile(ctx), time.Now()); err != nil {
36+
return fmt.Errorf("failed persisting login timestamp: %w", err)
37+
}
38+
3439
user, err := flyutil.NewClientFromOptions(ctx, fly.ClientOptions{
3540
AccessToken: token,
3641
}).GetCurrentUser(ctx)

internal/command/command.go

Lines changed: 93 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ import (
4141

4242
type Runner func(context.Context) error
4343

44+
const (
45+
// TokenTimeout defines how long a login session is valid before requiring re-authentication
46+
TokenTimeout = 30 * 24 * time.Hour // 30 days
47+
)
48+
4449
func New(usage, short, long string, fn Runner, p ...preparers.Preparer) *cobra.Command {
4550
return &cobra.Command{
4651
Use: usage,
@@ -553,50 +558,30 @@ func ExcludeFromMetrics(ctx context.Context) (context.Context, error) {
553558

554559
// RequireSession is a Preparer which makes sure a session exists.
555560
func RequireSession(ctx context.Context) (context.Context, error) {
556-
if !flyutil.ClientFromContext(ctx).Authenticated() {
557-
io := iostreams.FromContext(ctx)
558-
// Ensure we have a session, and that the user hasn't set any flags that would lead them to expect consistent output or a lack of prompts
559-
if io.IsInteractive() &&
560-
!env.IsCI() &&
561-
!flag.GetBool(ctx, "now") &&
562-
!flag.GetBool(ctx, "json") &&
563-
!flag.GetBool(ctx, "quiet") &&
564-
!flag.GetBool(ctx, "yes") {
565-
566-
// Ask before we start opening things
567-
confirmed, err := prompt.Confirm(ctx, "You must be logged in to do this. Would you like to sign in?")
568-
if err != nil {
569-
return nil, err
570-
}
571-
if !confirmed {
572-
return nil, fly.ErrNoAuthToken
573-
}
561+
client := flyutil.ClientFromContext(ctx)
562+
cfg := config.FromContext(ctx)
574563

575-
// Attempt to log the user in
576-
token, err := webauth.RunWebLogin(ctx, false)
577-
if err != nil {
578-
return nil, err
579-
}
580-
if err := webauth.SaveToken(ctx, token); err != nil {
581-
return nil, err
582-
}
564+
// Check if user is authenticated
565+
if !client.Authenticated() {
566+
return handleReLogin(ctx, "not_authenticated")
567+
}
583568

584-
// Reload the config
585-
logger.FromContext(ctx).Debug("reloading config after login")
586-
if ctx, err = prepare(ctx, preparers.LoadConfig); err != nil {
587-
return nil, err
588-
}
569+
// Skip timestamp validation if token is from environment variable (CI/CD use case)
570+
// This allows automated pipelines to continue working without session timeout
571+
tokenFromEnv := env.First(config.AccessTokenEnvKey, config.APITokenEnvKey) != ""
589572

590-
// first reset the client
591-
ctx = flyutil.NewContextWithClient(ctx, nil)
573+
if !tokenFromEnv {
574+
// Check if the token has expired due to age
575+
// If LastLogin is zero, it means the user has an old config without the timestamp
576+
if cfg.LastLogin.IsZero() {
577+
logger.FromContext(ctx).Debug("no login timestamp found, prompting for re-login")
578+
return handleReLogin(ctx, "no_timestamp")
579+
}
592580

593-
// Re-run the auth preparers to update the client with the new token
594-
logger.FromContext(ctx).Debug("re-running auth preparers after login")
595-
if ctx, err = prepare(ctx, authPreparers...); err != nil {
596-
return nil, err
597-
}
598-
} else {
599-
return nil, fly.ErrNoAuthToken
581+
// Check if the token has expired based on the timeout
582+
if time.Since(cfg.LastLogin) > TokenTimeout {
583+
logger.FromContext(ctx).Debugf("token expired (%v since login, timeout is %v)", time.Since(cfg.LastLogin), TokenTimeout)
584+
return handleReLogin(ctx, "expired")
600585
}
601586
}
602587

@@ -605,6 +590,74 @@ func RequireSession(ctx context.Context) (context.Context, error) {
605590
return ctx, nil
606591
}
607592

593+
// handleReLogin prompts the user to log in and handles the re-login flow
594+
// reason can be: "not_authenticated", "no_timestamp", or "expired"
595+
func handleReLogin(ctx context.Context, reason string) (context.Context, error) {
596+
io := iostreams.FromContext(ctx)
597+
598+
// Ensure we have a session, and that the user hasn't set any flags that would lead them to expect consistent output or a lack of prompts
599+
if io.IsInteractive() &&
600+
!env.IsCI() &&
601+
!flag.GetBool(ctx, "now") &&
602+
!flag.GetBool(ctx, "json") &&
603+
!flag.GetBool(ctx, "quiet") &&
604+
!flag.GetBool(ctx, "yes") {
605+
606+
// Display styled message based on reason
607+
colorize := io.ColorScheme()
608+
609+
if reason == "no_timestamp" || reason == "expired" {
610+
// User has been away - show welcome back message
611+
fmt.Fprintf(io.Out, "%s\n", colorize.Purple("Welcome back!"))
612+
fmt.Fprintf(io.Out, "Your session has expired, please log in to continue using flyctl.\n\n")
613+
}
614+
615+
// Ask before we start opening things
616+
var promptMessage string
617+
if reason == "not_authenticated" {
618+
promptMessage = "You must be logged in to do this. Would you like to sign in?"
619+
} else {
620+
promptMessage = "Would you like to sign in?"
621+
}
622+
623+
confirmed, err := prompt.Confirm(ctx, promptMessage)
624+
if err != nil {
625+
return nil, err
626+
}
627+
if !confirmed {
628+
return nil, fly.ErrNoAuthToken
629+
}
630+
631+
// Attempt to log the user in
632+
token, err := webauth.RunWebLogin(ctx, false)
633+
if err != nil {
634+
return nil, err
635+
}
636+
if err := webauth.SaveToken(ctx, token); err != nil {
637+
return nil, err
638+
}
639+
640+
// Reload the config
641+
logger.FromContext(ctx).Debug("reloading config after login")
642+
if ctx, err = prepare(ctx, preparers.LoadConfig); err != nil {
643+
return nil, err
644+
}
645+
646+
// first reset the client
647+
ctx = flyutil.NewContextWithClient(ctx, nil)
648+
649+
// Re-run the auth preparers to update the client with the new token
650+
logger.FromContext(ctx).Debug("re-running auth preparers after login")
651+
if ctx, err = prepare(ctx, authPreparers...); err != nil {
652+
return nil, err
653+
}
654+
655+
return ctx, nil
656+
} else {
657+
return nil, fly.ErrNoAuthToken
658+
}
659+
}
660+
608661
// Apply uiex client to uiex
609662
func RequireUiex(ctx context.Context) (context.Context, error) {
610663
cfg := config.FromContext(ctx)

internal/command/deploy/deploy_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ import (
88
"os"
99
"path/filepath"
1010
"testing"
11+
"time"
1112

1213
"github.com/superfly/fly-go"
14+
"github.com/superfly/fly-go/tokens"
15+
"github.com/superfly/flyctl/internal/config"
1316
"github.com/superfly/flyctl/internal/flapsutil"
1417
"github.com/superfly/flyctl/internal/flyutil"
1518
"github.com/superfly/flyctl/internal/inmem"
@@ -45,6 +48,13 @@ func TestCommand_Execute(t *testing.T) {
4548
ctx = task.NewWithContext(ctx)
4649
ctx = logger.NewContext(ctx, logger.New(&buf, logger.Info, true))
4750

51+
// Set up config with LastLogin timestamp to satisfy session timeout check
52+
cfg := &config.Config{
53+
Tokens: tokens.Parse("test-token"),
54+
LastLogin: time.Now(),
55+
}
56+
ctx = config.NewContext(ctx, cfg)
57+
4858
server := inmem.NewServer()
4959
server.CreateApp(&fly.App{
5060
Name: "test-basic",

internal/config/config.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"io/fs"
77
"sync"
8+
"time"
89

910
"github.com/spf13/pflag"
1011

@@ -34,6 +35,7 @@ const (
3435
AppSecretsMinverFileKey = "app_secrets_minvers"
3536
WireGuardStateFileKey = "wire_guard_state"
3637
WireGuardWebsocketsFileKey = "wire_guard_websockets"
38+
LastLoginFileKey = "last_login"
3739
APITokenEnvKey = "FLY_API_TOKEN"
3840
orgEnvKey = "FLY_ORG"
3941
registryHostEnvKey = "FLY_REGISTRY_HOST"
@@ -108,6 +110,9 @@ type Config struct {
108110

109111
// MetricsToken denotes the user's metrics token.
110112
MetricsToken string
113+
114+
// LastLogin denotes the timestamp of the last successful login.
115+
LastLogin time.Time
111116
}
112117

113118
func Load(ctx context.Context, path string) (*Config, error) {
@@ -171,12 +176,13 @@ func (cfg *Config) applyFile(path string) (err error) {
171176
defer cfg.mu.Unlock()
172177

173178
var w struct {
174-
AccessToken string `yaml:"access_token"`
175-
MetricsToken string `yaml:"metrics_token"`
176-
SendMetrics bool `yaml:"send_metrics"`
177-
AutoUpdate bool `yaml:"auto_update"`
178-
SyntheticsAgent bool `yaml:"synthetics_agent"`
179-
DisableManagedBuilders bool `yaml:"disable_managed_builders"`
179+
AccessToken string `yaml:"access_token"`
180+
MetricsToken string `yaml:"metrics_token"`
181+
SendMetrics bool `yaml:"send_metrics"`
182+
AutoUpdate bool `yaml:"auto_update"`
183+
SyntheticsAgent bool `yaml:"synthetics_agent"`
184+
DisableManagedBuilders bool `yaml:"disable_managed_builders"`
185+
LastLogin time.Time `yaml:"last_login"`
180186
}
181187
w.SendMetrics = true
182188
w.AutoUpdate = true
@@ -190,6 +196,7 @@ func (cfg *Config) applyFile(path string) (err error) {
190196
cfg.AutoUpdate = w.AutoUpdate
191197
cfg.SyntheticsAgent = w.SyntheticsAgent
192198
cfg.DisableManagedBuilders = w.DisableManagedBuilders
199+
cfg.LastLogin = w.LastLogin
193200
}
194201

195202
return

internal/config/file.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"io"
77
"os"
88
"path/filepath"
9+
"time"
910

1011
"github.com/superfly/flyctl/wg"
1112
"gopkg.in/yaml.v3"
@@ -33,6 +34,14 @@ func SetAccessToken(path, token string) error {
3334
})
3435
}
3536

37+
// SetLastLogin sets the last login timestamp at the configuration file
38+
// found at path.
39+
func SetLastLogin(path string, timestamp time.Time) error {
40+
return set(path, map[string]interface{}{
41+
LastLoginFileKey: timestamp,
42+
})
43+
}
44+
3645
// SetMetricsToken sets the value of the metrics token at the configuration file
3746
// found at path.
3847
func SetMetricsToken(path, token string) error {
@@ -85,12 +94,13 @@ func SetAppSecretsMinvers(path string, minvers AppSecretsMinvers) error {
8594
})
8695
}
8796

88-
// Clear clears the access token, metrics token, and wireguard-related keys of the configuration
97+
// Clear clears the access token, metrics token, last login timestamp, and wireguard-related keys of the configuration
8998
// file found at path.
9099
func Clear(path string) (err error) {
91100
return set(path, map[string]interface{}{
92101
AccessTokenFileKey: "",
93102
MetricsTokenFileKey: "",
103+
LastLoginFileKey: time.Time{}, // Zero value for time.Time
94104
WireGuardStateFileKey: map[string]interface{}{},
95105
AppSecretsMinverFileKey: AppSecretsMinvers{},
96106
})

0 commit comments

Comments
 (0)