From eeb09321e2661d3806dbbef17813faabc5206e14 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Wed, 18 Feb 2026 12:48:23 +0100 Subject: [PATCH] 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