diff --git a/src/porchlight/config.py b/src/porchlight/config.py index 9167c2b..3317e67 100644 --- a/src/porchlight/config.py +++ b/src/porchlight/config.py @@ -1,7 +1,10 @@ # src/porchlight/config.py +import os from enum import StrEnum +from typing import Any -from pydantic_settings import BaseSettings +from pydantic import BaseModel +from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, TomlConfigSettingsSource class StorageBackend(StrEnum): @@ -9,8 +12,21 @@ class StorageBackend(StrEnum): MONGODB = "mongodb" +class ClientConfig(BaseModel): + client_secret: str + redirect_uris: list[str] + response_types: list[str] = ["code"] + scope: list[str] = ["openid"] + token_endpoint_auth_method: str = "client_secret_basic" + + class Settings(BaseSettings): - model_config = {"env_prefix": "OIDC_OP_"} + model_config = {"env_prefix": "OIDC_OP_", "toml_file": "porchlight.toml"} + + # Class-level bridge to pass _toml_file into the classmethod + # settings_customise_sources. Not thread-safe, but Settings is + # only instantiated once at startup. + _toml_file_override: str | None = None # Core issuer: str @@ -40,3 +56,33 @@ class Settings(BaseSettings): # Theme theme: str = "default" + + # OIDC clients + clients: dict[str, ClientConfig] = {} + + def __init__(self, _toml_file: str | None = None, **kwargs: Any) -> None: + Settings._toml_file_override = _toml_file + try: + super().__init__(**kwargs) + finally: + Settings._toml_file_override = None + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + toml_file = ( + cls._toml_file_override + or os.environ.get("OIDC_OP_CONFIG_FILE") + or settings_cls.model_config.get("toml_file") + ) + return ( + env_settings, + TomlConfigSettingsSource(settings_cls, toml_file=toml_file), + init_settings, + ) diff --git a/tests/test_config.py b/tests/test_config.py index a6f6941..c006602 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,4 +1,6 @@ # tests/test_config.py +from pathlib import Path + import pytest from porchlight.config import Settings, StorageBackend @@ -28,6 +30,11 @@ def test_mongodb_settings() -> None: assert settings.mongodb_database == "test_db" +def test_default_settings_has_empty_clients() -> None: + settings = Settings(issuer="http://localhost:8000") + assert settings.clients == {} + + def test_settings_from_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("OIDC_OP_ISSUER", "https://op.example.org") monkeypatch.setenv("OIDC_OP_STORAGE_BACKEND", "mongodb") @@ -35,3 +42,59 @@ def test_settings_from_env(monkeypatch: pytest.MonkeyPatch) -> None: settings = Settings() # type: ignore[call-arg] assert settings.issuer == "https://op.example.org" assert settings.storage_backend == StorageBackend.MONGODB + + +def test_settings_from_toml_file(tmp_path: Path) -> None: + toml_content = """\ +issuer = "https://toml.example.com" +debug = true +sqlite_path = "custom/path.db" + +[clients.my-app] +client_secret = "secret123" +redirect_uris = ["https://app.example.com/callback"] +scope = ["openid", "profile"] +""" + toml_file = tmp_path / "test.toml" + toml_file.write_text(toml_content) + + settings = Settings(_toml_file=str(toml_file)) # type: ignore[call-arg] + assert settings.issuer == "https://toml.example.com" + assert settings.debug is True + assert settings.sqlite_path == "custom/path.db" + assert "my-app" in settings.clients + assert settings.clients["my-app"].client_secret == "secret123" + assert settings.clients["my-app"].redirect_uris == ["https://app.example.com/callback"] + assert settings.clients["my-app"].scope == ["openid", "profile"] + assert settings.clients["my-app"].response_types == ["code"] + assert settings.clients["my-app"].token_endpoint_auth_method == "client_secret_basic" + + +def test_env_vars_override_toml_values(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + toml_content = """\ +issuer = "https://toml.example.com" +debug = false +""" + toml_file = tmp_path / "test.toml" + toml_file.write_text(toml_content) + + monkeypatch.setenv("OIDC_OP_ISSUER", "https://env.example.com") + monkeypatch.setenv("OIDC_OP_DEBUG", "true") + settings = Settings(_toml_file=str(toml_file)) # type: ignore[call-arg] + assert settings.issuer == "https://env.example.com" + assert settings.debug is True + + +def test_missing_toml_file_uses_defaults() -> None: + settings = Settings(issuer="http://localhost:8000", _toml_file="/nonexistent/path.toml") # type: ignore[call-arg] + assert settings.issuer == "http://localhost:8000" + assert settings.clients == {} + + +def test_config_file_env_var_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + toml_file = tmp_path / "test.toml" + toml_file.write_text('issuer = "https://custom-path.example.com"\n') + + monkeypatch.setenv("OIDC_OP_CONFIG_FILE", str(toml_file)) + settings = Settings() # type: ignore[call-arg] + assert settings.issuer == "https://custom-path.example.com"