From a9395e9f73161da9583e82ebaf78012355ce980d Mon Sep 17 00:00:00 2001 From: TowyTowy <85077986+TowyTowy@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:46:28 +0200 Subject: [PATCH] fix: derive jwt signing key from users in a stable order (#4886) Co-authored-by: Claude --- internal/auth/simple.go | 9 ++++++- internal/auth/simple_test.go | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 internal/auth/simple_test.go diff --git a/internal/auth/simple.go b/internal/auth/simple.go index 90cb7723..3e4bc658 100644 --- a/internal/auth/simple.go +++ b/internal/auth/simple.go @@ -3,7 +3,9 @@ package auth import ( "crypto/sha256" "errors" + "maps" "net/http" + "slices" "time" "github.com/go-chi/jwtauth/v5" @@ -18,8 +20,13 @@ type simpleAuthContext struct { var ErrInvalidCredentials = errors.New("invalid credentials") func NewSimpleAuth(userDatabase UserDatabase, ttl time.Duration) *simpleAuthContext { + // Hash the users in a stable order. Ranging over the map directly makes the + // digest depend on Go's randomized map iteration order, so any users.yml with + // more than one user derives a different signing key on every start and + // silently invalidates every session on restart. h := sha256.New() - for _, user := range userDatabase.Users { + for _, username := range slices.Sorted(maps.Keys(userDatabase.Users)) { + user := userDatabase.Users[username] h.Write([]byte(user.Password)) h.Write([]byte(user.RolesConfigured)) } diff --git a/internal/auth/simple_test.go b/internal/auth/simple_test.go new file mode 100644 index 00000000..c48d6d21 --- /dev/null +++ b/internal/auth/simple_test.go @@ -0,0 +1,50 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// The JWT signing key is derived from the users in users.yml so that a token +// stays valid across restarts and is only invalidated when a password or role +// actually changes. Deriving it by ranging over the user map made the digest +// depend on Go's randomized map iteration order, so every restart of a +// multi-user setup rolled the key and logged everyone out. +func TestSimpleAuthSigningKeyIsStableAcrossRestarts(t *testing.T) { + users := UserDatabase{ + Users: map[string]*User{ + "alice": {Username: "alice", Password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", RolesConfigured: "all"}, + "bob": {Username: "bob", Password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", RolesConfigured: "shell"}, + "carol": {Username: "carol", Password: "$2a$11$cccccccccccccccccccccccccccccccccccccccccccccccccccccc", RolesConfigured: "actions"}, + "dave": {Username: "dave", Password: "$2a$11$dddddddddddddddddddddddddddddddddddddddddddddddddddddd", RolesConfigured: "download"}, + }, + } + + _, token, err := NewSimpleAuth(users, 0).tokenAuth.Encode(map[string]any{"username": "alice"}) + require.NoError(t, err) + + // Each iteration stands in for a Dozzle restart against an unchanged users.yml. + for i := range 50 { + if _, err := NewSimpleAuth(users, 0).tokenAuth.Decode(token); err != nil { + t.Fatalf("token issued before restart %d was rejected: %v", i+1, err) + } + } +} + +// Changing a password or a role must still roll the key so old tokens stop working. +func TestSimpleAuthSigningKeyChangesWhenCredentialsChange(t *testing.T) { + users := UserDatabase{ + Users: map[string]*User{ + "alice": {Username: "alice", Password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", RolesConfigured: "all"}, + "bob": {Username: "bob", Password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", RolesConfigured: "shell"}, + }, + } + + _, token, err := NewSimpleAuth(users, 0).tokenAuth.Encode(map[string]any{"username": "alice"}) + require.NoError(t, err) + + users.Users["bob"].RolesConfigured = "shell,actions" + _, err = NewSimpleAuth(users, 0).tokenAuth.Decode(token) + require.Error(t, err) +}