Add token rotation

jarl token rotate, a Rotate button in the web UI and
POST /api/v1/tokens/{id}/rotate give a token a new secret while it keeps
its ID, name, user and grants. The old secret stops at once or after a
grace period of up to 7 days, during which jarld logs who still uses it.
A token that expires gets its lifetime again. --save writes the new
secret where the CLI got the old one (its configuration or --token @FILE),
--write FILE to any file, and --current picks the token the CLI runs
with, so a restricted token can renew itself. A restricted token can't
rotate a wider token of its user.

Also fixes token create --save for named servers, and future times
(token expiry) shown as "just now" in the web UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-08 13:58:50 +00:00
1 parent 6b44b92975
commit 0b2159b1a4
21 files changed
+755 -46

No files matched your search

+123 -14
View File
@@ -1,7 +1,10 @@
package server
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
@@ -106,25 +109,28 @@ func parseExpiry(in api.TokenInput) (*time.Time, error) {
if in.ExpiresIn == "" {
return nil, nil
}
v := strings.TrimSpace(in.ExpiresIn)
var d time.Duration
if n, ok := strings.CutSuffix(v, "d"); ok {
days, err := strconv.Atoi(n)
if err != nil || days <= 0 {
return nil, badRequest("invalid expires_in %q", v)
}
d = time.Duration(days) * 24 * time.Hour
} else {
var err error
d, err = time.ParseDuration(v)
if err != nil || d <= 0 {
return nil, badRequest("invalid expires_in %q (use e.g. 90d or 720h)", v)
}
d, err := parseDays(in.ExpiresIn)
if err != nil || d <= 0 {
return nil, badRequest("invalid expires_in %q (use e.g. 90d or 720h)", in.ExpiresIn)
}
t := time.Now().Add(d)
return &t, nil
}
// parseDays parses a duration in Go syntax ("720h", "15m") or in days
// ("90d").
func parseDays(v string) (time.Duration, error) {
v = strings.TrimSpace(v)
if n, ok := strings.CutSuffix(v, "d"); ok {
days, err := strconv.Atoi(n)
if err != nil || days < 0 {
return 0, fmt.Errorf("invalid duration %q", v)
}
return time.Duration(days) * 24 * time.Hour, nil
}
return time.ParseDuration(v)
}
func (s *Server) createTokenFor(w http.ResponseWriter, r *http.Request, p *auth.Principal, u *model.User) error {
var in api.TokenInput
if err := decodeJSON(r, &in); err != nil {
@@ -243,6 +249,109 @@ func (s *Server) revokeToken(w http.ResponseWriter, r *http.Request, p *auth.Pri
return nil
}
// maxRotationGrace is the longest an old secret may keep working.
const maxRotationGrace = 7 * 24 * time.Hour
// rotateToken: POST /tokens/{id}/rotate gives a token a new secret. The
// token keeps its ID, name, user and grants, so nothing needs to be set up
// again; only the clients that use it need the new secret. The old secret
// stops working at once, or after the grace period. A token that expires
// gets the same lifetime again, counted from now, unless expires_in or
// expires_at says otherwise.
func (s *Server) rotateToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
var in api.TokenRotation
body, err := readBody(r)
if err != nil {
return err
}
if body != nil {
dec := json.NewDecoder(bytes.NewReader(body))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
return badRequest("invalid JSON: %v", err)
}
}
t, err := s.store.GetToken(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return notFound("token %d not found", id)
}
return err
}
if t.UserID != p.User.ID && !p.IsAdmin() {
return notFound("token %d not found", id)
}
// A restricted token may rotate itself, and tokens no wider than
// itself, but not a wider token of the same user: it would learn that
// token's new secret.
if !p.IsAdmin() && p.Token != nil && p.Token.Grants != nil && p.Token.ID != t.ID && (t.Grants == nil || !narrower(t.Grants, p.Token.Grants)) {
return forbidden("a restricted token can only rotate itself and tokens with a subset of its own grants")
}
if t.RevokedAt != nil {
return conflict("token %d is revoked; create a new one", id)
}
now := time.Now()
var exp *time.Time
switch {
case in.ExpiresIn != "" || in.ExpiresAt != nil:
if in.NoExpiry {
return badRequest("no_expiry can't be combined with expires_in or expires_at")
}
if exp, err = parseExpiry(api.TokenInput{ExpiresIn: in.ExpiresIn, ExpiresAt: in.ExpiresAt}); err != nil {
return err
}
case in.NoExpiry:
case t.ExpiresAt != nil:
life := t.ExpiresAt.Sub(t.IssuedAt())
if life <= 0 {
return badRequest("can't tell the token's lifetime; give expires_in")
}
e := now.Add(life)
exp = &e
}
var prevUntil *time.Time
if in.Grace != "" {
g, err := parseDays(in.Grace)
if err != nil || g < 0 || g > maxRotationGrace {
return badRequest("invalid grace %q: use a duration up to 7d, e.g. 30m, 24h or 2d", in.Grace)
}
until := now.Add(g)
if t.ExpiresAt != nil && t.ExpiresAt.Before(until) {
until = *t.ExpiresAt // the old secret never outlives its token
}
if g > 0 && until.After(now) {
prevUntil = &until
}
}
secret, hash := auth.RotateToken(t.PublicID)
if err := s.store.RotateToken(r.Context(), t.ID, hash, exp, prevUntil); err != nil {
if errors.Is(err, store.ErrNotFound) {
return conflict("token %d is revoked; create a new one", id)
}
return err
}
t, err = s.store.GetToken(r.Context(), id)
if err != nil {
return err
}
detail := "old secret stops working now"
if prevUntil != nil {
detail = "old secret works until " + prevUntil.UTC().Format(time.RFC3339)
}
if t.ExpiresAt != nil {
detail += "; expires " + t.ExpiresAt.UTC().Format(time.RFC3339)
}
s.eng.Audit(r.Context(), p, "token.rotate", "token", t.ID, t.Username+"/"+t.Name, "success", detail)
out := presentToken(t)
out.Secret = secret
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err