Lock bootstrap admin token until password is changed (#6)
Format / gofmt (push) Successful in 17s
CI / Build (push) Successful in 28s
CI / Go Tests (push) Successful in 1m2s

Closes #2.
This commit was merged in pull request #6.
This commit is contained in:
s1d3sw1ped_bot
2026-08-31 22:57:37 -05:00
13 changed files with 445 additions and 40 deletions
+17 -13
View File
@@ -46,6 +46,7 @@ func streamsAPIResponseFrom(st store.Store, eng *proxy.Engine) []streamAPIRespon
func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.Manager, jwtMgr *auth.JWTManager) {
r.Route("/api", func(r chi.Router) {
r.Use(jwtMgr.Middleware)
r.Use(bootstrapLock(st))
// Health (mirrors original /api style)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
@@ -1091,12 +1092,10 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M
})
// Login for the single admin account (no registration, no multi-user).
// Default password is "password". On first use (when no password has been set in DB yet),
// login with "password" succeeds but the response includes mustChangePassword:true
// (skipped in PROXY_MODE=development for local dev convenience).
// After the admin sets a new password (via /users/me/password), it is bcrypt-hashed and stored in DB.
// Subsequent logins use the stored hash. Email is ignored (always the built-in admin).
// No 2FA/TOTP.
// Until a non-default password is stored, the default password is accepted and the
// JWT is a bootstrap token (mutating admin API other than change-password is 403).
// After /users/me/password, subsequent logins use the stored bcrypt hash.
// Email is ignored (always the built-in admin). No 2FA/TOTP.
r.Post("/login", func(w http.ResponseWriter, r *http.Request) {
var payload struct {
Password string `json:"password"`
@@ -1105,17 +1104,17 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M
http.Error(w, err.Error(), 400)
return
}
u, ok := st.GetUserByEmail("admin@example.com")
u, ok := st.GetUserByEmail(adminEmail)
if !ok {
// fallback to seeded values if lookup fails
u = store.User{ID: 1, Email: "admin@example.com", Name: "Admin", Roles: []string{"admin"}}
u = store.User{ID: 1, Email: adminEmail, Name: "Admin", Roles: []string{"admin"}}
}
mustChange := false
authed := false
bootstrap := false
if u.Password == "" {
// initial / not yet set: default "password" is accepted
if payload.Password == "password" {
if payload.Password == defaultAdminPassword {
authed = true
bootstrap = true
mustChange = !config.IsDevelopment()
}
} else if isBcryptPrefix(u.Password) {
@@ -1123,14 +1122,19 @@ func mountAPI(r chi.Router, st store.Store, eng *proxy.Engine, cm *certificate.M
authed = true
}
} else if u.Password == payload.Password {
// legacy plain (should not happen after first change)
authed = true
}
if !authed {
http.Error(w, "invalid credentials", 401)
return
}
token, err := jwtMgr.GenerateToken(u.ID, u.Email, u.Name, u.Roles)
var token string
var err error
if bootstrap {
token, err = jwtMgr.GenerateBootstrapToken(u.ID, u.Email, u.Name, u.Roles)
} else {
token, err = jwtMgr.GenerateToken(u.ID, u.Email, u.Name, u.Roles)
}
if err != nil {
http.Error(w, "token error", 500)
return
+1 -1
View File
@@ -14,7 +14,7 @@ import (
func (e *apiTestEnv) loginToken(t *testing.T) string {
t.Helper()
resp := apiPost(t, e.BaseURL+"/api/login", map[string]any{"password": "password"}, "")
resp := apiPost(t, e.BaseURL+"/api/login", map[string]any{"password": testLoginPassword()}, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("login: %d %s", resp.StatusCode, readBody(resp))
}
+104
View File
@@ -0,0 +1,104 @@
package main
import (
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"helix-proxy/internal/auth"
"helix-proxy/internal/config"
"helix-proxy/internal/store"
"golang.org/x/crypto/bcrypt"
)
const (
adminEmail = "admin@example.com"
defaultAdminPassword = "password"
testAdminPassword = "helix-test-admin"
)
// applyInitialAdminPassword sets the admin hash from ADMIN_PASSWORD on first boot.
// Production refuses to start if the password is still unset. Development may
// leave the password empty (bootstrap lock) when ADMIN_PASSWORD is not set.
func applyInitialAdminPassword(st store.Store) error {
u, ok := st.GetUserByEmail(adminEmail)
if !ok {
return fmt.Errorf("admin user missing")
}
if u.Password != "" {
return nil
}
envPW := os.Getenv("ADMIN_PASSWORD")
if envPW != "" {
if envPW == defaultAdminPassword {
return fmt.Errorf("ADMIN_PASSWORD cannot be the well-known default")
}
h, err := bcrypt.GenerateFromPassword([]byte(envPW), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash ADMIN_PASSWORD: %w", err)
}
u.Password = string(h)
if err := st.UpdateUser(u); err != nil {
return err
}
slog.Info("admin password set from ADMIN_PASSWORD")
return nil
}
if config.IsDevelopment() {
slog.Warn("admin password unset; bootstrap lock active until a non-default password is stored")
return nil
}
return fmt.Errorf("ADMIN_PASSWORD is required on first boot; the default password is not permitted")
}
func testLoginPassword() string {
if p := os.Getenv("ADMIN_PASSWORD"); p != "" {
return p
}
return defaultAdminPassword
}
func adminPasswordUnset(st store.Store) bool {
u, ok := st.GetUserByEmail(adminEmail)
return !ok || u.Password == ""
}
func bootstrapPathAllowed(r *http.Request) bool {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
p := strings.TrimSuffix(r.URL.Path, "/")
if r.Method == http.MethodPost && (p == "/api/login" || p == "/api/users/me/password") {
return true
}
return false
}
// bootstrapLock rejects mutating admin API (except login + change-password)
// while the default password is still in effect, or when the JWT is a bootstrap token.
func bootstrapLock(st store.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if bootstrapPathAllowed(r) {
next.ServeHTTP(w, r)
return
}
claims, ok := auth.GetUserFromContext(r.Context())
if !ok {
next.ServeHTTP(w, r)
return
}
if claims.Bootstrap || adminPasswordUnset(st) {
http.Error(w, "password change required", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
+167
View File
@@ -0,0 +1,167 @@
package main
import (
"net/http"
"strings"
"testing"
"helix-proxy/internal/config"
"helix-proxy/internal/store"
)
func newBootstrapAPIServer(t *testing.T) *apiTestEnv {
t.Helper()
t.Setenv("PROXY_MODE", "development")
t.Setenv("ADMIN_PASSWORD", "")
return newAPITestServer(t)
}
func TestBootstrapLock_MutatingAdminForbidden(t *testing.T) {
env := newBootstrapAPIServer(t)
resp := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("bootstrap login: %d %s", resp.StatusCode, readBody(resp))
}
var out struct {
Token string `json:"token"`
}
decodeJSON(t, resp, &out)
if out.Token == "" {
t.Fatal("empty bootstrap token")
}
tok := out.Token
mutating := []struct {
method string
path string
body any
}{
{http.MethodPost, "/api/proxy-hosts", map[string]any{
"domainNames": []string{"boot.example"},
"forwardHost": "127.0.0.1",
"forwardPort": 8080,
"forwardScheme": "http",
}},
{http.MethodPost, "/api/settings", map[string]any{"default_site": "404"}},
{http.MethodPost, "/api/access-lists", map[string]any{"name": "boot"}},
{http.MethodPost, "/api/streams", map[string]any{"incomingPort": 9000, "forwardingHost": "127.0.0.1", "forwardingPort": 9001}},
}
for _, tc := range mutating {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
r := apiRequest(t, tc.method, env.BaseURL+tc.path, tok, tc.body)
assertStatus(t, r, http.StatusForbidden)
})
}
assertStatus(t, apiGet(t, env.BaseURL+"/api/users/me", tok), http.StatusOK)
assertStatus(t, apiGet(t, env.BaseURL+"/api/proxy-hosts", tok), http.StatusOK)
}
func TestBootstrapLock_ChangePasswordThenDefaultFails(t *testing.T) {
env := newBootstrapAPIServer(t)
resp := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("bootstrap login: %d %s", resp.StatusCode, readBody(resp))
}
var out struct {
Token string `json:"token"`
}
decodeJSON(t, resp, &out)
ch := apiPost(t, env.BaseURL+"/api/users/me/password", map[string]any{
"currentPassword": defaultAdminPassword,
"newPassword": "changed-after-bootstrap",
}, out.Token)
assertStatus(t, ch, http.StatusOK)
fail := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}, "")
assertStatus(t, fail, http.StatusUnauthorized)
okLogin := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": "changed-after-bootstrap"}, "")
if okLogin.StatusCode != http.StatusOK {
t.Fatalf("new password login: %d %s", okLogin.StatusCode, readBody(okLogin))
}
var out2 struct {
Token string `json:"token"`
}
decodeJSON(t, okLogin, &out2)
create := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
"domainNames": []string{"after-change.example"},
"forwardHost": "127.0.0.1",
"forwardPort": 8080,
"forwardScheme": "http",
}, out2.Token)
if create.StatusCode != http.StatusCreated {
t.Fatalf("full token after change should mutate: %d %s", create.StatusCode, readBody(create))
}
_ = create.Body.Close()
locked := apiPost(t, env.BaseURL+"/api/settings", map[string]any{"default_site": "404"}, out.Token)
assertStatus(t, locked, http.StatusForbidden)
}
func TestDevTestsBootWithAdminPassword(t *testing.T) {
t.Setenv("PROXY_MODE", "development")
env := newAPITestServer(t)
if adminPasswordUnset(env.Store) {
t.Fatal("test env should store ADMIN_PASSWORD hash")
}
token := env.loginToken(t)
create := apiPost(t, env.BaseURL+"/api/proxy-hosts", map[string]any{
"domainNames": []string{"dev-admin.example"},
"forwardHost": "127.0.0.1",
"forwardPort": 8080,
"forwardScheme": "http",
}, token)
if create.StatusCode != http.StatusCreated {
t.Fatalf("dev ADMIN_PASSWORD token should be fully privileged: %d %s", create.StatusCode, readBody(create))
}
_ = create.Body.Close()
fail := apiPost(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword}, "")
assertStatus(t, fail, http.StatusUnauthorized)
}
func TestApplyInitialAdminPassword_RejectsDefault(t *testing.T) {
t.Setenv("PROXY_MODE", "development")
t.Setenv("ADMIN_PASSWORD", defaultAdminPassword)
tmp := t.TempDir()
config.ResetForTest()
t.Setenv("DATA_DIR", tmp)
if err := config.EnsureDataDirs(); err != nil {
t.Fatal(err)
}
st, err := store.New()
if err != nil {
t.Fatal(err)
}
if c, ok := st.(interface{ Close() error }); ok {
t.Cleanup(func() { _ = c.Close() })
}
err = applyInitialAdminPassword(st)
if err == nil || !strings.Contains(err.Error(), "default") {
t.Fatalf("expected default ADMIN_PASSWORD error, got %v", err)
}
}
func TestApplyInitialAdminPassword_ProductionRequiresEnv(t *testing.T) {
t.Setenv("PROXY_MODE", "production")
t.Setenv("ADMIN_PASSWORD", "")
tmp := t.TempDir()
config.ResetForTest()
t.Setenv("DATA_DIR", tmp)
if err := config.EnsureDataDirs(); err != nil {
t.Fatal(err)
}
st, err := store.New()
if err != nil {
t.Fatal(err)
}
if c, ok := st.(interface{ Close() error }); ok {
t.Cleanup(func() { _ = c.Close() })
}
err = applyInitialAdminPassword(st)
if err == nil || !strings.Contains(err.Error(), "ADMIN_PASSWORD") {
t.Fatalf("expected refuse without ADMIN_PASSWORD, got %v", err)
}
}
+6 -3
View File
@@ -21,15 +21,18 @@ func applyUmaskFromEnv() {
}
// adminListenAddr resolves the admin API/UI listen address from env.
// Defaults to loopback so the admin port is not published on all interfaces.
// Override host with ADMIN_HOST (e.g. 0.0.0.0) when the UI must be reachable off-box.
func adminListenAddr() string {
port := "8081"
if p := os.Getenv("ADMIN_PORT"); p != "" {
port = p
}
if os.Getenv("DISABLE_IPV6") == "1" {
return "0.0.0.0:" + port
host := os.Getenv("ADMIN_HOST")
if host == "" {
host = "127.0.0.1"
}
return net.JoinHostPort("", port)
return net.JoinHostPort(host, port)
}
// proxyHTTPSListenAddr resolves the TLS proxy listen address from env.
+8 -7
View File
@@ -11,27 +11,27 @@ func TestAdminListenAddr(t *testing.T) {
expect string
}{
{
name: "defaults ipv6",
name: "defaults loopback",
env: map[string]string{},
expect: ":8081",
expect: "127.0.0.1:8081",
},
{
name: "custom port",
env: map[string]string{"ADMIN_PORT": "9090"},
expect: ":9090",
expect: "127.0.0.1:9090",
},
{
name: "disable ipv6",
name: "disable ipv6 still loopback",
env: map[string]string{
"DISABLE_IPV6": "1",
},
expect: "0.0.0.0:8081",
expect: "127.0.0.1:8081",
},
{
name: "custom port ipv4 only",
name: "ADMIN_HOST override",
env: map[string]string{
"ADMIN_HOST": "0.0.0.0",
"ADMIN_PORT": "3000",
"DISABLE_IPV6": "1",
},
expect: "0.0.0.0:3000",
},
@@ -39,6 +39,7 @@ func TestAdminListenAddr(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("ADMIN_PORT", "")
t.Setenv("ADMIN_HOST", "")
t.Setenv("DISABLE_IPV6", "")
for k, v := range tc.env {
t.Setenv(k, v)
+16 -6
View File
@@ -60,8 +60,9 @@ func TestIsAdminCanManage(t *testing.T) {
func TestSingleAdminLoginDevModeSkipsMustChange(t *testing.T) {
t.Setenv("PROXY_MODE", "development")
t.Setenv("ADMIN_PASSWORD", "")
env := newAPITestServer(t)
resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "password"})
resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword})
if resp.StatusCode != 200 {
t.Fatalf("default login failed: %d", resp.StatusCode)
}
@@ -76,12 +77,13 @@ func TestSingleAdminLoginDevModeSkipsMustChange(t *testing.T) {
}
func TestSingleAdminLogin(t *testing.T) {
t.Setenv("PROXY_MODE", "")
// Bootstrap path is development-only; production refuses to start without ADMIN_PASSWORD.
t.Setenv("PROXY_MODE", "development")
t.Setenv("ADMIN_PASSWORD", "")
env := newAPITestServer(t)
st := env.Store
// initial default "password" login returns mustChangePassword
resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "password"})
resp := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword})
if resp.StatusCode != 200 {
t.Fatalf("default login failed: %d", resp.StatusCode)
}
@@ -91,9 +93,12 @@ func TestSingleAdminLogin(t *testing.T) {
MustChangePassword bool `json:"mustChangePassword"`
}
json.NewDecoder(resp.Body).Decode(&lr)
if lr.Token == "" || lr.User["id"] != float64(1) || !lr.MustChangePassword {
if lr.Token == "" || lr.User["id"] != float64(1) {
t.Errorf("default login resp bad: %+v", lr)
}
if lr.MustChangePassword {
t.Error("development bootstrap login should not set mustChangePassword")
}
// wrong default fails
respBad := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "wrong"})
@@ -104,11 +109,16 @@ func TestSingleAdminLogin(t *testing.T) {
// use the returned token to change password (initial case)
token := lr.Token
authH := func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+token) }
chResp := postJSONAuth(t, env.BaseURL+"/api/users/me/password", map[string]any{"currentPassword": "password", "newPassword": "newpass123"}, authH)
chResp := postJSONAuth(t, env.BaseURL+"/api/users/me/password", map[string]any{"currentPassword": defaultAdminPassword, "newPassword": "newpass123"}, authH)
if chResp.StatusCode != 200 {
t.Fatalf("initial pw change failed: %d", chResp.StatusCode)
}
respOld := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": defaultAdminPassword})
if respOld.StatusCode != 401 {
t.Errorf("default password after change should 401: %d", respOld.StatusCode)
}
// after change, login with new pw succeeds, no mustChange
resp2 := postJSON(t, env.BaseURL+"/api/login", map[string]any{"password": "newpass123"})
if resp2.StatusCode != 200 {
+3
View File
@@ -40,6 +40,9 @@ func run(ctx context.Context) error {
if err != nil {
return err
}
if err := applyInitialAdminPassword(st); err != nil {
return err
}
eng := proxy.NewEngine(st)
eng.ReloadFromStore()
+68
View File
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"strconv"
"strings"
"testing"
"time"
@@ -70,3 +71,70 @@ func TestRun_EnsureDataDirsFailure(t *testing.T) {
t.Fatal("expected error when data dirs cannot be created")
}
}
func TestRun_ProductionRefusesDefaultPassword(t *testing.T) {
t.Setenv("PROXY_MODE", "production")
t.Setenv("ADMIN_PASSWORD", "")
t.Setenv("DISABLE_IPV6", "1")
t.Setenv("RENEWAL_INITIAL_DELAY", "1h")
tmp := t.TempDir()
config.ResetForTest()
t.Setenv("DATA_DIR", tmp)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := run(ctx)
if err == nil {
t.Fatal("expected non-dev first boot without ADMIN_PASSWORD to refuse to start")
}
if !strings.Contains(err.Error(), "ADMIN_PASSWORD") {
t.Fatalf("error should mention ADMIN_PASSWORD: %v", err)
}
}
func TestRun_ProductionWithAdminPasswordStarts(t *testing.T) {
t.Setenv("PROXY_MODE", "production")
t.Setenv("ADMIN_PASSWORD", "prod-admin-not-default")
t.Setenv("DISABLE_IPV6", "1")
t.Setenv("RENEWAL_INITIAL_DELAY", "1h")
adminPort := pickFreePort(t)
httpPort := pickFreePort(t)
httpsPort := pickFreePort(t)
t.Setenv("ADMIN_PORT", strconv.Itoa(adminPort))
t.Setenv("PROXY_HTTP_PORT", strconv.Itoa(httpPort))
t.Setenv("PROXY_HTTPS_PORT", strconv.Itoa(httpsPort))
tmp := t.TempDir()
config.ResetForTest()
t.Setenv("DATA_DIR", tmp)
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
errCh <- run(ctx)
}()
adminAddr := "127.0.0.1:" + strconv.Itoa(adminPort)
waitForTCP(t, adminAddr, 5*time.Second)
resp, err := http.Get("http://" + adminAddr + "/api/")
if err != nil {
t.Fatalf("admin health: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin health status: %d", resp.StatusCode)
}
_ = resp.Body.Close()
cancel()
select {
case err := <-errCh:
if err != nil {
t.Fatalf("run returned error: %v", err)
}
case <-time.After(15 * time.Second):
t.Fatal("run did not exit after context cancel")
}
}
+6
View File
@@ -37,6 +37,9 @@ func newTestServer(t *testing.T, mode testServerMode) *apiTestEnv {
if _, ok := os.LookupEnv("PROXY_MODE"); !ok {
t.Setenv("PROXY_MODE", "development")
}
if _, ok := os.LookupEnv("ADMIN_PASSWORD"); !ok {
t.Setenv("ADMIN_PASSWORD", testAdminPassword)
}
tmp := t.TempDir()
config.ResetForTest()
t.Setenv("DATA_DIR", tmp)
@@ -47,6 +50,9 @@ func newTestServer(t *testing.T, mode testServerMode) *apiTestEnv {
if err != nil {
t.Fatalf("store: %v", err)
}
if err := applyInitialAdminPassword(st); err != nil {
t.Fatalf("admin password: %v", err)
}
eng := proxy.NewEngine(st)
eng.ReloadFromStore()
cm := certificate.NewManager(st)
+2 -1
View File
@@ -5,7 +5,8 @@ services:
restart: unless-stopped
ports:
- "80:80"
- "81:81"
# Admin binds 127.0.0.1; do not publish :81 on untrusted networks.
# - "81:81"
- "443:443"
volumes:
- ./data:/app/data
+12
View File
@@ -26,6 +26,7 @@ type Claims struct {
Email string `json:"email"`
Name string `json:"name"`
Roles []string `json:"roles"`
Bootstrap bool `json:"bootstrap,omitempty"`
jwt.RegisteredClaims
}
@@ -95,6 +96,16 @@ func NewJWTManager(secret string) *JWTManager {
// GenerateToken creates a JWT for the given user (1 hour expiry like typical).
func (m *JWTManager) GenerateToken(userID int, email, name string, roles []string) (string, error) {
return m.signToken(userID, email, name, roles, false)
}
// GenerateBootstrapToken issues a JWT that cannot call mutating admin APIs
// until the default password has been changed.
func (m *JWTManager) GenerateBootstrapToken(userID int, email, name string, roles []string) (string, error) {
return m.signToken(userID, email, name, roles, true)
}
func (m *JWTManager) signToken(userID int, email, name string, roles []string, bootstrap bool) (string, error) {
now := time.Now()
if roles == nil {
roles = []string{}
@@ -104,6 +115,7 @@ func (m *JWTManager) GenerateToken(userID int, email, name string, roles []strin
Email: email,
Name: name,
Roles: roles,
Bootstrap: bootstrap,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(1 * time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
+26
View File
@@ -32,6 +32,32 @@ func TestJWTManager_GenerateValidate_Roundtrip(t *testing.T) {
}
}
func TestJWTManager_BootstrapClaim(t *testing.T) {
m := NewJWTManager("test-secret-123")
token, err := m.GenerateBootstrapToken(1, "admin.com", "Admin", []string{"admin"})
if err != nil {
t.Fatal(err)
}
claims, err := m.ValidateToken(token)
if err != nil {
t.Fatal(err)
}
if !claims.Bootstrap {
t.Error("bootstrap token missing claim")
}
full, err := m.GenerateToken(1, "admin.com", "Admin", []string{"admin"})
if err != nil {
t.Fatal(err)
}
fullClaims, err := m.ValidateToken(full)
if err != nil {
t.Fatal(err)
}
if fullClaims.Bootstrap {
t.Error("normal token should not be bootstrap")
}
}
func TestJWTManager_EmptySecretRandomRoundtrip(t *testing.T) {
m := NewJWTManager("") // empty -> random in-memory secret
token, _ := m.GenerateToken(1, "a@b", "A", nil)