Skip to content

Commit 450ddff

Browse files
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.
1 parent b439b06 commit 450ddff

2 files changed

Lines changed: 38 additions & 1 deletion

File tree

coderd/httpmw/apikey.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,11 @@ func ValidateAPIKey(ctx context.Context, cfg ValidateAPIKeyConfig, r *http.Reque
424424
}
425425
changed = true
426426
}
427-
if !cfg.DisableSessionExpiryRefresh {
427+
// Only apply sliding-window expiry refresh to interactive login
428+
// sessions. Programmatic API tokens (LoginTypeToken, created via
429+
// `coder tokens create`) honor a fixed, finite lifetime and must not be
430+
// silently extended to now+lifetime on each authenticated request.
431+
if !cfg.DisableSessionExpiryRefresh && key.LoginType != database.LoginTypeToken {
428432
apiKeyLifetime := time.Duration(key.LifetimeSeconds) * time.Second
429433
if key.ExpiresAt.Sub(now) <= apiKeyLifetime-time.Hour {
430434
key.ExpiresAt = now.Add(apiKeyLifetime)

coderd/httpmw/apikey_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,39 @@ func TestAPIKey(t *testing.T) {
471471
require.NotEqual(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt)
472472
})
473473

474+
t.Run("TokenNoExpiryRefresh", func(t *testing.T) {
475+
t.Parallel()
476+
var (
477+
db, _ = dbtestutil.NewDB(t)
478+
user = dbgen.User(t, db, database.User{})
479+
sentAPIKey, token = dbgen.APIKey(t, db, database.APIKey{
480+
UserID: user.ID,
481+
LastUsed: dbtime.Now(),
482+
ExpiresAt: dbtime.Now().Add(time.Minute),
483+
LoginType: database.LoginTypeToken,
484+
})
485+
486+
r = httptest.NewRequest("GET", "/", nil)
487+
rw = httptest.NewRecorder()
488+
)
489+
r.Header.Set(codersdk.SessionTokenHeader, token)
490+
491+
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
492+
DB: db,
493+
RedirectToLogin: false,
494+
})(successHandler).ServeHTTP(rw, r)
495+
res := rw.Result()
496+
defer res.Body.Close()
497+
require.Equal(t, http.StatusOK, res.StatusCode)
498+
499+
gotAPIKey, err := db.GetAPIKeyByID(r.Context(), sentAPIKey.ID)
500+
require.NoError(t, err)
501+
502+
// Programmatic tokens honor a fixed lifetime, so the expiry must not be
503+
// extended on use even though it is within the refresh window.
504+
require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt)
505+
})
506+
474507
t.Run("NoRefresh", func(t *testing.T) {
475508
t.Parallel()
476509
var (

0 commit comments

Comments
 (0)