mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user