diff --git a/coderd/externalauth/externalauth.go b/coderd/externalauth/externalauth.go index 25777516c8a40..9b11b7f3ed79d 100644 --- a/coderd/externalauth/externalauth.go +++ b/coderd/externalauth/externalauth.go @@ -139,7 +139,8 @@ type Config struct { RefreshRetryMaxBackoff time.Duration // RefreshRetryTimeout overrides the total budget for retrying a transient // refresh failure across all attempts. A zero value applies - // defaultRefreshRetryTimeout. + // defaultRefreshRetryTimeout. A negative value disables transient-failure + // retries entirely, so exactly one refresh attempt is made. RefreshRetryTimeout time.Duration } @@ -389,9 +390,9 @@ validate: // refreshTokenWithRetry exchanges the refresh token for a new access token, // retrying with exponential backoff on transient failures. Permanent -// failures (as classified by isFailedRefresh) and the no-op case where no -// refresh token is set bypass the retry loop so a doomed refresh is not -// repeatedly attempted. +// failures (as classified by isFailedRefresh), the no-op case where no +// refresh token is set, and a negative RefreshRetryTimeout all bypass the +// retry loop so a doomed or unwanted refresh is not repeatedly attempted. func (c *Config) refreshTokenWithRetry(ctx context.Context, existingToken *oauth2.Token) (*oauth2.Token, error) { // Without a refresh token the oauth2 library short-circuits with // "token expired and refresh token is not set". No retry can recover @@ -400,6 +401,12 @@ func (c *Config) refreshTokenWithRetry(ctx context.Context, existingToken *oauth return c.TokenSource(ctx, existingToken).Token() } + // A negative RefreshRetryTimeout disables retries entirely, so make a + // single attempt and return. + if c.RefreshRetryTimeout < 0 { + return c.TokenSource(ctx, existingToken).Token() + } + initial := c.RefreshRetryInitialBackoff if initial <= 0 { initial = defaultRefreshRetryInitialBackoff @@ -409,7 +416,7 @@ func (c *Config) refreshTokenWithRetry(ctx context.Context, existingToken *oauth maximum = defaultRefreshRetryMaxBackoff } total := c.RefreshRetryTimeout - if total <= 0 { + if total == 0 { total = defaultRefreshRetryTimeout } @@ -430,7 +437,7 @@ func (c *Config) refreshTokenWithRetry(ctx context.Context, existingToken *oauth // retry.Wait selects between time.After(delay) and ctx.Done(); when // delay is zero and the context is already canceled the two cases // race nondeterministically, which would cause an unwanted extra - // refresh attempt with a near-zero budget (notably in tests). + // refresh attempt with a near-zero budget. if retryCtx.Err() != nil { return token, err } diff --git a/coderd/externalauth/externalauth_test.go b/coderd/externalauth/externalauth_test.go index 526c36c44c52c..4221e7330903d 100644 --- a/coderd/externalauth/externalauth_test.go +++ b/coderd/externalauth/externalauth_test.go @@ -156,9 +156,10 @@ func TestRefreshToken(t *testing.T) { // refresh attempts should ever happen. An invalid refresh token does // not magically become valid at some point in the future. // - // Internal retries are disabled in this subtest via RefreshRetryTimeout - // so each RefreshToken call results in exactly one IDP refresh attempt. - // The RefreshTokenWithBackoff subtest covers the retry-with-backoff path. + // Internal retries are disabled in this subtest via a negative + // RefreshRetryTimeout so each RefreshToken call results in exactly one + // IDP refresh attempt. The RefreshTokenWithBackoff subtest covers the + // retry-with-backoff path. t.Run("RefreshRetries", func(t *testing.T) { t.Parallel() @@ -182,9 +183,10 @@ func TestRefreshToken(t *testing.T) { }), }, ExternalAuthOpt: func(cfg *externalauth.Config) { - // Disable transient-error retries so the assertion below - // (1 IDP call per RefreshToken) holds. - cfg.RefreshRetryTimeout = time.Nanosecond + // Negative timeout disables retries (1 IDP call per RefreshToken). + // A tiny positive timeout is unreliable on coarse-clock platforms + // (Windows). + cfg.RefreshRetryTimeout = -1 }, })