package config import ( "encoding/base64" "errors" "log/slog" "net/url" "os" "strconv" "strings" ) const ( DefaultLocalGeneratedStorageDir = "data/static/generated" DefaultLocalUploadedStorageDir = "data/static/uploaded" ) type Config struct { AppEnv string HTTPAddr string DatabaseURL string IdentityMode string JWTSecret string ServerMainBaseURL string ServerMainInternalToken string ServerMainInternalKey string ServerMainInternalSecret string OIDCEnabled bool OIDCIssuer string OIDCAudience string OIDCTenantID string OIDCRolePrefix string OIDCRequiredScopes []string OIDCJWKSCacheTTLSeconds int OIDCAcceptLegacyHS256 bool OIDCIntrospectionEnabled bool OIDCIntrospectionClientID string OIDCIntrospectionClientSecret string OIDCSecurityEventsSecretStore string OIDCSecurityEventsSecretDir string OIDCSecurityEventsKubernetesNamespace string OIDCSecurityEventsKubernetesSecretName string OIDCSecurityEventsKubernetesAPIServer string OIDCSecurityEventsKubernetesTokenFile string OIDCSecurityEventsKubernetesCAFile string OIDCSecurityEventsHeartbeatIntervalSeconds int OIDCSecurityEventsStaleAfterSeconds int OIDCSecurityEventsClockSkewSeconds int OIDCJITProvisioningEnabled bool OIDCGatewayTenantKey string OIDCBrowserSessionEnabled bool OIDCSessionCookieSecure bool OIDCClientID string OIDCRedirectURI string OIDCPostLogoutRedirectURI string OIDCSessionEncryptionKey string OIDCSessionIdleTTLSeconds int OIDCSessionAbsoluteTTLSeconds int OIDCSessionRefreshBeforeSeconds int PublicBaseURL string WebBaseURL string LocalGeneratedStorageDir string LocalUploadedStorageDir string LocalTempAssetTTLHours int TaskProgressCallbackEnabled bool TaskProgressCallbackURL string TaskProgressCallbackTimeoutMS string TaskProgressCallbackMaxAttempts string CORSAllowedOrigin string GlobalHTTPProxy string GlobalHTTPProxySource string LogLevel slog.Level } func Load() Config { globalProxy := LoadGlobalHTTPProxyStatus() appEnv := env("APP_ENV", "development") oidcIssuer := strings.TrimRight(env("OIDC_ISSUER", ""), "/") introspectionClientID := env("OIDC_INTROSPECTION_CLIENT_ID", "") introspectionClientSecret := env("OIDC_INTROSPECTION_CLIENT_SECRET", "") return Config{ AppEnv: appEnv, HTTPAddr: env("HTTP_ADDR", ":8088"), DatabaseURL: gatewayDatabaseURL(), IdentityMode: env("IDENTITY_MODE", "hybrid"), JWTSecret: env("CONFIG_JWT_SECRET", "this is a very secret secret"), ServerMainBaseURL: strings.TrimRight( env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/", ), ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""), ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"), ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")), OIDCEnabled: env("OIDC_ENABLED", "false") == "true", OIDCIssuer: oidcIssuer, OIDCAudience: env("OIDC_AUDIENCE", ""), OIDCTenantID: env("OIDC_TENANT_ID", ""), OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."), OIDCRequiredScopes: splitCSV(env("OIDC_REQUIRED_SCOPES", "gateway.access")), OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300), OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true", OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true", OIDCIntrospectionClientID: introspectionClientID, OIDCIntrospectionClientSecret: introspectionClientSecret, OIDCSecurityEventsSecretStore: env("OIDC_SECURITY_EVENTS_SECRET_STORE", "file"), OIDCSecurityEventsSecretDir: env("OIDC_SECURITY_EVENTS_SECRET_DIR", ".local-secrets/ssf"), OIDCSecurityEventsKubernetesNamespace: env("OIDC_SECURITY_EVENTS_KUBERNETES_NAMESPACE", env("POD_NAMESPACE", "")), OIDCSecurityEventsKubernetesSecretName: env("OIDC_SECURITY_EVENTS_KUBERNETES_SECRET_NAME", "easyai-gateway-security-events"), OIDCSecurityEventsKubernetesAPIServer: env("OIDC_SECURITY_EVENTS_KUBERNETES_API_SERVER", "https://kubernetes.default.svc"), OIDCSecurityEventsKubernetesTokenFile: env("OIDC_SECURITY_EVENTS_KUBERNETES_TOKEN_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/token"), OIDCSecurityEventsKubernetesCAFile: env("OIDC_SECURITY_EVENTS_KUBERNETES_CA_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"), OIDCSecurityEventsHeartbeatIntervalSeconds: envInt("OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS", 60), OIDCSecurityEventsStaleAfterSeconds: envInt("OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS", 180), OIDCSecurityEventsClockSkewSeconds: envInt("OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS", 60), OIDCJITProvisioningEnabled: env("OIDC_JIT_PROVISIONING_ENABLED", "false") == "true", OIDCGatewayTenantKey: env("OIDC_GATEWAY_TENANT_KEY", ""), OIDCBrowserSessionEnabled: env("OIDC_BROWSER_SESSION_ENABLED", "true") == "true", OIDCSessionCookieSecure: env("OIDC_SESSION_COOKIE_SECURE", strconv.FormatBool(!isLocalEnvironment(appEnv)), ) == "true", OIDCClientID: env("OIDC_CLIENT_ID", ""), OIDCRedirectURI: env("OIDC_REDIRECT_URI", ""), OIDCPostLogoutRedirectURI: env("OIDC_POST_LOGOUT_REDIRECT_URI", ""), OIDCSessionEncryptionKey: env("OIDC_SESSION_ENCRYPTION_KEY", ""), OIDCSessionIdleTTLSeconds: envInt("OIDC_SESSION_IDLE_TTL_SECONDS", 1800), OIDCSessionAbsoluteTTLSeconds: envInt("OIDC_SESSION_ABSOLUTE_TTL_SECONDS", 28800), OIDCSessionRefreshBeforeSeconds: envInt("OIDC_SESSION_REFRESH_BEFORE_SECONDS", 60), PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"), WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"), LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))), LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)), LocalTempAssetTTLHours: envInt("AI_GATEWAY_LOCAL_TEMP_ASSET_TTL_HOURS", 24), TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true", TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL", strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks", ), TaskProgressCallbackTimeoutMS: env("TASK_PROGRESS_CALLBACK_TIMEOUT_MS", "5000"), TaskProgressCallbackMaxAttempts: env("TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS", "10"), CORSAllowedOrigin: env("CORS_ALLOWED_ORIGIN", "http://localhost:5178,http://127.0.0.1:5178"), GlobalHTTPProxy: globalProxy.HTTPProxy, GlobalHTTPProxySource: globalProxy.Source, LogLevel: logLevel(env("LOG_LEVEL", "info")), } } func (c Config) Validate() error { switch strings.ToLower(strings.TrimSpace(c.OIDCSecurityEventsSecretStore)) { case "": case "file": if strings.TrimSpace(c.OIDCSecurityEventsSecretDir) == "" { return errors.New("OIDC_SECURITY_EVENTS_SECRET_DIR is required for the file SecretStore") } case "kubernetes": if strings.TrimSpace(c.OIDCSecurityEventsKubernetesNamespace) == "" || strings.TrimSpace(c.OIDCSecurityEventsKubernetesSecretName) == "" { return errors.New("Kubernetes security event SecretStore requires namespace and Secret name") } default: return errors.New("OIDC_SECURITY_EVENTS_SECRET_STORE must be file or kubernetes") } if c.OIDCSecurityEventsHeartbeatIntervalSeconds != 0 || c.OIDCSecurityEventsStaleAfterSeconds != 0 || c.OIDCSecurityEventsClockSkewSeconds != 0 { if c.OIDCSecurityEventsHeartbeatIntervalSeconds <= 0 || c.OIDCSecurityEventsStaleAfterSeconds < 2*c.OIDCSecurityEventsHeartbeatIntervalSeconds || c.OIDCSecurityEventsClockSkewSeconds < 0 || c.OIDCSecurityEventsClockSkewSeconds > 300 { return errors.New("OIDC security event heartbeat, stale threshold, or clock skew is invalid") } } if c.OIDCJITProvisioningEnabled && strings.TrimSpace(c.OIDCGatewayTenantKey) == "" { return errors.New("OIDC_GATEWAY_TENANT_KEY is required when OIDC_JIT_PROVISIONING_ENABLED=true") } if c.OIDCEnabled && c.OIDCBrowserSessionEnabled { for _, scope := range c.OIDCRequiredScopes { if strings.EqualFold(strings.TrimSpace(scope), "offline_access") { return errors.New("OIDC_REQUIRED_SCOPES cannot contain offline_access for browser sessions") } } if strings.TrimSpace(c.OIDCClientID) == "" { return errors.New("OIDC_CLIENT_ID is required when OIDC browser sessions are enabled") } if !validPublicRedirectURL(c.OIDCRedirectURI) { return errors.New("OIDC_REDIRECT_URI must be an exact HTTPS URL (localhost HTTP is allowed for development)") } if !validPublicRedirectURL(c.OIDCPostLogoutRedirectURI) { return errors.New("OIDC_POST_LOGOUT_REDIRECT_URI must be an exact HTTPS URL (localhost HTTP is allowed for development)") } if _, err := c.OIDCSessionEncryptionKeyBytes(); err != nil { return err } if c.OIDCSessionIdleTTLSeconds <= 0 || c.OIDCSessionAbsoluteTTLSeconds <= c.OIDCSessionIdleTTLSeconds { return errors.New("OIDC session idle TTL must be positive and shorter than the absolute TTL") } if c.OIDCSessionRefreshBeforeSeconds <= 0 || c.OIDCSessionRefreshBeforeSeconds >= c.OIDCSessionIdleTTLSeconds { return errors.New("OIDC_SESSION_REFRESH_BEFORE_SECONDS must be positive and shorter than the idle TTL") } if !isLocalEnvironment(c.AppEnv) && !c.OIDCSessionCookieSecure { return errors.New("OIDC_SESSION_COOKIE_SECURE must be true outside local development and tests") } for _, origin := range strings.Split(c.CORSAllowedOrigin, ",") { if strings.TrimSpace(origin) == "*" { return errors.New("CORS_ALLOWED_ORIGIN cannot contain * when OIDC browser sessions are enabled") } } } return nil } func (c Config) OIDCSessionEncryptionKeyBytes() ([]byte, error) { raw := strings.TrimSpace(c.OIDCSessionEncryptionKey) for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.StdEncoding, base64.RawURLEncoding, base64.URLEncoding} { decoded, err := encoding.DecodeString(raw) if err == nil && len(decoded) == 32 { return decoded, nil } } return nil, errors.New("OIDC_SESSION_ENCRYPTION_KEY must be a base64-encoded 32-byte random key") } func validPublicRedirectURL(raw string) bool { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { return false } if parsed.Scheme == "https" { return true } return parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1") } func isLocalEnvironment(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "development", "dev", "local", "test": return true default: return false } } type GlobalHTTPProxyStatus struct { HTTPProxy string Source string } func LoadGlobalHTTPProxyStatus() GlobalHTTPProxyStatus { for _, key := range []string{ "AI_GATEWAY_GLOBAL_HTTP_PROXY", "GLOBAL_HTTP_PROXY", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", } { if value := envValue(key); value != "" { return GlobalHTTPProxyStatus{HTTPProxy: value, Source: key} } } return GlobalHTTPProxyStatus{} } func gatewayDatabaseURL() string { if value := envValue("AI_GATEWAY_DATABASE_URL"); value != "" { return normalizePostgresURL(value) } if value := envValue("DATABASE_URL"); value != "" { return normalizePostgresURL(value) } if memoryURL := envValue("MEMORY_DATABASE_URL"); memoryURL != "" { return normalizePostgresURL(withDatabase(memoryURL, env("AI_GATEWAY_DATABASE_NAME", "easyai_ai_gateway"))) } return normalizePostgresURL("postgresql://easyai:easyai2025@localhost:5432/easyai_ai_gateway?sslmode=disable") } func normalizePostgresURL(raw string) string { parsed, err := url.Parse(raw) if err != nil { return raw } values := parsed.Query() schema := values.Get("schema") if schema == "" { return raw } values.Del("schema") if values.Get("search_path") == "" { values.Set("search_path", schema) } parsed.RawQuery = values.Encode() return parsed.String() } func splitCSV(value string) []string { items := strings.Split(value, ",") result := make([]string, 0, len(items)) for _, item := range items { if trimmed := strings.TrimSpace(item); trimmed != "" { result = append(result, trimmed) } } return result } func withDatabase(raw string, databaseName string) string { parsed, err := url.Parse(raw) if err != nil || databaseName == "" { return raw } parsed.Path = "/" + databaseName return parsed.String() } func envValue(key string) string { return strings.TrimSpace(os.Getenv(key)) } func env(key string, fallback string) string { if value := envValue(key); value != "" { return value } return fallback } func envInt(key string, fallback int) int { value := envValue(key) if value == "" { return fallback } parsed, err := strconv.Atoi(value) if err != nil { return fallback } return parsed } func logLevel(value string) slog.Level { switch strings.ToLower(value) { case "debug": return slog.LevelDebug case "warn", "warning": return slog.LevelWarn case "error": return slog.LevelError default: return slog.LevelInfo } }