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

Commit0baaed6

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

File tree

15 files changed

+219
-17
lines changed

15 files changed

+219
-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: 20 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,14 @@ 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+
// Fallback to session default duration if not configured.
286+
refreshLifetime=lifetimes.DefaultDuration.Value()
287+
}
288+
refreshExpiresAt:=dbtime.Now().Add(refreshLifetime)
289+
283290
err=db.InTx(func(tx database.Store)error {
284291
ctx:=dbauthz.As(ctx,actor)
285292
err=tx.DeleteOAuth2ProviderAppCodeByID(ctx,dbCode.ID)
@@ -307,7 +314,7 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
307314
_,err=tx.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{
308315
ID:uuid.New(),
309316
CreatedAt:dbtime.Now(),
310-
ExpiresAt:key.ExpiresAt,
317+
ExpiresAt:refreshExpiresAt,
311318
HashPrefix: []byte(refreshToken.Prefix),
312319
RefreshHash: []byte(refreshToken.Hashed),
313320
AppSecretID:dbSecret.ID,
@@ -401,6 +408,14 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
401408
}
402409

403410
// Replace the token.
411+
// Determine refresh token expiry independently from the access token.
412+
refreshLifetime:=lifetimes.RefreshDefaultDuration.Value()
413+
ifrefreshLifetime==0 {
414+
// Fallback to session default duration if not configured.
415+
refreshLifetime=lifetimes.DefaultDuration.Value()
416+
}
417+
refreshExpiresAt:=dbtime.Now().Add(refreshLifetime)
418+
404419
err=db.InTx(func(tx database.Store)error {
405420
ctx:=dbauthz.As(ctx,actor)
406421
err=tx.DeleteAPIKeyByID(ctx,prevKey.ID)// This cascades to the token.
@@ -416,7 +431,7 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
416431
_,err=tx.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{
417432
ID:uuid.New(),
418433
CreatedAt:dbtime.Now(),
419-
ExpiresAt:key.ExpiresAt,
434+
ExpiresAt:refreshExpiresAt,
420435
HashPrefix: []byte(refreshToken.Prefix),
421436
RefreshHash: []byte(refreshToken.Hashed),
422437
AppSecretID:dbToken.AppSecretID,

‎codersdk/deployment.go‎

Lines changed: 36 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,27 @@ 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 should 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+
// Treat zero as configured to fallback to access duration, which should be rejected
3252+
// because it would defeat the purpose of refresh tokens.
3253+
ifrefresh==0||refresh<=access {
3254+
returnxerrors.Errorf(
3255+
"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",
3256+
refresh,access,
3257+
)
3258+
}
3259+
returnnil
3260+
}
3261+
32263262
// DeploymentOptionsWithoutSecrets returns a copy of the OptionSet with secret values omitted.
32273263
funcDeploymentOptionsWithoutSecrets(set serpent.OptionSet) serpent.OptionSet {
32283264
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