diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 40a87647f3633..6565786504cd8 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -424,7 +424,11 @@ func ValidateAPIKey(ctx context.Context, cfg ValidateAPIKeyConfig, r *http.Reque } changed = true } - if !cfg.DisableSessionExpiryRefresh { + // Only apply sliding-window expiry refresh to interactive login + // sessions. Programmatic API tokens (LoginTypeToken, created via + // `coder tokens create`) honor a fixed, finite lifetime and must not be + // silently extended to now+lifetime on each authenticated request. + if !cfg.DisableSessionExpiryRefresh && key.LoginType != database.LoginTypeToken { apiKeyLifetime := time.Duration(key.LifetimeSeconds) * time.Second if key.ExpiresAt.Sub(now) <= apiKeyLifetime-time.Hour { key.ExpiresAt = now.Add(apiKeyLifetime) diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go index 5178860fc58c4..e4c2dd5f100b6 100644 --- a/coderd/httpmw/apikey_test.go +++ b/coderd/httpmw/apikey_test.go @@ -471,6 +471,39 @@ func TestAPIKey(t *testing.T) { require.NotEqual(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt) }) + t.Run("TokenNoExpiryRefresh", func(t *testing.T) { + t.Parallel() + var ( + db, _ = dbtestutil.NewDB(t) + user = dbgen.User(t, db, database.User{}) + sentAPIKey, token = dbgen.APIKey(t, db, database.APIKey{ + UserID: user.ID, + LastUsed: dbtime.Now(), + ExpiresAt: dbtime.Now().Add(time.Minute), + LoginType: database.LoginTypeToken, + }) + + r = httptest.NewRequest("GET", "/", nil) + rw = httptest.NewRecorder() + ) + r.Header.Set(codersdk.SessionTokenHeader, token) + + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + RedirectToLogin: false, + })(successHandler).ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + + gotAPIKey, err := db.GetAPIKeyByID(r.Context(), sentAPIKey.ID) + require.NoError(t, err) + + // Programmatic tokens honor a fixed lifetime, so the expiry must not be + // extended on use even though it is within the refresh window. + require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt) + }) + t.Run("NoRefresh", func(t *testing.T) { t.Parallel() var (