diff --git a/docs/configuration.md b/docs/configuration.md index 1597229..63836a6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,7 +38,10 @@ The following flags are available: | `session.inactivity` | boolean | Automatically expire user sessions if they have not refreshed their tokens within a given duration. | | | `session.inactivity-timeout` | duration | Inactivity timeout for user sessions. | `30m` | | `session.max-lifetime` | duration | Max lifetime for user sessions. | `1h` | -| `session.refresh` | boolean | Enable refresh tokens. In standalone mode, will automatically refresh tokens if they are expired as long as the session is valid (i.e. not exceeding `session.max-lifetime` or `session.inactivity-timeout`). | | +| `session.refresh` | boolean | Enable refresh tokens. | | +| `session.refresh-auto` | boolean | Enable automatic refresh of tokens. Only available in standalone mode. Will automatically refresh tokens if they are expired as long as the session is valid (i.e. not exceeding `session.max-lifetime` or `session.inactivity-timeout`). | | +| `shutdown-graceful-period` | duration | Graceful shutdown period when receiving a shutdown signal after which the server is forcibly exited. | | +| `shutdown-wait-before-period` | duration | Wait period when receiving a shutdown signal before actually starting a graceful shutdown. Useful for allowing propagation of Endpoint updates in Kubernetes. | | | `sso.domain` | string | The domain that the session cookies should be set for, usually the second-level domain name (e.g. `example.com`). | | | `sso.enabled` | boolean | Enable single sign-on mode; one server acting as the OIDC Relying Party, and N proxies. The proxies delegate most endpoint operations to the server, and only implements a reverse proxy that reads the user's session data from the shared store. | | | `sso.mode` | string | The SSO mode for this instance. Must be one of `server` or `proxy`. | `server` | diff --git a/docs/sessions.md b/docs/sessions.md index cb17bfb..f6e9ba5 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -29,12 +29,13 @@ The ability to refresh tokens requires the `session.refresh` flag to be enabled. The behaviour for refreshing depends on the [runtime mode](configuration.md#modes) for Wonderwall. -In standalone mode, tokens will at the _earliest_ automatically be renewed 5 minutes before they expire. +In standalone mode, tokens can automatically be refreshed by enabling the `session.refresh-auto` flag. +If enabled, token will at the _earliest_ automatically be renewed 5 minutes before they expire. If the token already _has_ expired, a refresh attempt is still automatically triggered as long as the session itself not has ended or is marked as inactive. Automatic refreshes happens whenever the end-user visits or requests any path that is proxied to the upstream application. -In SSO mode, tokens are not automatically refreshed, and must be manually refreshed by performing a request to [the `/oauth2/session/refresh` endpoint](endpoints.md#oauth2sessionrefresh). +In SSO mode, tokens can not be automatically refreshed. They must be refreshed by performing a request to [the `/oauth2/session/refresh` endpoint](endpoints.md#oauth2sessionrefresh). ## Session Inactivity diff --git a/pkg/config/config.go b/pkg/config/config.go index 60f52b0..b16dec4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -42,6 +42,7 @@ type Session struct { InactivityTimeout time.Duration `json:"inactivity-timeout"` MaxLifetime time.Duration `json:"max-lifetime"` Refresh bool `json:"refresh"` + RefreshAuto bool `json:"refresh-auto"` } type SSO struct { @@ -86,6 +87,7 @@ const ( SessionInactivityTimeout = "session.inactivity-timeout" SessionMaxLifetime = "session.max-lifetime" SessionRefresh = "session.refresh" + SessionRefreshAuto = "session.refresh-auto" SSOEnabled = "sso.enabled" SSODomain = "sso.domain" SSOModeFlag = "sso.mode" @@ -116,7 +118,8 @@ func Initialize() (*Config, error) { flag.Bool(SessionInactivity, false, "Automatically expire user sessions if they have not refreshed their tokens within a given duration.") flag.Duration(SessionInactivityTimeout, 30*time.Minute, "Inactivity timeout for user sessions.") flag.Duration(SessionMaxLifetime, time.Hour, "Max lifetime for user sessions.") - flag.Bool(SessionRefresh, false, "Enable refresh tokens. In standalone mode, will automatically refresh tokens if they are expired as long as the session is valid (i.e. not exceeding 'session.max-lifetime' or 'session.inactivity-timeout').") + flag.Bool(SessionRefresh, false, "Enable refresh tokens.") + flag.Bool(SessionRefreshAuto, false, "Enable automatic refresh of tokens. Only available in standalone mode. Will automatically refresh tokens if they are expired as long as the session is valid (i.e. not exceeding 'session.max-lifetime' or 'session.inactivity-timeout').") flag.Bool(SSOEnabled, false, "Enable single sign-on mode; one server acting as the OIDC Relying Party, and N proxies. The proxies delegate most endpoint operations to the server, and only implements a reverse proxy that reads the user's session data from the shared store.") flag.String(SSODomain, "", "The domain that the session cookies should be set for, usually the second-level domain name (e.g. example.com).") @@ -186,6 +189,10 @@ func (c *Config) Validate() error { return fmt.Errorf("%q cannot be enabled without %q", SessionInactivity, SessionRefresh) } + if c.Session.RefreshAuto && !c.Session.Refresh { + return fmt.Errorf("%q cannot be enabled without %q", SessionRefreshAuto, SessionRefresh) + } + if c.SSO.Enabled { if len(c.Redis.Address) == 0 { return fmt.Errorf("%q must not be empty when %s is set", RedisAddress, SSOEnabled) @@ -195,6 +202,10 @@ func (c *Config) Validate() error { return fmt.Errorf("%q must not be empty when %s is set", SSOSessionCookieName, SSOEnabled) } + if c.Session.RefreshAuto { + return fmt.Errorf("%q cannot be enabled when %q is enabled", SessionRefreshAuto, SSOEnabled) + } + switch c.SSO.Mode { case SSOModeProxy: _, err := url.ParseRequestURI(c.SSO.ServerURL) diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go index c810c63..5c142f8 100644 --- a/pkg/handler/handler.go +++ b/pkg/handler/handler.go @@ -388,7 +388,7 @@ func (s *Standalone) sessionWriteMetadataResponse(w http.ResponseWriter, r *http } metadata := sess.MetadataVerboseRefresh() - if s.Config.SSO.Enabled { + if !s.Config.Session.RefreshAuto { metadata.Tokens.NextAutoRefreshInSeconds = int64(-1) } diff --git a/pkg/handler/handler_test.go b/pkg/handler/handler_test.go index 048413e..ab5312e 100644 --- a/pkg/handler/handler_test.go +++ b/pkg/handler/handler_test.go @@ -210,9 +210,9 @@ func TestSessionRefresh(t *testing.T) { assert.LessOrEqual(t, tokenExpiryDuration, idp.ProviderHandler.TokenDuration) assert.Greater(t, tokenExpiryDuration, time.Second) - // 1 second < next token refresh <= seconds until token expires - assert.LessOrEqual(t, refreshedData.Tokens.NextAutoRefreshInSeconds, refreshedData.Tokens.ExpireInSeconds) - assert.Greater(t, refreshedData.Tokens.NextAutoRefreshInSeconds, int64(1)) + // auto refresh is not enabled + assert.Less(t, refreshedData.Tokens.NextAutoRefreshInSeconds, refreshedData.Tokens.ExpireInSeconds) + assert.Equal(t, refreshedData.Tokens.NextAutoRefreshInSeconds, int64(-1)) assert.True(t, refreshedData.Tokens.RefreshCooldown) // 1 second < refresh cooldown <= minimum refresh interval @@ -296,10 +296,43 @@ func TestSessionRefresh_WithInactivity(t *testing.T) { assert.WithinDuration(t, expectedTimeoutAt, time.Now().Add(refreshedTimeoutDuration), maxDelta) } -func TestSession(t *testing.T) { +func TestSessionRefresh_WithRefreshAuto(t *testing.T) { cfg := mock.Config() cfg.Session.Refresh = true + cfg.Session.RefreshAuto = true + idp := mock.NewIdentityProvider(cfg) + idp.ProviderHandler.TokenDuration = 5 * time.Second + defer idp.Close() + + rpClient := idp.RelyingPartyClient() + login(t, rpClient, idp) + + // get initial session info + resp := sessionInfo(t, idp, rpClient) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var data session.MetadataVerboseWithRefresh + err := json.Unmarshal([]byte(resp.Body), &data) + assert.NoError(t, err) + + // wait until refresh cooldown has reached zero before refresh + waitForRefreshCooldownTimer(t, idp, rpClient) + + resp = sessionRefresh(t, idp, rpClient) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var refreshedData session.MetadataVerboseWithRefresh + err = json.Unmarshal([]byte(resp.Body), &refreshedData) + assert.NoError(t, err) + + // 1 second < next token refresh <= seconds until token expires + assert.LessOrEqual(t, refreshedData.Tokens.NextAutoRefreshInSeconds, refreshedData.Tokens.ExpireInSeconds) + assert.Greater(t, refreshedData.Tokens.NextAutoRefreshInSeconds, int64(1)) +} + +func TestSession(t *testing.T) { + cfg := mock.Config() idp := mock.NewIdentityProvider(cfg) idp.ProviderHandler.TokenDuration = 5 * time.Minute defer idp.Close() @@ -400,9 +433,9 @@ func TestSession_WithRefresh(t *testing.T) { assert.LessOrEqual(t, tokenExpiryDuration, idp.ProviderHandler.TokenDuration) assert.Greater(t, tokenExpiryDuration, time.Second) - // 1 second < next token refresh <= seconds until token expires - assert.LessOrEqual(t, data.Tokens.NextAutoRefreshInSeconds, data.Tokens.ExpireInSeconds) - assert.Greater(t, data.Tokens.NextAutoRefreshInSeconds, int64(1)) + // auto refresh is not enabled + assert.Less(t, data.Tokens.NextAutoRefreshInSeconds, data.Tokens.ExpireInSeconds) + assert.Equal(t, data.Tokens.NextAutoRefreshInSeconds, int64(-1)) assert.True(t, data.Tokens.RefreshCooldown) // 1 second < refresh cooldown <= minimum refresh interval @@ -414,6 +447,30 @@ func TestSession_WithRefresh(t *testing.T) { assert.Equal(t, int64(-1), data.Session.TimeoutInSeconds) } +func TestSession_WithRefreshAuto(t *testing.T) { + cfg := mock.Config() + cfg.Session.Refresh = true + cfg.Session.RefreshAuto = true + + idp := mock.NewIdentityProvider(cfg) + idp.ProviderHandler.TokenDuration = 5 * time.Minute + defer idp.Close() + + rpClient := idp.RelyingPartyClient() + login(t, rpClient, idp) + + resp := sessionInfo(t, idp, rpClient) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var data session.MetadataVerboseWithRefresh + err := json.Unmarshal([]byte(resp.Body), &data) + assert.NoError(t, err) + + // 1 second < next token refresh <= seconds until token expires + assert.LessOrEqual(t, data.Tokens.NextAutoRefreshInSeconds, data.Tokens.ExpireInSeconds) + assert.Greater(t, data.Tokens.NextAutoRefreshInSeconds, int64(1)) +} + func TestPing(t *testing.T) { cfg := mock.Config() idp := mock.NewIdentityProvider(cfg) diff --git a/pkg/session/session_manager.go b/pkg/session/session_manager.go index 950e559..49a3418 100644 --- a/pkg/session/session_manager.go +++ b/pkg/session/session_manager.go @@ -108,7 +108,7 @@ func (in *manager) GetOrRefresh(r *http.Request) (*Session, error) { return nil, fmt.Errorf("getting session: %w", err) } - if !sess.shouldRefresh() { + if !in.cfg.Session.RefreshAuto || !sess.shouldRefresh() { return sess, nil }