fix(oidc): use preferred_username/name claim for auto-created username (#1173) (#1176)

fix(oidc): use preferred_username/name claim for auto-created username

When auto-creating an OIDC user without a valid email claim, derive the
username from preferred_username or name IdP claims instead of falling
back to the opaque provider_sub[:30].
This commit is contained in:
Sn0rrii
2026-05-02 12:14:32 +02:00
committed by GitHub
parent 3320c7fd45
commit d0d0be89ea
17 changed files with 807 additions and 10 deletions
+2
View File
@@ -10,6 +10,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Home Assistant addon detection — Settings → Updates and the in-app update banner now defer to the HA Supervisor** ([#1167](https://github.com/maziggy/bambuddy/issues/1167), reported by @Spegeli) — Bambuddy already shipped `HA_URL`/`HA_TOKEN` env-var support specifically labelled "for HA Add-on deployments" ([#283](https://github.com/maziggy/bambuddy/issues/283)) and a community-maintained HA addon (`hobbypunk90/homeassistant-addon-bambuddy`) exists upstream, so an HA-supervised installation is a real first-class deployment shape. Until now though, the update UI didn't know about it: HA addon users got the same "Update available!" banner as everyone else and, if they clicked through to Settings, saw the docker-compose snippet ("`docker compose pull && docker compose up -d`") which they cannot run from inside an HA addon container — that's the Supervisor's job. Detection uses the canonical signal: HA Supervisor injects `SUPERVISOR_TOKEN` into every addon container, and that variable is not set in any other environment. A new `_is_ha_addon()` helper in `backend/app/api/routes/updates.py` flips a request-level boolean which `/updates/check` surfaces as `is_ha_addon: bool` + an extended `update_method: 'git' | 'docker' | 'ha_addon'` enum. The check is checked **before** Docker on `/updates/apply` because HA addons *are* Docker containers — checking docker first would mis-classify them and serve the wrong message; the response also keeps `is_docker: true` alongside `is_ha_addon: true` so older frontend bundles still hit a managed-deployment branch (degrading to the Docker UX) instead of rendering an in-app Install button that can't work. Frontend branches identically: `SettingsPage.tsx`'s update card checks `is_ha_addon` first and renders "Updates are managed by the Home Assistant Supervisor. Open Settings → Add-ons → Bambuddy in Home Assistant to install the new version." in place of the docker-compose hint; `Layout.tsx`'s update banner is suppressed entirely for HA addons since the HA Supervisor's own update notification already surfaces the new version natively in the HA UI and a duplicate Bambuddy banner would just be noise that links to a page that says "go to HA". Plain Docker deployments are unaffected — the existing docker-compose hint and the in-app banner still render the same way they did. Localised across all 8 UI languages (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW) with full translations of the new `settings.updateViaHomeAssistant` string. 6 new tests pin the contract: 3 backend unit tests for `_is_ha_addon()` (env var present → true, absent → false, empty string treated as unset to guard against shells that export it empty), 1 backend integration test for the HA-precedes-Docker rejection on `/updates/apply` (asserts the message says "Home Assistant" and not "Docker Compose"), 2 backend integration tests for `/updates/check` covering the HA-addon branch (`update_method == "ha_addon"`, both flags true) and the plain-Docker branch (`is_ha_addon: false`, `update_method == "docker"`); 2 frontend SettingsPage tests pin the mutually-exclusive UI rendering (HA branch shows the HA copy and not the docker-compose snippet; Docker branch shows the snippet and not the HA copy, neither shows the Install button); 2 frontend Layout tests pin the banner suppression for HA and its retention for plain Docker.
- **OIDC auto-created users now get readable usernames and land in a configurable group** ([#1173](https://github.com/maziggy/bambuddy/issues/1173)) — Two improvements to the OIDC auto-create flow: (1) **Username derivation**: Bambuddy now derives the username from `preferred_username`, then `name`, before falling back to the opaque `provider_sub[:30]`. Each candidate is sanitized independently — alphanumeric plus `./-/_`, whitespace collapsed, deduplication suffix appended on collision — so a value that strips to empty (e.g. `"!!!"`) correctly falls through to the next option rather than silently producing `"oidcuser"`. (2) **Default group**: each OIDC provider gains a `default_group_id` field. When set, auto-created users are placed in that group; when unset, the existing "Viewers" fallback is preserved, so behaviour is unchanged for existing deployments. The column is nullable with `ON DELETE SET NULL`; SQLite does not enforce FK constraints here, so a deleted configured group falls through to Viewers at runtime. `default_group_id` is validated on create/update (422 on a non-existent group). Exposed in the OIDC settings form as a group dropdown. **Limitation:** to clear a configured default group, delete the group or select a different one — explicit reset-to-null is not currently supported.
- **Filament Track Switch (FTS) support — print modal filament dropdown is no longer empty when an X2D / H2D has the FTS accessory installed** ([#1162](https://github.com/maziggy/bambuddy/issues/1162), reported by @mkavalecz) — When the FTS accessory is installed the printer's MQTT changes one nibble of the per-AMS `info` bitmask: bits 8-11 flip from a fixed extruder ID (0x0 / 0x1) to `0xE` ("uninitialized"), because the AMS is no longer wired to a single nozzle — the FTS dynamically routes any slot to either extruder. Bambuddy's MQTT parser already skipped 0xE entries when building `ams_extruder_map` (matching BambuStudio's reading for boot-time transient state), so with the FTS installed the map ended up empty and the print modal's filament dropdown — which filters by `extruderId === nozzle_id` to prevent cross-nozzle assignment ("position of left hotend is abnormal" failures) — filtered out *every* loaded slot. Net effect: empty Filament Mapping dropdown on every dual-nozzle print with the FTS, even when the AMS was fully loaded with the right material. Detection comes from a new MQTT field — `print.device.fila_switch` — which is non-null only when the accessory is installed; it carries the routing topology as two arrays: `in[track] = currently fed slot (-1 = empty)` and `out[track] = extruder this track terminates at`. The fix surfaces this through a new `FilaSwitchState` dataclass on `PrinterState` (`installed`, `in_slots`, `out_extruders`, `stat`, `info`) and the equivalent `FilaSwitchResponse` Pydantic schema on the `GET /printers/{id}/status` route. Frontend (`useFilamentMapping.ts` + `FilamentMapping.tsx`) skips the per-extruder filter when `printerStatus.fila_switch?.installed === true` so any compatible AMS slot can satisfy any nozzle's filament requirement, since the FTS handles the routing. Slots currently fed into a track also get a routing badge in the dropdown — `[L]` or `[R]` — so the user can tell at a glance which slot the FTS is currently routing where (idle slots get no badge: they can be routed to either extruder on demand). The hard "no cross-nozzle assignment" filter on real dual-nozzle printers without the FTS stays untouched (still trips the same way it always has — `fila_switch == null` keeps the existing behaviour). 4 backend tests in `test_bambu_mqtt.py::TestFilamentTrackSwitchDetection` (default-not-installed, detect-from-MQTT-using-the-reporter's-bundle, no-fila_switch-field-stays-not-installed, missing-in-out-arrays-don't-crash) and 2 frontend tests in `useFilamentMapping.test.ts` (FTS-active drops the nozzle filter; explicit `fila_switch: null` keeps the filter applied). Upstream fila_switch payloads with anything other than the documented shape are tolerated — `installed` flips on the *presence* of the field, the routing arrays default to empty lists if missing, and the dropdown skips the badge for slots not currently in `in_slots`.
### Fixed
+47 -9
View File
@@ -1168,6 +1168,13 @@ async def create_oidc_provider(
db: AsyncSession = Depends(get_db),
) -> OIDCProviderResponse:
"""Create a new OIDC provider (admin only)."""
if body.default_group_id is not None:
grp_chk = await db.execute(select(Group).where(Group.id == body.default_group_id))
if not grp_chk.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="default_group_id references a non-existent group",
)
provider = OIDCProvider(
name=body.name,
issuer_url=body.issuer_url.rstrip("/"),
@@ -1180,6 +1187,7 @@ async def create_oidc_provider(
email_claim=body.email_claim,
require_email_verified=body.require_email_verified,
icon_url=body.icon_url,
default_group_id=body.default_group_id,
)
# SEC-1 + SEC-6: runtime guard mirrors the OIDCProviderCreate model_validator in schemas/auth.py.
# Catches any future path that bypasses Pydantic validation (direct ORM, scripts).
@@ -1203,6 +1211,14 @@ async def update_oidc_provider(
if not provider:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
if body.default_group_id is not None:
grp_chk = await db.execute(select(Group).where(Group.id == body.default_group_id))
if not grp_chk.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="default_group_id references a non-existent group",
)
for field, value in body.model_dump(exclude_none=True).items():
if field == "issuer_url" and value:
value = value.rstrip("/")
@@ -1572,7 +1588,21 @@ async def oidc_callback(
if provider_email:
raw = provider_email.split("@")[0]
else:
raw = provider_sub[:30]
# Prefer a human-readable IdP claim over the opaque sub.
# isinstance guards are required: claims may carry non-string
# values (e.g. a list) that would break .strip().
# Sanitization is applied per-candidate so that a value that
# strips to empty (e.g. "!!!") correctly falls through to the
# next candidate rather than silently becoming "oidcuser".
_pref = claims.get("preferred_username")
_name = claims.get("name")
raw = ""
if isinstance(_pref, str):
raw = re.sub(r"[^a-zA-Z0-9._-]", "", _pref.strip())[:30]
if not raw and isinstance(_name, str):
raw = re.sub(r"[^a-zA-Z0-9._-]", "", _name.strip())[:30]
if not raw:
raw = provider_sub[:30]
candidate = re.sub(r"[^a-zA-Z0-9._-]", "", raw)[:30] or "oidcuser"
username = candidate
@@ -1584,13 +1614,21 @@ async def oidc_callback(
username = f"{candidate}{counter}"
counter += 1
# I9: Assign new OIDC users to the default "Viewers" group so they
# have read-only access rather than starting with no permissions.
# Fetch the group BEFORE creating the user so we can set the
# relationship before flush — accessing new_user.groups after a
# flush triggers a lazy-load which fails in async context.
viewers_result = await db.execute(select(Group).where(Group.name == "Viewers"))
viewers_group = viewers_result.scalar_one_or_none()
# I9: Assign new OIDC users to a group before flush — accessing
# new_user.groups after a flush triggers a lazy-load which fails
# in async context. Resolution order:
# 1. provider.default_group_id (operator-configured)
# 2. "Viewers" (system fallback for read-only access)
# 3. no group (last resort if Viewers was deleted)
# SQLite does not enforce ON DELETE SET NULL, so a dangling
# default_group_id returns None here and falls through to Viewers.
default_group: Group | None = None
if provider.default_group_id is not None:
dg_result = await db.execute(select(Group).where(Group.id == provider.default_group_id))
default_group = dg_result.scalar_one_or_none()
if default_group is None:
viewers_result = await db.execute(select(Group).where(Group.name == "Viewers"))
default_group = viewers_result.scalar_one_or_none()
new_user = User(
username=username,
@@ -1601,7 +1639,7 @@ async def oidc_callback(
password_hash=None, # OIDC users never use password auth
role="user",
is_active=True,
groups=[viewers_group] if viewers_group else [],
groups=[default_group] if default_group else [],
)
db.add(new_user)
await db.flush()
+8
View File
@@ -1745,6 +1745,14 @@ async def run_migrations(conn):
# is still present in sqlite_master (SQLite cannot ALTER TABLE DROP/ADD CONSTRAINT).
await _migrate_update_auto_link_constraint(conn)
# Migration: Add default_group_id to oidc_providers.
# Must run AFTER _migrate_update_auto_link_constraint to avoid being dropped during
# the SQLite table recreation that function performs on stale-formula databases.
await _safe_execute(
conn,
"ALTER TABLE oidc_providers ADD COLUMN default_group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL",
)
# Migration: Add password_changed_at to users (M-R7-B)
# Tracks the last time a user's password was changed/reset. JWTs whose iat
# predates this timestamp are rejected in all six auth validation paths.
+6
View File
@@ -73,6 +73,12 @@ class OIDCProvider(Base):
# Has no effect when email_claim is not "email": the custom-claim path never
# performs an email_verified check regardless of this setting.
require_email_verified: Mapped[bool] = mapped_column(Boolean, default=True)
# Nullable FK — configurable default group for auto-created OIDC users.
# Falls back to "Viewers" when None. ON DELETE SET NULL fires on PostgreSQL;
# SQLite ignores it (no PRAGMA foreign_keys=ON), so runtime resolution handles dangling refs.
default_group_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, default=None
)
# Optional icon URL (SVG/PNG) shown on the login button
icon_url: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+3
View File
@@ -371,6 +371,7 @@ class OIDCProviderCreate(BaseModel):
email_claim: str = Field(default="email", max_length=64)
require_email_verified: bool = True
icon_url: str | None = None
default_group_id: int | None = None
@field_validator("issuer_url")
@classmethod
@@ -426,6 +427,7 @@ class OIDCProviderUpdate(BaseModel):
email_claim: str | None = Field(default=None, max_length=64)
require_email_verified: bool | None = None
icon_url: str | None = None
default_group_id: int | None = None
@field_validator("scopes")
@classmethod
@@ -471,6 +473,7 @@ class OIDCProviderResponse(BaseModel):
email_claim: str = "email"
require_email_verified: bool = True
icon_url: str | None = None
default_group_id: int | None = None
class Config:
from_attributes = True
+603
View File
@@ -862,6 +862,151 @@ class TestOIDCProviders:
)
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_provider_with_default_group_id(self, async_client: AsyncClient, db_session: AsyncSession):
"""Creating a provider with a valid default_group_id stores and returns the value."""
from sqlalchemy import select
from backend.app.models.group import Group
token = await _setup_and_login(async_client, "oidcdg_create", "OidcDgCreate1!")
grp_result = await db_session.execute(select(Group).where(Group.name == "Operators"))
operators = grp_result.scalar_one()
resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "DgCreateProvider",
"issuer_url": "https://dgcreate.example.com",
"client_id": "dgcreate-client",
"client_secret": "secret",
"scopes": "openid",
"is_enabled": True,
"auto_create_users": False,
"default_group_id": operators.id,
},
headers=_auth_header(token),
)
assert resp.status_code == 201, resp.text
assert resp.json()["default_group_id"] == operators.id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_provider_invalid_default_group_id_returns_422(self, async_client: AsyncClient):
"""A default_group_id referencing a non-existent group returns 422."""
token = await _setup_and_login(async_client, "oidcdg_bad", "OidcDgBad1!")
resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "DgBadProvider",
"issuer_url": "https://dgbad.example.com",
"client_id": "dgbad-client",
"client_secret": "secret",
"scopes": "openid",
"is_enabled": True,
"auto_create_users": False,
"default_group_id": 999999,
},
headers=_auth_header(token),
)
assert resp.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_provider_omit_default_group_id_stores_null(self, async_client: AsyncClient):
"""Omitting default_group_id results in null in the response."""
token = await _setup_and_login(async_client, "oidcdg_null", "OidcDgNull1!")
resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "DgNullProvider",
"issuer_url": "https://dgnull.example.com",
"client_id": "dgnull-client",
"client_secret": "secret",
"scopes": "openid",
"is_enabled": True,
"auto_create_users": False,
},
headers=_auth_header(token),
)
assert resp.status_code == 201, resp.text
assert resp.json()["default_group_id"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_provider_default_group_id(self, async_client: AsyncClient, db_session: AsyncSession):
"""Updating default_group_id via PUT stores the new value."""
from sqlalchemy import select
from backend.app.models.group import Group
token = await _setup_and_login(async_client, "oidcdg_update", "OidcDgUpdate1!")
create_resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "DgUpdateProvider",
"issuer_url": "https://dgupdate.example.com",
"client_id": "dgupdate-client",
"client_secret": "secret",
"scopes": "openid",
"is_enabled": True,
"auto_create_users": False,
},
headers=_auth_header(token),
)
provider_id = create_resp.json()["id"]
assert create_resp.json()["default_group_id"] is None
grp_result = await db_session.execute(select(Group).where(Group.name == "Operators"))
operators = grp_result.scalar_one()
put_resp = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}",
json={"default_group_id": operators.id},
headers=_auth_header(token),
)
assert put_resp.status_code == 200, put_resp.text
assert put_resp.json()["default_group_id"] == operators.id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_default_group_id_in_public_and_admin_list(self, async_client: AsyncClient, db_session: AsyncSession):
"""default_group_id appears in both the public and admin list responses."""
from sqlalchemy import select
from backend.app.models.group import Group
token = await _setup_and_login(async_client, "oidcdg_list", "OidcDgList1!")
grp_result = await db_session.execute(select(Group).where(Group.name == "Operators"))
operators = grp_result.scalar_one()
create_resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "DgListProvider",
"issuer_url": "https://dglist.example.com",
"client_id": "dglist-client",
"client_secret": "secret",
"scopes": "openid",
"is_enabled": True,
"auto_create_users": False,
"default_group_id": operators.id,
},
headers=_auth_header(token),
)
provider_id = create_resp.json()["id"]
all_resp = await async_client.get("/api/v1/auth/oidc/providers/all", headers=_auth_header(token))
match = next((p for p in all_resp.json() if p["id"] == provider_id), None)
assert match is not None
assert match["default_group_id"] == operators.id
pub_resp = await async_client.get("/api/v1/auth/oidc/providers")
pub_match = next((p for p in pub_resp.json() if p["id"] == provider_id), None)
assert pub_match is not None
assert pub_match["default_group_id"] == operators.id
# ===========================================================================
# Security: pre-auth token single-use
@@ -4240,3 +4385,461 @@ class TestOIDCFallCAutoLinkE2E:
link = result.scalar_one_or_none()
assert link is not None, "UserOIDCLink must have been created by auto-link"
assert link.provider_user_id == "azure-sub-alice"
class TestOIDCAutoCreateUsername:
"""Username derivation priority for auto-created OIDC users (#1173).
Priority order: email local-part > preferred_username > name > provider_sub.
Covers: plain claim, spaces-sanitized, name fallback, sub fallback,
non-string isinstance guard, sanitizes-to-empty fallback, collision counter.
"""
# ── shared helpers ───────────────────────────────────────────────────────
@staticmethod
async def _create_provider(async_client: AsyncClient, admin_token: str, issuer: str, client_id: str) -> int:
resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": f"AutoUser-{secrets.token_hex(4)}",
"issuer_url": issuer,
"client_id": client_id,
"client_secret": "secret",
"scopes": "openid profile",
"is_enabled": True,
"auto_create_users": True,
"email_claim": "email",
"require_email_verified": True,
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
@staticmethod
async def _exchange_username(async_client: AsyncClient, location: str) -> str:
assert "oidc_token=" in location, f"No oidc_token in redirect: {location}"
token = location.split("oidc_token=")[1].split("&")[0].split("#")[-1]
resp = await async_client.post("/api/v1/auth/oidc/exchange", json={"oidc_token": token})
assert resp.status_code == 200, resp.text
return resp.json()["user"]["username"]
# ── tests ────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
@pytest.mark.integration
async def test_preferred_username_used_when_no_email(self, async_client: AsyncClient, db_session: AsyncSession):
"""preferred_username='johndoe' → username 'johndoe' (no email claim present)."""
private_pem, jwks_data = _make_test_rsa_key()
issuer = "https://au-pref.example"
client_id = "au-pref-client"
admin_token = await _setup_and_login(async_client, "au_pref_adm", "AuPrefAdm1!")
provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
location = await _run_oidc_callback(
async_client,
db_session,
provider_id=provider_id,
claims={"sub": "pref-sub-1", "preferred_username": "johndoe"},
private_pem=private_pem,
jwks_data=jwks_data,
issuer=issuer,
client_id=client_id,
)
username = await self._exchange_username(async_client, location)
assert username == "johndoe"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_preferred_username_spaces_sanitized(self, async_client: AsyncClient, db_session: AsyncSession):
"""preferred_username='John Doe' → sanitized to 'JohnDoe'."""
private_pem, jwks_data = _make_test_rsa_key()
issuer = "https://au-spaces.example"
client_id = "au-spaces-client"
admin_token = await _setup_and_login(async_client, "au_spaces_adm", "AuSpacesAdm1!")
provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
location = await _run_oidc_callback(
async_client,
db_session,
provider_id=provider_id,
claims={"sub": "spaces-sub-1", "preferred_username": "John Doe"},
private_pem=private_pem,
jwks_data=jwks_data,
issuer=issuer,
client_id=client_id,
)
username = await self._exchange_username(async_client, location)
assert username == "JohnDoe"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_name_claim_used_when_no_preferred_username(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""name='Jane Smith', no preferred_username → username 'JaneSmith'."""
private_pem, jwks_data = _make_test_rsa_key()
issuer = "https://au-name.example"
client_id = "au-name-client"
admin_token = await _setup_and_login(async_client, "au_name_adm", "AuNameAdm1!")
provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
location = await _run_oidc_callback(
async_client,
db_session,
provider_id=provider_id,
claims={"sub": "name-sub-1", "name": "Jane Smith"},
private_pem=private_pem,
jwks_data=jwks_data,
issuer=issuer,
client_id=client_id,
)
username = await self._exchange_username(async_client, location)
assert username == "JaneSmith"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_provider_sub_fallback_when_no_claims(self, async_client: AsyncClient, db_session: AsyncSession):
"""No preferred_username, no name, no email → username derived from provider_sub."""
private_pem, jwks_data = _make_test_rsa_key()
issuer = "https://au-sub.example"
client_id = "au-sub-client"
admin_token = await _setup_and_login(async_client, "au_sub_adm", "AuSubAdm1!")
provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
location = await _run_oidc_callback(
async_client,
db_session,
provider_id=provider_id,
claims={"sub": "abc123xyz"},
private_pem=private_pem,
jwks_data=jwks_data,
issuer=issuer,
client_id=client_id,
)
username = await self._exchange_username(async_client, location)
assert username == "abc123xyz"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_non_string_preferred_username_falls_through_to_name(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""preferred_username is a list (non-string) → isinstance guard skips it, uses name."""
private_pem, jwks_data = _make_test_rsa_key()
issuer = "https://au-nonstr.example"
client_id = "au-nonstr-client"
admin_token = await _setup_and_login(async_client, "au_nonstr_adm", "AuNonstrAdm1!")
provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
location = await _run_oidc_callback(
async_client,
db_session,
provider_id=provider_id,
claims={"sub": "nonstr-sub-2", "preferred_username": ["listval"], "name": "BobJones"},
private_pem=private_pem,
jwks_data=jwks_data,
issuer=issuer,
client_id=client_id,
)
username = await self._exchange_username(async_client, location)
assert username == "BobJones"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_preferred_username_sanitizes_to_empty_falls_through_to_name(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""preferred_username='!!!' sanitizes to '' → falls through to name claim."""
private_pem, jwks_data = _make_test_rsa_key()
issuer = "https://au-empty.example"
client_id = "au-empty-client"
admin_token = await _setup_and_login(async_client, "au_empty_adm", "AuEmptyAdm1!")
provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
location = await _run_oidc_callback(
async_client,
db_session,
provider_id=provider_id,
claims={"sub": "empty-sub-1", "preferred_username": "!!!", "name": "bob"},
private_pem=private_pem,
jwks_data=jwks_data,
issuer=issuer,
client_id=client_id,
)
username = await self._exchange_username(async_client, location)
assert username == "bob"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_username_collision_appends_counter(self, async_client: AsyncClient, db_session: AsyncSession):
"""When preferred_username 'collider' is already taken, counter suffix is appended."""
from backend.app.core.auth import get_password_hash
# Pre-create a user occupying the candidate username
existing = User(
username="collider",
email="collider@example.com",
password_hash=get_password_hash("irrelevant"),
role="user",
is_active=True,
)
db_session.add(existing)
await db_session.commit()
private_pem, jwks_data = _make_test_rsa_key()
issuer = "https://au-collision.example"
client_id = "au-collision-client"
admin_token = await _setup_and_login(async_client, "au_col_adm", "AuColAdm1!")
provider_id = await self._create_provider(async_client, admin_token, issuer, client_id)
location = await _run_oidc_callback(
async_client,
db_session,
provider_id=provider_id,
claims={"sub": "col-sub-1", "preferred_username": "collider"},
private_pem=private_pem,
jwks_data=jwks_data,
issuer=issuer,
client_id=client_id,
)
username = await self._exchange_username(async_client, location)
assert username == "collider1"
# ===========================================================================
# OIDC auto-create: configurable default group (#1173 Thread 2)
# ===========================================================================
class TestOIDCAutoCreateDefaultGroup:
"""Auto-created OIDC users receive the provider's configured default group.
Resolution order:
1. provider.default_group_id (configured)
2. "Viewers" system group (fallback when default_group_id is None)
3. no group (last resort when both are unavailable)
All tests are DB-agnostic: they verify group membership via the OIDC
exchange response, which includes the user's group list.
"""
@staticmethod
async def _create_provider(
async_client: AsyncClient,
admin_token: str,
*,
issuer: str,
client_id: str,
default_group_id: int | None = None,
) -> int:
payload: dict = {
"name": f"DgAutoProvider-{secrets.token_hex(4)}",
"issuer_url": issuer,
"client_id": client_id,
"client_secret": "secret",
"scopes": "openid profile",
"is_enabled": True,
"auto_create_users": True,
"email_claim": "email",
"require_email_verified": True,
}
if default_group_id is not None:
payload["default_group_id"] = default_group_id
resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json=payload,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
@staticmethod
async def _run_autocreate_and_get_groups(
async_client: AsyncClient,
db_session: AsyncSession,
*,
provider_id: int,
sub: str,
issuer: str,
client_id: str,
private_pem: bytes,
jwks_data: dict,
) -> list[str]:
"""Complete OIDC callback + exchange and return the new user's group names."""
location = await _run_oidc_callback(
async_client,
db_session,
provider_id=provider_id,
claims={"sub": sub},
private_pem=private_pem,
jwks_data=jwks_data,
issuer=issuer,
client_id=client_id,
)
assert "oidc_token=" in location, f"No oidc_token in redirect: {location}"
token = location.split("oidc_token=")[1].split("&")[0].split("#")[-1]
resp = await async_client.post("/api/v1/auth/oidc/exchange", json={"oidc_token": token})
assert resp.status_code == 200, resp.text
return [g["name"] for g in resp.json()["user"]["groups"]]
# ── tests ────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
@pytest.mark.integration
async def test_configured_group_assigned_to_auto_created_user(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""Auto-created user is placed in the provider's configured default_group_id."""
from sqlalchemy import select
from backend.app.models.group import Group
private_pem, jwks_data = _make_test_rsa_key()
issuer = "https://dg-configured.example"
client_id = "dg-configured-client"
admin_token = await _setup_and_login(async_client, "dg_cfg_adm", "DgCfgAdm1!")
grp_result = await db_session.execute(select(Group).where(Group.name == "Operators"))
operators = grp_result.scalar_one()
provider_id = await self._create_provider(
async_client,
admin_token,
issuer=issuer,
client_id=client_id,
default_group_id=operators.id,
)
group_names = await self._run_autocreate_and_get_groups(
async_client,
db_session,
provider_id=provider_id,
sub="dg-cfg-sub-1",
issuer=issuer,
client_id=client_id,
private_pem=private_pem,
jwks_data=jwks_data,
)
assert "Operators" in group_names, f"Expected Operators, got {group_names}"
assert "Viewers" not in group_names
@pytest.mark.asyncio
@pytest.mark.integration
async def test_null_default_group_id_falls_back_to_viewers(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""When default_group_id is None, auto-created user falls back to Viewers."""
private_pem, jwks_data = _make_test_rsa_key()
issuer = "https://dg-null.example"
client_id = "dg-null-client"
admin_token = await _setup_and_login(async_client, "dg_null_adm", "DgNullAdm1!")
provider_id = await self._create_provider(
async_client,
admin_token,
issuer=issuer,
client_id=client_id,
)
group_names = await self._run_autocreate_and_get_groups(
async_client,
db_session,
provider_id=provider_id,
sub="dg-null-sub-1",
issuer=issuer,
client_id=client_id,
private_pem=private_pem,
jwks_data=jwks_data,
)
assert "Viewers" in group_names, f"Expected Viewers, got {group_names}"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_dangling_default_group_id_falls_back_to_viewers(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""When configured group is deleted, auto-created user falls back to Viewers.
SQLite does not enforce FK ON DELETE SET NULL (no PRAGMA foreign_keys=ON),
so provider.default_group_id may point to a deleted group. The runtime
resolution chain must handle this and fall back to Viewers.
"""
from sqlalchemy import delete as sa_delete, select
from backend.app.models.group import Group
private_pem, jwks_data = _make_test_rsa_key()
issuer = "https://dg-dangling.example"
client_id = "dg-dangling-client"
admin_token = await _setup_and_login(async_client, "dg_dangle_adm", "DgDangleAdm1!")
# Create a temporary group and use it as default_group_id
temp_group = Group(name="TempGroup-DgDangle", permissions=[])
db_session.add(temp_group)
await db_session.commit()
await db_session.refresh(temp_group)
temp_group_id = temp_group.id
provider_id = await self._create_provider(
async_client,
admin_token,
issuer=issuer,
client_id=client_id,
default_group_id=temp_group_id,
)
# Delete the group — simulates dangling FK (especially on SQLite)
await db_session.execute(sa_delete(Group).where(Group.id == temp_group_id))
await db_session.commit()
group_names = await self._run_autocreate_and_get_groups(
async_client,
db_session,
provider_id=provider_id,
sub="dg-dangle-sub-1",
issuer=issuer,
client_id=client_id,
private_pem=private_pem,
jwks_data=jwks_data,
)
assert "Viewers" in group_names, f"Expected Viewers fallback, got {group_names}"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_administrators_group_can_be_set_as_default(
self, async_client: AsyncClient, db_session: AsyncSession
):
"""Operators can configure Administrators as the default group (e.g. single-tenant IdP)."""
from sqlalchemy import select
from backend.app.models.group import Group
private_pem, jwks_data = _make_test_rsa_key()
issuer = "https://dg-admin.example"
client_id = "dg-admin-client"
admin_token = await _setup_and_login(async_client, "dg_admgrp_adm", "DgAdmgrpAdm1!")
grp_result = await db_session.execute(select(Group).where(Group.name == "Administrators"))
administrators = grp_result.scalar_one()
provider_id = await self._create_provider(
async_client,
admin_token,
issuer=issuer,
client_id=client_id,
default_group_id=administrators.id,
)
group_names = await self._run_autocreate_and_get_groups(
async_client,
db_session,
provider_id=provider_id,
sub="dg-admin-sub-1",
issuer=issuer,
client_id=client_id,
private_pem=private_pem,
jwks_data=jwks_data,
)
assert "Administrators" in group_names, f"Expected Administrators, got {group_names}"
@@ -24,6 +24,7 @@ const mockProviders = [
email_claim: 'email',
require_email_verified: true,
icon_url: null,
default_group_id: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
@@ -148,5 +149,81 @@ describe('OIDCProviderSettings', () => {
expect(screen.getByText(/Email Claim/i)).toBeInTheDocument();
expect(screen.getByText(/Require Email Verified/i)).toBeInTheDocument();
});
it('renders Default Group label in provider details', async () => {
render(<OIDCProviderSettings />);
await waitFor(() => {
expect(screen.getByText('TestIdP')).toBeInTheDocument();
});
expect(screen.getByText(/Default Group/i)).toBeInTheDocument();
});
it('shows Viewers fallback label when default_group_id is null', async () => {
render(<OIDCProviderSettings />);
await waitFor(() => {
expect(screen.getByText('TestIdP')).toBeInTheDocument();
});
// null default_group_id should display the Viewers fallback text
expect(screen.getByText(/Viewers.*default/i)).toBeInTheDocument();
});
it('shows group name when default_group_id matches a known group', async () => {
server.use(
http.get('/api/v1/auth/oidc/providers/all', () =>
HttpResponse.json([{ ...mockProviders[0], default_group_id: 2 }])
)
);
render(<OIDCProviderSettings />);
await waitFor(() => {
expect(screen.getByText('TestIdP')).toBeInTheDocument();
});
// default_group_id=2 matches Operators in the global MSW mock
expect(screen.getByText('Operators')).toBeInTheDocument();
});
});
describe('ProviderForm — default group dropdown', () => {
it('renders a Default Group select in the create form', async () => {
server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
render(<OIDCProviderSettings />);
await waitFor(() => {
expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
});
await userEvent.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
await waitFor(() => {
expect(screen.getByText(/Default Group/i)).toBeInTheDocument();
});
// Dropdown should render with Viewers fallback option
const select = screen.getByRole('combobox');
expect(select).toBeInTheDocument();
expect(screen.getByText(/Viewers.*default/i)).toBeInTheDocument();
});
it('populates Default Group dropdown with groups from API', async () => {
server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
render(<OIDCProviderSettings />);
await waitFor(() => {
expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
});
await userEvent.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
await waitFor(() => {
// Global MSW mock returns Administrators, Operators, Viewers
const options = screen.getAllByRole('option');
const optionTexts = options.map((o) => o.textContent);
expect(optionTexts).toContain('Operators');
expect(optionTexts).toContain('Administrators');
});
});
});
});
+2
View File
@@ -2682,6 +2682,7 @@ export interface OIDCProvider {
email_claim: string;
require_email_verified: boolean;
icon_url?: string | null;
default_group_id?: number | null;
}
export interface OIDCProviderCreate {
@@ -2696,6 +2697,7 @@ export interface OIDCProviderCreate {
email_claim?: string;
require_email_verified?: boolean;
icon_url?: string | null;
default_group_id?: number | null;
}
export interface OIDCLink {
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import type { OIDCProvider, OIDCProviderCreate } from '../api/client';
import type { Group, OIDCProvider, OIDCProviderCreate } from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
import { Toggle } from './Toggle';
@@ -22,18 +22,21 @@ const EMPTY_FORM: OIDCProviderCreate = {
email_claim: 'email',
require_email_verified: true,
icon_url: undefined,
default_group_id: null,
};
// ─── Provider form (create / edit) ───────────────────────────────────────────
function ProviderForm({
initial,
isEdit = false,
groups = [],
onSave,
onCancel,
isPending,
}: {
initial: OIDCProviderCreate;
isEdit?: boolean;
groups?: Group[];
onSave: (data: OIDCProviderCreate) => void;
onCancel: () => void;
isPending: boolean;
@@ -157,6 +160,21 @@ function ProviderForm({
)}
</div>
<div>
<label className={labelCls}>{t('settings.oidc.form.defaultGroup')}</label>
<select
className={inputCls}
value={form.default_group_id ?? ''}
onChange={(e) => set('default_group_id', e.target.value ? Number(e.target.value) : null)}
>
<option value="">{t('settings.oidc.form.defaultGroupViewersFallback')}</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
<p className="text-bambu-gray text-xs mt-1">{t('settings.oidc.form.defaultGroupDesc')}</p>
</div>
<div className="flex gap-3 pt-2">
<Button variant="secondary" onClick={onCancel} className="flex-1">
{t('common.cancel')}
@@ -189,6 +207,11 @@ export function OIDCProviderSettings() {
queryFn: () => api.getOIDCProvidersAll(),
});
const { data: groups = [] } = useQuery({
queryKey: ['groups'],
queryFn: () => api.getGroups(),
});
const createMutation = useMutation({
mutationFn: (data: OIDCProviderCreate) => api.createOIDCProvider(data),
onSuccess: () => {
@@ -256,6 +279,7 @@ export function OIDCProviderSettings() {
<h4 className="text-white font-medium mb-4">{t('settings.oidc.newProvider')}</h4>
<ProviderForm
initial={EMPTY_FORM}
groups={groups}
onSave={(data) => createMutation.mutate(data)}
onCancel={() => setShowCreate(false)}
isPending={createMutation.isPending}
@@ -335,6 +359,7 @@ export function OIDCProviderSettings() {
<div className="border-t border-bambu-dark-tertiary pt-4">
<ProviderForm
isEdit={true}
groups={groups}
initial={{
name: provider.name,
issuer_url: provider.issuer_url,
@@ -347,6 +372,7 @@ export function OIDCProviderSettings() {
email_claim: provider.email_claim,
require_email_verified: provider.require_email_verified,
icon_url: provider.icon_url ?? undefined,
default_group_id: provider.default_group_id ?? null,
}}
onSave={(data) => updateMutation.mutate({ id: provider.id, data })}
onCancel={() => setEditingId(null)}
@@ -389,6 +415,14 @@ export function OIDCProviderSettings() {
{provider.require_email_verified ? t('common.yes') : t('common.no')}
</dd>
</div>
<div>
<dt className="text-bambu-gray">{t('settings.oidc.form.defaultGroup')}</dt>
<dd className="text-white">
{provider.default_group_id
? (groups.find((g) => g.id === provider.default_group_id)?.name ?? t('settings.oidc.form.defaultGroupViewersFallback'))
: t('settings.oidc.form.defaultGroupViewersFallback')}
</dd>
</div>
</dl>
</CardContent>
)}
+3
View File
@@ -2234,6 +2234,9 @@ export default {
requireEmailVerifiedDesc: 'E-Mail-Claim nur akzeptieren, wenn der Provider ihn als verifiziert markiert.',
requireEmailVerifiedWarning: 'Warnung: E-Mail wird auch ohne Verifizierung akzeptiert. Nur bei vertrauenswürdigen Providern verwenden.',
requireEmailVerifiedAutoLink: 'Auto-Verknüpfung zuerst deaktivieren, um diese Einstellung zu ändern.',
defaultGroup: 'Standardgruppe',
defaultGroupDesc: 'Gruppe, der automatisch erstellte Benutzer zugewiesen werden. Fallback auf Viewers, wenn nicht gesetzt.',
defaultGroupViewersFallback: 'Viewers (Standard)',
},
},
+3
View File
@@ -2237,6 +2237,9 @@ export default {
requireEmailVerifiedDesc: 'Only accept the email claim when the provider marks it as verified.',
requireEmailVerifiedWarning: 'Warning: email will be accepted even without verification. Use only with trusted providers.',
requireEmailVerifiedAutoLink: 'Disable auto-link first to change this setting.',
defaultGroup: 'Default Group',
defaultGroupDesc: 'Group assigned to auto-created users. Falls back to Viewers if not set.',
defaultGroupViewersFallback: 'Viewers (default)',
},
},
+3
View File
@@ -2178,6 +2178,9 @@ export default {
requireEmailVerifiedDesc: "N'accepter le claim e-mail que si le fournisseur le marque comme vérifié.",
requireEmailVerifiedWarning: "Avertissement : l'e-mail sera accepté sans vérification. À utiliser uniquement avec des fournisseurs de confiance.",
requireEmailVerifiedAutoLink: 'Désactiver le lien automatique d\'abord pour modifier ce paramètre.',
defaultGroup: 'Groupe par défaut',
defaultGroupDesc: 'Groupe attribué aux utilisateurs créés automatiquement. Repli sur Viewers si non défini.',
defaultGroupViewersFallback: 'Viewers (par défaut)',
},
},
+3
View File
@@ -2177,6 +2177,9 @@ export default {
requireEmailVerifiedDesc: "Accetta il claim email solo se il provider lo contrassegna come verificato.",
requireEmailVerifiedWarning: 'Attenzione: l\'email sarà accettata senza verifica. Usare solo con provider affidabili.',
requireEmailVerifiedAutoLink: 'Disabilitare prima il collegamento automatico per modificare questa impostazione.',
defaultGroup: 'Gruppo predefinito',
defaultGroupDesc: 'Gruppo assegnato agli utenti creati automaticamente. Ritorno a Viewers se non impostato.',
defaultGroupViewersFallback: 'Viewers (predefinito)',
},
},
+3
View File
@@ -2233,6 +2233,9 @@ export default {
requireEmailVerifiedDesc: 'プロバイダーが確認済みとしてマークした場合にのみメールクレームを受け入れます。',
requireEmailVerifiedWarning: '警告:確認なしでメールが受け入れられます。信頼できるプロバイダーのみで使用してください。',
requireEmailVerifiedAutoLink: 'この設定を変更するには、まず自動リンクを無効にしてください。',
defaultGroup: 'デフォルトグループ',
defaultGroupDesc: '自動作成ユーザーに割り当てられるグループ。未設定の場合はViewersにフォールバックします。',
defaultGroupViewersFallback: 'Viewers(デフォルト)',
},
},
+3
View File
@@ -2177,6 +2177,9 @@ export default {
requireEmailVerifiedDesc: 'Aceitar o claim de e-mail apenas quando o provedor o marcar como verificado.',
requireEmailVerifiedWarning: 'Aviso: o e-mail será aceito sem verificação. Use apenas com provedores confiáveis.',
requireEmailVerifiedAutoLink: 'Desabilite o vínculo automático primeiro para alterar esta configuração.',
defaultGroup: 'Grupo padrão',
defaultGroupDesc: 'Grupo atribuído aos usuários criados automaticamente. Retorna a Viewers se não definido.',
defaultGroupViewersFallback: 'Viewers (padrão)',
},
},
+3
View File
@@ -2221,6 +2221,9 @@ export default {
requireEmailVerifiedDesc: '仅在提供商将邮箱声明标记为已验证时才接受。',
requireEmailVerifiedWarning: '警告:将在未经验证的情况下接受邮箱。仅对受信任的提供商使用。',
requireEmailVerifiedAutoLink: '请先禁用自动关联以更改此设置。',
defaultGroup: '默认组',
defaultGroupDesc: '自动创建用户时分配的组。未设置时回退到 Viewers。',
defaultGroupViewersFallback: 'Viewers(默认)',
},
},
+3
View File
@@ -2221,6 +2221,9 @@ export default {
requireEmailVerifiedAutoLink: '請先停用自動連結以變更此設定。',
secretHint: '留空以保留目前',
secretPlaceholder: '新金鑰',
defaultGroup: '預設群組',
defaultGroupDesc: '自動建立使用者時分配的群組。未設定時回退到 Viewers。',
defaultGroupViewersFallback: 'Viewers(預設)',
},
},