From 93eee6f6839e8de967201a029dc0350e49f5f991 Mon Sep 17 00:00:00 2001 From: Jakub Domeracki Date: Mon, 15 Jun 2026 18:46:25 +0200 Subject: [PATCH] fix(coderd/httpmw): honor fixed lifetime for CLI API tokens (#26376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What API key validation applied a sliding-window expiry refresh to every key type. Programmatic API tokens (created via `coder tokens create`, login type `token`) had their `expires_at` extended to `now + lifetime` on each authenticated request (with a ~1h debounce), so a token used within its lifetime window never actually expired. This restricts the sliding-window refresh to interactive login sessions (password / OIDC / GitHub). Programmatic tokens now honor their fixed `expires_at`. ## Why A finite token `--lifetime` is expected to be a hard expiry. Silently extending it on use defeats that expectation and prevents rotation of long-lived automation credentials. ## Changes - `coderd/httpmw/apikey.go`: skip the expiry refresh when `key.LoginType == database.LoginTypeToken`. - `coderd/httpmw/apikey_test.go`: regression test asserting a token's expiry is not extended on use. ## Notes - Interactive sessions are unaffected (they still slide while active). - Tokens already extended are not retroactively shortened; this prevents future extension.
Validation - `go build ./coderd/httpmw/...` - `go test ./coderd/httpmw/ -run TestAPIKey -count=1` (all pass, including the new `TokenNoExpiryRefresh` and the interactive `ValidUpdateExpiry`) - `golangci-lint run ./coderd/httpmw/` (clean) - Confirmed the new test fails without the production change and passes with it.
--- 🤖 Generated by Coder Agents on behalf of @jdomeracki-coder. (cherry picked from commit 450ddff568afbed9e61d4ab1628fcc1c2f6d4e05) --- coderd/httpmw/apikey.go | 6 +++++- coderd/httpmw/apikey_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) 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 d060330427bd2..a56b8a825f298 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 (