From 61ca3063ca766661a6239f1a19cd999b23d2dc61 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Wed, 18 Feb 2026 12:38:40 +0100 Subject: [PATCH 1/4] feat: add TOML config file support with client registrations --- src/porchlight/config.py | 50 +++++++++++++++++++++++++++++-- tests/test_config.py | 63 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/src/porchlight/config.py b/src/porchlight/config.py index 9167c2b..3317e67 100644 --- a/src/porchlight/config.py +++ b/src/porchlight/config.py @@ -1,7 +1,10 @@ # src/porchlight/config.py +import os from enum import StrEnum +from typing import Any -from pydantic_settings import BaseSettings +from pydantic import BaseModel +from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, TomlConfigSettingsSource class StorageBackend(StrEnum): @@ -9,8 +12,21 @@ class StorageBackend(StrEnum): 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_"} + model_config = {"env_prefix": "OIDC_OP_", "toml_file": "porchlight.toml"} + + # Class-level bridge to pass _toml_file into the classmethod + # settings_customise_sources. Not thread-safe, but Settings is + # only instantiated once at startup. + _toml_file_override: str | None = None # Core issuer: str @@ -40,3 +56,33 @@ class Settings(BaseSettings): # Theme theme: str = "default" + + # OIDC clients + clients: dict[str, ClientConfig] = {} + + def __init__(self, _toml_file: str | None = None, **kwargs: Any) -> None: + Settings._toml_file_override = _toml_file + try: + super().__init__(**kwargs) + finally: + Settings._toml_file_override = None + + @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 = ( + cls._toml_file_override + or os.environ.get("OIDC_OP_CONFIG_FILE") + or settings_cls.model_config.get("toml_file") + ) + return ( + env_settings, + TomlConfigSettingsSource(settings_cls, toml_file=toml_file), + init_settings, + ) diff --git a/tests/test_config.py b/tests/test_config.py index a6f6941..c006602 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,4 +1,6 @@ # tests/test_config.py +from pathlib import Path + import pytest from porchlight.config import Settings, StorageBackend @@ -28,6 +30,11 @@ def test_mongodb_settings() -> None: assert settings.mongodb_database == "test_db" +def test_default_settings_has_empty_clients() -> None: + settings = Settings(issuer="http://localhost:8000") + assert settings.clients == {} + + def test_settings_from_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("OIDC_OP_ISSUER", "https://op.example.org") monkeypatch.setenv("OIDC_OP_STORAGE_BACKEND", "mongodb") @@ -35,3 +42,59 @@ def test_settings_from_env(monkeypatch: pytest.MonkeyPatch) -> None: settings = Settings() # type: ignore[call-arg] assert settings.issuer == "https://op.example.org" assert settings.storage_backend == StorageBackend.MONGODB + + +def test_settings_from_toml_file(tmp_path: Path) -> 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"] +""" + toml_file = tmp_path / "test.toml" + toml_file.write_text(toml_content) + + settings = Settings(_toml_file=str(toml_file)) # 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" + + +def test_env_vars_override_toml_values(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + toml_content = """\ +issuer = "https://toml.example.com" +debug = false +""" + toml_file = tmp_path / "test.toml" + toml_file.write_text(toml_content) + + monkeypatch.setenv("OIDC_OP_ISSUER", "https://env.example.com") + monkeypatch.setenv("OIDC_OP_DEBUG", "true") + settings = Settings(_toml_file=str(toml_file)) # type: ignore[call-arg] + assert settings.issuer == "https://env.example.com" + assert settings.debug is True + + +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(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + toml_file = tmp_path / "test.toml" + toml_file.write_text('issuer = "https://custom-path.example.com"\n') + + monkeypatch.setenv("OIDC_OP_CONFIG_FILE", str(toml_file)) + settings = Settings() # type: ignore[call-arg] + assert settings.issuer == "https://custom-path.example.com" From eeb09321e2661d3806dbbef17813faabc5206e14 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Wed, 18 Feb 2026 12:48:23 +0100 Subject: [PATCH 2/4] feat: register OIDC clients from config file --- src/porchlight/app.py | 14 +++++++++ tests/test_client_registration.py | 52 +++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 tests/test_client_registration.py diff --git a/src/porchlight/app.py b/src/porchlight/app.py index 90f6f84..3cc1881 100644 --- a/src/porchlight/app.py +++ b/src/porchlight/app.py @@ -56,6 +56,20 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: oidc_server = create_oidc_server(settings) app.state.oidc_server = oidc_server + # 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) + # Register management client manage_secret = settings.session_secret or secrets.token_hex(32) oidc_server.context.cdb[settings.manage_client_id] = { diff --git a/tests/test_client_registration.py b/tests/test_client_registration.py new file mode 100644 index 0000000..59a2cf7 --- /dev/null +++ b/tests/test_client_registration.py @@ -0,0 +1,52 @@ +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(tmp_path: Path) -> 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"] +""" + toml_file = tmp_path / "test.toml" + toml_file.write_text(toml_content) + + settings = Settings(_toml_file=str(toml_file)) # type: ignore[call-arg] + app = create_app(settings) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + # Trigger lifespan + async with app.router.lifespan_context(app): + 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"] + + +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: + async with app.router.lifespan_context(app): + response = await client.get("/health") + assert response.status_code == 200 + + oidc_server = app.state.oidc_server + assert "manage-app" in oidc_server.context.cdb From 8c91edf137299b666ea4be7a78f094def1b07503 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Wed, 18 Feb 2026 12:54:43 +0100 Subject: [PATCH 3/4] docs: add example config file and update README --- README.md | 37 ++++++++++++++++++++++++++++++++++++- porchlight.example.toml | 23 +++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 porchlight.example.toml diff --git a/README.md b/README.md index 1afad76..78f18cf 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,9 @@ uv run porchlight initial-admin admin --group admin --group superusers ### Configuration -All settings are read from environment variables with the `OIDC_OP_` prefix: +All settings are read from environment variables with the `OIDC_OP_` prefix. +Settings can also be provided via a TOML config file (see below). Environment +variables always take priority over file values. | Variable | Default | Description | |---|---|---| @@ -93,9 +95,42 @@ All settings are read from environment variables with the `OIDC_OP_` prefix: | `OIDC_OP_SIGNING_KEY_PATH` | `data/keys` | OIDC signing key storage | | `OIDC_OP_INVITE_TTL` | `86400` | Magic link expiry in seconds | | `OIDC_OP_MANAGE_CLIENT_ID` | `manage-app` | Client ID for the management UI | +| `OIDC_OP_CONFIG_FILE` | `porchlight.toml` | Path to TOML config file | Database migrations run automatically on startup. +### Configuration file + +Copy `porchlight.example.toml` to `porchlight.toml` and edit to suit your +deployment. The file supports all the same settings as environment variables +(without the `OIDC_OP_` prefix), plus OIDC client registrations. + +```toml +issuer = "https://auth.example.com" +session_secret = "your-random-secret" + +[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" +``` + +Each `[clients.]` section registers an OIDC Relying Party on +startup. Only `client_secret` and `redirect_uris` are required; the other +fields have sensible defaults (`response_types = ["code"]`, +`scope = ["openid"]`, `token_endpoint_auth_method = "client_secret_basic"`). + +To use a config file at a different path: + +```bash +export OIDC_OP_CONFIG_FILE=/etc/porchlight/config.toml +``` + +If the config file does not exist, it is silently ignored and all settings +fall back to environment variables and defaults. + ## Development Setup ### Prerequisites diff --git a/porchlight.example.toml b/porchlight.example.toml new file mode 100644 index 0000000..801155b --- /dev/null +++ b/porchlight.example.toml @@ -0,0 +1,23 @@ +# 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" From 64f8c1936be240152decd9a15f7d13936e5aaac8 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Wed, 18 Feb 2026 13:08:03 +0100 Subject: [PATCH 4/4] refactor: fix lint warnings and remove stale type: ignore comments --- .gitignore | 3 +++ src/porchlight/app.py | 2 +- src/porchlight/cli.py | 4 +-- tests/test_client_registration.py | 43 +++++++++++++++++-------------- tests/test_config.py | 10 +++---- 5 files changed, 34 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index 5542551..40b4925 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ wheels/ # Git worktrees .worktrees/ + +# Runtime data +data/ diff --git a/src/porchlight/app.py b/src/porchlight/app.py index 3cc1881..f0bc34f 100644 --- a/src/porchlight/app.py +++ b/src/porchlight/app.py @@ -91,7 +91,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: def create_app(settings: Settings | None = None) -> FastAPI: if settings is None: - settings = Settings() # type: ignore[call-arg] + settings = Settings() app = FastAPI( title="Porchlight", diff --git a/src/porchlight/cli.py b/src/porchlight/cli.py index 6bc5f7a..30a4d5b 100644 --- a/src/porchlight/cli.py +++ b/src/porchlight/cli.py @@ -52,7 +52,7 @@ def create_invite( note: Annotated[str | None, typer.Option(help="Optional note stored with the link")] = None, ) -> None: """Generate a magic link registration URL for a new user.""" - settings = Settings() # type: ignore[call-arg] + settings = Settings() effective_ttl = ttl if ttl is not None else settings.invite_ttl url = asyncio.run(_create_invite(settings, username, effective_ttl, note)) typer.echo(url) @@ -64,7 +64,7 @@ def initial_admin( group: Annotated[list[str] | None, typer.Option(help="Groups to assign (repeatable)")] = None, ) -> None: """Bootstrap the first admin user with a registration link.""" - settings = Settings() # type: ignore[call-arg] + settings = Settings() groups = group if group is not None else ["admin", "users"] url = asyncio.run(_initial_admin(settings, username, groups)) typer.echo(url) diff --git a/tests/test_client_registration.py b/tests/test_client_registration.py index 59a2cf7..d1d7e2b 100644 --- a/tests/test_client_registration.py +++ b/tests/test_client_registration.py @@ -19,23 +19,24 @@ scope = ["openid", "profile"] toml_file = tmp_path / "test.toml" toml_file.write_text(toml_content) - settings = Settings(_toml_file=str(toml_file)) # type: ignore[call-arg] + settings = Settings(_toml_file=str(toml_file)) app = create_app(settings) - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - # Trigger lifespan - async with app.router.lifespan_context(app): - response = await client.get("/health") - assert response.status_code == 200 + async with ( + AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client, + app.router.lifespan_context(app), + ): + 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"] + 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"] async def test_manage_app_always_registered() -> None: @@ -43,10 +44,12 @@ async def test_manage_app_always_registered() -> None: settings = Settings(issuer="https://test.example.com") app = create_app(settings) - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - async with app.router.lifespan_context(app): - response = await client.get("/health") - assert response.status_code == 200 + async with ( + AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client, + app.router.lifespan_context(app), + ): + response = await client.get("/health") + assert response.status_code == 200 - oidc_server = app.state.oidc_server - assert "manage-app" in oidc_server.context.cdb + oidc_server = app.state.oidc_server + assert "manage-app" in oidc_server.context.cdb diff --git a/tests/test_config.py b/tests/test_config.py index c006602..8a91127 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -39,7 +39,7 @@ def test_settings_from_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("OIDC_OP_ISSUER", "https://op.example.org") monkeypatch.setenv("OIDC_OP_STORAGE_BACKEND", "mongodb") monkeypatch.setenv("OIDC_OP_MONGODB_URI", "mongodb://remote:27017") - settings = Settings() # type: ignore[call-arg] + settings = Settings() assert settings.issuer == "https://op.example.org" assert settings.storage_backend == StorageBackend.MONGODB @@ -58,7 +58,7 @@ scope = ["openid", "profile"] toml_file = tmp_path / "test.toml" toml_file.write_text(toml_content) - settings = Settings(_toml_file=str(toml_file)) # type: ignore[call-arg] + settings = Settings(_toml_file=str(toml_file)) assert settings.issuer == "https://toml.example.com" assert settings.debug is True assert settings.sqlite_path == "custom/path.db" @@ -80,13 +80,13 @@ debug = false monkeypatch.setenv("OIDC_OP_ISSUER", "https://env.example.com") monkeypatch.setenv("OIDC_OP_DEBUG", "true") - settings = Settings(_toml_file=str(toml_file)) # type: ignore[call-arg] + settings = Settings(_toml_file=str(toml_file)) assert settings.issuer == "https://env.example.com" assert settings.debug is True def test_missing_toml_file_uses_defaults() -> None: - settings = Settings(issuer="http://localhost:8000", _toml_file="/nonexistent/path.toml") # type: ignore[call-arg] + settings = Settings(issuer="http://localhost:8000", _toml_file="/nonexistent/path.toml") assert settings.issuer == "http://localhost:8000" assert settings.clients == {} @@ -96,5 +96,5 @@ def test_config_file_env_var_override(tmp_path: Path, monkeypatch: pytest.Monkey toml_file.write_text('issuer = "https://custom-path.example.com"\n') monkeypatch.setenv("OIDC_OP_CONFIG_FILE", str(toml_file)) - settings = Settings() # type: ignore[call-arg] + settings = Settings() assert settings.issuer == "https://custom-path.example.com"