Files
jarl/internal/server/tokens_test.go
T
abrown38andClaude Opus 5.5 0b2159b1a4 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>
2026-10-08 13:58:50 +00:00

99 lines
4.2 KiB
Go

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)
}
}