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>
99 lines
4.2 KiB
Go
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)
|
|
}
|
|
}
|