Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 088d149

Browse files
authored
feat: ensure OAuth2 refresh tokens outlive access tokens (#19769)
1 parent be7aa58 commit 088d149

File tree

15 files changed

+229
-17
lines changed

15 files changed

+229
-17
lines changed

cli/server.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,11 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
350350
return xerrors.Errorf("access-url must include a scheme (e.g. 'http://' or 'https://)")
351351
}
352352

353+
// Cross-field configuration validation after initial parsing.
354+
if err := vals.Validate(); err != nil {
355+
return err
356+
}
357+
353358
// Disable rate limits if the `--dangerous-disable-rate-limits` flag
354359
// was specified.
355360
loginRateLimit := 60

cli/testdata/coder_server_--help.golden

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ OPTIONS:
2525
systemd. This directory is NOT safe to be configured as a shared
2626
directory across coderd/provisionerd replicas.
2727

28+
--default-oauth-refresh-lifetime duration, $CODER_DEFAULT_OAUTH_REFRESH_LIFETIME (default: 720h0m0s)
29+
The default lifetime duration for OAuth2 refresh tokens. This controls
30+
how long refresh tokens remain valid after issuance or rotation.
31+
2832
--default-token-lifetime duration, $CODER_DEFAULT_TOKEN_LIFETIME (default: 168h0m0s)
2933
The default lifetime duration for API tokens. This value is used when
3034
creating a token without specifying a duration, such as when

cli/testdata/server-config.yaml.golden

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,10 @@ updateCheck: false
454454
# IDE plugin.
455455
# (default: 168h0m0s, type: duration)
456456
defaultTokenLifetime: 168h0m0s
457+
# The default lifetime duration for OAuth2 refresh tokens. This controls how long
458+
# refresh tokens remain valid after issuance or rotation.
459+
# (default: 720h0m0s, type: duration)
460+
defaultOAuthRefreshLifetime: 720h0m0s
457461
# Expose the swagger endpoint via /swagger.
458462
# (default: <unset>, type: bool)
459463
enableSwagger: false

cli/vpndaemon_darwin.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
"github.com/coder/serpent"
1111
)
1212

