Merge branch 'feature/config-file'
This commit is contained in:
commit
404fcac4dd
8 changed files with 246 additions and 7 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -11,3 +11,6 @@ wheels/
|
||||||
|
|
||||||
# Git worktrees
|
# Git worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
data/
|
||||||
|
|
|
||||||
37
README.md
37
README.md
|
|
@ -82,7 +82,9 @@ uv run porchlight initial-admin admin --group admin --group superusers
|
||||||
|
|
||||||
### Configuration
|
### 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 |
|
| 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_SIGNING_KEY_PATH` | `data/keys` | OIDC signing key storage |
|
||||||
| `OIDC_OP_INVITE_TTL` | `86400` | Magic link expiry in seconds |
|
| `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_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.
|
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.<client-id>]` 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
|
## Development Setup
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
|
||||||
23
porchlight.example.toml
Normal file
23
porchlight.example.toml
Normal file
|
|
@ -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.<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"
|
||||||
|
|
@ -56,6 +56,20 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
oidc_server = create_oidc_server(settings)
|
oidc_server = create_oidc_server(settings)
|
||||||
app.state.oidc_server = oidc_server
|
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
|
# Register management client
|
||||||
manage_secret = settings.session_secret or secrets.token_hex(32)
|
manage_secret = settings.session_secret or secrets.token_hex(32)
|
||||||
oidc_server.context.cdb[settings.manage_client_id] = {
|
oidc_server.context.cdb[settings.manage_client_id] = {
|
||||||
|
|
@ -77,7 +91,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
|
||||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
if settings is None:
|
if settings is None:
|
||||||
settings = Settings() # type: ignore[call-arg]
|
settings = Settings()
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Porchlight",
|
title="Porchlight",
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ def create_invite(
|
||||||
note: Annotated[str | None, typer.Option(help="Optional note stored with the link")] = None,
|
note: Annotated[str | None, typer.Option(help="Optional note stored with the link")] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Generate a magic link registration URL for a new user."""
|
"""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
|
effective_ttl = ttl if ttl is not None else settings.invite_ttl
|
||||||
url = asyncio.run(_create_invite(settings, username, effective_ttl, note))
|
url = asyncio.run(_create_invite(settings, username, effective_ttl, note))
|
||||||
typer.echo(url)
|
typer.echo(url)
|
||||||
|
|
@ -64,7 +64,7 @@ def initial_admin(
|
||||||
group: Annotated[list[str] | None, typer.Option(help="Groups to assign (repeatable)")] = None,
|
group: Annotated[list[str] | None, typer.Option(help="Groups to assign (repeatable)")] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Bootstrap the first admin user with a registration link."""
|
"""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"]
|
groups = group if group is not None else ["admin", "users"]
|
||||||
url = asyncio.run(_initial_admin(settings, username, groups))
|
url = asyncio.run(_initial_admin(settings, username, groups))
|
||||||
typer.echo(url)
|
typer.echo(url)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
# src/porchlight/config.py
|
# src/porchlight/config.py
|
||||||
|
import os
|
||||||
from enum import StrEnum
|
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):
|
class StorageBackend(StrEnum):
|
||||||
|
|
@ -9,8 +12,21 @@ class StorageBackend(StrEnum):
|
||||||
MONGODB = "mongodb"
|
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):
|
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
|
# Core
|
||||||
issuer: str
|
issuer: str
|
||||||
|
|
@ -40,3 +56,33 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
# Theme
|
# Theme
|
||||||
theme: str = "default"
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
55
tests/test_client_registration.py
Normal file
55
tests/test_client_registration.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
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))
|
||||||
|
app = create_app(settings)
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
# tests/test_config.py
|
# tests/test_config.py
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from porchlight.config import Settings, StorageBackend
|
from porchlight.config import Settings, StorageBackend
|
||||||
|
|
@ -28,10 +30,71 @@ def test_mongodb_settings() -> None:
|
||||||
assert settings.mongodb_database == "test_db"
|
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:
|
def test_settings_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setenv("OIDC_OP_ISSUER", "https://op.example.org")
|
monkeypatch.setenv("OIDC_OP_ISSUER", "https://op.example.org")
|
||||||
monkeypatch.setenv("OIDC_OP_STORAGE_BACKEND", "mongodb")
|
monkeypatch.setenv("OIDC_OP_STORAGE_BACKEND", "mongodb")
|
||||||
monkeypatch.setenv("OIDC_OP_MONGODB_URI", "mongodb://remote:27017")
|
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.issuer == "https://op.example.org"
|
||||||
assert settings.storage_backend == StorageBackend.MONGODB
|
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))
|
||||||
|
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))
|
||||||
|
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")
|
||||||
|
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()
|
||||||
|
assert settings.issuer == "https://custom-path.example.com"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue