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

Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 deletions cli/server.go
Original file line number Diff line number Diff 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 deletions cli/testdata/coder_server_--help.golden
Original file line number Diff line number Diff 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 deletions cli/testdata/server-config.yaml.golden
Original file line number Diff line number Diff 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 deletion cli/vpndaemon_darwin.go
Original file line number Diff line number Diff 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 deletions coderd/apidoc/docs.go

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

4 changes: 4 additions & 0 deletions coderd/apidoc/swagger.json

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

67 changes: 67 additions & 0 deletions coderd/oauth2_test.go
Original file line number Diff line number Diff 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 Down Expand 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 deletions coderd/oauth2provider/tokens.go
Original file line number Diff line number Diff 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 Down Expand 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 Down Expand 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 Down Expand 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 deletions codersdk/deployment.go
Original file line number Diff line number Diff 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 that RefreshDefaultDuration is strictly greater than access token lifetime, and raise an error if this is not the case?

Copy link
Member Author

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 Down Expand 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 Down Expand 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 deletions codersdk/deployment_test.go
Original file line number Diff line number Diff 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 deletion docs/reference/api/general.md

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

24 changes: 14 additions & 10 deletions docs/reference/api/schemas.md

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

Loading
Loading