diff --git a/docs/go-c8y-cli/docs/configuration/settings.md b/docs/go-c8y-cli/docs/configuration/settings.md index 1e3e95f13..ba55164bc 100644 --- a/docs/go-c8y-cli/docs/configuration/settings.md +++ b/docs/go-c8y-cli/docs/configuration/settings.md @@ -417,6 +417,10 @@ Enable `UPDATE` commands. If set to `false` then all `UPDATE` related commands w Default username which is used when creating a new command via `c8y sessions create` +### session.tokenValidFor: string + +The minimum duration (e.g. `8h`) that a token should be valid for in order to reuse it. You can control when to renew it when setting the active session, and ignore the token if it is to expire soon based on the duration. + ### storage.storepassword: boolean Enable storage of your password in your session file. Disable if you do not want to store sensitive information to file. However this means you will be prompted for your password when you select a session via `set-session`. diff --git a/go.mod b/go.mod index 256f528d7..8c98e35b7 100644 --- a/go.mod +++ b/go.mod @@ -47,6 +47,7 @@ require github.com/hashicorp/go-version v1.6.0 require ( github.com/cli/browser v1.3.0 + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/hashicorp/go-retryablehttp v0.7.5 github.com/reubenmiller/gojsonq/v2 v2.0.0-20221119213524-0fd921ac20a3 ) diff --git a/go.sum b/go.sum index 15085c4ea..5bc1f97cb 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,8 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= diff --git a/pkg/cmd/sessions/set/set.manual.go b/pkg/cmd/sessions/set/set.manual.go index 1ce6627ab..812a0c264 100644 --- a/pkg/cmd/sessions/set/set.manual.go +++ b/pkg/cmd/sessions/set/set.manual.go @@ -6,6 +6,7 @@ import ( "time" "github.com/MakeNowJust/heredoc/v2" + "github.com/golang-jwt/jwt/v5" "github.com/reubenmiller/go-c8y-cli/v2/pkg/c8ylogin" "github.com/reubenmiller/go-c8y-cli/v2/pkg/c8ysession" "github.com/reubenmiller/go-c8y-cli/v2/pkg/cmd/factory" @@ -154,6 +155,19 @@ func (n *CmdSet) RunE(cmd *cobra.Command, args []string) error { if n.ClearToken { client.SetToken("") + } else { + // Check if token is valid for the minimum period + if tok := cfg.MustGetToken(); tok != "" { + shouldBeValidFor := cfg.TokenValidFor() + expiresSoon, expiresAt := ShouldRenewToken(tok, shouldBeValidFor) + + if expiresSoon { + log.Warnf("Ignoring existing token as it will expire soon. minimumValidFor=%s, tokenExpiresAt=%s", shouldBeValidFor, expiresAt.Format(time.RFC3339)) + client.SetToken("") + } else if expiresAt != nil { + log.Infof("Token expiresAt: %s", expiresAt.Format(time.RFC3339)) + } + } } if err := utilities.CheckEncryption(n.factory.IOStreams, cfg, client); err != nil { @@ -233,3 +247,21 @@ func hasChanged(client *c8y.Client, cfg *config.Config) bool { } return false } + +func ShouldRenewToken(t string, validFor time.Duration) (bool, *time.Time) { + claims := jwt.RegisteredClaims{} + parser := jwt.NewParser() + _, _, err := parser.ParseUnverified(t, &claims) + + if err != nil { + // Invalid token + return true, nil + } + + if claims.ExpiresAt != nil { + limit := claims.ExpiresAt.Add(-1 * validFor) + expiresSoon := limit.Before(time.Now()) + return expiresSoon, &claims.ExpiresAt.Time + } + return true, nil +} diff --git a/pkg/cmd/settings/update/update.manual.go b/pkg/cmd/settings/update/update.manual.go index a7f2643d3..76924da5d 100644 --- a/pkg/cmd/settings/update/update.manual.go +++ b/pkg/cmd/settings/update/update.manual.go @@ -303,6 +303,13 @@ var updateSettingsOptions = map[string]argumentHandler{ "true", "false", }, nil, cobra.ShellCompDirectiveNoFileComp}, + "session.tokenValidFor": {"session.tokenValidFor", "string", config.SettingsSessionTokenValidFor, []string{ + "1h", + "8h", + "24h", + "48h", + "7d", + }, nil, cobra.ShellCompDirectiveNoFileComp}, // cache "defaults.cache": {"defaults.cache", "bool", config.SettingsDefaultsCacheEnabled, []string{ diff --git a/pkg/config/cliConfiguration.go b/pkg/config/cliConfiguration.go index a4ec3ea02..7305d2f00 100644 --- a/pkg/config/cliConfiguration.go +++ b/pkg/config/cliConfiguration.go @@ -291,6 +291,9 @@ const ( // SettingsSessionAlwaysIncludePassword should the password always be included in the session variables or not SettingsSessionAlwaysIncludePassword = "settings.session.alwaysIncludePassword" + // SettingsSessionTokenValidFor interval which the token must be valid for in order to reuse it + SettingsSessionTokenValidFor = "settings.session.tokenValidFor" + // Cache settings // SettingsDefaultsCacheEnabled enable caching SettingsDefaultsCacheEnabled = "settings.defaults.cache" @@ -498,6 +501,7 @@ func (c *Config) bindSettings() { // Session options WithBindEnv(SettingsSessionAlwaysIncludePassword, false), + WithBindEnv(SettingsSessionTokenValidFor, "8h"), WithBindEnv(SettingsBrowser, ""), @@ -978,6 +982,17 @@ func (c *Config) AlwaysIncludePassword() bool { return c.viper.GetBool(SettingsSessionAlwaysIncludePassword) } +// TokenValidFor minimum validity of a token in order to reuse it +func (c *Config) TokenValidFor() time.Duration { + value := c.viper.GetString(SettingsSessionTokenValidFor) + duration, err := flags.GetDuration(value, true, time.Second) + if err != nil { + c.Logger.Warnf("Invalid duration. value=%s, err=%s", duration, err) + return 0 + } + return duration +} + // CachePassphraseVariables return true if the passphrase variables should be persisted or not func (c *Config) CachePassphraseVariables() bool { return c.viper.GetBool(SettingEncryptionCachePassphrase)