# TOML Configuration File Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add TOML config file support with OIDC client registrations to Porchlight. **Architecture:** Extend pydantic-settings `Settings` with `TomlConfigSettingsSource` for file-based config. Add `ClientConfig` model for typed client definitions. Register configured clients in the OIDC server during app lifespan. Priority: env vars > TOML > defaults. **Tech Stack:** pydantic-settings `TomlConfigSettingsSource`, stdlib `tomllib`, existing `Settings` class. --- ### Task 1: Add ClientConfig model and TOML support to Settings **Files:** - Modify: `src/porchlight/config.py` - Test: `tests/test_config.py` **Step 1: Write the failing tests** Add to `tests/test_config.py`: ```python import tempfile from pathlib import Path def test_default_settings_has_empty_clients() -> None: settings = Settings(issuer="http://localhost:8000") assert settings.clients == {} def test_settings_from_toml_file() -> 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"] """ with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: f.write(toml_content) toml_path = f.name try: settings = Settings(_toml_file=toml_path) # 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" finally: Path(toml_path).unlink() def test_env_vars_override_toml_values(monkeypatch: pytest.MonkeyPatch) -> None: toml_content = """\ issuer = "https://toml.example.com" debug = false """ with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: f.write(toml_content) toml_path = f.name try: monkeypatch.setenv("OIDC_OP_ISSUER", "https://env.example.com") monkeypatch.setenv("OIDC_OP_DEBUG", "true") settings = Settings(_toml_file=toml_path) # type: ignore[call-arg] assert settings.issuer == "https://env.example.com" assert settings.debug is True finally: Path(toml_path).unlink() 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(monkeypatch: pytest.MonkeyPatch) -> None: toml_content = 'issuer = "https://custom-path.example.com"\n' with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: f.write(toml_content) toml_path = f.name try: monkeypatch.setenv("OIDC_OP_CONFIG_FILE", toml_path) settings = Settings() # type: ignore[call-arg] assert settings.issuer == "https://custom-path.example.com" finally: Path(toml_path).unlink() ``` **Step 2: Run tests to verify they fail** Run: `uv run python -m pytest tests/test_config.py -v` Expected: FAIL — `ClientConfig` not defined, no TOML source configured. **Step 3: Write the implementation** Update `src/porchlight/config.py`: ```python # src/porchlight/config.py import os from enum import StrEnum from pydantic import BaseModel from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, TomlConfigSettingsSource class StorageBackend(StrEnum): SQLITE = "sqlite" 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_", "toml_file": "porchlight.toml"} # Core issuer: str debug: bool = False # Storage storage_backend: StorageBackend = StorageBackend.SQLITE # SQLite sqlite_path: str = "data/oidc_op.db" # MongoDB mongodb_uri: str = "mongodb://localhost:27017" mongodb_database: str = "oidc_op" # Management RP manage_client_id: str = "manage-app" # Session session_secret: str | None = None # Magic links invite_ttl: int = 86400 # Signing keys signing_key_path: str = "data/keys" # Theme theme: str = "default" # OIDC clients clients: dict[str, ClientConfig] = {} @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 = os.environ.get("OIDC_OP_CONFIG_FILE", settings_cls.model_config.get("toml_file")) return ( env_settings, TomlConfigSettingsSource(settings_cls, toml_file=toml_file), init_settings, ) ``` **Step 4: Run tests to verify they pass** Run: `uv run python -m pytest tests/test_config.py -v` Expected: All PASS. **Step 5: Commit** ```bash git add src/porchlight/config.py tests/test_config.py git commit -m "feat: add TOML config file support with client registrations" ``` --- ### Task 2: Register configured clients in app lifespan **Files:** - Modify: `src/porchlight/app.py:55-72` - Test: `tests/test_app.py` (or new `tests/test_client_registration.py`) **Step 1: Write the failing test** Add `tests/test_client_registration.py`: ```python import tempfile from pathlib import Path from httpx import ASGITransport, AsyncClient from porchlight.app import create_app from porchlight.config import Settings async def test_configured_clients_are_registered() -> None: """Clients defined in config should be registered in the OIDC server.""" toml_content = """\ issuer = "https://test.example.com" [clients.test-rp] client_secret = "test-secret-0123456789abcdef" redirect_uris = ["https://app.example.com/callback"] scope = ["openid", "profile"] """ with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: f.write(toml_content) toml_path = f.name try: settings = Settings(_toml_file=toml_path) # type: ignore[call-arg] app = create_app(settings) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: # Trigger lifespan response = await client.get("/health") assert response.status_code == 200 oidc_server = app.state.oidc_server assert "test-rp" in oidc_server.context.cdb cdb_entry = oidc_server.context.cdb["test-rp"] assert cdb_entry["client_id"] == "test-rp" assert cdb_entry["client_secret"] == "test-secret-0123456789abcdef" assert ("https://app.example.com/callback", {}) in cdb_entry["redirect_uris"] assert cdb_entry["scope"] == ["openid", "profile"] assert cdb_entry["allowed_scopes"] == ["openid", "profile"] finally: Path(toml_path).unlink() async def test_manage_app_always_registered() -> None: """The internal manage-app client is always registered, even without config file clients.""" settings = Settings(issuer="https://test.example.com") app = create_app(settings) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: response = await client.get("/health") assert response.status_code == 200 oidc_server = app.state.oidc_server assert "manage-app" in oidc_server.context.cdb ``` **Step 2: Run tests to verify they fail** Run: `uv run python -m pytest tests/test_client_registration.py -v` Expected: FAIL — `test-rp` not in `oidc_server.context.cdb`. **Step 3: Write the implementation** Add client registration loop to `src/porchlight/app.py` in the `lifespan` function, after the OIDC server is created and before the `manage-app` registration: ```python # Register configured clients for client_id, client_cfg in settings.clients.items(): oidc_server.context.cdb[client_id] = { "client_id": client_id, "client_secret": client_cfg.client_secret, "redirect_uris": [(uri, {}) for uri in client_cfg.redirect_uris], "response_types_supported": client_cfg.response_types, "token_endpoint_auth_method": client_cfg.token_endpoint_auth_method, "scope": client_cfg.scope, "allowed_scopes": client_cfg.scope, "client_salt": secrets.token_hex(8), } oidc_server.keyjar.add_symmetric(client_id, client_cfg.client_secret) ``` **Step 4: Run tests to verify they pass** Run: `uv run python -m pytest tests/test_client_registration.py -v` Expected: All PASS. **Step 5: Run full test suite** Run: `uv run python -m pytest -v` Expected: All 165+ tests PASS. **Step 6: Commit** ```bash git add src/porchlight/app.py tests/test_client_registration.py git commit -m "feat: register OIDC clients from config file" ``` --- ### Task 3: Add example config file and update README **Files:** - Create: `porchlight.example.toml` - Modify: `README.md` **Step 1: Create example config file** Create `porchlight.example.toml`: ```toml # Porchlight OIDC Provider Configuration # # Copy this file to porchlight.toml and edit to suit your deployment. # Environment variables (OIDC_OP_*) override values set here. # To use a different path: export OIDC_OP_CONFIG_FILE=/path/to/config.toml issuer = "https://auth.example.com" # debug = false # session_secret = "generate-a-random-string-here" # sqlite_path = "data/oidc_op.db" # signing_key_path = "data/keys" # invite_ttl = 86400 # Register OIDC Relying Party clients below. # Each [clients.] section defines one client. # [clients.my-webapp] # client_secret = "change-me-to-a-long-random-string" # redirect_uris = ["https://app.example.com/callback"] # response_types = ["code"] # scope = ["openid", "profile", "email"] # token_endpoint_auth_method = "client_secret_basic" ``` **Step 2: Update README** Add a "Configuration file" section after the existing "Configuration" section, documenting `porchlight.toml`, the example file, env var override, and client registration. **Step 3: Commit** ```bash git add porchlight.example.toml README.md git commit -m "docs: add example config file and update README" ``` --- ### Task 4: Quality check **Step 1: Run formatter and linter** Run: `make reformat && make lint` **Step 2: Run type checker** Run: `make typecheck` **Step 3: Run full test suite** Run: `make test` **Step 4: Fix any issues and commit** ```bash git add -A git commit -m "refactor: fix lint and type check issues" ```