From 367a698f75333c343f7d80996ed0cbaf792ee6fc Mon Sep 17 00:00:00 2001 From: theoctopusperson <131688218+theoctopusperson@users.noreply.github.com> Date: Tue, 4 Nov 2025 15:20:33 +0200 Subject: [PATCH 1/8] Introducing 30-day cli session timeout Add 30-day session timeout for CLI authentication Addresses the issue where users returning to the CLI after extended periods may have stale or invalid tokens. Users are now automatically prompted to re-authenticate after 30 days of inactivity. Users with no existing timestamp are immediately prompted to login. Changes: - Add last_login timestamp tracking in config.yml - Implement token age validation in RequireSession preparer - Display styled "Welcome back!" message when session expires - Handle migration for existing users without login timestamps - Gracefully prompt for re-login in interactive sessions Non-interactive sessions return appropriate error messages. --- internal/command/auth/webauth/webauth.go | 5 + internal/command/command.go | 129 ++++++++++++++++------- internal/config/config.go | 19 ++-- internal/config/file.go | 12 ++- 4 files changed, 117 insertions(+), 48 deletions(-) diff --git a/internal/command/auth/webauth/webauth.go b/internal/command/auth/webauth/webauth.go index cfb70fc0ac..745d1bce58 100644 --- a/internal/command/auth/webauth/webauth.go +++ b/internal/command/auth/webauth/webauth.go @@ -31,6 +31,11 @@ func SaveToken(ctx context.Context, token string) error { return err } + // Record the login timestamp + if err := config.SetLastLogin(state.ConfigFile(ctx), time.Now()); err != nil { + return fmt.Errorf("failed persisting login timestamp: %w", err) + } + user, err := flyutil.NewClientFromOptions(ctx, fly.ClientOptions{ AccessToken: token, }).GetCurrentUser(ctx) diff --git a/internal/command/command.go b/internal/command/command.go index 100cc1dad2..b99444da8a 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -41,6 +41,11 @@ import ( type Runner func(context.Context) error +const ( + // TokenTimeout defines how long a login session is valid before requiring re-authentication + TokenTimeout = 30 * 24 * time.Hour // 30 days +) + func New(usage, short, long string, fn Runner, p ...preparers.Preparer) *cobra.Command { return &cobra.Command{ Use: usage, @@ -553,56 +558,98 @@ func ExcludeFromMetrics(ctx context.Context) (context.Context, error) { // RequireSession is a Preparer which makes sure a session exists. func RequireSession(ctx context.Context) (context.Context, error) { - if !flyutil.ClientFromContext(ctx).Authenticated() { - io := iostreams.FromContext(ctx) - // 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 - if io.IsInteractive() && - !env.IsCI() && - !flag.GetBool(ctx, "now") && - !flag.GetBool(ctx, "json") && - !flag.GetBool(ctx, "quiet") && - !flag.GetBool(ctx, "yes") { - - // Ask before we start opening things - confirmed, err := prompt.Confirm(ctx, "You must be logged in to do this. Would you like to sign in?") - if err != nil { - return nil, err - } - if !confirmed { - return nil, fly.ErrNoAuthToken - } + client := flyutil.ClientFromContext(ctx) + cfg := config.FromContext(ctx) - // Attempt to log the user in - token, err := webauth.RunWebLogin(ctx, false) - if err != nil { - return nil, err - } - if err := webauth.SaveToken(ctx, token); err != nil { - return nil, err - } + // Check if user is authenticated + if !client.Authenticated() { + return handleReLogin(ctx, "not_authenticated") + } - // Reload the config - logger.FromContext(ctx).Debug("reloading config after login") - if ctx, err = prepare(ctx, preparers.LoadConfig); err != nil { - return nil, err - } + // Check if the token has expired due to age + // If LastLogin is zero, it means the user has an old config without the timestamp + if cfg.LastLogin.IsZero() { + logger.FromContext(ctx).Debug("no login timestamp found, prompting for re-login") + return handleReLogin(ctx, "no_timestamp") + } - // first reset the client - ctx = flyutil.NewContextWithClient(ctx, nil) + // Check if the token has expired based on the timeout + if time.Since(cfg.LastLogin) > TokenTimeout { + logger.FromContext(ctx).Debugf("token expired (%v since login, timeout is %v)", time.Since(cfg.LastLogin), TokenTimeout) + return handleReLogin(ctx, "expired") + } - // Re-run the auth preparers to update the client with the new token - logger.FromContext(ctx).Debug("re-running auth preparers after login") - if ctx, err = prepare(ctx, authPreparers...); err != nil { - return nil, err - } + config.MonitorTokens(ctx, config.Tokens(ctx), tryOpenUserURL) + + return ctx, nil +} + +// handleReLogin prompts the user to log in and handles the re-login flow +// reason can be: "not_authenticated", "no_timestamp", or "expired" +func handleReLogin(ctx context.Context, reason string) (context.Context, error) { + io := iostreams.FromContext(ctx) + + // 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 + if io.IsInteractive() && + !env.IsCI() && + !flag.GetBool(ctx, "now") && + !flag.GetBool(ctx, "json") && + !flag.GetBool(ctx, "quiet") && + !flag.GetBool(ctx, "yes") { + + // Display styled message based on reason + colorize := io.ColorScheme() + + if reason == "no_timestamp" || reason == "expired" { + // User has been away - show welcome back message + fmt.Fprintf(io.Out, "%s\n", colorize.Purple("Welcome back!")) + fmt.Fprintf(io.Out, "Your session has expired, please log in to continue using flyctl.\n\n") + } + + // Ask before we start opening things + var promptMessage string + if reason == "not_authenticated" { + promptMessage = "You must be logged in to do this. Would you like to sign in?" } else { + promptMessage = "Would you like to sign in?" + } + + confirmed, err := prompt.Confirm(ctx, promptMessage) + if err != nil { + return nil, err + } + if !confirmed { return nil, fly.ErrNoAuthToken } - } - config.MonitorTokens(ctx, config.Tokens(ctx), tryOpenUserURL) + // Attempt to log the user in + token, err := webauth.RunWebLogin(ctx, false) + if err != nil { + return nil, err + } + if err := webauth.SaveToken(ctx, token); err != nil { + return nil, err + } - return ctx, nil + // Reload the config + logger.FromContext(ctx).Debug("reloading config after login") + if ctx, err = prepare(ctx, preparers.LoadConfig); err != nil { + return nil, err + } + + // first reset the client + ctx = flyutil.NewContextWithClient(ctx, nil) + + // Re-run the auth preparers to update the client with the new token + logger.FromContext(ctx).Debug("re-running auth preparers after login") + if ctx, err = prepare(ctx, authPreparers...); err != nil { + return nil, err + } + + return ctx, nil + } else { + return nil, fly.ErrNoAuthToken + } } // Apply uiex client to uiex diff --git a/internal/config/config.go b/internal/config/config.go index 018957db99..b9c82380ab 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "errors" "io/fs" "sync" + "time" "github.com/spf13/pflag" @@ -34,6 +35,7 @@ const ( AppSecretsMinverFileKey = "app_secrets_minvers" WireGuardStateFileKey = "wire_guard_state" WireGuardWebsocketsFileKey = "wire_guard_websockets" + LastLoginFileKey = "last_login" APITokenEnvKey = "FLY_API_TOKEN" orgEnvKey = "FLY_ORG" registryHostEnvKey = "FLY_REGISTRY_HOST" @@ -108,6 +110,9 @@ type Config struct { // MetricsToken denotes the user's metrics token. MetricsToken string + + // LastLogin denotes the timestamp of the last successful login. + LastLogin time.Time } func Load(ctx context.Context, path string) (*Config, error) { @@ -171,12 +176,13 @@ func (cfg *Config) applyFile(path string) (err error) { defer cfg.mu.Unlock() var w struct { - AccessToken string `yaml:"access_token"` - MetricsToken string `yaml:"metrics_token"` - SendMetrics bool `yaml:"send_metrics"` - AutoUpdate bool `yaml:"auto_update"` - SyntheticsAgent bool `yaml:"synthetics_agent"` - DisableManagedBuilders bool `yaml:"disable_managed_builders"` + AccessToken string `yaml:"access_token"` + MetricsToken string `yaml:"metrics_token"` + SendMetrics bool `yaml:"send_metrics"` + AutoUpdate bool `yaml:"auto_update"` + SyntheticsAgent bool `yaml:"synthetics_agent"` + DisableManagedBuilders bool `yaml:"disable_managed_builders"` + LastLogin time.Time `yaml:"last_login"` } w.SendMetrics = true w.AutoUpdate = true @@ -190,6 +196,7 @@ func (cfg *Config) applyFile(path string) (err error) { cfg.AutoUpdate = w.AutoUpdate cfg.SyntheticsAgent = w.SyntheticsAgent cfg.DisableManagedBuilders = w.DisableManagedBuilders + cfg.LastLogin = w.LastLogin } return diff --git a/internal/config/file.go b/internal/config/file.go index 77c678e0ea..8916c61ff1 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "time" "github.com/superfly/flyctl/wg" "gopkg.in/yaml.v3" @@ -33,6 +34,14 @@ func SetAccessToken(path, token string) error { }) } +// SetLastLogin sets the last login timestamp at the configuration file +// found at path. +func SetLastLogin(path string, timestamp time.Time) error { + return set(path, map[string]interface{}{ + LastLoginFileKey: timestamp, + }) +} + // SetMetricsToken sets the value of the metrics token at the configuration file // found at path. func SetMetricsToken(path, token string) error { @@ -85,12 +94,13 @@ func SetAppSecretsMinvers(path string, minvers AppSecretsMinvers) error { }) } -// Clear clears the access token, metrics token, and wireguard-related keys of the configuration +// Clear clears the access token, metrics token, last login timestamp, and wireguard-related keys of the configuration // file found at path. func Clear(path string) (err error) { return set(path, map[string]interface{}{ AccessTokenFileKey: "", MetricsTokenFileKey: "", + LastLoginFileKey: time.Time{}, // Zero value for time.Time WireGuardStateFileKey: map[string]interface{}{}, AppSecretsMinverFileKey: AppSecretsMinvers{}, }) From 3fc8318107a50d43b7f0fe3380cf0cfd2c4fcca6 Mon Sep 17 00:00:00 2001 From: theoctopusperson <131688218+theoctopusperson@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:02:57 +0200 Subject: [PATCH 2/8] Fix deploy test to work with session timeout feature Add proper config setup with LastLogin timestamp in the deploy test to satisfy the session timeout validation in RequireSession. This ensures unit tests pass while maintaining the security feature that forces users with expired tokens to re-authenticate. --- internal/command/deploy/deploy_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/command/deploy/deploy_test.go b/internal/command/deploy/deploy_test.go index 59884a67ba..459ff689ee 100644 --- a/internal/command/deploy/deploy_test.go +++ b/internal/command/deploy/deploy_test.go @@ -8,8 +8,11 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/superfly/fly-go" + "github.com/superfly/fly-go/tokens" + "github.com/superfly/flyctl/internal/config" "github.com/superfly/flyctl/internal/flapsutil" "github.com/superfly/flyctl/internal/flyutil" "github.com/superfly/flyctl/internal/inmem" @@ -42,6 +45,13 @@ func TestCommand_Execute(t *testing.T) { ctx = task.NewWithContext(ctx) ctx = logger.NewContext(ctx, logger.New(&buf, logger.Info, true)) + // Set up config with LastLogin timestamp to satisfy session timeout check + cfg := &config.Config{ + Tokens: tokens.Parse("test-token"), + LastLogin: time.Now(), + } + ctx = config.NewContext(ctx, cfg) + server := inmem.NewServer() server.CreateApp(&fly.App{ Name: "test-basic", From ec67290a75bf609b8e3b44bbd354616449c13e7e Mon Sep 17 00:00:00 2001 From: theoctopusperson <131688218+theoctopusperson@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:22:31 +0200 Subject: [PATCH 3/8] Skip session timeout for CI/CD pipelines using environment tokens When FLY_ACCESS_TOKEN or FLY_API_TOKEN environment variables are set, skip the session timeout validation. This ensures CI/CD pipelines continue to work without requiring manual re-authentication. The timestamp check only applies to interactive users with file-based config, protecting against stale sessions while not breaking automation. Tested both scenarios: - Without env var: timeout enforced after 30 days - With env var: timeout bypassed for CI/CD use --- internal/command/command.go | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/internal/command/command.go b/internal/command/command.go index b99444da8a..8e71804168 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -566,17 +566,23 @@ func RequireSession(ctx context.Context) (context.Context, error) { return handleReLogin(ctx, "not_authenticated") } - // Check if the token has expired due to age - // If LastLogin is zero, it means the user has an old config without the timestamp - if cfg.LastLogin.IsZero() { - logger.FromContext(ctx).Debug("no login timestamp found, prompting for re-login") - return handleReLogin(ctx, "no_timestamp") - } + // Skip timestamp validation if token is from environment variable (CI/CD use case) + // This allows automated pipelines to continue working without session timeout + tokenFromEnv := env.First(config.AccessTokenEnvKey, config.APITokenEnvKey) != "" + + if !tokenFromEnv { + // Check if the token has expired due to age + // If LastLogin is zero, it means the user has an old config without the timestamp + if cfg.LastLogin.IsZero() { + logger.FromContext(ctx).Debug("no login timestamp found, prompting for re-login") + return handleReLogin(ctx, "no_timestamp") + } - // Check if the token has expired based on the timeout - if time.Since(cfg.LastLogin) > TokenTimeout { - logger.FromContext(ctx).Debugf("token expired (%v since login, timeout is %v)", time.Since(cfg.LastLogin), TokenTimeout) - return handleReLogin(ctx, "expired") + // Check if the token has expired based on the timeout + if time.Since(cfg.LastLogin) > TokenTimeout { + logger.FromContext(ctx).Debugf("token expired (%v since login, timeout is %v)", time.Since(cfg.LastLogin), TokenTimeout) + return handleReLogin(ctx, "expired") + } } config.MonitorTokens(ctx, config.Tokens(ctx), tryOpenUserURL) From c9a2b156a7af4f9b26dd2b9a9425e55c6f44d894 Mon Sep 17 00:00:00 2001 From: theoctopusperson <131688218+theoctopusperson@users.noreply.github.com> Date: Tue, 4 Nov 2025 15:20:33 +0200 Subject: [PATCH 4/8] Introducing 30-day cli session timeout Add 30-day session timeout for CLI authentication Addresses the issue where users returning to the CLI after extended periods may have stale or invalid tokens. Users are now automatically prompted to re-authenticate after 30 days of inactivity. Users with no existing timestamp are immediately prompted to login. Changes: - Add last_login timestamp tracking in config.yml - Implement token age validation in RequireSession preparer - Display styled "Welcome back!" message when session expires - Handle migration for existing users without login timestamps - Gracefully prompt for re-login in interactive sessions Non-interactive sessions return appropriate error messages. --- internal/command/auth/webauth/webauth.go | 5 + internal/command/command.go | 129 ++++++++++++++++------- internal/config/config.go | 19 ++-- internal/config/file.go | 12 ++- 4 files changed, 117 insertions(+), 48 deletions(-) diff --git a/internal/command/auth/webauth/webauth.go b/internal/command/auth/webauth/webauth.go index cfb70fc0ac..745d1bce58 100644 --- a/internal/command/auth/webauth/webauth.go +++ b/internal/command/auth/webauth/webauth.go @@ -31,6 +31,11 @@ func SaveToken(ctx context.Context, token string) error { return err } + // Record the login timestamp + if err := config.SetLastLogin(state.ConfigFile(ctx), time.Now()); err != nil { + return fmt.Errorf("failed persisting login timestamp: %w", err) + } + user, err := flyutil.NewClientFromOptions(ctx, fly.ClientOptions{ AccessToken: token, }).GetCurrentUser(ctx) diff --git a/internal/command/command.go b/internal/command/command.go index 100cc1dad2..b99444da8a 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -41,6 +41,11 @@ import ( type Runner func(context.Context) error +const ( + // TokenTimeout defines how long a login session is valid before requiring re-authentication + TokenTimeout = 30 * 24 * time.Hour // 30 days +) + func New(usage, short, long string, fn Runner, p ...preparers.Preparer) *cobra.Command { return &cobra.Command{ Use: usage, @@ -553,56 +558,98 @@ func ExcludeFromMetrics(ctx context.Context) (context.Context, error) { // RequireSession is a Preparer which makes sure a session exists. func RequireSession(ctx context.Context) (context.Context, error) { - if !flyutil.ClientFromContext(ctx).Authenticated() { - io := iostreams.FromContext(ctx) - // 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 - if io.IsInteractive() && - !env.IsCI() && - !flag.GetBool(ctx, "now") && - !flag.GetBool(ctx, "json") && - !flag.GetBool(ctx, "quiet") && - !flag.GetBool(ctx, "yes") { - - // Ask before we start opening things - confirmed, err := prompt.Confirm(ctx, "You must be logged in to do this. Would you like to sign in?") - if err != nil { - return nil, err - } - if !confirmed { - return nil, fly.ErrNoAuthToken - } + client := flyutil.ClientFromContext(ctx) + cfg := config.FromContext(ctx) - // Attempt to log the user in - token, err := webauth.RunWebLogin(ctx, false) - if err != nil { - return nil, err - } - if err := webauth.SaveToken(ctx, token); err != nil { - return nil, err - } + // Check if user is authenticated + if !client.Authenticated() { + return handleReLogin(ctx, "not_authenticated") + } - // Reload the config - logger.FromContext(ctx).Debug("reloading config after login") - if ctx, err = prepare(ctx, preparers.LoadConfig); err != nil { - return nil, err - } + // Check if the token has expired due to age + // If LastLogin is zero, it means the user has an old config without the timestamp + if cfg.LastLogin.IsZero() { + logger.FromContext(ctx).Debug("no login timestamp found, prompting for re-login") + return handleReLogin(ctx, "no_timestamp") + } - // first reset the client - ctx = flyutil.NewContextWithClient(ctx, nil) + // Check if the token has expired based on the timeout + if time.Since(cfg.LastLogin) > TokenTimeout { + logger.FromContext(ctx).Debugf("token expired (%v since login, timeout is %v)", time.Since(cfg.LastLogin), TokenTimeout) + return handleReLogin(ctx, "expired") + } - // Re-run the auth preparers to update the client with the new token - logger.FromContext(ctx).Debug("re-running auth preparers after login") - if ctx, err = prepare(ctx, authPreparers...); err != nil { - return nil, err - } + config.MonitorTokens(ctx, config.Tokens(ctx), tryOpenUserURL) + + return ctx, nil +} + +// handleReLogin prompts the user to log in and handles the re-login flow +// reason can be: "not_authenticated", "no_timestamp", or "expired" +func handleReLogin(ctx context.Context, reason string) (context.Context, error) { + io := iostreams.FromContext(ctx) + + // 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 + if io.IsInteractive() && + !env.IsCI() && + !flag.GetBool(ctx, "now") && + !flag.GetBool(ctx, "json") && + !flag.GetBool(ctx, "quiet") && + !flag.GetBool(ctx, "yes") { + + // Display styled message based on reason + colorize := io.ColorScheme() + + if reason == "no_timestamp" || reason == "expired" { + // User has been away - show welcome back message + fmt.Fprintf(io.Out, "%s\n", colorize.Purple("Welcome back!")) + fmt.Fprintf(io.Out, "Your session has expired, please log in to continue using flyctl.\n\n") + } + + // Ask before we start opening things + var promptMessage string + if reason == "not_authenticated" { + promptMessage = "You must be logged in to do this. Would you like to sign in?" } else { + promptMessage = "Would you like to sign in?" + } + + confirmed, err := prompt.Confirm(ctx, promptMessage) + if err != nil { + return nil, err + } + if !confirmed { return nil, fly.ErrNoAuthToken } - } - config.MonitorTokens(ctx, config.Tokens(ctx), tryOpenUserURL) + // Attempt to log the user in + token, err := webauth.RunWebLogin(ctx, false) + if err != nil { + return nil, err + } + if err := webauth.SaveToken(ctx, token); err != nil { + return nil, err + } - return ctx, nil + // Reload the config + logger.FromContext(ctx).Debug("reloading config after login") + if ctx, err = prepare(ctx, preparers.LoadConfig); err != nil { + return nil, err + } + + // first reset the client + ctx = flyutil.NewContextWithClient(ctx, nil) + + // Re-run the auth preparers to update the client with the new token + logger.FromContext(ctx).Debug("re-running auth preparers after login") + if ctx, err = prepare(ctx, authPreparers...); err != nil { + return nil, err + } + + return ctx, nil + } else { + return nil, fly.ErrNoAuthToken + } } // Apply uiex client to uiex diff --git a/internal/config/config.go b/internal/config/config.go index 018957db99..b9c82380ab 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "errors" "io/fs" "sync" + "time" "github.com/spf13/pflag" @@ -34,6 +35,7 @@ const ( AppSecretsMinverFileKey = "app_secrets_minvers" WireGuardStateFileKey = "wire_guard_state" WireGuardWebsocketsFileKey = "wire_guard_websockets" + LastLoginFileKey = "last_login" APITokenEnvKey = "FLY_API_TOKEN" orgEnvKey = "FLY_ORG" registryHostEnvKey = "FLY_REGISTRY_HOST" @@ -108,6 +110,9 @@ type Config struct { // MetricsToken denotes the user's metrics token. MetricsToken string + + // LastLogin denotes the timestamp of the last successful login. + LastLogin time.Time } func Load(ctx context.Context, path string) (*Config, error) { @@ -171,12 +176,13 @@ func (cfg *Config) applyFile(path string) (err error) { defer cfg.mu.Unlock() var w struct { - AccessToken string `yaml:"access_token"` - MetricsToken string `yaml:"metrics_token"` - SendMetrics bool `yaml:"send_metrics"` - AutoUpdate bool `yaml:"auto_update"` - SyntheticsAgent bool `yaml:"synthetics_agent"` - DisableManagedBuilders bool `yaml:"disable_managed_builders"` + AccessToken string `yaml:"access_token"` + MetricsToken string `yaml:"metrics_token"` + SendMetrics bool `yaml:"send_metrics"` + AutoUpdate bool `yaml:"auto_update"` + SyntheticsAgent bool `yaml:"synthetics_agent"` + DisableManagedBuilders bool `yaml:"disable_managed_builders"` + LastLogin time.Time `yaml:"last_login"` } w.SendMetrics = true w.AutoUpdate = true @@ -190,6 +196,7 @@ func (cfg *Config) applyFile(path string) (err error) { cfg.AutoUpdate = w.AutoUpdate cfg.SyntheticsAgent = w.SyntheticsAgent cfg.DisableManagedBuilders = w.DisableManagedBuilders + cfg.LastLogin = w.LastLogin } return diff --git a/internal/config/file.go b/internal/config/file.go index 77c678e0ea..8916c61ff1 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "time" "github.com/superfly/flyctl/wg" "gopkg.in/yaml.v3" @@ -33,6 +34,14 @@ func SetAccessToken(path, token string) error { }) } +// SetLastLogin sets the last login timestamp at the configuration file +// found at path. +func SetLastLogin(path string, timestamp time.Time) error { + return set(path, map[string]interface{}{ + LastLoginFileKey: timestamp, + }) +} + // SetMetricsToken sets the value of the metrics token at the configuration file // found at path. func SetMetricsToken(path, token string) error { @@ -85,12 +94,13 @@ func SetAppSecretsMinvers(path string, minvers AppSecretsMinvers) error { }) } -// Clear clears the access token, metrics token, and wireguard-related keys of the configuration +// Clear clears the access token, metrics token, last login timestamp, and wireguard-related keys of the configuration // file found at path. func Clear(path string) (err error) { return set(path, map[string]interface{}{ AccessTokenFileKey: "", MetricsTokenFileKey: "", + LastLoginFileKey: time.Time{}, // Zero value for time.Time WireGuardStateFileKey: map[string]interface{}{}, AppSecretsMinverFileKey: AppSecretsMinvers{}, }) From 46f210a7de6264bc2ba41aad975390f02d44adfe Mon Sep 17 00:00:00 2001 From: theoctopusperson <131688218+theoctopusperson@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:02:57 +0200 Subject: [PATCH 5/8] Fix deploy test to work with session timeout feature Add proper config setup with LastLogin timestamp in the deploy test to satisfy the session timeout validation in RequireSession. This ensures unit tests pass while maintaining the security feature that forces users with expired tokens to re-authenticate. --- internal/command/deploy/deploy_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/command/deploy/deploy_test.go b/internal/command/deploy/deploy_test.go index 59884a67ba..459ff689ee 100644 --- a/internal/command/deploy/deploy_test.go +++ b/internal/command/deploy/deploy_test.go @@ -8,8 +8,11 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/superfly/fly-go" + "github.com/superfly/fly-go/tokens" + "github.com/superfly/flyctl/internal/config" "github.com/superfly/flyctl/internal/flapsutil" "github.com/superfly/flyctl/internal/flyutil" "github.com/superfly/flyctl/internal/inmem" @@ -42,6 +45,13 @@ func TestCommand_Execute(t *testing.T) { ctx = task.NewWithContext(ctx) ctx = logger.NewContext(ctx, logger.New(&buf, logger.Info, true)) + // Set up config with LastLogin timestamp to satisfy session timeout check + cfg := &config.Config{ + Tokens: tokens.Parse("test-token"), + LastLogin: time.Now(), + } + ctx = config.NewContext(ctx, cfg) + server := inmem.NewServer() server.CreateApp(&fly.App{ Name: "test-basic", From 12631b2b932a5d0cef0c506fcbf3a37edceb9047 Mon Sep 17 00:00:00 2001 From: theoctopusperson <131688218+theoctopusperson@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:22:31 +0200 Subject: [PATCH 6/8] Skip session timeout for CI/CD pipelines using environment tokens When FLY_ACCESS_TOKEN or FLY_API_TOKEN environment variables are set, skip the session timeout validation. This ensures CI/CD pipelines continue to work without requiring manual re-authentication. The timestamp check only applies to interactive users with file-based config, protecting against stale sessions while not breaking automation. Tested both scenarios: - Without env var: timeout enforced after 30 days - With env var: timeout bypassed for CI/CD use --- internal/command/command.go | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/internal/command/command.go b/internal/command/command.go index b99444da8a..8e71804168 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -566,17 +566,23 @@ func RequireSession(ctx context.Context) (context.Context, error) { return handleReLogin(ctx, "not_authenticated") } - // Check if the token has expired due to age - // If LastLogin is zero, it means the user has an old config without the timestamp - if cfg.LastLogin.IsZero() { - logger.FromContext(ctx).Debug("no login timestamp found, prompting for re-login") - return handleReLogin(ctx, "no_timestamp") - } + // Skip timestamp validation if token is from environment variable (CI/CD use case) + // This allows automated pipelines to continue working without session timeout + tokenFromEnv := env.First(config.AccessTokenEnvKey, config.APITokenEnvKey) != "" + + if !tokenFromEnv { + // Check if the token has expired due to age + // If LastLogin is zero, it means the user has an old config without the timestamp + if cfg.LastLogin.IsZero() { + logger.FromContext(ctx).Debug("no login timestamp found, prompting for re-login") + return handleReLogin(ctx, "no_timestamp") + } - // Check if the token has expired based on the timeout - if time.Since(cfg.LastLogin) > TokenTimeout { - logger.FromContext(ctx).Debugf("token expired (%v since login, timeout is %v)", time.Since(cfg.LastLogin), TokenTimeout) - return handleReLogin(ctx, "expired") + // Check if the token has expired based on the timeout + if time.Since(cfg.LastLogin) > TokenTimeout { + logger.FromContext(ctx).Debugf("token expired (%v since login, timeout is %v)", time.Since(cfg.LastLogin), TokenTimeout) + return handleReLogin(ctx, "expired") + } } config.MonitorTokens(ctx, config.Tokens(ctx), tryOpenUserURL) From 77441e27086e69ea4c41a4fdbdbc860980b1bfb4 Mon Sep 17 00:00:00 2001 From: theoctopusperson <131688218+theoctopusperson@users.noreply.github.com> Date: Fri, 7 Nov 2025 12:28:41 +0200 Subject: [PATCH 7/8] Debugging for CI failures --- internal/command/command.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/command/command.go b/internal/command/command.go index 8e71804168..42981d1154 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -561,20 +561,30 @@ func RequireSession(ctx context.Context) (context.Context, error) { client := flyutil.ClientFromContext(ctx) cfg := config.FromContext(ctx) + // DEBUG: Log authentication state for troubleshooting CI failures + log := logger.FromContext(ctx) + log.Debugf("RequireSession DEBUG: client.Authenticated()=%v", client.Authenticated()) + log.Debugf("RequireSession DEBUG: cfg.LastLogin=%v, IsZero=%v", cfg.LastLogin, cfg.LastLogin.IsZero()) + log.Debugf("RequireSession DEBUG: FLY_ACCESS_TOKEN set=%v, FLY_API_TOKEN set=%v", + env.First(config.AccessTokenEnvKey, "") != "", + env.First(config.APITokenEnvKey, "") != "") + // Check if user is authenticated if !client.Authenticated() { + log.Debug("RequireSession DEBUG: client NOT authenticated, calling handleReLogin") return handleReLogin(ctx, "not_authenticated") } // Skip timestamp validation if token is from environment variable (CI/CD use case) // This allows automated pipelines to continue working without session timeout tokenFromEnv := env.First(config.AccessTokenEnvKey, config.APITokenEnvKey) != "" + log.Debugf("RequireSession DEBUG: tokenFromEnv=%v", tokenFromEnv) if !tokenFromEnv { // Check if the token has expired due to age // If LastLogin is zero, it means the user has an old config without the timestamp if cfg.LastLogin.IsZero() { - logger.FromContext(ctx).Debug("no login timestamp found, prompting for re-login") + log.Debug("RequireSession DEBUG: LastLogin is zero, calling handleReLogin") return handleReLogin(ctx, "no_timestamp") } @@ -585,6 +595,7 @@ func RequireSession(ctx context.Context) (context.Context, error) { } } + log.Debug("RequireSession DEBUG: all checks passed, session valid") config.MonitorTokens(ctx, config.Tokens(ctx), tryOpenUserURL) return ctx, nil From c547a0677c3fe5de6813c8120b9f933c38126ff9 Mon Sep 17 00:00:00 2001 From: theoctopusperson <131688218+theoctopusperson@users.noreply.github.com> Date: Fri, 7 Nov 2025 12:34:03 +0200 Subject: [PATCH 8/8] Update command.go --- internal/command/command.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/command/command.go b/internal/command/command.go index ae738ea126..01ec8716ba 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -561,26 +561,36 @@ func RequireSession(ctx context.Context) (context.Context, error) { client := flyutil.ClientFromContext(ctx) cfg := config.FromContext(ctx) + // DEBUG: Log authentication state for troubleshooting CI failures + log := logger.FromContext(ctx) + log.Debugf("RequireSession DEBUG: client.Authenticated()=%v", client.Authenticated()) + log.Debugf("RequireSession DEBUG: cfg.LastLogin=%v, IsZero=%v", cfg.LastLogin, cfg.LastLogin.IsZero()) + log.Debugf("RequireSession DEBUG: FLY_ACCESS_TOKEN set=%v, FLY_API_TOKEN set=%v", + env.First(config.AccessTokenEnvKey, "") != "", + env.First(config.APITokenEnvKey, "") != "") + // Check if user is authenticated if !client.Authenticated() { + log.Debug("RequireSession DEBUG: client NOT authenticated, calling handleReLogin") return handleReLogin(ctx, "not_authenticated") } // Skip timestamp validation if token is from environment variable (CI/CD use case) // This allows automated pipelines to continue working without session timeout tokenFromEnv := env.First(config.AccessTokenEnvKey, config.APITokenEnvKey) != "" + log.Debugf("RequireSession DEBUG: tokenFromEnv=%v", tokenFromEnv) if !tokenFromEnv { // Check if the token has expired due to age // If LastLogin is zero, it means the user has an old config without the timestamp if cfg.LastLogin.IsZero() { - logger.FromContext(ctx).Debug("no login timestamp found, prompting for re-login") + log.Debug("RequireSession DEBUG: LastLogin is zero, calling handleReLogin") return handleReLogin(ctx, "no_timestamp") } // Check if the token has expired based on the timeout if time.Since(cfg.LastLogin) > TokenTimeout { - logger.FromContext(ctx).Debugf("token expired (%v since login, timeout is %v)", time.Since(cfg.LastLogin), TokenTimeout) + log.Debugf("token expired (%v since login, timeout is %v)", time.Since(cfg.LastLogin), TokenTimeout) return handleReLogin(ctx, "expired") } }