From 34e1d83a79abdccefe7e7a5f5f6d41bbfc904c1c Mon Sep 17 00:00:00 2001 From: Roshan Ramani Date: Fri, 2 Oct 2026 20:11:20 +0530 Subject: [PATCH 1/3] fix: keep config defaults for keys a config file omits The loader decoded the TOML file into a zero Config and passed each section to the With* options, which copy booleans and some ints as given. So any config file, even one without a [session] section, turned session.http_only off, set max_sessions_per_user to 0 (no limit) and turned security.cors.allow_credentials off. Running with no file kept the defaults. Decode the file on top of DefaultConfig, the defaults NewConfig now starts from, so an omitted key keeps its default and an explicit one still wins. --- cmd/shared/configloader/configloader.go | 7 ++- cmd/shared/configloader/configloader_test.go | 62 ++++++++++++++++++++ config/options.go | 16 +++-- 3 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 cmd/shared/configloader/configloader_test.go diff --git a/cmd/shared/configloader/configloader.go b/cmd/shared/configloader/configloader.go index 96d1aeaf..3da9a19e 100644 --- a/cmd/shared/configloader/configloader.go +++ b/cmd/shared/configloader/configloader.go @@ -23,8 +23,11 @@ func Load(path string) (*authulamodels.Config, bool, error) { return nil, false, fmt.Errorf("read config file: %w", err) } - var loaded authulamodels.Config - if err := toml.Unmarshal(data, &loaded); err != nil { + // Decode on top of the defaults: the With* options below copy booleans + // such as session.http_only as given, so a key the file omits would + // otherwise arrive as false and replace a default of true. + loaded := authulaconfig.DefaultConfig() + if err := toml.Unmarshal(data, loaded); err != nil { return nil, true, fmt.Errorf("failed to unmarshal config: %w", err) } diff --git a/cmd/shared/configloader/configloader_test.go b/cmd/shared/configloader/configloader_test.go new file mode 100644 index 00000000..28385b6b --- /dev/null +++ b/cmd/shared/configloader/configloader_test.go @@ -0,0 +1,62 @@ +package configloader + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadKeepsDefaultsForOmittedKeys(t *testing.T) { + tests := []struct { + name string + file string + wantHttpOnly bool + wantMaxSessions int + wantAllowCredentials bool + wantAllowedOrigins []string + }{ + { + name: "file without a session or security section", + file: "app_name = \"demo\"\n", + wantHttpOnly: true, + wantMaxSessions: 5, + wantAllowCredentials: true, + wantAllowedOrigins: []string{"*"}, + }, + { + name: "session section that sets only the cookie name", + file: "[session]\ncookie_name = \"sid\"\n", + wantHttpOnly: true, + wantMaxSessions: 5, + wantAllowCredentials: true, + wantAllowedOrigins: []string{"*"}, + }, + { + name: "explicit values still win", + file: "[session]\nhttp_only = false\nmax_sessions_per_user = 2\n[security.cors]\nallow_credentials = false\nallowed_origins = [\"https://example.com\"]\n", + wantHttpOnly: false, + wantMaxSessions: 2, + wantAllowCredentials: false, + wantAllowedOrigins: []string{"https://example.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(path, []byte(tt.file), 0o600)) + + config, exists, err := Load(path) + require.NoError(t, err) + require.True(t, exists) + + assert.Equal(t, tt.wantHttpOnly, config.Session.HttpOnly, "session.http_only") + assert.Equal(t, tt.wantMaxSessions, config.Session.MaxSessionsPerUser, "session.max_sessions_per_user") + assert.Equal(t, tt.wantAllowCredentials, config.Security.CORS.AllowCredentials, "security.cors.allow_credentials") + assert.Equal(t, tt.wantAllowedOrigins, config.Security.CORS.AllowedOrigins, "security.cors.allowed_origins") + }) + } +} diff --git a/config/options.go b/config/options.go index 1b7ce6f3..159daad7 100644 --- a/config/options.go +++ b/config/options.go @@ -18,11 +18,11 @@ const defaultSecret = "authula-secret-0123456789" type ConfigOption func(*models.Config) -// NewConfig builds a Config using functional options with sensible defaults. -// Panics if event bus configuration is invalid or if required secrets are missing in production. -func NewConfig(options ...ConfigOption) *models.Config { - // Define sensible defaults first - config := &models.Config{ +// DefaultConfig returns the defaults NewConfig starts from, without applying +// options or validating. A config file is decoded on top of it, so a key the +// file leaves out keeps its default instead of becoming the zero value. +func DefaultConfig() *models.Config { + return &models.Config{ AppName: "Authula", BaseURL: "http://localhost:8080", BasePath: "/auth", @@ -74,6 +74,12 @@ func NewConfig(options ...ConfigOption) *models.Config { PreParsedConfigs: make(map[string]any), CoreServiceHooks: nil, } +} + +// NewConfig builds a Config using functional options with sensible defaults. +// Panics if event bus configuration is invalid or if required secrets are missing in production. +func NewConfig(options ...ConfigOption) *models.Config { + config := DefaultConfig() // Apply the options - they override defaults only if non-zero/non-empty for _, option := range options { From a7b1328b1e32cd3202fda78f7f75b6a5bed56014 Mon Sep 17 00:00:00 2001 From: Tanvir Ahmed Date: Mon, 5 Oct 2026 01:37:44 +0000 Subject: [PATCH 2/3] chore: Updated default config function name --- cmd/shared/configloader/configloader.go | 7 +++---- config/options.go | 8 ++++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/cmd/shared/configloader/configloader.go b/cmd/shared/configloader/configloader.go index 3da9a19e..58261475 100644 --- a/cmd/shared/configloader/configloader.go +++ b/cmd/shared/configloader/configloader.go @@ -23,10 +23,9 @@ func Load(path string) (*authulamodels.Config, bool, error) { return nil, false, fmt.Errorf("read config file: %w", err) } - // Decode on top of the defaults: the With* options below copy booleans - // such as session.http_only as given, so a key the file omits would - // otherwise arrive as false and replace a default of true. - loaded := authulaconfig.DefaultConfig() + // Decode on top of the default config to ensure that all fields + // are populated with defaults if not specified in the file. + loaded := authulaconfig.NewDefaultConfig() if err := toml.Unmarshal(data, loaded); err != nil { return nil, true, fmt.Errorf("failed to unmarshal config: %w", err) } diff --git a/config/options.go b/config/options.go index 159daad7..1536454f 100644 --- a/config/options.go +++ b/config/options.go @@ -18,10 +18,10 @@ const defaultSecret = "authula-secret-0123456789" type ConfigOption func(*models.Config) -// DefaultConfig returns the defaults NewConfig starts from, without applying +// NewDefaultConfig returns the default configuration, without applying // options or validating. A config file is decoded on top of it, so a key the // file leaves out keeps its default instead of becoming the zero value. -func DefaultConfig() *models.Config { +func NewDefaultConfig() *models.Config { return &models.Config{ AppName: "Authula", BaseURL: "http://localhost:8080", @@ -77,9 +77,9 @@ func DefaultConfig() *models.Config { } // NewConfig builds a Config using functional options with sensible defaults. -// Panics if event bus configuration is invalid or if required secrets are missing in production. +// Panics if certain required fields are missing or invalid. func NewConfig(options ...ConfigOption) *models.Config { - config := DefaultConfig() + config := NewDefaultConfig() // Apply the options - they override defaults only if non-zero/non-empty for _, option := range options { From a4b658017c9b7a8a6f79fbdbccc03d8c5e980f30 Mon Sep 17 00:00:00 2001 From: Tanvir Ahmed Date: Mon, 5 Oct 2026 01:45:15 +0000 Subject: [PATCH 3/3] chore: Updated tests to use a helper function for toml clarity --- cmd/shared/configloader/configloader_test.go | 26 +++++++++++++----- util/helpers.go | 28 ++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/cmd/shared/configloader/configloader_test.go b/cmd/shared/configloader/configloader_test.go index 28385b6b..aeaef4e4 100644 --- a/cmd/shared/configloader/configloader_test.go +++ b/cmd/shared/configloader/configloader_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/Authula/authula/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -19,24 +20,37 @@ func TestLoadKeepsDefaultsForOmittedKeys(t *testing.T) { wantAllowedOrigins []string }{ { - name: "file without a session or security section", - file: "app_name = \"demo\"\n", + name: "file without a session or security section", + file: util.Dedent(` + app_name = "demo" + `), wantHttpOnly: true, wantMaxSessions: 5, wantAllowCredentials: true, wantAllowedOrigins: []string{"*"}, }, { - name: "session section that sets only the cookie name", - file: "[session]\ncookie_name = \"sid\"\n", + name: "session section that sets only the cookie name", + file: util.Dedent(` + [session] + cookie_name = "sid" + `), wantHttpOnly: true, wantMaxSessions: 5, wantAllowCredentials: true, wantAllowedOrigins: []string{"*"}, }, { - name: "explicit values still win", - file: "[session]\nhttp_only = false\nmax_sessions_per_user = 2\n[security.cors]\nallow_credentials = false\nallowed_origins = [\"https://example.com\"]\n", + name: "explicit values still win", + file: util.Dedent(` + [session] + http_only = false + max_sessions_per_user = 2 + + [security.cors] + allow_credentials = false + allowed_origins = ["https://example.com"] + `), wantHttpOnly: false, wantMaxSessions: 2, wantAllowCredentials: false, diff --git a/util/helpers.go b/util/helpers.go index c3e0fa7d..53701e09 100644 --- a/util/helpers.go +++ b/util/helpers.go @@ -241,3 +241,31 @@ func NormalizeRoutePattern(pattern string) string { } return strings.ReplaceAll(trimmed, "//", "/") } + +// Dedent removes common leading whitespace from each line in a multi-line string. +func Dedent(s string) string { + lines := strings.Split(strings.Trim(s, "\n"), "\n") + + min := -1 + for _, line := range lines { + trimmed := strings.TrimLeft(line, " \t") + if trimmed == "" { + continue + } + indent := len(line) - len(trimmed) + if min < 0 || indent < min { + min = indent + } + } + if min > 0 { + for i, line := range lines { + if strings.TrimLeft(line, " \t") == "" { + lines[i] = "" + continue + } + lines[i] = line[min:] + } + } + + return strings.Join(lines, "\n") + "\n" +}