feat: remove feature flags for session refresh

These feature flags were enabled by default. We specifically disallowed
the use of automatic refresh with the SSO mode, though this poses some
complexity if using the forward-auth feature.

To simplify configuration and code, we remove the flags in their
entirety as session refresh behaviour is mostly already handled by the
implementation of GetSession() in the handlers. Specifically:

- the Standalone handler needs to refresh sessions when reverse-proxying
  to the upstream.
- the SSO server handler needs to refresh sessions only when using the
  forward-auth feature. It does not have an upstream to reverse proxy
  to.
- the SSO proxy handler is a read-only upstream proxy and does not
  possess the ability to refresh sessions itself, though it will
  delegate traffic for the session endpoints to the configured SSO server.

Automatic refreshing is thus only disabled when running in SSO mode
without the forward-auth feature.
This commit is contained in:
Trong Huu Nguyen
2025-01-16 10:14:15 +01:00
parent 0258ce7cfd
commit 3143940b08
13 changed files with 257 additions and 462 deletions

View File

@@ -51,8 +51,6 @@ The following flags are available:
| `session.inactivity` | boolean | `false` | Automatically expire user sessions if they have not refreshed their tokens within a given duration. |
| `session.inactivity-timeout` | duration | `30m` | Inactivity timeout for user sessions. |
| `session.max-lifetime` | duration | `10h` | Max lifetime for user sessions. |
| `session.refresh` | boolean | `true` | Enable refresh tokens. |
| `session.refresh-auto` | boolean | `true` | 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 | `30s` | Graceful shutdown period when receiving a shutdown signal after which the server is forcibly exited. |
| `shutdown-wait-before-period` | duration | `0s` | 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`). |

View File

@@ -6,14 +6,14 @@ Wonderwall exposes and owns these endpoints (which means they will never be prox
Endpoints that are available for use by applications:
| Path | Description | Notes |
|-----------------------------------|----------------------------------------------------------------------|---------------------------------------------------|
| `GET /oauth2/login` | Initiates the OpenID Connect Authorization Code flow | |
| `GET /oauth2/logout` | Performs local logout and redirects the user to global/single-logout | |
| `GET /oauth2/logout/local` | Performs local logout only | Disabled when `openid.provider` is `idporten`. |
| `GET /oauth2/session` | Returns the current user's session metadata | |
| `POST /oauth2/session/refresh` | Refreshes the tokens for the user's session. | Requires the `session.refresh` flag to be enabled |
| `GET /oauth2/session/forwardauth` | Checks the user's session and refreshes it, if necessary. | |
| Path | Description | Notes |
|-----------------------------------|----------------------------------------------------------------------|------------------------------------------------|
| `GET /oauth2/login` | Initiates the OpenID Connect Authorization Code flow | |
| `GET /oauth2/logout` | Performs local logout and redirects the user to global/single-logout | |
| `GET /oauth2/logout/local` | Performs local logout only | Disabled when `openid.provider` is `idporten`. |
| `GET /oauth2/session` | Returns the current user's session metadata | |
| `POST /oauth2/session/refresh` | Refreshes the tokens for the user's session. | |
| `GET /oauth2/session/forwardauth` | Checks the user's session and refreshes it, if necessary. | |
## Endpoints for Identity Providers
@@ -129,7 +129,10 @@ Content-Type: application/json
"tokens": {
"expire_at": "2022-08-31T14:03:47.318251953Z",
"refreshed_at": "2022-08-31T12:53:58.318251953Z",
"expire_in_seconds": 4166
"expire_in_seconds": 4166,
"next_auto_refresh_in_seconds": -1,
"refresh_cooldown": false,
"refresh_cooldown_seconds": 0
}
}
```
@@ -145,40 +148,6 @@ Content-Type: application/json
| `tokens.expire_at` | The timestamp that denotes when the tokens within the session will expire. |
| `tokens.expire_in_seconds` | The number of seconds until `tokens.expire_at`. |
| `tokens.refreshed_at` | The timestamp that denotes when the tokens within the session was last refreshed. |
If the `session.refresh` flag is enabled, the metadata response will contain a few additional fields:
#### Request:
```
GET /oauth2/session
```
#### Response:
```
HTTP/2 200 OK
Content-Type: application/json
```
```json
{
"session": {
...
},
"tokens": {
...
"next_auto_refresh_in_seconds": -1,
"refresh_cooldown": false,
"refresh_cooldown_seconds": 0
}
}
```
(fields shown earlier are omitted from this example for brevity)
| Field | Description |
|---------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `tokens.next_auto_refresh_in_seconds` | The number of seconds until the earliest time where the tokens will automatically be refreshed. A value of -1 means that automatic refreshing is not enabled. |
| `tokens.refresh_cooldown` | A boolean indicating whether or not the refresh operation is on cooldown or not. |
| `tokens.refresh_cooldown_seconds` | The number of seconds until the refresh operation is no longer on cooldown. |
@@ -187,8 +156,6 @@ Content-Type: application/json
### `/oauth2/session/refresh`
This endpoint only exists if the `session.refresh` flag is enabled.
Perform a `POST` request from the user agent to this endpoint to manually refresh the tokens for the user's session.
The endpoint will respond with a `HTTP 401 Unauthorized` if the session is [_inactive_](sessions.md#session-inactivity).

View File

@@ -31,14 +31,12 @@ This is indicated by the `tokens.expire_at` and `tokens.expire_in_seconds` field
If you've configured a session lifetime that is longer than the token expiry, you'll probably want to _refresh_ the tokens to avoid redirecting end-users to the `/oauth2/login` endpoint whenever the access tokens have expired.
The ability to refresh tokens requires the `session.refresh` flag to be enabled.
### Automatic vs Manual Refresh
The behaviour for refreshing depends on the [runtime mode](configuration.md#modes) for Wonderwall.
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.
In standalone mode, tokens are automatically refreshed.
Tokens 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.
@@ -53,7 +51,7 @@ This happens if the time since the last _refresh_ exceeds the given _inactivity
An _inactive_ session _cannot_ be refreshed; a new session must be acquired by redirecting the user to the `/oauth2/login` endpoint.
This is useful if you want to ensure that an end-user can re-authenticate with the identity provider if they've been gone from an authenticated session for some time.
Inactivity support is enabled with the `session.inactivity` option, which also requires `session.refresh`.
Inactivity support is enabled with the `session.inactivity` option.
The activity state of the session is indicated by the `session.active` field in the session metadata.

View File

@@ -138,10 +138,6 @@ func (c *Config) Validate() error {
return err
}
if err := c.Session.Validate(); err != nil {
return err
}
if err := c.SSO.Validate(c); err != nil {
return err
}
@@ -157,6 +153,10 @@ func (c *Config) Validate() error {
return nil
}
func (c *Config) AutoRefreshDisabled() bool {
return c.SSO.Enabled && !c.Session.ForwardAuth
}
func (c *Config) validateUpstream() error {
if c.UpstreamIP == "" && c.UpstreamPort == 0 {
return nil

View File

@@ -40,20 +40,6 @@ func TestConfig_Validate(t *testing.T) {
cfg.Cookie.SameSite = "invalid"
},
},
{
"session inactivity without session refresh",
func(cfg *config.Config) {
cfg.Session.Inactivity = true
cfg.Session.Refresh = false
},
},
{
"session auto refresh without session refresh",
func(cfg *config.Config) {
cfg.Session.RefreshAuto = true
cfg.Session.Refresh = false
},
},
{
"upstream ip must be set if port is set",
func(cfg *config.Config) {
@@ -111,7 +97,6 @@ func TestConfig_Validate(t *testing.T) {
server.SSO.Domain = "example.com"
server.SSO.SessionCookieName = "some-cookie"
server.SSO.ServerDefaultRedirectURL = "https://default.local"
server.Session.RefreshAuto = false
server.Redis.Address = "localhost:6379"
run("sso server", &server, []test{
@@ -127,12 +112,6 @@ func TestConfig_Validate(t *testing.T) {
cfg.SSO.SessionCookieName = ""
},
},
{
"with session auto refresh",
func(cfg *config.Config) {
cfg.Session.RefreshAuto = true
},
},
{
"missing session cookie name",
func(cfg *config.Config) {
@@ -164,7 +143,6 @@ func TestConfig_Validate(t *testing.T) {
proxy.SSO.Mode = config.SSOModeProxy
proxy.SSO.ServerURL = "https://sso-server.local"
proxy.SSO.SessionCookieName = "some-cookie"
proxy.Session.RefreshAuto = false
proxy.Redis.Address = "localhost:6379"
run("sso proxy", &proxy, []test{
@@ -180,12 +158,6 @@ func TestConfig_Validate(t *testing.T) {
cfg.SSO.SessionCookieName = ""
},
},
{
"with session auto refresh",
func(cfg *config.Config) {
cfg.Session.RefreshAuto = true
},
},
{
"missing session cookie name",
func(cfg *config.Config) {

View File

@@ -1,7 +1,6 @@
package config
import (
"fmt"
"time"
flag "github.com/spf13/pflag"
@@ -12,20 +11,6 @@ type Session struct {
Inactivity bool `json:"inactivity"`
InactivityTimeout time.Duration `json:"inactivity-timeout"`
MaxLifetime time.Duration `json:"max-lifetime"`
Refresh bool `json:"refresh"`
RefreshAuto bool `json:"refresh-auto"`
}
func (s Session) Validate() error {
if s.Inactivity && !s.Refresh {
return fmt.Errorf("%q cannot be enabled without %q", SessionInactivity, SessionRefresh)
}
if s.RefreshAuto && !s.Refresh {
return fmt.Errorf("%q cannot be enabled without %q", SessionRefreshAuto, SessionRefresh)
}
return nil
}
const (
@@ -33,8 +18,6 @@ const (
SessionInactivity = "session.inactivity"
SessionInactivityTimeout = "session.inactivity-timeout"
SessionMaxLifetime = "session.max-lifetime"
SessionRefresh = "session.refresh"
SessionRefreshAuto = "session.refresh-auto"
)
func sessionFlags() {
@@ -42,6 +25,4 @@ func sessionFlags() {
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, 10*time.Hour, "Max lifetime for user sessions.")
flag.Bool(SessionRefresh, true, "Enable refresh tokens.")
flag.Bool(SessionRefreshAuto, true, "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').")
}

View File

@@ -36,10 +36,6 @@ func (s SSO) Validate(c *Config) error {
return fmt.Errorf("at least one of %q or %q must be set when %s is set", RedisAddress, RedisURI, SSOEnabled)
}
if c.Session.RefreshAuto {
return fmt.Errorf("%q cannot be enabled when %q is enabled", SessionRefreshAuto, SSOEnabled)
}
if len(s.SessionCookieName) == 0 {
return fmt.Errorf("%q must not be empty when %s is set", SSOSessionCookieName, SSOEnabled)
}

View File

@@ -112,6 +112,9 @@ func (s *Standalone) GetPath(r *http.Request) string {
}
func (s *Standalone) GetSession(r *http.Request) (*session.Session, error) {
if s.Config.AutoRefreshDisabled() {
return s.SessionManager.Get(r)
}
return s.SessionManager.GetOrRefresh(r)
}
@@ -389,11 +392,6 @@ func (s *Standalone) Session(w http.ResponseWriter, r *http.Request) {
}
func (s *Standalone) SessionRefresh(w http.ResponseWriter, r *http.Request) {
if !s.Config.Session.Refresh {
http.NotFound(w, r)
return
}
logger := mw.LogEntryFrom(r)
sess, err := s.SessionManager.Get(r)
@@ -428,12 +426,8 @@ func (s *Standalone) SessionRefresh(w http.ResponseWriter, r *http.Request) {
func (s *Standalone) sessionWriteMetadataResponse(w http.ResponseWriter, r *http.Request, sess *session.Session) error {
w.Header().Set("Content-Type", "application/json")
if !s.Config.Session.Refresh {
return json.NewEncoder(w).Encode(sess.MetadataVerbose())
}
metadata := sess.MetadataVerboseRefresh()
if !s.Config.Session.RefreshAuto {
metadata := sess.MetadataVerbose()
if s.Config.AutoRefreshDisabled() {
metadata.Tokens.NextAutoRefreshInSeconds = int64(-1)
}
@@ -446,7 +440,7 @@ func (s *Standalone) SessionForwardAuth(w http.ResponseWriter, r *http.Request)
return
}
_, err := s.SessionManager.GetOrRefresh(r)
_, err := s.GetSession(r)
if err != nil {
logger := mw.LogEntryFrom(r)
if errors.Is(err, session.ErrInvalidExternal) || errors.Is(err, session.ErrInvalid) || errors.Is(err, session.ErrNotFound) {

View File

@@ -15,6 +15,7 @@ import (
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/stretchr/testify/assert"
"github.com/nais/wonderwall/pkg/config"
"github.com/nais/wonderwall/pkg/cookie"
"github.com/nais/wonderwall/pkg/mock"
"github.com/nais/wonderwall/pkg/session"
@@ -203,240 +204,24 @@ func TestFrontChannelLogout(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestSessionRefresh(t *testing.T) {
cfg := mock.Config()
cfg.Session.Refresh = true
idp := mock.NewIdentityProvider(cfg)
idp.ProviderHandler.TokenDuration = 5 * time.Second
defer idp.Close()
rpClient := idp.RelyingPartyClient()
noSessionResp := sessionInfo(t, idp, rpClient)
assert.Equal(t, http.StatusUnauthorized, noSessionResp.StatusCode)
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)
// session create and end times should be unchanged
assert.WithinDuration(t, data.Session.CreatedAt, refreshedData.Session.CreatedAt, 0)
assert.WithinDuration(t, data.Session.EndsAt, refreshedData.Session.EndsAt, 0)
// token expiration and refresh times should be later than before
assert.True(t, refreshedData.Tokens.ExpireAt.After(data.Tokens.ExpireAt))
assert.True(t, refreshedData.Tokens.RefreshedAt.After(data.Tokens.RefreshedAt))
allowedSkew := 5 * time.Second
assert.WithinDuration(t, time.Now().Add(idp.ProviderHandler.TokenDuration), refreshedData.Tokens.ExpireAt, allowedSkew)
assert.WithinDuration(t, time.Now(), refreshedData.Tokens.RefreshedAt, allowedSkew)
sessionEndDuration := time.Duration(refreshedData.Session.EndsInSeconds) * time.Second
// 1 second < time until session ends <= configured max session lifetime
assert.LessOrEqual(t, sessionEndDuration, cfg.Session.MaxLifetime)
assert.Greater(t, sessionEndDuration, time.Second)
tokenExpiryDuration := time.Duration(refreshedData.Tokens.ExpireInSeconds) * time.Second
// 1 second < time until token expires <= max duration for tokens from IDP
assert.LessOrEqual(t, tokenExpiryDuration, idp.ProviderHandler.TokenDuration)
assert.Greater(t, tokenExpiryDuration, time.Second)
// 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
assert.LessOrEqual(t, refreshedData.Tokens.RefreshCooldownSeconds, session.RefreshMinInterval)
assert.Greater(t, refreshedData.Tokens.RefreshCooldownSeconds, int64(1))
assert.True(t, data.Session.Active)
assert.True(t, refreshedData.Session.Active)
assert.True(t, data.Session.TimeoutAt.IsZero())
assert.True(t, refreshedData.Session.TimeoutAt.IsZero())
assert.Equal(t, int64(-1), data.Session.TimeoutInSeconds)
assert.Equal(t, int64(-1), refreshedData.Session.TimeoutInSeconds)
}
func TestSessionRefresh_Disabled(t *testing.T) {
cfg := mock.Config()
cfg.Session.Refresh = false
idp := mock.NewIdentityProvider(cfg)
idp.ProviderHandler.TokenDuration = 5 * time.Second
defer idp.Close()
rpClient := idp.RelyingPartyClient()
login(t, rpClient, idp)
resp := sessionRefresh(t, idp, rpClient)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestSessionRefresh_WithInactivity(t *testing.T) {
cfg := mock.Config()
cfg.Session.Refresh = true
cfg.Session.Inactivity = true
cfg.Session.InactivityTimeout = 10 * time.Minute
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)
maxDelta := 5 * time.Second
assert.True(t, data.Session.Active)
assert.True(t, refreshedData.Session.Active)
assert.False(t, data.Session.TimeoutAt.IsZero())
assert.False(t, refreshedData.Session.TimeoutAt.IsZero())
expectedTimeoutAt := time.Now().Add(cfg.Session.InactivityTimeout)
assert.WithinDuration(t, expectedTimeoutAt, data.Session.TimeoutAt, maxDelta)
assert.WithinDuration(t, expectedTimeoutAt, refreshedData.Session.TimeoutAt, maxDelta)
assert.True(t, refreshedData.Session.TimeoutAt.After(data.Session.TimeoutAt))
previousTimeoutDuration := time.Duration(data.Session.TimeoutInSeconds) * time.Second
assert.WithinDuration(t, expectedTimeoutAt, time.Now().Add(previousTimeoutDuration), maxDelta)
refreshedTimeoutDuration := time.Duration(refreshedData.Session.TimeoutInSeconds) * time.Second
assert.WithinDuration(t, expectedTimeoutAt, time.Now().Add(refreshedTimeoutDuration), maxDelta)
}
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()
rpClient := idp.RelyingPartyClient()
noSessionResp := sessionInfo(t, idp, rpClient)
assert.Equal(t, http.StatusUnauthorized, noSessionResp.StatusCode)
login(t, rpClient, idp)
resp := sessionInfo(t, idp, rpClient)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var data session.MetadataVerbose
err := json.Unmarshal([]byte(resp.Body), &data)
assert.NoError(t, err)
allowedSkew := 5 * time.Second
assert.WithinDuration(t, time.Now(), data.Session.CreatedAt, allowedSkew)
assert.WithinDuration(t, time.Now().Add(cfg.Session.MaxLifetime), data.Session.EndsAt, allowedSkew)
assert.WithinDuration(t, time.Now().Add(idp.ProviderHandler.TokenDuration), data.Tokens.ExpireAt, allowedSkew)
assert.WithinDuration(t, time.Now(), data.Tokens.RefreshedAt, allowedSkew)
sessionEndDuration := time.Duration(data.Session.EndsInSeconds) * time.Second
// 1 second < time until session ends <= configured max session lifetime
assert.LessOrEqual(t, sessionEndDuration, cfg.Session.MaxLifetime)
assert.Greater(t, sessionEndDuration, time.Second)
tokenExpiryDuration := time.Duration(data.Tokens.ExpireInSeconds) * time.Second
// 1 second < time until token expires <= max duration for tokens from IDP
assert.LessOrEqual(t, tokenExpiryDuration, idp.ProviderHandler.TokenDuration)
assert.Greater(t, tokenExpiryDuration, time.Second)
assert.True(t, data.Session.Active)
assert.True(t, data.Session.TimeoutAt.IsZero())
assert.Equal(t, int64(-1), data.Session.TimeoutInSeconds)
data := assertSessionInfo(t, idp, cfg)
assertAutoRefreshEnabled(t, data)
}
func TestSession_WithInactivity(t *testing.T) {
cfg := mock.Config()
cfg.Session.Refresh = true
cfg.Session.Inactivity = true
cfg.Session.InactivityTimeout = 10 * time.Minute
idp := mock.NewIdentityProvider(cfg)
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.MetadataVerbose
err := json.Unmarshal([]byte(resp.Body), &data)
assert.NoError(t, err)
data := assertSessionInfo(t, idp, cfg)
maxDelta := 5 * time.Second
assert.True(t, data.Session.Active)
@@ -449,76 +234,92 @@ func TestSession_WithInactivity(t *testing.T) {
assert.WithinDuration(t, expectedTimeoutAt, time.Now().Add(actualTimeoutDuration), maxDelta)
}
func TestSession_WithRefresh(t *testing.T) {
func TestSession_WithSSO(t *testing.T) {
cfg := mock.Config()
cfg.Session.Refresh = true
cfg.SSO.Enabled = 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)
allowedSkew := 5 * time.Second
assert.WithinDuration(t, time.Now(), data.Session.CreatedAt, allowedSkew)
assert.WithinDuration(t, time.Now().Add(cfg.Session.MaxLifetime), data.Session.EndsAt, allowedSkew)
assert.WithinDuration(t, time.Now().Add(idp.ProviderHandler.TokenDuration), data.Tokens.ExpireAt, allowedSkew)
assert.WithinDuration(t, time.Now(), data.Tokens.RefreshedAt, allowedSkew)
sessionEndDuration := time.Duration(data.Session.EndsInSeconds) * time.Second
// 1 second < time until session ends <= configured max session lifetime
assert.LessOrEqual(t, sessionEndDuration, cfg.Session.MaxLifetime)
assert.Greater(t, sessionEndDuration, time.Second)
tokenExpiryDuration := time.Duration(data.Tokens.ExpireInSeconds) * time.Second
// 1 second < time until token expires <= max duration for tokens from IDP
assert.LessOrEqual(t, tokenExpiryDuration, idp.ProviderHandler.TokenDuration)
assert.Greater(t, tokenExpiryDuration, time.Second)
// 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
assert.LessOrEqual(t, data.Tokens.RefreshCooldownSeconds, session.RefreshMinInterval)
assert.Greater(t, data.Tokens.RefreshCooldownSeconds, int64(1))
assert.True(t, data.Session.Active)
assert.True(t, data.Session.TimeoutAt.IsZero())
assert.Equal(t, int64(-1), data.Session.TimeoutInSeconds)
data := assertSessionInfo(t, idp, cfg)
assertAutoRefreshDisabled(t, data)
}
func TestSession_WithRefreshAuto(t *testing.T) {
func TestSession_WithForwardAuth(t *testing.T) {
cfg := mock.Config()
cfg.Session.Refresh = true
cfg.Session.RefreshAuto = true
cfg.SSO.Enabled = true
cfg.Session.ForwardAuth = true
idp := mock.NewIdentityProvider(cfg)
idp.ProviderHandler.TokenDuration = 5 * time.Minute
defer idp.Close()
rpClient := idp.RelyingPartyClient()
login(t, rpClient, idp)
data := assertSessionInfo(t, idp, cfg)
assertAutoRefreshEnabled(t, data)
}
resp := sessionInfo(t, idp, rpClient)
assert.Equal(t, http.StatusOK, resp.StatusCode)
func TestSessionRefresh(t *testing.T) {
cfg := mock.Config()
idp := mock.NewIdentityProvider(cfg)
defer idp.Close()
var data session.MetadataVerboseWithRefresh
err := json.Unmarshal([]byte(resp.Body), &data)
assert.NoError(t, err)
sess := assertSessionRefresh(t, idp, cfg)
assertAutoRefreshEnabled(t, sess.initial)
assertAutoRefreshEnabled(t, sess.refreshed)
}
// 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 TestSessionRefresh_WithInactivity(t *testing.T) {
cfg := mock.Config()
cfg.Session.Inactivity = true
cfg.Session.InactivityTimeout = 10 * time.Minute
idp := mock.NewIdentityProvider(cfg)
defer idp.Close()
sess := assertSessionRefresh(t, idp, cfg)
assertAutoRefreshEnabled(t, sess.initial)
assertAutoRefreshEnabled(t, sess.refreshed)
maxDelta := 5 * time.Second
assert.False(t, sess.initial.Session.TimeoutAt.IsZero())
assert.False(t, sess.refreshed.Session.TimeoutAt.IsZero())
expectedTimeoutAt := time.Now().Add(cfg.Session.InactivityTimeout)
assert.WithinDuration(t, expectedTimeoutAt, sess.initial.Session.TimeoutAt, maxDelta)
assert.WithinDuration(t, expectedTimeoutAt, sess.refreshed.Session.TimeoutAt, maxDelta)
assert.True(t, sess.refreshed.Session.TimeoutAt.After(sess.initial.Session.TimeoutAt))
previousTimeoutDuration := time.Duration(sess.initial.Session.TimeoutInSeconds) * time.Second
assert.WithinDuration(t, expectedTimeoutAt, time.Now().Add(previousTimeoutDuration), maxDelta)
refreshedTimeoutDuration := time.Duration(sess.refreshed.Session.TimeoutInSeconds) * time.Second
assert.WithinDuration(t, expectedTimeoutAt, time.Now().Add(refreshedTimeoutDuration), maxDelta)
}
func TestSessionRefresh_WithSSO(t *testing.T) {
cfg := mock.Config()
cfg.SSO.Enabled = true
idp := mock.NewIdentityProvider(cfg)
defer idp.Close()
sess := assertSessionRefresh(t, idp, cfg)
assertAutoRefreshDisabled(t, sess.initial)
assertAutoRefreshDisabled(t, sess.refreshed)
}
func TestSessionRefresh_WithForwardAuth(t *testing.T) {
cfg := mock.Config()
cfg.SSO.Enabled = true
cfg.Session.ForwardAuth = true
idp := mock.NewIdentityProvider(cfg)
defer idp.Close()
sess := assertSessionRefresh(t, idp, cfg)
assertAutoRefreshEnabled(t, sess.initial)
assertAutoRefreshEnabled(t, sess.refreshed)
}
func TestSessionForwardAuth(t *testing.T) {
@@ -738,7 +539,7 @@ func waitForRefreshCooldownTimer(t *testing.T, idp *mock.IdentityProvider, rpCli
resp := sessionInfo(t, idp, rpClient)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var temp session.MetadataVerboseWithRefresh
var temp session.MetadataVerbose
err := json.Unmarshal([]byte(resp.Body), &temp)
assert.NoError(t, err)
@@ -882,3 +683,135 @@ func getCookieFromJar(name string, cookies []*http.Cookie) *http.Cookie {
return nil
}
func assertAutoRefreshEnabled(t *testing.T, data session.MetadataVerbose) {
assert.LessOrEqual(t, data.Tokens.NextAutoRefreshInSeconds, data.Tokens.ExpireInSeconds)
assert.Greater(t, data.Tokens.NextAutoRefreshInSeconds, int64(1))
}
func assertAutoRefreshDisabled(t *testing.T, data session.MetadataVerbose) {
assert.Less(t, data.Tokens.NextAutoRefreshInSeconds, data.Tokens.ExpireInSeconds)
assert.Equal(t, int64(-1), data.Tokens.NextAutoRefreshInSeconds)
}
func assertSessionInfo(t *testing.T, idp *mock.IdentityProvider, cfg *config.Config) session.MetadataVerbose {
rpClient := idp.RelyingPartyClient()
noSessionResp := sessionInfo(t, idp, rpClient)
assert.Equal(t, http.StatusUnauthorized, noSessionResp.StatusCode)
login(t, rpClient, idp)
resp := sessionInfo(t, idp, rpClient)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var data session.MetadataVerbose
err := json.Unmarshal([]byte(resp.Body), &data)
assert.NoError(t, err)
allowedSkew := 5 * time.Second
assert.WithinDuration(t, time.Now(), data.Session.CreatedAt, allowedSkew)
assert.WithinDuration(t, time.Now().Add(cfg.Session.MaxLifetime), data.Session.EndsAt, allowedSkew)
assert.WithinDuration(t, time.Now().Add(idp.ProviderHandler.TokenDuration), data.Tokens.ExpireAt, allowedSkew)
assert.WithinDuration(t, time.Now(), data.Tokens.RefreshedAt, allowedSkew)
sessionEndDuration := time.Duration(data.Session.EndsInSeconds) * time.Second
// 1 second < time until session ends <= configured max session lifetime
assert.LessOrEqual(t, sessionEndDuration, cfg.Session.MaxLifetime)
assert.Greater(t, sessionEndDuration, time.Second)
tokenExpiryDuration := time.Duration(data.Tokens.ExpireInSeconds) * time.Second
// 1 second < time until token expires <= max duration for tokens from IDP
assert.LessOrEqual(t, tokenExpiryDuration, idp.ProviderHandler.TokenDuration)
assert.Greater(t, tokenExpiryDuration, time.Second)
assert.True(t, data.Tokens.RefreshCooldown)
// 1 second < refresh cooldown <= minimum refresh interval
assert.LessOrEqual(t, data.Tokens.RefreshCooldownSeconds, session.RefreshMinInterval)
assert.Greater(t, data.Tokens.RefreshCooldownSeconds, int64(1))
assert.True(t, data.Session.Active)
if !cfg.Session.Inactivity {
assert.True(t, data.Session.TimeoutAt.IsZero())
assert.Equal(t, int64(-1), data.Session.TimeoutInSeconds)
}
return data
}
type sessionRefreshLifecycle struct {
initial session.MetadataVerbose
refreshed session.MetadataVerbose
}
func assertSessionRefresh(t *testing.T, idp *mock.IdentityProvider, cfg *config.Config) sessionRefreshLifecycle {
idp.ProviderHandler.TokenDuration = 5 * time.Second
rpClient := idp.RelyingPartyClient()
noSessionResp := sessionInfo(t, idp, rpClient)
assert.Equal(t, http.StatusUnauthorized, noSessionResp.StatusCode)
login(t, rpClient, idp)
// get initial session info
resp := sessionInfo(t, idp, rpClient)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var initial session.MetadataVerbose
err := json.Unmarshal([]byte(resp.Body), &initial)
assert.NoError(t, err)
// wait until refresh cooldown has reached zero before attempting refresh
waitForRefreshCooldownTimer(t, idp, rpClient)
// do the refresh
resp = sessionRefresh(t, idp, rpClient)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var refreshed session.MetadataVerbose
err = json.Unmarshal([]byte(resp.Body), &refreshed)
assert.NoError(t, err)
// session create and end times should be unchanged
assert.WithinDuration(t, initial.Session.CreatedAt, refreshed.Session.CreatedAt, 0)
assert.WithinDuration(t, initial.Session.EndsAt, refreshed.Session.EndsAt, 0)
// token expiration and refresh times should be later than initial
assert.True(t, refreshed.Tokens.ExpireAt.After(initial.Tokens.ExpireAt))
assert.True(t, refreshed.Tokens.RefreshedAt.After(initial.Tokens.RefreshedAt))
allowedSkew := 5 * time.Second
assert.WithinDuration(t, time.Now().Add(idp.ProviderHandler.TokenDuration), refreshed.Tokens.ExpireAt, allowedSkew)
assert.WithinDuration(t, time.Now(), refreshed.Tokens.RefreshedAt, allowedSkew)
sessionEndDuration := time.Duration(refreshed.Session.EndsInSeconds) * time.Second
// 1 second < time until session ends <= configured max session lifetime
assert.LessOrEqual(t, sessionEndDuration, cfg.Session.MaxLifetime)
assert.Greater(t, sessionEndDuration, time.Second)
tokenExpiryDuration := time.Duration(refreshed.Tokens.ExpireInSeconds) * time.Second
// 1 second < time until token expires <= max duration for tokens from IDP
assert.LessOrEqual(t, tokenExpiryDuration, idp.ProviderHandler.TokenDuration)
assert.Greater(t, tokenExpiryDuration, time.Second)
assert.True(t, refreshed.Tokens.RefreshCooldown)
// 1 second < refresh cooldown <= minimum refresh interval
assert.LessOrEqual(t, refreshed.Tokens.RefreshCooldownSeconds, session.RefreshMinInterval)
assert.Greater(t, refreshed.Tokens.RefreshCooldownSeconds, int64(1))
assert.True(t, initial.Session.Active)
assert.True(t, refreshed.Session.Active)
if !cfg.Session.Inactivity {
assert.True(t, initial.Session.TimeoutAt.IsZero())
assert.True(t, refreshed.Session.TimeoutAt.IsZero())
assert.Equal(t, int64(-1), initial.Session.TimeoutInSeconds)
assert.Equal(t, int64(-1), refreshed.Session.TimeoutInSeconds)
}
return sessionRefreshLifecycle{
initial: initial,
refreshed: refreshed,
}
}

View File

@@ -234,40 +234,27 @@ func (in *Metadata) Verbose() MetadataVerbose {
endTime := in.Session.EndsAt
timeoutTime := in.Session.TimeoutAt
mv := MetadataVerbose{
Session: MetadataSessionVerbose{
MetadataSession: in.Session,
EndsInSeconds: toSeconds(endTime.Sub(now)),
Active: !in.IsTimedOut(),
TimeoutInSeconds: toSeconds(timeoutTime.Sub(now)),
},
Tokens: MetadataTokensVerbose{
MetadataTokens: in.Tokens,
ExpireInSeconds: toSeconds(expireTime.Sub(now)),
},
session := MetadataSessionVerbose{
MetadataSession: in.Session,
EndsInSeconds: toSeconds(endTime.Sub(now)),
Active: !in.IsTimedOut(),
TimeoutInSeconds: toSeconds(timeoutTime.Sub(now)),
}
if timeoutTime.IsZero() {
mv.Session.TimeoutInSeconds = int64(-1)
session.TimeoutInSeconds = int64(-1)
}
return mv
}
tokens := MetadataTokensVerbose{
MetadataTokens: in.Tokens,
ExpireInSeconds: toSeconds(expireTime.Sub(now)),
NextAutoRefreshInSeconds: toSeconds(in.NextRefresh().Sub(now)),
RefreshCooldown: in.IsRefreshOnCooldown(),
RefreshCooldownSeconds: toSeconds(in.RefreshCooldown().Sub(now)),
}
func (in *Metadata) VerboseWithRefresh() MetadataVerboseWithRefresh {
now := time.Now()
verbose := in.Verbose()
nextRefreshTime := in.NextRefresh()
return MetadataVerboseWithRefresh{
Session: verbose.Session,
Tokens: MetadataTokensVerboseWithRefresh{
MetadataTokensVerbose: verbose.Tokens,
NextAutoRefreshInSeconds: toSeconds(nextRefreshTime.Sub(now)),
RefreshCooldown: in.IsRefreshOnCooldown(),
RefreshCooldownSeconds: toSeconds(in.RefreshCooldown().Sub(now)),
},
return MetadataVerbose{
Session: session,
Tokens: tokens,
}
}
@@ -276,11 +263,6 @@ type MetadataVerbose struct {
Tokens MetadataTokensVerbose `json:"tokens"`
}
type MetadataVerboseWithRefresh struct {
Session MetadataSessionVerbose `json:"session"`
Tokens MetadataTokensVerboseWithRefresh `json:"tokens"`
}
type MetadataSessionVerbose struct {
MetadataSession
EndsInSeconds int64 `json:"ends_in_seconds"`
@@ -290,11 +272,7 @@ type MetadataSessionVerbose struct {
type MetadataTokensVerbose struct {
MetadataTokens
ExpireInSeconds int64 `json:"expire_in_seconds"`
}
type MetadataTokensVerboseWithRefresh struct {
MetadataTokensVerbose
ExpireInSeconds int64 `json:"expire_in_seconds"`
NextAutoRefreshInSeconds int64 `json:"next_auto_refresh_in_seconds"`
RefreshCooldown bool `json:"refresh_cooldown"`
RefreshCooldownSeconds int64 `json:"refresh_cooldown_seconds"`

View File

@@ -277,32 +277,14 @@ func TestMetadata_Verbose(t *testing.T) {
actual = time.Now().Add(durationSeconds(verbose.Tokens.ExpireInSeconds))
assert.WithinDuration(t, expected, actual, maxDelta)
assert.True(t, verbose.Session.Active)
assert.True(t, verbose.Session.TimeoutAt.IsZero())
assert.Equal(t, int64(-1), verbose.Session.TimeoutInSeconds)
}
func TestMetadata_VerboseWithRefresh(t *testing.T) {
tokenLifetime := 30 * time.Minute
sessionLifetime := time.Hour
metadata := session.NewMetadata(tokenLifetime, sessionLifetime)
verbose := metadata.VerboseWithRefresh()
maxDelta := time.Second
expected := time.Now().Add(sessionLifetime)
actual := time.Now().Add(durationSeconds(verbose.Session.EndsInSeconds))
assert.WithinDuration(t, expected, actual, maxDelta)
expected = time.Now().Add(tokenLifetime)
actual = time.Now().Add(durationSeconds(verbose.Tokens.ExpireInSeconds))
assert.WithinDuration(t, expected, actual, maxDelta)
expected = time.Now().Add(tokenLifetime).Add(-session.RefreshLeeway)
actual = time.Now().Add(durationSeconds(verbose.Tokens.NextAutoRefreshInSeconds))
assert.WithinDuration(t, expected, actual, maxDelta)
assert.True(t, verbose.Session.Active)
assert.True(t, verbose.Session.TimeoutAt.IsZero())
assert.Equal(t, int64(-1), verbose.Session.TimeoutInSeconds)
t.Run("refresh on cooldown", func(t *testing.T) {
assert.True(t, verbose.Tokens.RefreshCooldown)
@@ -314,7 +296,7 @@ func TestMetadata_VerboseWithRefresh(t *testing.T) {
t.Run("refresh not on cooldown", func(t *testing.T) {
metadata := session.NewMetadata(tokenLifetime, sessionLifetime)
metadata.Tokens.RefreshedAt = time.Now().Add(-5 * time.Minute)
verbose := metadata.VerboseWithRefresh()
verbose := metadata.Verbose()
assert.False(t, verbose.Tokens.RefreshCooldown)
assert.Equal(t, int64(0), verbose.Tokens.RefreshCooldownSeconds)

View File

@@ -84,10 +84,6 @@ func (in *Session) MetadataVerbose() MetadataVerbose {
return in.data.Metadata.Verbose()
}
func (in *Session) MetadataVerboseRefresh() MetadataVerboseWithRefresh {
return in.data.Metadata.VerboseWithRefresh()
}
func (in *Session) SetCookie(w http.ResponseWriter, opts cookie.Options, crypter crypto.Crypter) error {
return in.ticket.SetCookie(w, opts, crypter)
}

View File

@@ -104,7 +104,7 @@ func (in *manager) GetOrRefresh(r *http.Request) (*Session, error) {
return nil, fmt.Errorf("getting session: %w", err)
}
if !in.cfg.Session.RefreshAuto || !sess.shouldRefresh() {
if !sess.shouldRefresh() {
return sess, nil
}
@@ -125,7 +125,7 @@ func (in *manager) GetOrRefresh(r *http.Request) (*Session, error) {
}
func (in *manager) Refresh(r *http.Request, sess *Session) (*Session, error) {
if !in.cfg.Session.Refresh || !sess.canRefresh() {
if !sess.canRefresh() {
return sess, nil
}