porchlight/docs/plans/2026-02-18-config-file-plan.md
2026-02-18 12:12:49 +01:00

11 KiB

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:

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:

# 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

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:

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:

            # 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

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:

# 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.<client-id>] 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

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

git add -A
git commit -m "refactor: fix lint and type check issues"