From 94f777fc8facd9394e45b8b4d65295a8a76307db Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Wed, 18 Feb 2026 12:12:49 +0100 Subject: [PATCH] docs: add config file implementation plan --- docs/plans/2026-02-18-config-file-plan.md | 385 ++++++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 docs/plans/2026-02-18-config-file-plan.md diff --git a/docs/plans/2026-02-18-config-file-plan.md b/docs/plans/2026-02-18-config-file-plan.md new file mode 100644 index 0000000..5d8cc7d --- /dev/null +++ b/docs/plans/2026-02-18-config-file-plan.md @@ -0,0 +1,385 @@ +# 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" +```