- Notifications
You must be signed in to change notification settings - Fork1k
feat: ensure OAuth2 refresh tokens outlive access tokens#19769
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
Uh oh!
There was an error while loading.Please reload this page.
Changes fromall commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.
Uh oh!
There was an error while loading.Please reload this page.
Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.
Uh oh!
There was an error while loading.Please reload this page.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"` | ||
Comment on lines +570 to +572 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. In what case would it make sense to set the default refresh duration to be the same as the access token lifetime? In that case you may as well just disable refresh tokens entirely. If we allow setting this equal to or very close to access token lifetime, I foresee customers running into issues. Would it make sense to validate that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. Yeah, that makes sense. I've added a validation to catch those cases. It errors out if the constraint of refresh token lifetime < access token lifetime, since debugging why refresh tokens aren't issued would become even harder if we fail silently. | ||
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)) | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
}) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. Don't forget to also add a test to hit the code path you added to check for uninitialized default duration | ||
} | ||
func TestDeploymentValues_DurationFormatNanoseconds(t *testing.T) { | ||
t.Parallel() | ||
Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.
Uh oh!
There was an error while loading.Please reload this page.
Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.
Uh oh!
There was an error while loading.Please reload this page.
Uh oh!
There was an error while loading.Please reload this page.
Uh oh!
There was an error while loading.Please reload this page.