docs: add config file implementation plan
This commit is contained in:
parent
edeb036086
commit
94f777fc8f
1 changed files with 385 additions and 0 deletions
385
docs/plans/2026-02-18-config-file-plan.md
Normal file
385
docs/plans/2026-02-18-config-file-plan.md
Normal 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"
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue