fix(oidc): strip the required BAMBUDDY_OIDC_* values and register the local-login bypass

A Kubernetes Secret written as a block scalar carries a trailing newline, and
the schema bounds the four required variables by max_length only, so an
unstripped issuer_url was stored and enabled and then raised httpx.InvalidURL
on the first click of the SSO button -- the authorize-time failure the
all-or-nothing rule exists to prevent. Whitespace-only values got through the
same way, contradicting the reader's own "an empty required var counts as
unset". The optional variables have always treated blank as unset; the
required ones now do too.

Also registers BAMBUDDY_LOCAL_LOGIN (#1589) in the typo guard, which logged
"possible typo" for it on every boot while listing every BAMBUDDY_OIDC_*
variable as legitimate.
This commit is contained in:
maziggy 2026-08-01 11:37:01 +02:00
parent 8b46006644
commit aef4f3a3e9
7 changed files with 56 additions and 11 deletions

View file

@ -15,6 +15,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Git backup now actually writes cloud profiles (#2717, reporter @jmoore-skild)** — Enabling **Cloud Profiles** for a Git backup produced nothing. The collector looked for a `setting` list in the Bambu Cloud response, which is keyed by preset type instead, so the loop never ran once — and it asked for the credential store used when authentication is *disabled*, so on any install with authentication on it found no account to collect from in the first place. Neither failure was visible: `backup_metadata.json` still recorded `cloud_profiles: true`, and the log line read `Collected cloud profiles: 0 filament, 0 printer, 0 process`, which looks like a successful backup of an empty account. Cloud profiles are now collected from **every connected account across both Bambu Cloud and Orca Cloud**, one directory per cloud per account, keyed by user ID so no email address is written into a backup repository. Bambu presets are stored with the payload needed to recreate them rather than just their names, and Bambu's bundled public catalogue is skipped — it is identical for everyone, re-downloadable, and would rewrite the repository on every run. The metadata now records what was actually collected, per cloud and per account, and a run that collects nothing while the category is enabled says so as a warning instead of an INFO line that reads like success. The Cloud Profiles checkbox no longer keys off your own Bambu sign-in — it enables when *any* account is connected and shows how many are in scope, which matters on a multi-user install where the presets being backed up are other people's. A backup also no longer disconnects an Orca Cloud account whose session it can't refresh: Orca reports every rejection with one composite reason, so a genuine revocation is indistinguishable from a lost token-rotation race, and an unattended job should not be the thing that guesses. The account is skipped with a warning, and the dead credentials are cleared the next time you open Orca Cloud Profiles — where you can pair again on the spot. Translated in all locales; wiki updated. Covered by backend tests.
### Added
- **Configure an OIDC provider from environment variables (#2593, contributor @munin92)** — Single Sign-On could only be set up by clicking through Settings → Authentication, which does not exist as a step in a deployment driven by a compose file, a Helm chart or a GitOps repository: every fresh install of the same stack needed someone to log in and re-enter the same issuer, client ID and secret by hand. One provider can now be defined entirely from `BAMBUDDY_OIDC_*` variables. It is written on startup and re-applied on every boot, so the file in your repository is the source of truth, and providers created in the UI keep working alongside it, untouched. Only the four required variables — name, issuer URL, client ID and client secret — activate it, and all four or none: a half-configured provider is refused at boot with a log line naming what was wrong, rather than appearing on the login page and failing when somebody clicks it. Values are validated by exactly the same rules the settings UI enforces, including the one that refuses account auto-linking on an unverified email address, so the environment cannot reach a configuration the UI would have rejected. A typo never stops the app from booting and never takes a working provider down: the previous configuration keeps running and the reason is logged. In the UI the provider is shown with a lock badge and no edit, delete or enable controls, because startup would overwrite any change at the next restart. Removing the variables disables the provider instead of deleting it — deleting would permanently unlink every account bound to it — and re-adding them brings it back with those links intact. `BAMBUDDY_LOCAL_LOGIN=true` remains the way back in if a misconfigured provider ever locks you out. All thirteen variables are documented in `.env.example`; wiki updated. Covered by backend and frontend tests.
- **Keep the AMS slots the slicer picked (#2700, contributor @Striker72rus)** — Bambu Studio and OrcaSlicer resolve which physical AMS tray feeds each filament themselves, right before sending. Bambuddy threw that away: a queue-mode virtual printer worked the mapping out again at dispatch time, from the filament type and colour baked into the 3MF. That is usually the better answer — it is computed against the printer's live trays and it respects **Prefer lowest filament** and the AMS-backup gate that goes with it — but it has nothing to go on when the match isn't unique. Two spools of the same red PLA, and the slot you deliberately chose in the slicer is a coin toss. A new per-virtual-printer **Save AMS mapping** toggle keeps the slicer's pick instead: the print dispatches to exactly those trays, and the mapping is stored on the archive so a reprint can reuse the same physical spools — a **Mapping** button in the print modal selects every slot from it in one click, and the archive card and queue row say so. Because a tray number only means something on the AMS it was resolved against, the mapping records its printer and is only ever offered on that same printer; a model-based ("Any [model]") virtual printer has no fixed printer and is unaffected. Off by default, so nothing changes for existing virtual printers until you turn it on, and **Force color match** still wins for the print being dispatched when both are on. Translated in all locales; wiki updated. Covered by backend and frontend tests.
- **P2S/X2D accessory fans: left auxiliary cooling and chamber exhaust (#2691, contributor @gzimbric, requested in #2660)** — The P2S and X2D have two fans Bambuddy could not show or drive. The **left auxiliary part cooling fan** had no tile and no control at all, because the printer only reports it inside its air-duct data and never in the ordinary fan fields Bambuddy was reading. The **chamber exhaust fan** had the opposite problem: its tile appeared on every P2S whether or not the fan was fitted, so owners of a base machine had a control that did nothing. Both are add-on kits on the P2S and fitted at the factory on the X2D. Both tiles now appear only when the printer itself reports the hardware, so a base P2S looks exactly as it does today and a kitted one gains the fans it actually has. The left auxiliary fan is set from the same speed popover as the others, and the enclosure fan is labelled **Exhaust** on the P2S and X2D — matching the printer's own screen and Bambu Studio — while every other enclosed model keeps **Chamber Fan**. The confirmation message after changing a speed uses the same name as the tile that was clicked. The four tiles are ordered part cooling, left auxiliary, auxiliary, exhaust, so they read left to right in the same order as the physical fans. Both fields are also published through the status endpoint, the WebSocket feed and the MQTT relay, so external automations can read them. Translated in all locales; wiki updated. Covered by backend and frontend tests, including the case where the printer sends a partial fan report — a tile must not disappear mid-print just because one update didn't mention it.
- **Live print progress in the browser tab (#2693, contributor @Chachigo, requested in #1041)** — Watching a print meant keeping the Bambuddy tab in view, or switching back to it every few minutes. Enable **Print progress in tab** under Settings → Appearance and the tab title becomes `42% · Bambuddy` while the favicon turns into a progress ring in your theme accent colour, both updating live over the WebSocket the rest of the UI already uses. With several printers running, the tab follows the one finishing soonest, tie-broken by highest progress; title and favicon return to their defaults as soon as nothing is printing or the toggle goes off. Off by default, and stored per browser (like the light/dark toggle) so a wall-mounted dashboard and a laptop can each have their own setting. Translated in all locales; wiki updated. Covered by frontend tests.

View file

@ -135,6 +135,10 @@ _INTENTIONAL_UNSETTINGS = {
"LOG_DIR", # config.py (above)
"LOG_LEVEL", # main.py logging setup
"BUG_REPORT_RELAY_URL", # config.py (above)
# #1589 — api/routes/auth.py reads this on the login path. Unregistered it
# logged "possible typo" at every boot, telling an operator who is locked
# out and following the documented recovery that the variable is not real.
"BAMBUDDY_LOCAL_LOGIN",
# #2593 — core/oidc_env.py reads these directly; they are not Settings
# fields because they map to an OIDCProvider row, not to app config.
"BAMBUDDY_OIDC_NAME",

View file

@ -65,16 +65,24 @@ 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.
a compose file is a forgotten value, not an intentional empty secret. Blank
means blank *after* stripping, and the surviving value is stripped too: a
Kubernetes Secret written as a block scalar (``stringData: secret: |``) or
created from a file carries a trailing newline that nothing downstream
rejects -- max_length is the only bound the schema puts on these four. An
issuer_url with a trailing newline is stored and enabled, and then fails
with httpx.InvalidURL on the first click of the SSO button, which is the
authorize-time failure the all-or-nothing rule above exists to prevent.
"""
if not all(os.environ.get(key) for key in _REQUIRED):
required = {key: (os.environ.get(key) or "").strip() for key in _REQUIRED}
if not all(required.values()):
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"],
"name": required["BAMBUDDY_OIDC_NAME"],
"issuer_url": required["BAMBUDDY_OIDC_ISSUER_URL"],
"client_id": required["BAMBUDDY_OIDC_CLIENT_ID"],
"client_secret": required["BAMBUDDY_OIDC_CLIENT_SECRET"],
"scopes": (os.environ.get("BAMBUDDY_OIDC_SCOPES") or "").strip() or "openid email profile",
"is_enabled": env_bool("BAMBUDDY_OIDC_ENABLED", True),
"auto_create_users": env_bool("BAMBUDDY_OIDC_AUTO_CREATE_USERS", False),

View file

@ -116,6 +116,15 @@ class TestLocalLoginGate:
# Bypass stays off (same 401 as no env var), and crucially not a 500.
assert response.status_code == 401, response.text
def test_the_bypass_var_is_registered_in_the_typo_guard(self):
"""config.py logs "possible typo" for any unregistered BAMBUDDY_* var.
Unregistered, this one tells an operator who is locked out and following
the documented recovery that the variable they just set is not real --
while the same line lists every BAMBUDDY_OIDC_* var as legitimate."""
from backend.app.core.config import _INTENTIONAL_UNSETTINGS
assert "BAMBUDDY_LOCAL_LOGIN" in _INTENTIONAL_UNSETTINGS
@pytest.mark.asyncio
@pytest.mark.integration
async def test_forgot_password_rejected_when_local_disabled(

View file

@ -56,14 +56,37 @@ def test_returns_none_when_any_single_required_var_is_missing(monkeypatch, missi
assert read_env_oidc_config() is None
def test_an_empty_required_var_counts_as_unset(monkeypatch):
@pytest.mark.parametrize("raw", ["", " ", "\n", " \t\n "])
@pytest.mark.parametrize("key", sorted(REQUIRED))
def test_an_empty_required_var_counts_as_unset(monkeypatch, key, raw):
"""`BAMBUDDY_OIDC_CLIENT_SECRET=` in a compose file is a forgotten value,
not an intentional empty secret."""
not an intentional empty secret -- and neither is one holding only
whitespace, which the optional vars have always treated as unset."""
_set_required(monkeypatch)
monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_SECRET", "")
monkeypatch.setenv(key, raw)
assert read_env_oidc_config() is None
@pytest.mark.parametrize("key", sorted(REQUIRED))
def test_a_required_var_is_stripped(monkeypatch, key):
"""A Kubernetes Secret written as a block scalar carries a trailing
newline, and the schema bounds these four by max_length only -- so an
unstripped issuer_url reaches the database, enables the SSO button and
then raises httpx.InvalidURL on the first click, long after startup could
have refused it."""
_set_required(monkeypatch)
monkeypatch.setenv(key, f" {REQUIRED[key]}\n")
cfg = read_env_oidc_config()
field = {
"BAMBUDDY_OIDC_NAME": "name",
"BAMBUDDY_OIDC_ISSUER_URL": "issuer_url",
"BAMBUDDY_OIDC_CLIENT_ID": "client_id",
"BAMBUDDY_OIDC_CLIENT_SECRET": "client_secret",
}[key]
assert cfg[field] == REQUIRED[key]
def test_reads_the_required_vars(monkeypatch):
_set_required(monkeypatch)
cfg = read_env_oidc_config()

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CbDmTKuP.js"></script>
<script type="module" crossorigin src="/assets/index-CCCWDEkl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
</head>
<body>