Add optional API key expiry and a missing way to list them

Part of the same security-hardening pass as the last five commits, and
the last item in its backlog. User API keys had no expiry at all --
unlike service-account keys, visibly distinct only by their "tdsa_"
prefix -- and, it turns out while implementing this, no way to list
them either: only create (returns the raw key once) and delete-by-id
existed, so a key's owner had no way to even discover what keys they
had short of remembering IDs from creation time.

handleCreateAPIKey takes an optional expires_in_days (0, the default,
keeps today's behavior: never expires, so no existing integration is
affected). apiKeyUser's lookup now carries `expires_at IS NULL OR
expires_at > now` as part of the query itself, the same way serveAs's
disabled_at check already works -- an expired key simply fails to
resolve, like a wrong one, rather than resolving and being caught
after the fact. New GET /api/users/{id}/api-keys (requireSelfOrAdmin,
same as create/delete) lists id/name/created_at/last_used_at/expires_at,
never the raw key.

Scoped down from the original plan on request: no web UI change, since
there turned out to be no existing API-keys UI at all to extend --
building one from scratch would have been a real feature addition, not
a hardening tweak.

Mirrored the additive expires_at field in terdut-tui's APIKey struct
(separate commit, separate repo) per this workspace's version-coupling
rule; the TUI does not create or list expiring keys itself yet.

New tests (api_keys_test.go): default never-expires, expires_in_days
sets expires_at, out-of-range values rejected, an expired key fails
auth after a fresh one worked, the listing never includes the raw key.
Also added the new GET route to authz_scope_test.go's self-or-admin
table from the previous commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Niklas Ye
2026-10-07 22:31:44 +02:00
parent a2ca9c25d0
commit 926aa2d3ec
7 changed files with 236 additions and 5 deletions
+76 -3
View File
@@ -234,6 +234,11 @@ func handleDeleteUser(db *sql.DB) http.HandlerFunc {
}
}
// maxAPIKeyExpiryDays bounds expires_in_days: generous enough for any real
// rotation policy, tight enough to reject a typo (a year in hours, say) that
// would otherwise mint a key that outlives the server by decades.
const maxAPIKeyExpiryDays = 3650 // ~10 years
func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
@@ -247,6 +252,11 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
var req struct {
Name string `json:"name"`
// ExpiresInDays is optional and, left zero, means the key never
// expires — the only behavior any key had before this field
// existed, so an existing integration that does not send it is
// unaffected.
ExpiresInDays int64 `json:"expires_in_days,omitempty"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
@@ -256,6 +266,10 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusBadRequest, errResp("name is required"))
return
}
if req.ExpiresInDays < 0 || req.ExpiresInDays > maxAPIKeyExpiryDays {
respond(w, http.StatusBadRequest, errResp("expires_in_days must be 0 (never expires) or up to "+strconv.Itoa(maxAPIKeyExpiryDays)))
return
}
var exists int
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = $1", userID).Scan(&exists); err != nil {
@@ -268,18 +282,77 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
var expiresAt *int64
var expiresAtTime *time.Time
if req.ExpiresInDays > 0 {
t := time.Now().AddDate(0, 0, int(req.ExpiresInDays)).UTC()
u := t.Unix()
expiresAt = &u
expiresAtTime = &t
}
var keyID int64
if err := db.QueryRowContext(r.Context(),
"INSERT INTO api_keys (user_id, key_hash, name) VALUES ($1, $2, $3) RETURNING id",
userID, hash, req.Name).Scan(&keyID); err != nil {
"INSERT INTO api_keys (user_id, key_hash, name, expires_at) VALUES ($1, $2, $3, $4) RETURNING id",
userID, hash, req.Name, expiresAt).Scan(&keyID); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
key := models.APIKey{ID: keyID, UserID: userID, Name: req.Name, Key: raw, CreatedAt: time.Now().UTC()}
key := models.APIKey{
ID: keyID, UserID: userID, Name: req.Name, Key: raw,
CreatedAt: time.Now().UTC(), ExpiresAt: expiresAtTime,
}
respond(w, http.StatusCreated, key)
}
}
// handleListAPIKeys lists a user's own API keys: never the raw key itself
// (only ever returned once, at creation), just enough to tell them apart,
// see which are stale (last_used_at) and which are about to stop working
// (expires_at) — the data handleCreateAPIKey and apiKeyUser's last-use stamp
// already produce, with no endpoint to read it back until now.
func handleListAPIKeys(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
if !requireSelfOrAdmin(w, r, userID) {
return
}
rows, err := db.QueryContext(r.Context(),
`SELECT id, name, created_at, last_used_at, expires_at
FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC`, userID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
keys := []models.APIKey{}
for rows.Next() {
var k models.APIKey
var created int64
var lastUsed, expires *int64
if err := rows.Scan(&k.ID, &k.Name, &created, &lastUsed, &expires); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
k.UserID = userID
k.CreatedAt = time.Unix(created, 0).UTC()
k.LastUsedAt = unixPtr(lastUsed)
k.ExpiresAt = unixPtr(expires)
keys = append(keys, k)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, keys)
}
}
func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)