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