Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commitceb24de

Browse files
committed
feat(oauth2): add configurable refresh token lifetime
Change-Id: I988093e8fc7328a09d2a0b2c5d476bad75e064c8Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent6d39077 commitceb24de

File tree

15 files changed

+220
-17
lines changed

15 files changed

+220
-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
returnxerrors.Errorf("access-url must include a scheme (e.g. 'http://' or 'https://)")
351351
}
352352

353+
// Cross-field configuration validation after initial parsing.
354+
iferr:=vals.Validate();err!=nil {
355+
returnerr
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
rpcReadFDint64
1616
rpcWriteFDint64

‎coderd/apidoc/docs.go‎

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more aboutcustomizing 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 aboutcustomizing 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
funcTestOAuth2ProviderApps(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+
funcTestOAuth2RefreshExpiryOutlivesAccess(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
funccustomTokenExchange(ctx context.Context,baseURL,clientID,clientSecret,code,redirectURI,resourcestring) (*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
funcTokens(db database.Store,lifetimes codersdk.SessionLifetime) http.HandlerFunc {
9998
returnfunc(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+
ifrefreshLifetime==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+
ifrefreshLifetime==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
Iconstring`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+
ifaccess==0 {
3253+
returnxerrors.New("sessions configuration appears uninitialized - ensure all values are loaded before validation")
3254+
}
3255+
3256+
ifrefresh<=access {
3257+
returnxerrors.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+
returnnil
3263+
}
3264+
32263265
// DeploymentOptionsWithoutSecrets returns a copy of the OptionSet with secret values omitted.
32273266
funcDeploymentOptionsWithoutSecrets(set serpent.OptionSet) serpent.OptionSet {
32283267
cpy:=make(serpent.OptionSet,0,len(set))

‎codersdk/deployment_test.go‎

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

295+
funcTestDeploymentValues_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+
returndv
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("RefreshLonger_OK",func(t*testing.T) {
330+
t.Parallel()
331+
dv:=mk(1*time.Hour,48*time.Hour)
332+
err:=dv.Validate()
333+
require.NoError(t,err)
334+
})
335+
}
336+
295337
funcTestDeploymentValues_DurationFormatNanoseconds(t*testing.T) {
296338
t.Parallel()
297339

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp