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

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

Merged
Show file tree
Hide file tree
Changes fromall commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletionscli/server.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -350,6 +350,11 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
return xerrors.Errorf("access-url must include a scheme (e.g. 'http://' or 'https://)")
}

// Cross-field configuration validation after initial parsing.
if err := vals.Validate(); err != nil {
return err
}

// Disable rate limits if the `--dangerous-disable-rate-limits` flag
// was specified.
loginRateLimit := 60
Expand Down
4 changes: 4 additions & 0 deletionscli/testdata/coder_server_--help.golden
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,10 @@ OPTIONS:
systemd. This directory is NOT safe to be configured as a shared
directory across coderd/provisionerd replicas.

--default-oauth-refresh-lifetime duration, $CODER_DEFAULT_OAUTH_REFRESH_LIFETIME (default: 720h0m0s)
The default lifetime duration for OAuth2 refresh tokens. This controls
how long refresh tokens remain valid after issuance or rotation.

--default-token-lifetime duration, $CODER_DEFAULT_TOKEN_LIFETIME (default: 168h0m0s)
The default lifetime duration for API tokens. This value is used when
creating a token without specifying a duration, such as when
Expand Down
4 changes: 4 additions & 0 deletionscli/testdata/server-config.yaml.golden
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -454,6 +454,10 @@ updateCheck: false
# IDE plugin.
# (default: 168h0m0s, type: duration)
defaultTokenLifetime: 168h0m0s
# The default lifetime duration for OAuth2 refresh tokens. This controls how long
# refresh tokens remain valid after issuance or rotation.
# (default: 720h0m0s, type: duration)
defaultOAuthRefreshLifetime: 720h0m0s
# Expose the swagger endpoint via /swagger.
# (default: <unset>, type: bool)
enableSwagger: false
Expand Down
2 changes: 1 addition & 1 deletioncli/vpndaemon_darwin.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import (
"github.com/coder/serpent"
)

func (r*RootCmd) vpnDaemonRun() *serpent.Command {
func (*RootCmd) vpnDaemonRun() *serpent.Command {
var (
rpcReadFD int64
rpcWriteFD int64
Expand Down
4 changes: 4 additions & 0 deletionscoderd/apidoc/docs.go
View file
Open in desktop

Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.

4 changes: 4 additions & 0 deletionscoderd/apidoc/swagger.json
View file
Open in desktop

Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.

67 changes: 67 additions & 0 deletionscoderd/oauth2_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,13 +20,15 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/oauth2provider"
"github.com/coder/coder/v2/coderd/userpassword"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)

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

// TestOAuth2RefreshExpiryOutlivesAccess verifies that refresh token expiry is
// greater than the provisioned access token (API key) expiry per configuration.
func TestOAuth2RefreshExpiryOutlivesAccess(t *testing.T) {
t.Parallel()

// Set explicit lifetimes to make comparison deterministic.
db, pubsub := dbtestutil.NewDB(t)
dv := coderdtest.DeploymentValues(t, func(d *codersdk.DeploymentValues) {
d.Sessions.DefaultDuration = serpent.Duration(1 * time.Hour)
d.Sessions.RefreshDefaultDuration = serpent.Duration(48 * time.Hour)
})
ownerClient := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: pubsub,
DeploymentValues: dv,
})
_ = coderdtest.CreateFirstUser(t, ownerClient)
ctx := testutil.Context(t, testutil.WaitLong)

// Create app and secret
// Keep suffix short to satisfy name validation (<=32 chars, alnum + hyphens).
apps := generateApps(ctx, t, ownerClient, "ref-exp")
//nolint:gocritic // Owner permission required for app secret creation
secret, err := ownerClient.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID)
require.NoError(t, err)

cfg := &oauth2.Config{
ClientID: apps.Default.ID.String(),
ClientSecret: secret.ClientSecretFull,
Endpoint: oauth2.Endpoint{
AuthURL: apps.Default.Endpoints.Authorization,
DeviceAuthURL: apps.Default.Endpoints.DeviceAuth,
TokenURL: apps.Default.Endpoints.Token,
AuthStyle: oauth2.AuthStyleInParams,
},
RedirectURL: apps.Default.CallbackURL,
Scopes: []string{},
}

// Authorization and token exchange
code, err := authorizationFlow(ctx, ownerClient, cfg)
require.NoError(t, err)
tok, err := cfg.Exchange(ctx, code)
require.NoError(t, err)
require.NotEmpty(t, tok.AccessToken)
require.NotEmpty(t, tok.RefreshToken)

// Parse refresh token prefix (coder_<prefix>_<secret>)
parts := strings.Split(tok.RefreshToken, "_")
require.Len(t, parts, 3)
prefix := parts[1]

// Look up refresh token row and associated API key
dbToken, err := db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(prefix))
require.NoError(t, err)
apiKey, err := db.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), dbToken.APIKeyID)
require.NoError(t, err)

// Assert refresh token expiry is strictly after access token expiry
require.Truef(t, dbToken.ExpiresAt.After(apiKey.ExpiresAt),
"expected refresh expiry %s to be after access expiry %s",
dbToken.ExpiresAt, apiKey.ExpiresAt,
)
}

// customTokenExchange performs a custom OAuth2 token exchange with support for resource parameter
// This is needed because golang.org/x/oauth2 doesn't support custom parameters in token requests
func customTokenExchange(ctx context.Context, baseURL, clientID, clientSecret, code, redirectURI, resource string) (*oauth2.Token, error) {
Expand Down
23 changes: 18 additions & 5 deletionscoderd/oauth2provider/tokens.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,9 +92,8 @@ func extractTokenParams(r *http.Request, callbackURL *url.URL) (tokenParams, []c
}

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

// Do the actual token exchange in the database.
// Determine refresh token expiry independently from the access token.
refreshLifetime := lifetimes.RefreshDefaultDuration.Value()
if refreshLifetime == 0 {
refreshLifetime = lifetimes.DefaultDuration.Value()
}
refreshExpiresAt := dbtime.Now().Add(refreshLifetime)

err = db.InTx(func(tx database.Store) error {
ctx := dbauthz.As(ctx, actor)
err = tx.DeleteOAuth2ProviderAppCodeByID(ctx, dbCode.ID)
Expand DownExpand Up@@ -307,7 +313,7 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
_, err = tx.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{
ID: uuid.New(),
CreatedAt: dbtime.Now(),
ExpiresAt:key.ExpiresAt,
ExpiresAt:refreshExpiresAt,
HashPrefix: []byte(refreshToken.Prefix),
RefreshHash: []byte(refreshToken.Hashed),
AppSecretID: dbSecret.ID,
Expand DownExpand Up@@ -401,6 +407,13 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
}

// Replace the token.
// Determine refresh token expiry independently from the access token.
refreshLifetime := lifetimes.RefreshDefaultDuration.Value()
if refreshLifetime == 0 {
refreshLifetime = lifetimes.DefaultDuration.Value()
}
refreshExpiresAt := dbtime.Now().Add(refreshLifetime)

err = db.InTx(func(tx database.Store) error {
ctx := dbauthz.As(ctx, actor)
err = tx.DeleteAPIKeyByID(ctx, prevKey.ID) // This cascades to the token.
Expand All@@ -416,7 +429,7 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
_, err = tx.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{
ID: uuid.New(),
CreatedAt: dbtime.Now(),
ExpiresAt:key.ExpiresAt,
ExpiresAt:refreshExpiresAt,
HashPrefix: []byte(refreshToken.Prefix),
RefreshHash: []byte(refreshToken.Hashed),
AppSecretID: dbToken.AppSecretID,
Expand Down
39 changes: 39 additions & 0 deletionscodersdk/deployment.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Copy link
Member

Choose a reason for hiding this comment

The 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 thatRefreshDefaultDuration is strictly greater than access token lifetime, and raise an error if this is not the case?

ThomasK33 reacted with thumbs up emoji
Copy link
MemberAuthor

Choose a reason for hiding this comment

The 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"`
Expand DownExpand Up@@ -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.",
Expand DownExpand Up@@ -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))
Expand Down
51 changes: 51 additions & 0 deletionscodersdk/deployment_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
})
Copy link
Member

Choose a reason for hiding this comment

The 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()

Expand Down
3 changes: 2 additions & 1 deletiondocs/reference/api/general.md
View file
Open in desktop

Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.

24 changes: 14 additions & 10 deletionsdocs/reference/api/schemas.md
View file
Open in desktop

Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.

Loading
Loading

[8]ページ先頭

©2009-2025 Movatter.jp