From d6ecd9248086f1c9222b248c2eb38fbd975d0f5c Mon Sep 17 00:00:00 2001 From: Marian Date: Mon, 20 Jul 2026 13:28:59 +0000 Subject: [PATCH] feat(oidc): read BAMBUDDY_OIDC_* env config A declarative deployment has no way to click through the settings UI, so one provider can be configured entirely from the environment. This reads and defaults only -- validity is decided later by the same OIDCProviderCreate schema the API uses, so env config cannot bypass a check the UI enforces. All four required vars or nothing, and an empty one counts as unset: a provider missing its secret would otherwise be written to the database and fail at authorize time, far from the typo in the compose file that caused it. Booleans follow the project's existing spelling convention (true/1/yes), so an unrecognised value leaves the documented default rather than guessing. Refs #2593 --- backend/app/core/oidc_env.py | 54 ++++++++++ backend/tests/unit/test_oidc_env_reader.py | 118 +++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 backend/app/core/oidc_env.py create mode 100644 backend/tests/unit/test_oidc_env_reader.py diff --git a/backend/app/core/oidc_env.py b/backend/app/core/oidc_env.py new file mode 100644 index 000000000..60f557a98 --- /dev/null +++ b/backend/app/core/oidc_env.py @@ -0,0 +1,54 @@ +"""Read the single OIDC provider defined by BAMBUDDY_OIDC_* env vars (#2593). + +A declarative deployment (compose, Helm, GitOps) has no way to click through +the settings UI, so one provider can be configured entirely from the +environment. This module only reads and defaults; validity is decided by the +same OIDCProviderCreate schema the API uses, so env config cannot bypass a +check the UI enforces. +""" + +from __future__ import annotations + +import os + +# All four or nothing: a provider missing its secret would be written to the +# database and then fail at authorize time, long after the operator could +# connect the failure to a typo in their compose file. +_REQUIRED = ( + "BAMBUDDY_OIDC_NAME", + "BAMBUDDY_OIDC_ISSUER_URL", + "BAMBUDDY_OIDC_CLIENT_ID", + "BAMBUDDY_OIDC_CLIENT_SECRET", +) + +_TRUTHY = {"true", "1", "yes"} + + +def _env_bool(key: str, default: bool) -> bool: + value = os.environ.get(key) + return default if value is None else value.strip().lower() in _TRUTHY + + +def read_env_oidc_config() -> dict | None: + """The provider's fields from the environment, or None if it isn't configured. + + An empty required var counts as unset -- `BAMBUDDY_OIDC_CLIENT_SECRET=` in + a compose file is a forgotten value, not an intentional empty secret. + """ + if not all(os.environ.get(key) for key in _REQUIRED): + return None + + return { + "name": os.environ["BAMBUDDY_OIDC_NAME"], + "issuer_url": os.environ["BAMBUDDY_OIDC_ISSUER_URL"], + "client_id": os.environ["BAMBUDDY_OIDC_CLIENT_ID"], + "client_secret": os.environ["BAMBUDDY_OIDC_CLIENT_SECRET"], + "scopes": os.environ.get("BAMBUDDY_OIDC_SCOPES", "openid email profile"), + "is_enabled": _env_bool("BAMBUDDY_OIDC_ENABLED", True), + "auto_create_users": _env_bool("BAMBUDDY_OIDC_AUTO_CREATE_USERS", False), + "auto_link_existing_accounts": _env_bool("BAMBUDDY_OIDC_AUTO_LINK_EXISTING", False), + "email_claim": os.environ.get("BAMBUDDY_OIDC_EMAIL_CLAIM", "email"), + "require_email_verified": _env_bool("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", True), + "icon_url": os.environ.get("BAMBUDDY_OIDC_ICON_URL"), + "is_autologin": _env_bool("BAMBUDDY_OIDC_AUTOLOGIN", False), + } diff --git a/backend/tests/unit/test_oidc_env_reader.py b/backend/tests/unit/test_oidc_env_reader.py new file mode 100644 index 000000000..7e8c7b875 --- /dev/null +++ b/backend/tests/unit/test_oidc_env_reader.py @@ -0,0 +1,118 @@ +"""BAMBUDDY_OIDC_* reader (#2593). + +The reader is deliberately dumb: it maps env vars to field names and applies +defaults. Whether the resulting provider is *valid* is decided later, by the +same OIDCProviderCreate schema the API uses, so env config cannot bypass a +check the UI enforces. +""" + +from __future__ import annotations + +import pytest + +from backend.app.core.oidc_env import read_env_oidc_config + +REQUIRED = { + "BAMBUDDY_OIDC_NAME": "Keycloak", + "BAMBUDDY_OIDC_ISSUER_URL": "https://sso.example.com/realms/main", + "BAMBUDDY_OIDC_CLIENT_ID": "bambuddy", + "BAMBUDDY_OIDC_CLIENT_SECRET": "s3cr3t", +} + +OPTIONAL = ( + "BAMBUDDY_OIDC_SCOPES", + "BAMBUDDY_OIDC_ENABLED", + "BAMBUDDY_OIDC_AUTO_CREATE_USERS", + "BAMBUDDY_OIDC_AUTO_LINK_EXISTING", + "BAMBUDDY_OIDC_EMAIL_CLAIM", + "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", + "BAMBUDDY_OIDC_ICON_URL", + "BAMBUDDY_OIDC_AUTOLOGIN", +) + + +@pytest.fixture(autouse=True) +def clean_env(monkeypatch): + for key in (*REQUIRED, *OPTIONAL): + monkeypatch.delenv(key, raising=False) + + +def _set_required(monkeypatch): + for key, value in REQUIRED.items(): + monkeypatch.setenv(key, value) + + +def test_returns_none_when_nothing_is_configured(): + assert read_env_oidc_config() is None + + +@pytest.mark.parametrize("missing", sorted(REQUIRED)) +def test_returns_none_when_any_single_required_var_is_missing(monkeypatch, missing): + """All four or nothing -- a half-configured provider must not reach the + database, where it would fail at authorize time instead of at startup.""" + _set_required(monkeypatch) + monkeypatch.delenv(missing) + assert read_env_oidc_config() is None + + +def test_an_empty_required_var_counts_as_unset(monkeypatch): + """`BAMBUDDY_OIDC_CLIENT_SECRET=` in a compose file is a forgotten value, + not an intentional empty secret.""" + _set_required(monkeypatch) + monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_SECRET", "") + assert read_env_oidc_config() is None + + +def test_reads_the_required_vars(monkeypatch): + _set_required(monkeypatch) + cfg = read_env_oidc_config() + assert cfg["name"] == "Keycloak" + assert cfg["issuer_url"] == "https://sso.example.com/realms/main" + assert cfg["client_id"] == "bambuddy" + assert cfg["client_secret"] == "s3cr3t" + + +def test_applies_the_documented_defaults(monkeypatch): + _set_required(monkeypatch) + cfg = read_env_oidc_config() + assert cfg["scopes"] == "openid email profile" + assert cfg["is_enabled"] is True + assert cfg["auto_create_users"] is False + assert cfg["auto_link_existing_accounts"] is False + assert cfg["email_claim"] == "email" + assert cfg["require_email_verified"] is True + assert cfg["icon_url"] is None + assert cfg["is_autologin"] is False + + +@pytest.mark.parametrize("raw", ["true", "TRUE", "True", "1", "yes", "YES", " yes "]) +def test_booleans_accept_the_project_truthy_spellings(monkeypatch, raw): + _set_required(monkeypatch) + monkeypatch.setenv("BAMBUDDY_OIDC_AUTO_CREATE_USERS", raw) + assert read_env_oidc_config()["auto_create_users"] is True + + +@pytest.mark.parametrize("raw", ["false", "0", "no", "", "off", "nonsense"]) +def test_anything_else_is_false(monkeypatch, raw): + """Only the three documented spellings enable a flag; an unrecognised value + must not silently turn on auto-create-users.""" + _set_required(monkeypatch) + monkeypatch.setenv("BAMBUDDY_OIDC_AUTO_CREATE_USERS", raw) + assert read_env_oidc_config()["auto_create_users"] is False + + +def test_a_boolean_default_of_true_can_be_turned_off(monkeypatch): + _set_required(monkeypatch) + monkeypatch.setenv("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", "false") + assert read_env_oidc_config()["require_email_verified"] is False + + +def test_optional_strings_override_their_defaults(monkeypatch): + _set_required(monkeypatch) + monkeypatch.setenv("BAMBUDDY_OIDC_SCOPES", "openid profile groups") + monkeypatch.setenv("BAMBUDDY_OIDC_EMAIL_CLAIM", "mail") + monkeypatch.setenv("BAMBUDDY_OIDC_ICON_URL", "https://sso.example.com/logo.png") + cfg = read_env_oidc_config() + assert cfg["scopes"] == "openid profile groups" + assert cfg["email_claim"] == "mail" + assert cfg["icon_url"] == "https://sso.example.com/logo.png"