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:
1 parent
6b44b92975
commit
0b2159b1a4
21 files changed
+755
-46
No files matched your search
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -78,6 +79,31 @@ func (l *rateLimiter) cleanup() {
|
||||
}
|
||||
}
|
||||
|
||||
// throttle allows an event once a minute per key (for log lines that
|
||||
// could otherwise repeat on every request).
|
||||
type throttle struct {
|
||||
mu sync.Mutex
|
||||
last map[string]time.Time
|
||||
}
|
||||
|
||||
func (t *throttle) allow(key string, now time.Time) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.last == nil {
|
||||
t.last = map[string]time.Time{}
|
||||
}
|
||||
if now.Sub(t.last[key]) < time.Minute {
|
||||
return false
|
||||
}
|
||||
for k, v := range t.last {
|
||||
if now.Sub(v) > time.Hour {
|
||||
delete(t.last, k)
|
||||
}
|
||||
}
|
||||
t.last[key] = now
|
||||
return true
|
||||
}
|
||||
|
||||
var errUnauthenticated = &httpError{http.StatusUnauthorized, "unauthenticated", "authentication required: log in, or send an API token as 'Authorization: Bearer jarl_...'"}
|
||||
|
||||
// authenticate identifies the caller from a bearer token or a session
|
||||
@@ -143,10 +169,19 @@ func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*au
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !auth.CheckSecret(secret, t.SecretHash) {
|
||||
return nil, errors.New("wrong token secret")
|
||||
}
|
||||
now := time.Now()
|
||||
previous := false
|
||||
if !auth.CheckSecret(secret, t.SecretHash) {
|
||||
// After a rotation with a grace period the old secret works until
|
||||
// the grace period ends.
|
||||
if t.PrevSecretHash == "" || t.PrevExpiresAt == nil || !auth.CheckSecret(secret, t.PrevSecretHash) {
|
||||
return nil, errors.New("wrong token secret")
|
||||
}
|
||||
if now.After(*t.PrevExpiresAt) {
|
||||
return nil, errors.New("this is the token's old secret, from before it was rotated; its grace period has ended")
|
||||
}
|
||||
previous = true
|
||||
}
|
||||
if t.RevokedAt != nil {
|
||||
return nil, errors.New("token was revoked")
|
||||
}
|
||||
@@ -163,6 +198,9 @@ func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*au
|
||||
if t.LastUsedAt == nil || now.Sub(*t.LastUsedAt) > time.Minute || t.LastUsedIP != ip {
|
||||
s.store.TouchToken(ctx, t.ID, ip)
|
||||
}
|
||||
if previous && s.oldSecretWarn.allow(fmt.Sprintf("%d %s", t.ID, ip), now) {
|
||||
s.log.Warn("token used with its old secret; update this client before the grace period ends", "token_id", t.ID, "token", t.Username+"/"+t.Name, "ip", ip, "valid_until", t.PrevExpiresAt.Format(time.RFC3339))
|
||||
}
|
||||
return &auth.Principal{User: u, Token: t, Method: "token", IP: ip, Agent: agent}, nil
|
||||
}
|
||||
|
||||
|
||||
+123
-14
@@ -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
|
||||
|
||||
@@ -26,6 +26,7 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("GET "+p+"/me/tokens", s.authed(s.listOwnTokens))
|
||||
m.HandleFunc("POST "+p+"/me/tokens", s.authed(s.createOwnToken))
|
||||
m.HandleFunc("DELETE "+p+"/tokens/{id}", s.authed(s.revokeToken))
|
||||
m.HandleFunc("POST "+p+"/tokens/{id}/rotate", s.authed(s.rotateToken))
|
||||
m.HandleFunc("GET "+p+"/tokens", s.authed(s.listAllTokens))
|
||||
// Users (admin)
|
||||
m.HandleFunc("GET "+p+"/users", s.authed(s.listUsers))
|
||||
|
||||
@@ -53,6 +53,8 @@ type Server struct {
|
||||
web fs.FS
|
||||
certs *certReloader
|
||||
statsQ *stats.Querier
|
||||
// oldSecretWarn limits warnings about rotated tokens' old secrets.
|
||||
oldSecretWarn throttle
|
||||
|
||||
mu sync.Mutex
|
||||
srvs []*http.Server
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTokenRotation(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
e.do("POST", "/users", e.token, map[string]any{"username": "ci", "password": "", "grant_strings": []string{"records:write zones=example.com"}}, 201)
|
||||
var ciID int64
|
||||
for _, u := range e.list("/users", e.token) {
|
||||
if u["username"] == "ci" {
|
||||
ciID = int64(u["id"].(float64))
|
||||
}
|
||||
}
|
||||
created := e.do("POST", "/users/"+itoa(ciID)+"/tokens", e.token, map[string]any{"name": "deploy", "grant_strings": []string{"records:write zones=example.com types=TXT"}, "expires_in": "30d"}, 201)
|
||||
id := itoa(int64(created["id"].(float64)))
|
||||
old := created["token"].(string)
|
||||
works := func(tok string) bool {
|
||||
_, status := e.req("GET", "/auth/whoami", tok, nil)
|
||||
return status == 200
|
||||
}
|
||||
|
||||
// Rotation keeps the token and its grants; the old secret stops.
|
||||
rot := e.do("POST", "/tokens/"+id+"/rotate", e.token, nil, 200)
|
||||
tok1 := rot["token"].(string)
|
||||
if tok1 == old || rot["id"] != created["id"] || rot["name"] != "deploy" || rot["public_id"] != created["public_id"] || rot["rotated_at"] == nil {
|
||||
t.Errorf("rotated = %v", rot)
|
||||
}
|
||||
if g := rot["grant_strings"].([]any); len(g) != 1 || g[0] != "records:write zones=example.com types=TXT" {
|
||||
t.Errorf("grants after rotation = %v", g)
|
||||
}
|
||||
if !works(tok1) || works(old) {
|
||||
t.Errorf("after rotation: new works %v, old works %v", works(tok1), works(old))
|
||||
}
|
||||
// The 30-day lifetime starts again.
|
||||
exp, _ := time.Parse(time.RFC3339Nano, rot["expires_at"].(string))
|
||||
if d := time.Until(exp); d < 29*24*time.Hour || d > 31*24*time.Hour {
|
||||
t.Errorf("expires in %v, want about 30 days", d)
|
||||
}
|
||||
|
||||
// With a grace period both secrets work for a while.
|
||||
rot = e.do("POST", "/tokens/"+id+"/rotate", e.token, map[string]any{"grace": "1h"}, 200)
|
||||
tok2 := rot["token"].(string)
|
||||
if !works(tok2) || !works(tok1) || rot["previous_secret_valid_until"] == nil {
|
||||
t.Errorf("grace: new %v, old %v, %v", works(tok2), works(tok1), rot)
|
||||
}
|
||||
// When the grace period ends, the old secret stops.
|
||||
e.st.DB().ExecContext(context.Background(), `UPDATE tokens SET prev_expires_at = ? WHERE id = ?`, time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano), id)
|
||||
if b, status := e.req("GET", "/auth/whoami", tok1, nil); status != 401 || !strings.Contains(string(b), "grace period has ended") {
|
||||
t.Errorf("old secret after grace: %d %s", status, b)
|
||||
}
|
||||
// Rotating again without grace drops the previous secret at once.
|
||||
e.do("POST", "/tokens/"+id+"/rotate", e.token, map[string]any{"grace": "1h"}, 200)
|
||||
rot = e.do("POST", "/tokens/"+id+"/rotate", e.token, nil, 200)
|
||||
tok3 := rot["token"].(string)
|
||||
if works(tok2) || !works(tok3) || rot["previous_secret_valid_until"] != nil {
|
||||
t.Errorf("rotation without grace kept an old secret: %v", rot)
|
||||
}
|
||||
|
||||
// A restricted token may rotate itself...
|
||||
self := e.do("POST", "/tokens/"+id+"/rotate", tok3, map[string]any{"expires_in": "2d"}, 200)
|
||||
tok4 := self["token"].(string)
|
||||
if works(tok3) || !works(tok4) {
|
||||
t.Error("self-rotation didn't replace the secret")
|
||||
}
|
||||
// ...but not a wider token of its user, whose secret it would learn.
|
||||
wide := e.do("POST", "/users/"+itoa(ciID)+"/tokens", e.token, map[string]any{"name": "wide"}, 201)
|
||||
wideID := itoa(int64(wide["id"].(float64)))
|
||||
if b, status := e.req("POST", "/tokens/"+wideID+"/rotate", tok4, nil); status != 403 {
|
||||
t.Errorf("restricted token rotated a wider one: %d %s", status, b)
|
||||
}
|
||||
// The unrestricted token can rotate the narrower one.
|
||||
e.do("POST", "/tokens/"+id+"/rotate", wide["token"].(string), map[string]any{"no_expiry": true}, 200)
|
||||
// Nobody else's tokens.
|
||||
if _, status := e.req("POST", "/tokens/1/rotate", wide["token"].(string), nil); status != 404 {
|
||||
t.Errorf("rotated the admin's token: %d", status)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
body map[string]any
|
||||
code int
|
||||
}{
|
||||
{map[string]any{"grace": "8d"}, 400},
|
||||
{map[string]any{"grace": "soon"}, 400},
|
||||
{map[string]any{"expires_in": "1d", "no_expiry": true}, 400},
|
||||
} {
|
||||
if b, status := e.req("POST", "/tokens/"+id+"/rotate", e.token, c.body); status != c.code {
|
||||
t.Errorf("%v: %d %s", c.body, status, b)
|
||||
}
|
||||
}
|
||||
e.do("DELETE", "/tokens/"+id, e.token, nil, 204)
|
||||
if _, status := e.req("POST", "/tokens/"+id+"/rotate", e.token, nil); status != 409 {
|
||||
t.Errorf("rotated a revoked token: %d", status)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user