Skip to content
Open
5 changes: 5 additions & 0 deletions internal/command/auth/webauth/webauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
146 changes: 105 additions & 41 deletions internal/command/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions internal/command/deploy/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
19 changes: 13 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"io/fs"
"sync"
"time"

"github.com/spf13/pflag"

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 11 additions & 1 deletion internal/config/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"os"
"path/filepath"
"time"

"github.com/superfly/flyctl/wg"
"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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{},
})
Expand Down
Loading