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..01ec8716ba 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,115 @@ 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 - } + // 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() { + log.Debug("RequireSession DEBUG: LastLogin is zero, calling handleReLogin") + return handleReLogin(ctx, "no_timestamp") + } - // 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 based on the timeout + if time.Since(cfg.LastLogin) > TokenTimeout { + log.Debugf("token expired (%v since login, timeout is %v)", time.Since(cfg.LastLogin), TokenTimeout) + return handleReLogin(ctx, "expired") + } + } - // first reset the client - ctx = flyutil.NewContextWithClient(ctx, nil) + log.Debug("RequireSession DEBUG: all checks passed, session valid") + config.MonitorTokens(ctx, config.Tokens(ctx), tryOpenUserURL) - // 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 +} + +// 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/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", 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{}, })