diff --git a/cmd/shared/configloader/configloader.go b/cmd/shared/configloader/configloader.go index 96d1aeaf..58261475 100644 --- a/cmd/shared/configloader/configloader.go +++ b/cmd/shared/configloader/configloader.go @@ -23,8 +23,10 @@ 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 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/cmd/shared/configloader/configloader_test.go b/cmd/shared/configloader/configloader_test.go new file mode 100644 index 00000000..aeaef4e4 --- /dev/null +++ b/cmd/shared/configloader/configloader_test.go @@ -0,0 +1,76 @@ +package configloader + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Authula/authula/util" + "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: util.Dedent(` + app_name = "demo" + `), + wantHttpOnly: true, + wantMaxSessions: 5, + wantAllowCredentials: true, + wantAllowedOrigins: []string{"*"}, + }, + { + 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: 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, + 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..1536454f 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{ +// 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 NewDefaultConfig() *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 certain required fields are missing or invalid. +func NewConfig(options ...ConfigOption) *models.Config { + config := NewDefaultConfig() // Apply the options - they override defaults only if non-zero/non-empty for _, option := range options { 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" +}