feat(session): add feature toggle for automatic refreshing

This commit is contained in:
Trong Huu Nguyen
2023-09-15 08:58:12 +02:00
parent 0b3cd4d9f6
commit c4911b1344
6 changed files with 85 additions and 13 deletions

View File

@@ -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` |

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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
}