docs: add config file implementation plan

This commit is contained in:
Johan Lundberg 2026-02-18 12:12:49 +01:00
parent edeb036086
commit 94f777fc8f
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1

View file

@ -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.<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**
```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"
```