feat: ensure OAuth2 refresh tokens outlive access tokens (#19769)

This commit is contained in:
Thomas Kosiewski
2025-09-13 08:57:26 +02:00
committed by GitHub
parent be7aa58075
commit 088d14933c
15 changed files with 229 additions and 17 deletions
+39
View File
@@ -566,6 +566,11 @@ type SessionLifetime struct {
// DefaultDuration is only for browser, workspace app and oauth sessions.
DefaultDuration serpent.Duration `json:"default_duration" typescript:",notnull"`
// RefreshDefaultDuration is the default lifetime for OAuth2 refresh tokens.
// This should generally be longer than access token lifetimes to allow
// refreshing after access token expiry.
RefreshDefaultDuration serpent.Duration `json:"refresh_default_duration,omitempty" typescript:",notnull"`
DefaultTokenDuration serpent.Duration `json:"default_token_lifetime,omitempty" typescript:",notnull"`
MaximumTokenDuration serpent.Duration `json:"max_token_lifetime,omitempty" typescript:",notnull"`
@@ -2464,6 +2469,16 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
YAML: "defaultTokenLifetime",
Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"),
},
{
Name: "Default OAuth Refresh Lifetime",
Description: "The default lifetime duration for OAuth2 refresh tokens. This controls how long refresh tokens remain valid after issuance or rotation.",
Flag: "default-oauth-refresh-lifetime",
Env: "CODER_DEFAULT_OAUTH_REFRESH_LIFETIME",
Default: (30 * 24 * time.Hour).String(),
Value: &c.Sessions.RefreshDefaultDuration,
YAML: "defaultOAuthRefreshLifetime",
Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"),
},
{
Name: "Enable swagger endpoint",
Description: "Expose the swagger endpoint via /swagger.",
@@ -3223,6 +3238,30 @@ type LinkConfig struct {
Icon string `json:"icon" yaml:"icon" enums:"bug,chat,docs"`
}
// Validate checks cross-field constraints for deployment values.
// It must be called after all values are loaded from flags/env/YAML.
func (c *DeploymentValues) Validate() error {
// For OAuth2, access tokens (API keys) issued via the authorization code/refresh flows
// use Sessions.DefaultDuration as their lifetime, while refresh tokens use
// Sessions.RefreshDefaultDuration (falling back to DefaultDuration when set to 0).
// Enforce that refresh token lifetime is strictly greater than the access token lifetime.
access := c.Sessions.DefaultDuration.Value()
refresh := c.Sessions.RefreshDefaultDuration.Value()
// Check if values appear uninitialized
if access == 0 {
return xerrors.New("developer error: sessions configuration appears uninitialized - ensure all values are loaded before validation")
}
if refresh <= access {
return xerrors.Errorf(
"default OAuth refresh lifetime (%s) must be strictly greater than session duration (%s); set --default-oauth-refresh-lifetime to a value greater than --session-duration",
refresh, access,
)
}
return nil
}
// DeploymentOptionsWithoutSecrets returns a copy of the OptionSet with secret values omitted.
func DeploymentOptionsWithoutSecrets(set serpent.OptionSet) serpent.OptionSet {
cpy := make(serpent.OptionSet, 0, len(set))
+51
View File
@@ -292,6 +292,57 @@ func must[T any](value T, err error) T {
return value
}
func TestDeploymentValues_Validate_RefreshLifetime(t *testing.T) {
t.Parallel()
mk := func(access, refresh time.Duration) *codersdk.DeploymentValues {
dv := &codersdk.DeploymentValues{}
dv.Sessions.DefaultDuration = serpent.Duration(access)
dv.Sessions.RefreshDefaultDuration = serpent.Duration(refresh)
return dv
}
t.Run("EqualDurations_Error", func(t *testing.T) {
t.Parallel()
dv := mk(1*time.Hour, 1*time.Hour)
err := dv.Validate()
require.Error(t, err)
require.ErrorContains(t, err, "must be strictly greater")
})
t.Run("RefreshShorter_Error", func(t *testing.T) {
t.Parallel()
dv := mk(2*time.Hour, 1*time.Hour)
err := dv.Validate()
require.Error(t, err)
require.ErrorContains(t, err, "must be strictly greater")
})
t.Run("RefreshZero_Error", func(t *testing.T) {
t.Parallel()
dv := mk(1*time.Hour, 0)
err := dv.Validate()
require.Error(t, err)
require.ErrorContains(t, err, "must be strictly greater")
})
t.Run("AccessUninitialized_Error", func(t *testing.T) {
t.Parallel()
// Access duration is zero (uninitialized); refresh is valid.
dv := mk(0, 48*time.Hour)
err := dv.Validate()
require.Error(t, err)
require.ErrorContains(t, err, "developer error: sessions configuration appears uninitialized")
})
t.Run("RefreshLonger_OK", func(t *testing.T) {
t.Parallel()
dv := mk(1*time.Hour, 48*time.Hour)
err := dv.Validate()
require.NoError(t, err)
})
}
func TestDeploymentValues_DurationFormatNanoseconds(t *testing.T) {
t.Parallel()