385 lines
11 KiB
Markdown
385 lines
11 KiB
Markdown
# 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"
|
|
```
|