fix(coderd/httpmw): honor fixed lifetime for CLI API tokens (#26376)

## 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.

<details>
<summary>Validation</summary>

- `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.
</details>

---
🤖 Generated by Coder Agents on behalf of @jdomeracki-coder.
This commit is contained in:
Jakub Domeracki
2026-06-15 18:46:25 +02:00
committed by GitHub
parent b439b06ee6
commit 450ddff568
2 changed files with 38 additions and 1 deletions
+5 -1
View File
@@ -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)
+33
View File
@@ -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 (