feat: add TOML config file support with client registrations
This commit is contained in:
parent
94f777fc8f
commit
61ca3063ca
2 changed files with 111 additions and 2 deletions
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,6 +30,11 @@ 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")
|
||||||
|
|
@ -35,3 +42,59 @@ def test_settings_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
settings = Settings() # type: ignore[call-arg]
|
settings = Settings() # type: ignore[call-arg]
|
||||||
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)) # 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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue