diff --git a/cmd/oidcgate/config.go b/cmd/oidcgate/config.go new file mode 100644 index 0000000..32f76d8 --- /dev/null +++ b/cmd/oidcgate/config.go @@ -0,0 +1,193 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + "github.com/lukaszraczylo/traefikoidc" + "gopkg.in/yaml.v3" +) + +// Config is the top-level oidcgate configuration. The OIDC subtree maps 1:1 +// onto traefikoidc.Config; the few extra fields configure the daemon itself. +type Config struct { + Listen string `json:"listen"` + AuthPath string `json:"authPath"` + StartPath string `json:"startPath"` + OIDC traefikoidc.Config `json:"-"` +} + +// envScalarFields lists Config field names (within OIDC and top-level) +// that may be overridden via OIDCGATE_ environment +// variables. Only scalar strings/ints/bools are supported; nested structs +// (Redis, SecurityHeaders, DynamicClientRegistration) stay YAML-only. +var envScalarFields = []string{ + "Listen", "AuthPath", "StartPath", + "ProviderURL", "ClientID", "ClientSecret", "Audience", + "CallbackURL", "LogoutURL", "PostLogoutRedirectURI", + "SessionEncryptionKey", "CookiePrefix", "CookieDomain", + "LogLevel", "RevocationURL", "OIDCEndSessionURL", + "UserIdentifierClaim", "GroupClaimName", "RoleClaimName", + "ClientAuthMethod", "ClientAssertionPrivateKey", + "ClientAssertionKeyPath", "ClientAssertionKeyID", "ClientAssertionAlg", + "CACertPath", "CACertPEM", +} + +// Load reads YAML from path, applies env-var overrides, fills defaults, +// and forces TrustForwardedURI=true so the library honors X-Forwarded-Uri. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) //nolint:gosec // path is a trusted operator-supplied config file path + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + // Pass 1: YAML → generic map. + var raw map[string]any + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("yaml parse: %w", err) + } + + // Split the top-level oidcgate-specific keys away from the OIDC subtree. + listen, _ := raw["listen"].(string) + authPath, _ := raw["authPath"].(string) + startPath, _ := raw["startPath"].(string) + delete(raw, "listen") + delete(raw, "authPath") + delete(raw, "startPath") + + // Pass 2: remaining map → JSON → traefikoidc.Config (uses existing json tags). + jsonBytes, err := json.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("yaml→json: %w", err) + } + var oidcCfg traefikoidc.Config + if err := json.Unmarshal(jsonBytes, &oidcCfg); err != nil { + return nil, fmt.Errorf("oidc config parse: %w", err) + } + + cfg := &Config{ + Listen: listen, + AuthPath: authPath, + StartPath: startPath, + OIDC: oidcCfg, + } + + applyEnvOverrides(cfg) + applyDefaults(cfg) + + // Force standalone semantics: trust X-Forwarded-Uri. + cfg.OIDC.TrustForwardedURI = true + return cfg, nil +} + +// applyEnvOverrides walks the allow-listed scalar fields and replaces any +// non-empty OIDCGATE_ env var. Field name "ClientID" +// becomes "OIDCGATE_CLIENT_ID"; "SessionEncryptionKey" becomes +// "OIDCGATE_SESSION_ENCRYPTION_KEY". +func applyEnvOverrides(cfg *Config) { + for _, field := range envScalarFields { + env := os.Getenv("OIDCGATE_" + camelToSnakeUpper(field)) + if env == "" { + continue + } + setScalarField(cfg, field, env) + } +} + +func setScalarField(cfg *Config, field, value string) { + switch field { + case "Listen": + cfg.Listen = value + case "AuthPath": + cfg.AuthPath = value + case "StartPath": + cfg.StartPath = value + case "ProviderURL": + cfg.OIDC.ProviderURL = value + case "ClientID": + cfg.OIDC.ClientID = value + case "ClientSecret": + cfg.OIDC.ClientSecret = value + case "Audience": + cfg.OIDC.Audience = value + case "CallbackURL": + cfg.OIDC.CallbackURL = value + case "LogoutURL": + cfg.OIDC.LogoutURL = value + case "PostLogoutRedirectURI": + cfg.OIDC.PostLogoutRedirectURI = value + case "SessionEncryptionKey": + cfg.OIDC.SessionEncryptionKey = value + case "CookiePrefix": + cfg.OIDC.CookiePrefix = value + case "CookieDomain": + cfg.OIDC.CookieDomain = value + case "LogLevel": + cfg.OIDC.LogLevel = value + case "RevocationURL": + cfg.OIDC.RevocationURL = value + case "OIDCEndSessionURL": + cfg.OIDC.OIDCEndSessionURL = value + case "UserIdentifierClaim": + cfg.OIDC.UserIdentifierClaim = value + case "GroupClaimName": + cfg.OIDC.GroupClaimName = value + case "RoleClaimName": + cfg.OIDC.RoleClaimName = value + case "ClientAuthMethod": + cfg.OIDC.ClientAuthMethod = value + case "ClientAssertionPrivateKey": + cfg.OIDC.ClientAssertionPrivateKey = value + case "ClientAssertionKeyPath": + cfg.OIDC.ClientAssertionKeyPath = value + case "ClientAssertionKeyID": + cfg.OIDC.ClientAssertionKeyID = value + case "ClientAssertionAlg": + cfg.OIDC.ClientAssertionAlg = value + case "CACertPath": + cfg.OIDC.CACertPath = value + case "CACertPEM": + cfg.OIDC.CACertPEM = value + } + // strconv reserved for future int/bool fields when added to the allow-list. + _ = strconv.Atoi +} + +func applyDefaults(cfg *Config) { + if cfg.AuthPath == "" { + cfg.AuthPath = "/oauth2/auth" + } + if cfg.StartPath == "" { + cfg.StartPath = "/oauth2/start" + } +} + +// camelToSnakeUpper turns "ClientSecret" into "CLIENT_SECRET", +// "SessionEncryptionKey" into "SESSION_ENCRYPTION_KEY", etc. +// Multi-letter acronyms keep their grouping: "OIDCEndSessionURL" → +// "OIDC_END_SESSION_URL", "CACertPEM" → "CA_CERT_PEM". +func camelToSnakeUpper(s string) string { + var b strings.Builder + for i, r := range s { + if i > 0 && isUpper(r) { + prev := rune(s[i-1]) + next := rune(0) + if i+1 < len(s) { + next = rune(s[i+1]) + } + if !isUpper(prev) || (next != 0 && !isUpper(next)) { + b.WriteByte('_') + } + } + if r >= 'a' && r <= 'z' { + r -= 32 + } + b.WriteRune(r) + } + return b.String() +} + +func isUpper(r rune) bool { return r >= 'A' && r <= 'Z' } diff --git a/cmd/oidcgate/config_test.go b/cmd/oidcgate/config_test.go new file mode 100644 index 0000000..5c5ecf7 --- /dev/null +++ b/cmd/oidcgate/config_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad_YAMLRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(` +listen: ":9090" +authPath: "/auth" +startPath: "/start" +providerURL: "https://idp.example" +clientID: "abc" +clientSecret: "secret" +sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +callbackURL: "/oauth2/callback" +logoutURL: "/oauth2/logout" +`), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Listen != ":9090" { + t.Errorf("listen: want :9090, got %q", cfg.Listen) + } + if cfg.AuthPath != "/auth" { + t.Errorf("authPath: want /auth, got %q", cfg.AuthPath) + } + if cfg.StartPath != "/start" { + t.Errorf("startPath: want /start, got %q", cfg.StartPath) + } + if cfg.OIDC.ClientID != "abc" { + t.Errorf("clientID: want abc, got %q", cfg.OIDC.ClientID) + } + if cfg.OIDC.ClientSecret != "secret" { + t.Errorf("clientSecret: want secret, got %q", cfg.OIDC.ClientSecret) + } + if !cfg.OIDC.TrustForwardedURI { + t.Errorf("TrustForwardedURI should be forced true by Load") + } +} + +func TestLoad_EnvOverride(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(` +listen: ":8080" +providerURL: "https://idp.example" +clientID: "abc" +clientSecret: "from-file" +sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +callbackURL: "/oauth2/callback" +logoutURL: "/oauth2/logout" +`), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("OIDCGATE_CLIENT_SECRET", "from-env") + t.Setenv("OIDCGATE_LISTEN", ":9999") + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.OIDC.ClientSecret != "from-env" { + t.Errorf("env override (clientSecret): want from-env, got %q", cfg.OIDC.ClientSecret) + } + if cfg.Listen != ":9999" { + t.Errorf("env override (listen): want :9999, got %q", cfg.Listen) + } +} + +func TestLoad_Defaults(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(` +listen: ":8080" +providerURL: "https://idp.example" +clientID: "abc" +clientSecret: "secret" +sessionEncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +callbackURL: "/oauth2/callback" +logoutURL: "/oauth2/logout" +`), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.AuthPath != "/oauth2/auth" { + t.Errorf("AuthPath default: want /oauth2/auth, got %q", cfg.AuthPath) + } + if cfg.StartPath != "/oauth2/start" { + t.Errorf("StartPath default: want /oauth2/start, got %q", cfg.StartPath) + } +} + +func TestLoad_MissingFile(t *testing.T) { + if _, err := Load("/nonexistent/config.yaml"); err == nil { + t.Fatal("expected error for missing file") + } +} diff --git a/cmd/oidcgate/main.go b/cmd/oidcgate/main.go new file mode 100644 index 0000000..38dd16d --- /dev/null +++ b/cmd/oidcgate/main.go @@ -0,0 +1,3 @@ +package main + +func main() {}