13-
func (r *RootCmd) vpnDaemonRun() *serpent.Command {
13+
func (*RootCmd) vpnDaemonRun() *serpent.Command {
1414
var (
1515
rpcReadFD int64
1616
rpcWriteFD int64

coderd/apidoc/docs.go

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/oauth2_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@ import (
2020
"github.com/coder/coder/v2/coderd/coderdtest"
2121
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
2222
"github.com/coder/coder/v2/coderd/database"
23+
"github.com/coder/coder/v2/coderd/database/dbauthz"
2324
"github.com/coder/coder/v2/coderd/database/dbtestutil"
2425
"github.com/coder/coder/v2/coderd/database/dbtime"
2526
"github.com/coder/coder/v2/coderd/oauth2provider"
2627
"github.com/coder/coder/v2/coderd/userpassword"
2728
"github.com/coder/coder/v2/coderd/util/ptr"
2829
"github.com/coder/coder/v2/codersdk"
2930
"github.com/coder/coder/v2/testutil"
31+
"github.com/coder/serpent"
3032
)
3133

3234
func TestOAuth2ProviderApps(t *testing.T) {
@@ -1184,6 +1186,71 @@ func TestOAuth2ProviderCrossResourceAudienceValidation(t *testing.T) {
11841186
// For now, this verifies the basic token flow works correctly
11851187
}
11861188

1189+
// TestOAuth2RefreshExpiryOutlivesAccess verifies that refresh token expiry is
1190+
// greater than the provisioned access token (API key) expiry per configuration.
1191+
func TestOAuth2RefreshExpiryOutlivesAccess(t *testing.T) {
1192+
t.Parallel()
1193+
1194+
// Set explicit lifetimes to make comparison deterministic.
1195+
db, pubsub := dbtestutil.NewDB(t)
1196+
dv := coderdtest.DeploymentValues(t, func(d *codersdk.DeploymentValues) {
1197+
d.Sessions.DefaultDuration = serpent.Duration(1 * time.Hour)
1198+
d.Sessions.RefreshDefaultDuration = serpent.Duration(48 * time.Hour)
1199+
})
1200+
ownerClient := coderdtest.New(t, &coderdtest.Options{
1201+
Database: db,
1202+
Pubsub: pubsub,
1203+
DeploymentValues: dv,
1204+
})
1205+
_ = coderdtest.CreateFirstUser(t, ownerClient)
1206+
ctx := testutil.Context(t, testutil.WaitLong)
1207+
1208+
// Create app and secret
1209+
// Keep suffix short to satisfy name validation (<=32 chars, alnum + hyphens).
1210+
apps := generateApps(ctx, t, ownerClient, "ref-exp")
1211+
//nolint:gocritic // Owner permission required for app secret creation
1212+
secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
1213+
require.NoError(t, err)
1214+
1215+
cfg := &oauth2.Config{
1216+
ClientID: apps.Default.ID.String(),
1217+
ClientSecret: secret.ClientSecretFull,
1218+
Endpoint: oauth2.Endpoint{
1219+
AuthURL: apps.Default.Endpoints.Authorization,
1220+
DeviceAuthURL: apps.Default.Endpoints.DeviceAuth,
1221+
TokenURL: apps.Default.Endpoints.Token,
1222+
AuthStyle: oauth2.AuthStyleInParams,
1223+
},
1224+
RedirectURL: apps.Default.CallbackURL,
1225+
Scopes: []string{},
1226+
}
1227+
1228+
// Authorization and token exchange
1229+
code, err := authorizationFlow(ctx, ownerClient, cfg)
1230+
require.NoError(t, err)
1231+
tok, err := cfg.Exchange(ctx, code)
1232+
require.NoError(t, err)
1233+
require.NotEmpty(t, tok.AccessToken)
1234+
require.NotEmpty(t, tok.RefreshToken)
1235+
1236+
// Parse refresh token prefix (coder_<prefix>_<secret>)
1237+
parts := strings.Split(tok.RefreshToken, "_")
1238+
require.Len(t, parts, 3)
1239+
prefix := parts[1]
1240+
1241+
// Look up refresh token row and associated API key
1242+
dbToken, err := db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(prefix))
1243+
require.NoError(t, err)
1244+
apiKey, err := db.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), dbToken.APIKeyID)
1245+
require.NoError(t, err)
1246+
1247+
// Assert refresh token expiry is strictly after access token expiry
1248+
require.Truef(t, dbToken.ExpiresAt.After(apiKey.ExpiresAt),
1249+
"expected refresh expiry %s to be after access expiry %s",
1250+
dbToken.ExpiresAt, apiKey.ExpiresAt,
1251+
)
1252+
}
1253+
11871254
// customTokenExchange performs a custom OAuth2 token exchange with support for resource parameter
11881255
// This is needed because golang.org/x/oauth2 doesn't support custom parameters in token requests
11891256
func customTokenExchange(ctx context.Context, baseURL, clientID, clientSecret, code, redirectURI, resource string) (*oauth2.Token, error) {

coderd/oauth2provider/tokens.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,8 @@ func extractTokenParams(r *http.Request, callbackURL *url.URL) (tokenParams, []c
9292
}
9393

9494
// Tokens
95-
// TODO: the sessions lifetime config passed is for coder api tokens.
96-
// Should there be a separate config for oauth2 tokens? They are related,
97-
// but they are not the same.
95+
// Uses Sessions.DefaultDuration for access token (API key) TTL and
96+
// Sessions.RefreshDefaultDuration for refresh token TTL.
9897
func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerFunc {
9998
return func(rw http.ResponseWriter, r *http.Request) {
10099
ctx := r.Context()
@@ -280,6 +279,13 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
280279
}
281280

282281
// Do the actual token exchange in the database.
282+
// Determine refresh token expiry independently from the access token.
283+
refreshLifetime := lifetimes.RefreshDefaultDuration.Value()
284+
if refreshLifetime == 0 {
285+
refreshLifetime = lifetimes.DefaultDuration.Value()
286+
}
287+
refreshExpiresAt := dbtime.Now().Add(refreshLifetime)
288+
283289
err = db.InTx(func(tx database.Store) error {
284290
ctx := dbauthz.As(ctx, actor)
285291
err = tx.DeleteOAuth2ProviderAppCodeByID(ctx, dbCode.ID)
@@ -307,7 +313,7 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
307313
_, err = tx.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{
308314
ID: uuid.New(),
309315
CreatedAt: dbtime.Now(),
310-
ExpiresAt: key.ExpiresAt,
316+
ExpiresAt: refreshExpiresAt,
311317
HashPrefix: []byte(refreshToken.Prefix),
312318
RefreshHash: []byte(refreshToken.Hashed),
313319
AppSecretID: dbSecret.ID,
@@ -401,6 +407,13 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
401407
}
402408

403409
// Replace the token.
410+
// Determine refresh token expiry independently from the access token.
411+
refreshLifetime := lifetimes.RefreshDefaultDuration.Value()
412+
if refreshLifetime == 0 {
413+
refreshLifetime = lifetimes.DefaultDuration.Value()
414+
}
415+
refreshExpiresAt := dbtime.Now().Add(refreshLifetime)
416+
404417
err = db.InTx(func(tx database.Store) error {
405418
ctx := dbauthz.As(ctx, actor)
406419
err = tx.DeleteAPIKeyByID(ctx, prevKey.ID) // This cascades to the token.
@@ -416,7 +429,7 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
416429
_, err = tx.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{
417430
ID: uuid.New(),
418431
CreatedAt: dbtime.Now(),
419-
ExpiresAt: key.ExpiresAt,
432+
ExpiresAt: refreshExpiresAt,
420433
HashPrefix: []byte(refreshToken.Prefix),
421434
RefreshHash: []byte(refreshToken.Hashed),
422435
AppSecretID: dbToken.AppSecretID,

codersdk/deployment.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,11 @@ type SessionLifetime struct {
566566
// DefaultDuration is only for browser, workspace app and oauth sessions.
567567
DefaultDuration serpent.Duration `json:"default_duration" typescript:",notnull"`
568568

569+
// RefreshDefaultDuration is the default lifetime for OAuth2 refresh tokens.
570+
// This should generally be longer than access token lifetimes to allow
571+
// refreshing after access token expiry.
572+
RefreshDefaultDuration serpent.Duration `json:"refresh_default_duration,omitempty" typescript:",notnull"`
573+
569574
DefaultTokenDuration serpent.Duration `json:"default_token_lifetime,omitempty" typescript:",notnull"`
570575

571576
MaximumTokenDuration serpent.Duration `json:"max_token_lifetime,omitempty" typescript:",notnull"`
@@ -2464,6 +2469,16 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
24642469
YAML: "defaultTokenLifetime",
24652470
Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"),
24662471
},
2472+
{
2473+
Name: "Default OAuth Refresh Lifetime",
2474+
Description: "The default lifetime duration for OAuth2 refresh tokens. This controls how long refresh tokens remain valid after issuance or rotation.",
2475+
Flag: "default-oauth-refresh-lifetime",
2476+
Env: "CODER_DEFAULT_OAUTH_REFRESH_LIFETIME",
2477+
Default: (30 * 24 * time.Hour).String(),
2478+
Value: &c.Sessions.RefreshDefaultDuration,
2479+
YAML: "defaultOAuthRefreshLifetime",
2480+
Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"),
2481+
},
24672482
{
24682483
Name: "Enable swagger endpoint",
24692484
Description: "Expose the swagger endpoint via /swagger.",
@@ -3223,6 +3238,30 @@ type LinkConfig struct {
32233238
Icon string `json:"icon" yaml:"icon" enums:"bug,chat,docs"`
32243239
}
32253240

3241+
// Validate checks cross-field constraints for deployment values.
3242+
// It must be called after all values are loaded from flags/env/YAML.
3243+
func (c *DeploymentValues) Validate() error {
3244+
// For OAuth2, access tokens (API keys) issued via the authorization code/refresh flows
3245+
// use Sessions.DefaultDuration as their lifetime, while refresh tokens use
3246+
// Sessions.RefreshDefaultDuration (falling back to DefaultDuration when set to 0).
3247+
// Enforce that refresh token lifetime is strictly greater than the access token lifetime.
3248+
access := c.Sessions.DefaultDuration.Value()
3249+
refresh := c.Sessions.RefreshDefaultDuration.Value()
3250+
3251+
// Check if values appear uninitialized
3252+
if access == 0 {
3253+
return xerrors.New("developer error: sessions configuration appears uninitialized - ensure all values are loaded before validation")
3254+
}
3255+
3256+
if refresh <= access {
3257+
return xerrors.Errorf(
3258+
"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",
3259+
refresh, access,
3260+
)
3261+
}
3262+
return nil
3263+
}
3264+
32263265
// DeploymentOptionsWithoutSecrets returns a copy of the OptionSet with secret values omitted.
32273266
func DeploymentOptionsWithoutSecrets(set serpent.OptionSet) serpent.OptionSet {
32283267
cpy := make(serpent.OptionSet, 0, len(set))

codersdk/deployment_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,57 @@ func must[T any](value T, err error) T {
292292
return value
293293
}
294294

295+
func TestDeploymentValues_Validate_RefreshLifetime(t *testing.T) {
296+
t.Parallel()
297+
298+
mk := func(access, refresh time.Duration) *codersdk.DeploymentValues {
299+
dv := &codersdk.DeploymentValues{}
300+
dv.Sessions.DefaultDuration = serpent.Duration(access)
301+
dv.Sessions.RefreshDefaultDuration = serpent.Duration(refresh)
302+
return dv
303+
}
304+
305+
t.Run("EqualDurations_Error", func(t *testing.T) {
306+
t.Parallel()
307+
dv := mk(1*time.Hour, 1*time.Hour)
308+
err := dv.Validate()
309+
require.Error(t, err)
310+
require.ErrorContains(t, err, "must be strictly greater")
311+
})
312+
313+
t.Run("RefreshShorter_Error", func(t *testing.T) {
314+
t.Parallel()
315+
dv := mk(2*time.Hour, 1*time.Hour)
316+
err := dv.Validate()
317+
require.Error(t, err)
318+
require.ErrorContains(t, err, "must be strictly greater")
319+
})
320+
321+
t.Run("RefreshZero_Error", func(t *testing.T) {
322+
t.Parallel()
323+
dv := mk(1*time.Hour, 0)
324+
err := dv.Validate()
325+
require.Error(t, err)
326+
require.ErrorContains(t, err, "must be strictly greater")
327+
})
328+
329+
t.Run("AccessUninitialized_Error", func(t *testing.T) {
330+
t.Parallel()
331+
// Access duration is zero (uninitialized); refresh is valid.
332+
dv := mk(0, 48*time.Hour)
333+
err := dv.Validate()
334+
require.Error(t, err)
335+
require.ErrorContains(t, err, "developer error: sessions configuration appears uninitialized")
336+
})
337+
338+
t.Run("RefreshLonger_OK", func(t *testing.T) {
339+
t.Parallel()
340+
dv := mk(1*time.Hour, 48*time.Hour)
341+
err := dv.Validate()
342+
require.NoError(t, err)
343+
})
344+
}
345+
295346
func TestDeploymentValues_DurationFormatNanoseconds(t *testing.T) {
296347
t.Parallel()
297348

0 commit comments

Comments
 (0)