diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 4ad32ab..7ab2cb0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "strconv" + "strings" "time" ) @@ -29,6 +30,21 @@ type Config struct { MinConfidence float64 IncrementalSyncEvery time.Duration + + // OIDC login gate (Keycloak). PublicBaseURL is this app's own externally + // reachable origin (e.g. "https://geniusrun.example.com") -- it derives + // OIDCRedirectURL and whether session cookies can be marked Secure, + // instead of requiring both to be configured separately and risking them + // drifting out of sync. + PublicBaseURL string + OIDCIssuerURL string + OIDCClientID string + OIDCClientSecret string + OIDCRedirectURL string + OIDCRequiredRole string + SessionSecret []byte + SessionDuration time.Duration + SessionSecure bool } // Load reads configuration from environment variables, applying defaults @@ -42,6 +58,12 @@ func Load() (Config, error) { GarminTokenStore: os.Getenv("GARMIN_TOKENSTORE"), MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6), IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour), + PublicBaseURL: strings.TrimRight(os.Getenv("GENIUSRUN_PUBLIC_BASE_URL"), "/"), + OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"), + OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"), + OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"), + OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"), + SessionDuration: getEnvDuration("GENIUSRUN_SESSION_DURATION", 720*time.Hour), } if cfg.GarminPythonPath == "" { @@ -50,6 +72,26 @@ func Load() (Config, error) { if cfg.GarminServerPath == "" { return cfg, fmt.Errorf("MCP_GARMIN_SERVER is required (path to mcp-garmin's server.py)") } + if cfg.PublicBaseURL == "" { + return cfg, fmt.Errorf("GENIUSRUN_PUBLIC_BASE_URL is required (e.g. https://geniusrun.example.com)") + } + if cfg.OIDCIssuerURL == "" { + return cfg, fmt.Errorf("GENIUSRUN_OIDC_ISSUER_URL is required") + } + if cfg.OIDCClientID == "" { + return cfg, fmt.Errorf("GENIUSRUN_OIDC_CLIENT_ID is required") + } + if cfg.OIDCClientSecret == "" { + return cfg, fmt.Errorf("GENIUSRUN_OIDC_CLIENT_SECRET is required") + } + sessionSecret := os.Getenv("GENIUSRUN_SESSION_SECRET") + if len(sessionSecret) < 32 { + return cfg, fmt.Errorf("GENIUSRUN_SESSION_SECRET is required and must be at least 32 characters") + } + cfg.SessionSecret = []byte(sessionSecret) + cfg.OIDCRedirectURL = cfg.PublicBaseURL + "/api/session/callback" + cfg.SessionSecure = strings.HasPrefix(cfg.PublicBaseURL, "https://") + return cfg, nil } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..0a4f470 --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,98 @@ +package config + +import ( + "testing" + "time" +) + +func setRequiredEnv(t *testing.T) { + t.Helper() + t.Setenv("MCP_GARMIN_PYTHON", "/usr/bin/python3") + t.Setenv("MCP_GARMIN_SERVER", "/opt/mcp-garmin/server.py") + t.Setenv("GENIUSRUN_PUBLIC_BASE_URL", "https://geniusrun.example.com") + t.Setenv("GENIUSRUN_OIDC_ISSUER_URL", "https://keycloak.example.com/realms/myrealm") + t.Setenv("GENIUSRUN_OIDC_CLIENT_ID", "geniusrun") + t.Setenv("GENIUSRUN_OIDC_CLIENT_SECRET", "client-secret") + t.Setenv("GENIUSRUN_SESSION_SECRET", "a-session-secret-that-is-at-least-32-bytes-long") +} + +func TestLoad_DerivesOIDCSettingsFromPublicBaseURL(t *testing.T) { + setRequiredEnv(t) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.OIDCRedirectURL != "https://geniusrun.example.com/api/session/callback" { + t.Errorf("OIDCRedirectURL = %q", cfg.OIDCRedirectURL) + } + if !cfg.SessionSecure { + t.Error("SessionSecure = false, want true for an https base URL") + } + if cfg.OIDCRequiredRole != "geniusrun-user" { + t.Errorf("OIDCRequiredRole = %q, want default", cfg.OIDCRequiredRole) + } + if cfg.SessionDuration != 720*time.Hour { + t.Errorf("SessionDuration = %v, want default 720h", cfg.SessionDuration) + } +} + +func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) { + setRequiredEnv(t) + t.Setenv("GENIUSRUN_PUBLIC_BASE_URL", "http://localhost:8080") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.SessionSecure { + t.Error("SessionSecure = true, want false for an http base URL") + } + if cfg.OIDCRedirectURL != "http://localhost:8080/api/session/callback" { + t.Errorf("OIDCRedirectURL = %q", cfg.OIDCRedirectURL) + } +} + +func TestLoad_MissingRequiredOIDCVars(t *testing.T) { + cases := []string{ + "GENIUSRUN_PUBLIC_BASE_URL", + "GENIUSRUN_OIDC_ISSUER_URL", + "GENIUSRUN_OIDC_CLIENT_ID", + "GENIUSRUN_OIDC_CLIENT_SECRET", + } + for _, missing := range cases { + t.Run(missing, func(t *testing.T) { + setRequiredEnv(t) + t.Setenv(missing, "") + if _, err := Load(); err == nil { + t.Fatalf("expected error when %s is unset", missing) + } + }) + } +} + +func TestLoad_SessionSecretTooShort(t *testing.T) { + setRequiredEnv(t) + t.Setenv("GENIUSRUN_SESSION_SECRET", "too-short") + + if _, err := Load(); err == nil { + t.Fatal("expected error for a session secret under 32 characters") + } +} + +func TestLoad_CustomRoleAndDuration(t *testing.T) { + setRequiredEnv(t) + t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin") + t.Setenv("GENIUSRUN_SESSION_DURATION", "24h") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.OIDCRequiredRole != "admin" { + t.Errorf("OIDCRequiredRole = %q", cfg.OIDCRequiredRole) + } + if cfg.SessionDuration != 24*time.Hour { + t.Errorf("SessionDuration = %v", cfg.SessionDuration) + } +}