""" Configuration management for Gondulf IndieAuth server. Loads configuration from environment variables with GONDULF_ prefix. Validates required settings on startup and provides sensible defaults. """ import os from dotenv import load_dotenv # Load environment variables from .env file if present load_dotenv() class ConfigurationError(Exception): """Raised when configuration is invalid or missing required values.""" pass class Config: """Application configuration loaded from environment variables.""" # Required settings - no defaults SECRET_KEY: str BASE_URL: str # Database DATABASE_URL: str # SMTP Configuration SMTP_HOST: str SMTP_PORT: int SMTP_USERNAME: str | None SMTP_PASSWORD: str | None SMTP_FROM: str SMTP_USE_TLS: bool # Token and Code Expiry (seconds) TOKEN_EXPIRY: int CODE_EXPIRY: int # Token Cleanup (Phase 3) TOKEN_CLEANUP_ENABLED: bool TOKEN_CLEANUP_INTERVAL: int # Security Configuration (Phase 4b) HTTPS_REDIRECT: bool TRUST_PROXY: bool SECURE_COOKIES: bool # Logging LOG_LEVEL: str DEBUG: bool @classmethod def load(cls) -> None: """ Load and validate configuration from environment variables. Raises: ConfigurationError: If required settings are missing or invalid """ # Required - SECRET_KEY must exist and be sufficiently long secret_key = os.getenv("GONDULF_SECRET_KEY") if not secret_key: raise ConfigurationError( "GONDULF_SECRET_KEY is required. Generate with: " "python -c \"import secrets; print(secrets.token_urlsafe(32))\"" ) if len(secret_key) < 32: raise ConfigurationError( "GONDULF_SECRET_KEY must be at least 32 characters for security" ) cls.SECRET_KEY = secret_key # Required - BASE_URL must exist for OAuth metadata base_url = os.getenv("GONDULF_BASE_URL") if not base_url: raise ConfigurationError( "GONDULF_BASE_URL is required for OAuth 2.0 metadata endpoint. " "Examples: https://auth.example.com or http://localhost:8000 (development only)" ) # Normalize: remove trailing slash if present cls.BASE_URL = base_url.rstrip("/") # Database - with sensible default cls.DATABASE_URL = os.getenv( "GONDULF_DATABASE_URL", "sqlite:///./data/gondulf.db" ) # SMTP Configuration cls.SMTP_HOST = os.getenv("GONDULF_SMTP_HOST", "localhost") cls.SMTP_PORT = int(os.getenv("GONDULF_SMTP_PORT", "587")) cls.SMTP_USERNAME = os.getenv("GONDULF_SMTP_USERNAME") or None cls.SMTP_PASSWORD = os.getenv("GONDULF_SMTP_PASSWORD") or None cls.SMTP_FROM = os.getenv("GONDULF_SMTP_FROM", "noreply@example.com") cls.SMTP_USE_TLS = os.getenv("GONDULF_SMTP_USE_TLS", "true").lower() == "true" # Token and Code Expiry cls.TOKEN_EXPIRY = int(os.getenv("GONDULF_TOKEN_EXPIRY", "3600")) cls.CODE_EXPIRY = int(os.getenv("GONDULF_CODE_EXPIRY", "600")) # Token Cleanup Configuration cls.TOKEN_CLEANUP_ENABLED = os.getenv("GONDULF_TOKEN_CLEANUP_ENABLED", "false").lower() == "true" cls.TOKEN_CLEANUP_INTERVAL = int(os.getenv("GONDULF_TOKEN_CLEANUP_INTERVAL", "3600")) # Security Configuration (Phase 4b) cls.HTTPS_REDIRECT = os.getenv("GONDULF_HTTPS_REDIRECT", "true").lower() == "true" cls.TRUST_PROXY = os.getenv("GONDULF_TRUST_PROXY", "false").lower() == "true" cls.SECURE_COOKIES = os.getenv("GONDULF_SECURE_COOKIES", "true").lower() == "true" # Logging cls.DEBUG = os.getenv("GONDULF_DEBUG", "false").lower() == "true" # If DEBUG is true, default LOG_LEVEL to DEBUG, otherwise INFO default_log_level = "DEBUG" if cls.DEBUG else "INFO" cls.LOG_LEVEL = os.getenv("GONDULF_LOG_LEVEL", default_log_level).upper() # Validate log level valid_log_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} if cls.LOG_LEVEL not in valid_log_levels: raise ConfigurationError( f"GONDULF_LOG_LEVEL must be one of: {', '.join(valid_log_levels)}" ) @classmethod def validate(cls) -> None: """ Validate configuration after loading. Performs additional validation beyond initial loading. """ # Validate BASE_URL is a valid URL if not cls.BASE_URL.startswith(("http://", "https://")): raise ConfigurationError( "GONDULF_BASE_URL must start with http:// or https://" ) # Warn if using http:// in production-like settings if cls.BASE_URL.startswith("http://") and "localhost" not in cls.BASE_URL: import warnings warnings.warn( "GONDULF_BASE_URL uses http:// for non-localhost domain. " "HTTPS is required for production IndieAuth servers.", UserWarning ) # Validate SMTP port is reasonable if cls.SMTP_PORT < 1 or cls.SMTP_PORT > 65535: raise ConfigurationError( f"GONDULF_SMTP_PORT must be between 1 and 65535, got {cls.SMTP_PORT}" ) # Validate expiry times are positive and within bounds if cls.TOKEN_EXPIRY < 300: # Minimum 5 minutes raise ConfigurationError( "GONDULF_TOKEN_EXPIRY must be at least 300 seconds (5 minutes)" ) if cls.TOKEN_EXPIRY > 86400: # Maximum 24 hours raise ConfigurationError( "GONDULF_TOKEN_EXPIRY must be at most 86400 seconds (24 hours)" ) if cls.CODE_EXPIRY <= 0: raise ConfigurationError( f"GONDULF_CODE_EXPIRY must be positive, got {cls.CODE_EXPIRY}" ) # Validate cleanup interval if enabled if cls.TOKEN_CLEANUP_ENABLED and cls.TOKEN_CLEANUP_INTERVAL < 600: raise ConfigurationError( "GONDULF_TOKEN_CLEANUP_INTERVAL must be at least 600 seconds (10 minutes)" ) # Disable HTTPS redirect in development mode if cls.DEBUG: cls.HTTPS_REDIRECT = False # Configuration is loaded lazily or explicitly by the application # Tests should call Config.load() explicitly in fixtures # Production code should call Config.load() at startup