Sfoglia il codice sorgente

Sync OIDC provider groups to BamBuddy groups on every login (issue #3107) (#3122)

William Faircloth 2 giorni fa
parent
commit
d04514c842

+ 14 - 0
.env.example

@@ -96,6 +96,8 @@ LOG_TO_FILE=true
 # BAMBUDDY_OIDC_AUTO_LINK_EXISTING=false
 # BAMBUDDY_OIDC_EMAIL_CLAIM=email
 # BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED=true
+# BAMBUDDY_OIDC_GROUP_CLAIM=groups
+# BAMBUDDY_OIDC_GROUP_MAPPING=
 # BAMBUDDY_OIDC_ICON_URL=
 # BAMBUDDY_OIDC_AUTOLOGIN=false
 # BAMBUDDY_OIDC_DEFAULT_GROUP=
@@ -112,6 +114,18 @@ LOG_TO_FILE=true
 # FIRST boot that means no provider is created at all and no SSO button appears
 # -- create the group first. Removing the variable clears the group again.
 #
+# GROUP_CLAIM / GROUP_MAPPING enable OIDC group sync (#3107). GROUP_CLAIM names
+# the ID token claim holding the user's IdP groups (default "groups"). GROUP_MAPPING
+# is a JSON object mapping IdP group values to Bambuddy group NAMES, e.g.
+#   BAMBUDDY_OIDC_GROUP_MAPPING={"fablab-staff":"Operators","students":"Viewers"}
+# On every SSO login the mapped groups are synced from the claim: groups the
+# mapping names are replaced by IdP truth, anything else on the user is a manual
+# assignment and survives. Only mapping VALUES are checked against the database:
+# like DEFAULT_GROUP, a name matching no group refuses the whole provider config
+# (logged, app still starts). Blank/unset mapping = sync off, the default. The
+# claim accepts a JSON array (Keycloak, Authentik) or a space/comma-separated
+# string (Logto, legacy); matching is case-insensitive on the IdP side.
+#
 # AUTO_LINK_EXISTING binds an OIDC identity to an existing local account with
 # the same email address. With EMAIL_CLAIM=email it is refused unless
 # REQUIRE_EMAIL_VERIFIED=true, because an identity provider that does not

+ 76 - 3
backend/app/api/routes/mfa.py

@@ -71,6 +71,7 @@ from backend.app.schemas.auth import (
     OIDCExchangeRequest,
     OIDCLinkResponse,
     OIDCProviderCreate,
+    OIDCProviderPublicResponse,
     OIDCProviderResponse,
     OIDCProviderUpdate,
     TOTPDisableRequest,
@@ -1320,18 +1321,24 @@ async def admin_disable_2fa(
 # ===========================================================================
 
 
-@router.get("/oidc/providers", response_model=list[OIDCProviderResponse])
+@router.get("/oidc/providers", response_model=list[OIDCProviderPublicResponse])
 async def list_oidc_providers(
     db: AsyncSession = Depends(get_db),
-) -> list[OIDCProviderResponse]:
+) -> list[OIDCProviderPublicResponse]:
     """List all enabled OIDC providers (public).
 
     The login page renders icons via /oidc/providers/{id}/icon — `icon_data`
     stays deferred so this list query never pulls the BLOB.
+
+    #3107: returns the slim public shape only. The login page needs id, name,
+    has_icon and is_autologin; the full response (scopes, claims, and now
+    group_claim / group_mapping) is served by the permission-gated
+    /oidc/providers/all below, so an unauthenticated caller cannot learn
+    which IdP group name maps to which Bambuddy group.
     """
     result = await db.execute(select(OIDCProvider).where(OIDCProvider.is_enabled.is_(True)))
     providers = result.scalars().all()
-    return [_build_provider_response(p) for p in providers]
+    return [OIDCProviderPublicResponse.model_validate(p) for p in providers]
 
 
 @router.get("/oidc/providers/all", response_model=list[OIDCProviderResponse])
@@ -1365,6 +1372,17 @@ async def create_oidc_provider(
                 detail="default_group_id references a non-existent group",
             )
 
+    # #3107 — every mapping value must reference an existing Bambuddy group,
+    # same answer default_group_id gets. Checked as a set: a mapping with three
+    # entries naming the same group is one lookup, not three.
+    if body.group_mapping:
+        missing_groups = await _missing_group_names(db, set(body.group_mapping.values()))
+        if missing_groups:
+            raise HTTPException(
+                status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
+                detail=f"group_mapping references non-existent groups: {', '.join(sorted(missing_groups))}",
+            )
+
     # Fetch the icon BEFORE creating the row so a failure leaves the DB clean.
     icon_data: bytes | None = None
     icon_content_type: str | None = None
@@ -1383,6 +1401,8 @@ async def create_oidc_provider(
         auto_link_existing_accounts=body.auto_link_existing_accounts,
         email_claim=body.email_claim,
         require_email_verified=body.require_email_verified,
+        group_claim=body.group_claim,
+        group_mapping=body.group_mapping,
         icon_url=body.icon_url,
         icon_data=icon_data,
         icon_content_type=icon_content_type,
@@ -1416,6 +1436,21 @@ def _refuse_if_env_managed(provider: OIDCProvider) -> None:
         )
 
 
+async def _missing_group_names(db: AsyncSession, names: set[str]) -> set[str]:
+    """#3107 — names from a group_mapping with no matching Bambuddy group.
+
+    Exact match only, same as the sync's own ``Group.name.in_()`` lookup and
+    the env path's ``Group.name == target``: the sync resolves names exactly,
+    so admitting a case-variant here would pass validation only to have the
+    sync silently never grant it — the exact failure this check exists to
+    catch at save time, in front of the admin, instead of at login time.
+    """
+    if not names:
+        return set()
+    exact = set((await db.execute(select(Group.name).where(Group.name.in_(names)))).scalars().all())
+    return names - exact
+
+
 @router.put("/oidc/providers/{provider_id}", response_model=OIDCProviderResponse)
 async def update_oidc_provider(
     provider_id: int,
@@ -1448,6 +1483,17 @@ async def update_oidc_provider(
                 detail="default_group_id references a non-existent group",
             )
 
+    # #3107 — same existence check as the create route, on the submitted
+    # mapping only. A null group_mapping (field absent) leaves the stored one
+    # alone; an explicit {} empties it, and emptiness needs no group lookup.
+    if body.group_mapping:
+        missing_groups = await _missing_group_names(db, set(body.group_mapping.values()))
+        if missing_groups:
+            raise HTTPException(
+                status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
+                detail=f"group_mapping references non-existent groups: {', '.join(sorted(missing_groups))}",
+            )
+
     dumped = body.model_dump(exclude_none=True)
 
     # Decide whether an icon refetch is needed BEFORE mutating the ORM object,
@@ -2060,6 +2106,33 @@ async def oidc_callback(
             if not user or not user.is_active:
                 return RedirectResponse(url=f"{frontend_error_url}account_inactive", status_code=302)
 
+            # #3107 — apply the provider's group mapping on every login, not
+            # just at account creation. Same managed-slice contract as the
+            # LDAP sync (#1292): only groups named in group_mapping values are
+            # touched, manual assignments elsewhere survive. A sync failure is
+            # logged inside and never blocks the login.
+            if provider.group_mapping:
+                from backend.app.services.oidc_group_sync import sync_oidc_user_groups
+
+                await sync_oidc_user_groups(
+                    db,
+                    user,
+                    group_claim=provider.group_claim,
+                    group_mapping=provider.group_mapping,
+                    claims=claims,
+                )
+                # Post-rollback guard, not token freshness: nothing below reads
+                # user.groups (the callback only puts the username on the exchange
+                # token; /oidc/exchange re-selects the user with selectinload).
+                # ALL attributes, not just groups: a sync that raised called
+                # db.rollback(), which expires every loaded object in the
+                # session — the next username=user.username below would then
+                # lazy-load and raise MissingGreenlet, landing the user on
+                # ?oidc_error=user_resolution_failed, i.e. a failed sync would
+                # block the login after all. Inside the mapping branch so
+                # sync-off installs do not pay the query.
+                await db.refresh(user)
+
             # Issue an OIDC exchange token (short-lived, single-use) stored in DB.
             # I7: Opportunistically prune expired exchange tokens to keep the table small.
             now2 = datetime.now(timezone.utc)

+ 2 - 0
backend/app/core/config.py

@@ -151,6 +151,8 @@ _INTENTIONAL_UNSETTINGS = {
     "BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
     "BAMBUDDY_OIDC_EMAIL_CLAIM",
     "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
+    "BAMBUDDY_OIDC_GROUP_CLAIM",
+    "BAMBUDDY_OIDC_GROUP_MAPPING",
     "BAMBUDDY_OIDC_ICON_URL",
     "BAMBUDDY_OIDC_AUTOLOGIN",
     "BAMBUDDY_OIDC_DEFAULT_GROUP",

+ 11 - 0
backend/app/core/database.py

@@ -1851,6 +1851,17 @@ async def run_migrations(conn):
     # Migration: Add is_favorite column to print_archives
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0")
 
+    # Migration: Add OIDC group sync columns (#3107). group_claim defaults to
+    # 'groups'; group_mapping defaults to '{}' (empty JSON object = sync off,
+    # the pre-#3107 behaviour). NOT NULL DEFAULT explicitly so an upgraded
+    # database matches what create_all builds on a fresh install (the model
+    # columns are non-nullable with server defaults) — a bare DEFAULT would
+    # leave the column nullable on the ALTER path and the two installs would
+    # disagree on the schema. Existing rows backfill the default on both
+    # SQLite and PostgreSQL.
+    await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN group_claim VARCHAR(64) NOT NULL DEFAULT 'groups'")
+    await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN group_mapping JSON NOT NULL DEFAULT '{}'")
+
     # Migration: Add wallet_charge_skipped column to print_archives so deleted print charges stay deleted
     if is_sqlite():
         await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN wallet_charge_skipped BOOLEAN DEFAULT 0")

+ 69 - 3
backend/app/core/oidc_env.py

@@ -10,6 +10,7 @@ check the UI enforces.
 from __future__ import annotations
 
 import contextlib
+import json
 import logging
 import os
 
@@ -33,10 +34,42 @@ _TRUTHY = {"true", "1", "yes"}
 _FALSY = {"false", "0", "no"}
 
 
+def _env_group_mapping() -> dict[str, str]:
+    """#3107 — parse BAMBUDDY_OIDC_GROUP_MAPPING as a JSON object.
+
+    Unset or blank -> {} (sync off). Invalid JSON or a non-object raises
+    EnvOIDCConfigError so the config is refused loudly at boot instead of
+    silently running without the mapping the operator thought they set —
+    same disposition as a bad boolean. Key/value contents are validated by
+    the OIDCProviderCreate schema like every other field.
+    """
+    raw = (os.environ.get("BAMBUDDY_OIDC_GROUP_MAPPING") or "").strip()
+    if not raw:
+        return {}
+    try:
+        parsed = json.loads(raw)
+    except json.JSONDecodeError as exc:
+        raise EnvOIDCConfigError(f"BAMBUDDY_OIDC_GROUP_MAPPING is not valid JSON ({exc.lineno}:{exc.colno})") from exc
+    if not isinstance(parsed, dict):
+        raise EnvOIDCConfigError("BAMBUDDY_OIDC_GROUP_MAPPING must be a JSON object")
+    # Shape-checked here, before apply looks the values up as group names: a
+    # null or a number would otherwise reach that query and surface only as
+    # "could not be applied: TypeError", naming neither the variable nor why.
+    # Imported here for the same cycle reason as in _apply_env_oidc_provider.
+    from backend.app.schemas.auth import _validate_group_mapping
+
+    try:
+        return _validate_group_mapping(parsed)
+    except ValueError as exc:
+        raise EnvOIDCConfigError(f"BAMBUDDY_OIDC_GROUP_MAPPING is invalid: {exc}") from exc
+
+
 class EnvOIDCConfigError(Exception):
     """A BAMBUDDY_OIDC_* value the reader cannot interpret. Only ever carries a
-    boolean variable's name and value -- booleans are not secret, so the message
-    is safe to log in full (unlike client_secret, which never reaches here)."""
+    boolean variable's name and value, or a GROUP_MAPPING problem (a JSON parse
+    position, or the IdP group names involved) -- none of it is secret, so the
+    message is safe to log in full (unlike client_secret, which never reaches
+    here)."""
 
 
 def env_bool(key: str, default: bool, *, strict: bool = True) -> bool:
@@ -89,6 +122,12 @@ def read_env_oidc_config() -> dict | None:
         "auto_link_existing_accounts": env_bool("BAMBUDDY_OIDC_AUTO_LINK_EXISTING", False),
         "email_claim": (os.environ.get("BAMBUDDY_OIDC_EMAIL_CLAIM") or "").strip() or "email",
         "require_email_verified": env_bool("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", True),
+        # #3107 — group sync. The mapping is JSON: {"IdP group": "Bambuddy group"}.
+        # Blank/unset means "no mapping", which leaves sync off — the same
+        # default the UI path has. Values are group *names* (not ids), resolved
+        # against the database below alongside DEFAULT_GROUP.
+        "group_claim": (os.environ.get("BAMBUDDY_OIDC_GROUP_CLAIM") or "").strip() or "groups",
+        "group_mapping": _env_group_mapping(),
         "icon_url": (os.environ.get("BAMBUDDY_OIDC_ICON_URL") or "").strip() or None,
         "is_autologin": env_bool("BAMBUDDY_OIDC_AUTOLOGIN", False),
         # A name, not an id: ids are assigned per install, so the same compose
@@ -111,6 +150,11 @@ _APPLIED_FIELDS = (
     "auto_link_existing_accounts",
     "email_claim",
     "require_email_verified",
+    # #3107 — written on every boot like the rest, so removing the env vars
+    # disables group sync rather than leaving a stale mapping behind (the
+    # environment is the whole truth for this row).
+    "group_claim",
+    "group_mapping",
     "icon_url",
     "is_autologin",
     # Written on every boot, so a group that is no longer declared is cleared:
@@ -153,7 +197,7 @@ async def _apply_env_oidc_provider(db: AsyncSession) -> None:
     except EnvOIDCConfigError as exc:
         # Same disposition as a ValidationError or an unmatched DEFAULT_GROUP:
         # log clearly and leave any running provider as it was. Safe to log the
-        # full message -- EnvOIDCConfigError only ever carries a boolean var.
+        # full message -- EnvOIDCConfigError never carries a secret (see its docstring).
         logger.error("BAMBUDDY_OIDC_* config rejected, provider not applied: %s", exc)
         return
 
@@ -215,6 +259,28 @@ async def _apply_env_oidc_provider(db: AsyncSession) -> None:
             return
         config["default_group_id"] = group.id
 
+    # #3107 — mapping values are Bambuddy group *names* (the sync resolves them
+    # per login, same as the LDAP mapping stores names). Names, not ids, for the
+    # same reason as DEFAULT_GROUP: ids differ per install, so a shared compose
+    # file would point somewhere else on every deployment. Every value must
+    # resolve here, or the provider is not applied: a half-resolved mapping
+    # would grant exactly the groups whose names happened to match and silently
+    # drop the rest, which is the least diagnosable failure mode available.
+    env_mapping = config.get("group_mapping") or {}
+    if env_mapping:
+        missing = []
+        for target in env_mapping.values():
+            found = (await db.execute(select(Group.id).where(Group.name == target))).scalar_one_or_none()
+            if found is None:
+                missing.append(target)
+        if missing:
+            logger.error(
+                "BAMBUDDY_OIDC_GROUP_MAPPING values match no group (%s), provider not applied (%s).",
+                ", ".join(sorted(set(missing))),
+                "previous config left running" if existing is not None else "no provider created",
+            )
+            return
+
     try:
         # The same schema the API uses, so env config cannot reach a state the
         # UI would have refused (notably the SEC-1 auto-link check).

+ 13 - 0
backend/app/models/oidc_provider.py

@@ -3,6 +3,7 @@ from __future__ import annotations
 from datetime import datetime
 
 from sqlalchemy import (
+    JSON,
     Boolean,
     CheckConstraint,
     DateTime,
@@ -103,6 +104,18 @@ class OIDCProvider(Base):
     default_group_id: Mapped[int | None] = mapped_column(
         Integer, ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, default=None
     )
+    # #3107 — JWT claim name the group sync reads IdP groups from. Defaults to
+    # "groups". Providers put groups in different claims (Keycloak: "groups"
+    # as a JSON array after a client mapper; Authentik: "groups" as an array,
+    # but some setups ship "roles" or a custom claim), so it is configurable
+    # like email_claim. Same character rules enforced by _validate_email_claim_name.
+    group_claim: Mapped[str] = mapped_column(String(64), default="groups", server_default="groups")
+    # #3107 — mapping of IdP group value -> Bambuddy group name. Empty dict
+    # (the default) disables group sync entirely: the provider behaves exactly
+    # as it did before the column existed. Only groups named in the values are
+    # managed by the sync; everything else on the user is a manual assignment
+    # and survives logins (#1292 semantics, same as the LDAP sync).
+    group_mapping: Mapped[dict[str, str]] = mapped_column(JSON, default=dict, server_default="{}")
     # Optional icon URL the admin entered. The actual image bytes are fetched
     # server-side and cached in icon_data — the SPA never hotlinks this URL
     # (would require loosening img-src CSP; see PR #1333 / issue #1333).

+ 118 - 0
backend/app/schemas/auth.py

@@ -343,6 +343,68 @@ def _validate_email_claim_name(v: str) -> str:
     return v
 
 
+def _validate_group_claim_name(v: str) -> str:
+    """#3107 — like _validate_email_claim_name, but also allows one slash.
+
+    Auth0 (and Auth0-compatible providers) only expose custom claims under a
+    non-reserved namespace, e.g. ``https://example.com/roles`` or ``app/roles``,
+    so the email-claim charset would refuse every valid Auth0 group claim.
+    The slash is structurally safe here: the value never reaches a URL, a
+    path or SQL — it is only a JWT claim lookup key inside ``claims.get`` —
+    so the wider charset does not widen any injection surface. The 64-char
+    cap and the "starts with a letter" rule are kept. The full-URL form of
+    an Auth0 namespace exceeds 64 chars, but that is Auth0's documented
+    short-namespace territory; the limit matches email_claim and keeps the
+    column bound meaningful.
+    """
+    if not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9_\-/]{0,63}", v):
+        raise ValueError("Invalid claim name")
+    return v
+
+
+def _validate_group_mapping(v: dict[str, str]) -> dict[str, str]:
+    """#3107 — normalise and bound an IdP-group -> Bambuddy-group mapping.
+
+    Values must reference Bambuddy group names; existence is checked against
+    the database in the route handlers (same split as default_group_id), since
+    the schema layer has no session. Keys are left as-is apart from stripping:
+    IdP group values are opaque strings (DNs, UUIDs, names) and must match the
+    claim byte-for-byte, so any normalisation beyond whitespace would silently
+    break the lookup.
+
+    Keys colliding case-insensitively are rejected: the sync matches the IdP
+    side case-insensitively, so {"Admins": "Administrators", "admins":
+    "Viewers"} would silently collapse to whichever entry the dict happens to
+    keep last — a member of "Admins" could end up in Viewers. Rejecting the
+    pair at save time (here, so the env path gets the same answer) turns an
+    undiagnosable runtime behaviour into a form error. Two keys differing only
+    by case and mapping to the SAME group are pointless but harmless, and are
+    rejected too for the same reason: they read as a mistake.
+    """
+    if not isinstance(v, dict):
+        raise ValueError("group_mapping must be a JSON object")
+    if len(v) > 100:
+        raise ValueError("group_mapping must have at most 100 entries")
+    cleaned: dict[str, str] = {}
+    seen_ci: dict[str, str] = {}
+    for key, value in v.items():
+        if not isinstance(key, str) or not key.strip():
+            raise ValueError("group_mapping keys must be non-empty strings")
+        if not isinstance(value, str) or not value.strip():
+            raise ValueError("group_mapping values must be non-empty group names")
+        k = key.strip()
+        ci = k.lower()
+        if ci in seen_ci:
+            raise ValueError(
+                f"group_mapping has two IdP groups differing only by case: "
+                f"'{seen_ci[ci]}' and '{k}' — the sync matches case-insensitively, "
+                f"so both cannot be honored"
+            )
+        seen_ci[ci] = k
+        cleaned[k] = value.strip()
+    return cleaned
+
+
 def _validate_icon_url(v: str | None) -> str | None:
     """Reject non-HTTPS icon URLs and SSRF-unsafe hosts.
 
@@ -436,6 +498,9 @@ class OIDCProviderCreate(BaseModel):
     auto_link_existing_accounts: bool = False  # M-2: conservative default, opt-in only
     email_claim: str = Field(default="email", max_length=64)
     require_email_verified: bool = True
+    # #3107 — group sync config. group_mapping empty (default) = no sync.
+    group_claim: str = Field(default="groups", max_length=64)
+    group_mapping: dict[str, str] = Field(default_factory=dict)
     icon_url: str | None = None
     default_group_id: int | None = None
     is_autologin: bool = False  # #1589 — at most one provider may carry this
@@ -461,6 +526,17 @@ class OIDCProviderCreate(BaseModel):
     def validate_email_claim(cls, v: str) -> str:
         return _validate_email_claim_name(v)
 
+    @field_validator("group_claim")
+    @classmethod
+    def validate_group_claim(cls, v: str) -> str:
+        # Namespaced claims allowed here (Auth0 et al) — see _validate_group_claim_name.
+        return _validate_group_claim_name(v)
+
+    @field_validator("group_mapping")
+    @classmethod
+    def validate_group_mapping(cls, v: dict[str, str]) -> dict[str, str]:
+        return _validate_group_mapping(v)
+
     @field_validator("icon_url")
     @classmethod
     def validate_icon_url(cls, v: str | None) -> str | None:
@@ -493,6 +569,10 @@ class OIDCProviderUpdate(BaseModel):
     auto_link_existing_accounts: bool | None = None
     email_claim: str | None = Field(default=None, max_length=64)
     require_email_verified: bool | None = None
+    # #3107 — group sync config. None = leave unchanged, same as every other
+    # optional field here; an explicit {} clears the mapping and disables sync.
+    group_claim: str | None = Field(default=None, max_length=64)
+    group_mapping: dict[str, str] | None = None
     icon_url: str | None = None
     default_group_id: int | None = None
     is_autologin: bool | None = None  # #1589
@@ -509,6 +589,20 @@ class OIDCProviderUpdate(BaseModel):
             return None
         return _validate_email_claim_name(v)
 
+    @field_validator("group_claim")
+    @classmethod
+    def validate_group_claim(cls, v: str | None) -> str | None:
+        if v is None:
+            return None
+        return _validate_group_claim_name(v)
+
+    @field_validator("group_mapping")
+    @classmethod
+    def validate_group_mapping(cls, v: dict[str, str] | None) -> dict[str, str] | None:
+        if v is None:
+            return None
+        return _validate_group_mapping(v)
+
     @field_validator("icon_url")
     @classmethod
     def validate_icon_url(cls, v: str | None) -> str | None:
@@ -540,6 +634,9 @@ class OIDCProviderResponse(BaseModel):
     auto_link_existing_accounts: bool = False
     email_claim: str = "email"
     require_email_verified: bool = True
+    # #3107 — group sync config, echoed back so the settings UI can render it.
+    group_claim: str = "groups"
+    group_mapping: dict[str, str] = {}
     icon_url: str | None = None
     default_group_id: int | None = None
     is_autologin: bool = False  # #1589
@@ -557,6 +654,27 @@ class OIDCProviderResponse(BaseModel):
         from_attributes = True
 
 
+class OIDCProviderPublicResponse(BaseModel):
+    """#3107 — what the unauthenticated login page is allowed to see.
+
+    GET /oidc/providers is public so the login page can render the SSO
+    buttons, and it needs exactly four fields: id + name for the button,
+    has_icon for the avatar, is_autologin for the redirect-on-mount (#1589).
+    The full OIDCProviderResponse carries group_claim / group_mapping —
+    which IdP group name maps to which Bambuddy group, including
+    Administrators — and leaking that to anonymous visitors would tell
+    anyone who can reach the login page exactly which IdP group to aim for.
+    """
+
+    id: int
+    name: str
+    has_icon: bool
+    is_autologin: bool = False
+
+    class Config:
+        from_attributes = True
+
+
 class OIDCAuthorizeResponse(BaseModel):
     auth_url: str
 

+ 153 - 0
backend/app/services/oidc_group_sync.py

@@ -0,0 +1,153 @@
+"""OIDC group sync (#3107).
+
+Mirrors the LDAP group sync semantics from api/routes/auth.py
+(`_sync_ldap_user`) for OIDC logins: the provider's ``group_mapping``
+configures which Bambuddy groups the IdP is allowed to manage, and every
+login replaces only that managed slice — manual assignments to any other
+group survive (#1292, same fix the LDAP path needed).
+
+Differences from LDAP worth stating:
+
+- LDAP reads group DNs from the directory entry. OIDC reads group values
+  from a JWT claim, and providers disagree on the shape: Keycloak ships a
+  JSON array, Authentik ships an array, Logto and some legacy setups ship a
+  space- or comma-separated string. ``extract_idp_groups`` accepts both.
+- LDAP has a ``default_group`` fallback when no mapped group matches. The
+  OIDC path already has ``default_group_id`` applied at account creation,
+  and re-asserting it on every login would fight manual upgrades: an admin
+  who promotes an auto-created user out of Viewers would see the promotion
+  reverted at the next SSO login. So the OIDC sync has no fallback — an
+  empty resolved set simply means "the IdP grants none of the mapped
+  groups", which removes exactly the mapped groups and nothing else.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import logging
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.group import Group
+from backend.app.models.user import User
+
+logger = logging.getLogger(__name__)
+
+# Bound on the claim parsing below. A legitimate groups claim holds tens of
+# entries; anything past this is a malformed or hostile token, and iterating
+# it would just burn cycles before the mapping lookup ignores the extras.
+_MAX_CLAIM_ITEMS = 500
+
+
+def extract_idp_groups(claim_value: object) -> list[str]:
+    """Normalise a raw JWT claim value into a list of IdP group strings.
+
+    Accepts the shapes seen in the wild:
+    - list of strings (Keycloak, Authentik, most modern providers)
+    - single string, space- or comma-separated (Logto, some legacy setups)
+    - a single group name as a bare string
+
+    Non-string entries, empty fragments, and obvious non-group payloads
+    (dicts, numbers) are dropped rather than rejected: a provider adding an
+    unexpected claim shape must not lock users out of their mapped groups.
+    Duplicates are removed while preserving order (first occurrence wins).
+    The result is bounded by _MAX_CLAIM_ITEMS for both shapes — a string
+    claim splits into arbitrarily many fragments, so the slice applies after
+    splitting, not only on the list path.
+    """
+    if claim_value is None:
+        return []
+    if isinstance(claim_value, list):
+        raw_items = [item for item in claim_value[:_MAX_CLAIM_ITEMS] if isinstance(item, str)]
+    elif isinstance(claim_value, str):
+        # Space-separated is the OIDC convention (scope-style); commas are a
+        # pragmatic extra since some IdPs stringify arrays that way.
+        raw_items = claim_value.replace(",", " ").split(" ")[:_MAX_CLAIM_ITEMS]
+    else:
+        return []
+    seen: set[str] = set()
+    result: list[str] = []
+    for item in raw_items:
+        cleaned = item.strip()
+        if cleaned and cleaned not in seen:
+            seen.add(cleaned)
+            result.append(cleaned)
+    return result
+
+
+def resolve_oidc_group_mapping(idp_groups: list[str], group_mapping: dict[str, str]) -> list[str]:
+    """Map IdP group values to Bambuddy group names (case-insensitive on the key).
+
+    Same contract as ldap_service.resolve_group_mapping: returns the Bambuddy
+    group names the user should hold among the mapped set. Values are compared
+    case-insensitively because IdP group casing is not stable across providers
+    (Keycloak preserves case; some LDAP-backed OIDC deployments downcase),
+    and a case mismatch silently dropping a group is the failure mode an
+    admin can least diagnose from the UI.
+    """
+    if not group_mapping:
+        return []
+    mapping_lower = {k.lower(): v for k, v in group_mapping.items()}
+    result: list[str] = []
+    for idp_group in idp_groups:
+        mapped = mapping_lower.get(idp_group.lower())
+        if mapped and mapped not in result:
+            result.append(mapped)
+    return result
+
+
+async def sync_oidc_user_groups(
+    db: AsyncSession,
+    user: User,
+    *,
+    group_claim: str,
+    group_mapping: dict[str, str],
+    claims: dict,
+) -> None:
+    """Apply the provider's group mapping to ``user`` after a successful login.
+
+    Only Bambuddy groups named in ``group_mapping`` values are managed; every
+    other group on the user is a manual assignment and is preserved. Commits
+    only when something actually changed (the LDAP sync logs on change; same
+    here). Never raises: a group-sync failure must not abort the login the
+    token exchange already authenticated — the exception is logged and the
+    user keeps the groups they had.
+    """
+    if not group_mapping:
+        # No mapping configured: nothing is managed, so nothing may change.
+        # This is the default state and must remain a no-op for upgrades.
+        return
+
+    try:
+        mapped_names = resolve_oidc_group_mapping(extract_idp_groups(claims.get(group_claim)), group_mapping)
+
+        # Only groups that exist locally can be granted; a mapping entry
+        # pointing at a deleted group is skipped (the same dangling-FK
+        # tolerance default_group_id documents for SQLite).
+        if mapped_names:
+            groups_result = await db.execute(select(Group).where(Group.name.in_(mapped_names)))
+            target_groups = list(groups_result.scalars().all())
+        else:
+            target_groups = []
+
+        managed_names = set(group_mapping.values())
+        preserved = [g for g in user.groups if g.name not in managed_names]
+        new_groups = preserved + target_groups
+
+        current_ids = {g.id for g in user.groups}
+        new_ids = {g.id for g in new_groups}
+        if current_ids == new_ids:
+            return
+
+        user.groups = new_groups
+        await db.commit()
+        logger.info(
+            "OIDC group sync: user %s groups -> %s",
+            user.username,
+            sorted(g.name for g in new_groups),
+        )
+    except Exception:  # noqa: BLE001 -- login must survive a sync failure
+        logger.exception("OIDC group sync failed for user %s; groups left unchanged", user.username)
+        with contextlib.suppress(Exception):
+            await db.rollback()

+ 10 - 2
backend/tests/integration/test_mfa_api.py

@@ -972,7 +972,14 @@ class TestOIDCProviders:
     @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."""
+        """default_group_id appears in the admin list response.
+
+        #3107 review: the public list now serves the slim login-page shape
+        (id, name, has_icon, is_autologin) so group sync config and other
+        provider internals stay behind the permission gate — the previous
+        expectation of default_group_id on the public list is what let the
+        group mapping leak to anonymous callers.
+        """
         from sqlalchemy import select
 
         from backend.app.models.group import Group
@@ -1005,7 +1012,8 @@ class TestOIDCProviders:
         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
+        # Slim public shape: the config fields live on the admin response.
+        assert set(pub_match.keys()) == {"id", "name", "has_icon", "is_autologin"}
 
 
 # ===========================================================================

+ 423 - 0
backend/tests/integration/test_oidc_group_sync.py

@@ -0,0 +1,423 @@
+"""Integration tests for OIDC group sync (#3107).
+
+Same regression contract as the LDAP group sync (test_ldap_group_sync.py,
+#1292): the sync manages only the Bambuddy groups named in the provider's
+group_mapping values, and every login replaces exactly that slice. Manual
+assignments to groups outside the mapping survive; revocation at the IdP
+propagates on the next login.
+
+One deliberate difference from LDAP: there is no default-group fallback in
+the OIDC sync. The provider's default_group_id is applied once at account
+creation (routes/mfa.py) and never re-asserted, so promoting an auto-created
+user out of Viewers is a manual action that sticks.
+"""
+
+import logging
+from typing import NoReturn
+
+import pytest
+from pydantic import ValidationError
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.models.group import Group
+from backend.app.models.oidc_provider import OIDCProvider
+from backend.app.models.user import User
+from backend.app.schemas.auth import OIDCProviderCreate
+from backend.app.services.oidc_group_sync import (
+    _MAX_CLAIM_ITEMS,
+    extract_idp_groups,
+    resolve_oidc_group_mapping,
+    sync_oidc_user_groups,
+)
+
+
+async def _make_group(db: AsyncSession, name: str) -> Group:
+    group = Group(name=name, description=f"Test group {name}")
+    db.add(group)
+    await db.commit()
+    await db.refresh(group)
+    return group
+
+
+async def _make_user(db: AsyncSession, username: str, groups: list[Group]) -> User:
+    user = User(
+        username=username,
+        email=f"{username}@example.com",
+        password_hash=None,
+        role="user",
+        auth_source="oidc",
+        is_active=True,
+    )
+    user.groups = groups
+    db.add(user)
+    await db.commit()
+    await db.refresh(user, attribute_names=["groups"])
+    return user
+
+
+# ─── claim-shape helpers ──────────────────────────────────────────────────────
+
+
+class TestExtractIdpGroups:
+    """Providers disagree on the groups-claim shape (#3107). All accepted."""
+
+    def test_json_array(self):
+        assert extract_idp_groups(["fablab-staff", "students"]) == ["fablab-staff", "students"]
+
+    def test_space_separated_string(self):
+        assert extract_idp_groups("fablab-staff students") == ["fablab-staff", "students"]
+
+    def test_comma_separated_string(self):
+        assert extract_idp_groups("fablab-staff, students") == ["fablab-staff", "students"]
+
+    def test_single_group_string(self):
+        assert extract_idp_groups("fablab-staff") == ["fablab-staff"]
+
+    def test_duplicates_removed(self):
+        assert extract_idp_groups(["a", "b", "a", "b "]) == ["a", "b"]
+
+    def test_none_and_non_group_payloads(self):
+        assert extract_idp_groups(None) == []
+        assert extract_idp_groups(42) == []
+        assert extract_idp_groups({"odd": "shape"}) == []
+        assert extract_idp_groups(["ok", 7, None, ""]) == ["ok"]
+
+    def test_list_claim_is_bounded(self):
+        # Review on #3122: the bound is the only defence against a hostile
+        # oversized token, and it must hold for every accepted shape, not
+        # just the list one.
+        oversized = [f"g{i}" for i in range(_MAX_CLAIM_ITEMS + 100)]
+        result = extract_idp_groups(oversized)
+        assert len(result) == _MAX_CLAIM_ITEMS
+        assert result[0] == "g0"
+        assert result[-1] == f"g{_MAX_CLAIM_ITEMS - 1}"
+
+    def test_space_separated_claim_is_bounded(self):
+        # The shape the original bound missed: a string claim splits into
+        # arbitrarily many fragments, so the slice has to apply after
+        # splitting, not only on the list path.
+        oversized = " ".join(f"g{i}" for i in range(_MAX_CLAIM_ITEMS + 100))
+        result = extract_idp_groups(oversized)
+        assert len(result) == _MAX_CLAIM_ITEMS
+        assert result[-1] == f"g{_MAX_CLAIM_ITEMS - 1}"
+
+    def test_comma_separated_claim_is_bounded(self):
+        # No spaces around the commas: the split happens on raw fragments,
+        # so ", "-joined input would spend half the budget on empty
+        # fragments. The contract being pinned is the upper bound.
+        oversized = ",".join(f"g{i}" for i in range(_MAX_CLAIM_ITEMS + 100))
+        result = extract_idp_groups(oversized)
+        assert len(result) == _MAX_CLAIM_ITEMS
+
+
+class TestResolveMapping:
+    def test_case_insensitive_on_idp_side(self):
+        assert resolve_oidc_group_mapping(["IDP-STAFF"], {"idp-staff": "Operators"}) == ["Operators"]
+
+    def test_unmapped_groups_ignored(self):
+        assert resolve_oidc_group_mapping(["nope", "idp-staff"], {"idp-staff": "Operators"}) == ["Operators"]
+
+    def test_empty_mapping_disables(self):
+        assert resolve_oidc_group_mapping(["idp-staff"], {}) == []
+
+    def test_two_idp_groups_to_one_bambuddy_group(self):
+        mapping = {"staff": "Operators", "admins": "Operators"}
+        assert resolve_oidc_group_mapping(["admins", "staff"], mapping) == ["Operators"]
+
+
+# ─── sync semantics ───────────────────────────────────────────────────────────
+
+
+class TestSyncOidcUserGroups:
+    @pytest.mark.asyncio
+    async def test_adds_mapped_group_on_login(self, db_session: AsyncSession):
+        operators = await _make_group(db_session, "Operators")
+        user = await _make_user(db_session, "alice", [])
+
+        await sync_oidc_user_groups(
+            db_session,
+            user,
+            group_claim="groups",
+            group_mapping={"idp-staff": "Operators"},
+            claims={"groups": ["idp-staff"]},
+        )
+        await db_session.refresh(user, attribute_names=["groups"])
+        assert {g.id for g in user.groups} == {operators.id}
+
+    @pytest.mark.asyncio
+    async def test_manual_group_survives_login(self, db_session: AsyncSession):
+        """The #1292 contract: a group outside the mapping is a manual
+        assignment and must never be touched by the sync."""
+        admins = await _make_group(db_session, "Administrators")
+        await _make_group(db_session, "Operators")
+
+        user = await _make_user(db_session, "alice", [admins])
+
+        await sync_oidc_user_groups(
+            db_session,
+            user,
+            group_claim="groups",
+            group_mapping={"idp-staff": "Operators"},
+            claims={"groups": ["idp-staff"]},
+        )
+        await db_session.refresh(user, attribute_names=["groups"])
+        assert {g.name for g in user.groups} == {"Administrators", "Operators"}
+
+    @pytest.mark.asyncio
+    async def test_revocation_at_idp_propagates(self, db_session: AsyncSession):
+        """Losing the IdP group must remove the mapped Bambuddy group on the
+        next login — otherwise IdP-side revocation would be decorative."""
+        operators = await _make_group(db_session, "Operators")
+        user = await _make_user(db_session, "bob", [operators])
+
+        await sync_oidc_user_groups(
+            db_session,
+            user,
+            group_claim="groups",
+            group_mapping={"idp-staff": "Operators"},
+            claims={"groups": []},
+        )
+        await db_session.refresh(user, attribute_names=["groups"])
+        assert {g.name for g in user.groups} == set()
+
+    @pytest.mark.asyncio
+    async def test_revocation_keeps_manual_groups(self, db_session: AsyncSession):
+        admins = await _make_group(db_session, "Administrators")
+        operators = await _make_group(db_session, "Operators")
+
+        user = await _make_user(db_session, "carol", [admins, operators])
+
+        await sync_oidc_user_groups(
+            db_session,
+            user,
+            group_claim="groups",
+            group_mapping={"idp-staff": "Operators"},
+            claims={"groups": []},
+        )
+        await db_session.refresh(user, attribute_names=["groups"])
+        assert {g.name for g in user.groups} == {"Administrators"}
+
+    @pytest.mark.asyncio
+    async def test_manual_assignment_to_managed_group_overridden(self, db_session: AsyncSession):
+        """An admin who manually grants a mapped group is overridden by IdP
+        truth, same as LDAP: revocation must work for those users too."""
+        operators = await _make_group(db_session, "Operators")
+        user = await _make_user(db_session, "dave", [operators])
+
+        await sync_oidc_user_groups(
+            db_session,
+            user,
+            group_claim="groups",
+            group_mapping={"idp-staff": "Operators"},
+            claims={"groups": []},
+        )
+        await db_session.refresh(user, attribute_names=["groups"])
+        assert {g.name for g in user.groups} == set()
+
+    @pytest.mark.asyncio
+    async def test_no_mapping_is_a_noop(self, db_session: AsyncSession):
+        """Default state for every upgraded install: nothing configured, so
+        nothing changes — including groups that would have matched a mapping
+        if one existed."""
+        admins = await _make_group(db_session, "Administrators")
+        user = await _make_user(db_session, "eve", [admins])
+
+        await sync_oidc_user_groups(
+            db_session,
+            user,
+            group_claim="groups",
+            group_mapping={},
+            claims={"groups": ["idp-staff"]},
+        )
+        await db_session.refresh(user, attribute_names=["groups"])
+        assert {g.name for g in user.groups} == {"Administrators"}
+
+    @pytest.mark.asyncio
+    async def test_missing_claim_is_not_fatal(self, db_session: AsyncSession):
+        """A provider that never sends the claim means 'no mapped groups',
+        not an error: the login must proceed and the managed slice clears."""
+        operators = await _make_group(db_session, "Operators")
+        user = await _make_user(db_session, "frank", [operators])
+
+        await sync_oidc_user_groups(
+            db_session,
+            user,
+            group_claim="groups",
+            group_mapping={"idp-staff": "Operators"},
+            claims={},  # claim absent entirely
+        )
+        await db_session.refresh(user, attribute_names=["groups"])
+        assert {g.name for g in user.groups} == set()
+
+    @pytest.mark.asyncio
+    async def test_mapping_to_deleted_group_skipped(self, db_session: AsyncSession):
+        """A dangling mapping value (group deleted after the mapping was saved)
+        is skipped at sync time, mirroring default_group_id's SQLite story."""
+        await _make_group(db_session, "Operators")
+        user = await _make_user(db_session, "grace", [])
+
+        await sync_oidc_user_groups(
+            db_session,
+            user,
+            group_claim="groups",
+            group_mapping={"idp-staff": "Operators", "idp-ghost": "DeletedGroup"},
+            claims={"groups": ["idp-staff", "idp-ghost"]},
+        )
+        await db_session.refresh(user, attribute_names=["groups"])
+        assert {g.name for g in user.groups} == {"Operators"}
+
+    @pytest.mark.asyncio
+    async def test_custom_claim_name(self, db_session: AsyncSession):
+        """group_claim='roles' reads the roles claim and ignores a groups
+        claim that happens to be present."""
+        operators = await _make_group(db_session, "Operators")
+        user = await _make_user(db_session, "heidi", [])
+
+        await sync_oidc_user_groups(
+            db_session,
+            user,
+            group_claim="roles",
+            group_mapping={"op": "Operators"},
+            claims={"roles": ["op"], "groups": ["unrelated"]},
+        )
+        await db_session.refresh(user, attribute_names=["groups"])
+        assert {g.id for g in user.groups} == {operators.id}
+
+    @pytest.mark.asyncio
+    async def test_sync_failure_never_blocks_login(self, db_session: AsyncSession, monkeypatch, caplog):
+        """The service's contract with oidc_callback: never raise. A failure
+        mid-sync is logged and the user keeps the groups they had — the login
+        already authenticated, so the sync must not take it down with it.
+
+        The commit is the interesting failure point: by then the user object
+        is dirty, so the except path's rollback has real work to do and the
+        in-memory relationship is post-rollback state. Database truth is
+        re-selected rather than read off the expired instance."""
+        admins = await _make_group(db_session, "Administrators")
+        await _make_group(db_session, "Operators")
+        user = await _make_user(db_session, "ivan", [admins])
+        user_id = user.id  # captured pre-sync: the rollback expires the whole instance, PK included
+
+        async def _failing_commit() -> NoReturn:
+            raise RuntimeError("simulated commit failure")
+
+        monkeypatch.setattr(db_session, "commit", _failing_commit)
+
+        with caplog.at_level(logging.ERROR):
+            await sync_oidc_user_groups(  # must not raise
+                db_session,
+                user,
+                group_claim="groups",
+                group_mapping={"idp-staff": "Operators"},
+                claims={"groups": ["idp-staff"]},
+            )
+
+        fresh = (
+            await db_session.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
+        ).scalar_one()
+        assert {g.name for g in fresh.groups} == {"Administrators"}
+        assert "OIDC group sync failed for user ivan" in caplog.text
+
+
+# ─── schema validation ────────────────────────────────────────────────────────
+
+
+class TestProviderSchema:
+    def test_create_defaults(self):
+        provider = OIDCProviderCreate(name="t", issuer_url="https://id.example.com", client_id="a", client_secret="b")
+        assert provider.group_claim == "groups"
+        assert provider.group_mapping == {}
+
+    def test_create_with_mapping(self):
+        provider = OIDCProviderCreate(
+            name="t",
+            issuer_url="https://id.example.com",
+            client_id="a",
+            client_secret="b",
+            group_claim="roles",
+            group_mapping={"op": "Operators"},
+        )
+        assert provider.group_claim == "roles"
+        assert provider.group_mapping == {"op": "Operators"}
+
+    def test_invalid_group_claim_rejected(self):
+        with pytest.raises(ValidationError):
+            OIDCProviderCreate(
+                name="t",
+                issuer_url="https://id.example.com",
+                client_id="a",
+                client_secret="b",
+                group_claim="not a claim!",
+            )
+
+    def test_non_object_mapping_rejected(self):
+        with pytest.raises(ValidationError):
+            OIDCProviderCreate(
+                name="t",
+                issuer_url="https://id.example.com",
+                client_id="a",
+                client_secret="b",
+                group_mapping=["not", "an", "object"],
+            )
+
+    def test_empty_mapping_values_rejected(self):
+        with pytest.raises(ValidationError):
+            OIDCProviderCreate(
+                name="t",
+                issuer_url="https://id.example.com",
+                client_id="a",
+                client_secret="b",
+                group_mapping={"op": "   "},
+            )
+
+    def test_case_colliding_mapping_keys_rejected(self):
+        """Review round 2: {"Admins": ..., "admins": ...} would silently
+        collapse in the sync's case-insensitive lookup — rejected at save."""
+        with pytest.raises(ValidationError):
+            OIDCProviderCreate(
+                name="t",
+                issuer_url="https://id.example.com",
+                client_id="a",
+                client_secret="b",
+                group_mapping={"Admins": "Administrators", "admins": "Viewers"},
+            )
+
+    def test_case_colliding_keys_same_target_also_rejected(self):
+        with pytest.raises(ValidationError):
+            OIDCProviderCreate(
+                name="t",
+                issuer_url="https://id.example.com",
+                client_id="a",
+                client_secret="b",
+                group_mapping={"Admins": "Operators", "admins": "Operators"},
+            )
+
+    def test_update_none_leaves_unchanged(self):
+        from backend.app.schemas.auth import OIDCProviderUpdate
+
+        update = OIDCProviderUpdate()
+        assert update.group_claim is None
+        assert update.group_mapping is None
+
+
+# ─── model column round-trip ──────────────────────────────────────────────────
+
+
+class TestProviderModelRoundTrip:
+    @pytest.mark.asyncio
+    async def test_columns_persist(self, db_session: AsyncSession):
+        provider = OIDCProvider(
+            name="idp-test",
+            issuer_url="https://id.example.com",
+            client_id="a",
+            client_secret="b",
+            group_claim="roles",
+            group_mapping={"op": "Operators"},
+        )
+        db_session.add(provider)
+        await db_session.commit()
+        await db_session.refresh(provider)
+        assert provider.group_claim == "roles"
+        assert provider.group_mapping == {"op": "Operators"}

+ 585 - 0
backend/tests/integration/test_oidc_group_sync_routes.py

@@ -0,0 +1,585 @@
+"""Route-level and end-to-end coverage for OIDC group sync (#3107).
+
+The original suite (test_oidc_group_sync.py) covers the helpers, the service
+and the schema. This file covers the three gaps from PR #3122 review:
+
+1. The public provider list must not leak group sync config — an anonymous
+   visitor who can reach the login page must not learn which IdP group name
+   maps to which Bambuddy group (the review's blocker).
+2. The 422s on create/update when a mapping value names no existing group.
+3. The env path: BAMBUDDY_OIDC_GROUP_CLAIM / BAMBUDDY_OIDC_GROUP_MAPPING
+   applied at startup, refused on unknown group names, refused on bad JSON.
+4. oidc_callback end to end with a mapping configured: the auto-created
+   user lands in the mapped group, and a second login applies revocation.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import secrets
+import time
+from datetime import datetime, timedelta, timezone
+from unittest.mock import patch
+
+import jwt as pyjwt
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.oidc_env import apply_env_oidc_provider
+from backend.app.models.group import Group
+from backend.app.models.oidc_provider import OIDCProvider
+from backend.app.models.user import User
+from backend.tests.integration.test_mfa_api import (
+    _auth_header,
+    _make_test_rsa_key,
+    _setup_and_login,
+)
+
+PROVIDER_BASE = {
+    "name": "GroupSyncIdP",
+    "issuer_url": "https://gs.test.example.com",
+    "client_id": "gs-client",
+    "client_secret": "gs-secret",
+    "scopes": "openid email profile",
+    "is_enabled": True,
+    "auto_create_users": True,
+}
+
+
+async def _get_or_make_group(db: AsyncSession, name: str):
+    """The conftest seeds the system groups, so anything named like one of
+    those is fetched rather than created (its UNIQUE(name) would reject us)."""
+    from sqlalchemy import select as sa_select
+
+    row = (await db.execute(sa_select(Group).where(Group.name == name))).scalar_one_or_none()
+    if row is not None:
+        return row
+    group = Group(name=name, description=f"Test group {name}")
+    db.add(group)
+    await db.commit()
+    await db.refresh(group)
+    return group
+
+
+_ADMIN_TOKEN: dict[str, str] = {}
+
+
+async def _admin_token(async_client: AsyncClient) -> str:
+    """One admin per test database: /auth/setup enables auth and mints THE
+    admin account, so repeated _setup_and_login calls with different
+    usernames 401 (the second setup is refused). Cache the token per client.
+    """
+    key = str(id(async_client))
+    if key not in _ADMIN_TOKEN:
+        _ADMIN_TOKEN[key] = await _setup_and_login(async_client, "gsadmin", "gsadmin1")
+    return _ADMIN_TOKEN[key]
+
+
+async def _create_provider(async_client: AsyncClient, **overrides):
+    token = await _admin_token(async_client)
+    body = {**PROVIDER_BASE, **overrides}
+    resp = await async_client.post(
+        "/api/v1/auth/oidc/providers",
+        json=body,
+        headers=_auth_header(token),
+    )
+    return resp
+
+
+class TestPublicListDoesNotLeakGroupSyncConfig:
+    """The review's blocker: GET /oidc/providers is public and must serve the
+    slim shape only. group_claim / group_mapping tell an attacker which IdP
+    group to aim for (possibly Administrators)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_public_list_omits_group_fields(self, async_client: AsyncSession, db_session: AsyncSession):
+        await _get_or_make_group(db_session, "Operators")
+        resp = await _create_provider(
+            async_client,
+            group_claim="roles",
+            group_mapping={"idp-ops": "Operators"},
+        )
+        assert resp.status_code == 201, resp.text
+
+        public = await async_client.get("/api/v1/auth/oidc/providers")
+        assert public.status_code == 200
+        entry = next(p for p in public.json() if p["name"] == "GroupSyncIdP")
+        assert set(entry.keys()) == {"id", "name", "has_icon", "is_autologin"}, (
+            f"public provider response must stay slim, got keys: {sorted(entry.keys())}"
+        )
+        assert "group_claim" not in entry
+        assert "group_mapping" not in entry
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_list_still_carries_group_fields(self, async_client: AsyncSession, db_session: AsyncSession):
+        await _get_or_make_group(db_session, "Operators")
+        resp = await _create_provider(
+            async_client,
+            group_claim="roles",
+            group_mapping={"idp-ops": "Operators"},
+        )
+        assert resp.status_code == 201
+
+        token = await _admin_token(async_client)
+        admin_list = await async_client.get("/api/v1/auth/oidc/providers/all", headers=_auth_header(token))
+        entry = next(p for p in admin_list.json() if p["name"] == "GroupSyncIdP")
+        assert entry["group_claim"] == "roles"
+        assert entry["group_mapping"] == {"idp-ops": "Operators"}
+
+
+class TestMappingGroupValidation:
+    """The 422s: mapping values must name existing groups, on create and update."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_rejects_unknown_group(self, async_client: AsyncSession):
+        resp = await _create_provider(async_client, group_mapping={"idp-ops": "NoSuchGroup"})
+        assert resp.status_code == 422, resp.text
+        assert "NoSuchGroup" in resp.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_accepts_existing_group(self, async_client: AsyncSession, db_session: AsyncSession):
+        await _get_or_make_group(db_session, "Operators")
+        resp = await _create_provider(async_client, group_mapping={"idp-ops": "Operators"})
+        assert resp.status_code == 201
+        assert resp.json()["group_mapping"] == {"idp-ops": "Operators"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_rejects_case_variant_of_existing_group(
+        self, async_client: AsyncSession, db_session: AsyncSession
+    ):
+        """Exact match only (review point 8): a case variant would pass the
+        check and then silently never resolve in the sync's exact lookup."""
+        await _get_or_make_group(db_session, "Operators")
+        resp = await _create_provider(async_client, group_mapping={"idp-ops": "operators"})
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_rejects_unknown_group(self, async_client: AsyncSession, db_session: AsyncSession):
+        created = await _create_provider(async_client)
+        assert created.status_code == 201
+        provider_id = created.json()["id"]
+
+        token = await _admin_token(async_client)
+        resp = await async_client.put(
+            f"/api/v1/auth/oidc/providers/{provider_id}",
+            json={"group_mapping": {"idp-ops": "NoSuchGroup"}},
+            headers=_auth_header(token),
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_clears_mapping_with_empty_object(self, async_client: AsyncSession, db_session: AsyncSession):
+        await _get_or_make_group(db_session, "Operators")
+        created = await _create_provider(async_client, group_mapping={"idp-ops": "Operators"})
+        provider_id = created.json()["id"]
+
+        token = await _admin_token(async_client)
+        resp = await async_client.put(
+            f"/api/v1/auth/oidc/providers/{provider_id}",
+            json={"group_mapping": {}},
+            headers=_auth_header(token),
+        )
+        assert resp.status_code == 200
+        assert resp.json()["group_mapping"] == {}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_namespaced_group_claim_accepted(self, async_client: AsyncSession):
+        """Auth0-style namespaced claim names must be configurable (review note)."""
+        resp = await _create_provider(async_client, group_claim="app/roles")
+        assert resp.status_code == 201, resp.text
+        assert resp.json()["group_claim"] == "app/roles"
+
+
+class TestEnvGroupMapping:
+    """The env path: the code most likely to strand an operator at boot."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_env_mapping_applied(self, db_session: AsyncSession, monkeypatch):
+        await _get_or_make_group(db_session, "Operators")
+        await db_session.commit()
+        monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "EnvIdP")
+        monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://env.test.example.com")
+        monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "env-client")
+        monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_SECRET", "env-secret")
+        monkeypatch.setenv("BAMBUDDY_OIDC_GROUP_CLAIM", "roles")
+        monkeypatch.setenv("BAMBUDDY_OIDC_GROUP_MAPPING", '{"idp-ops": "Operators"}')
+        for key in ("BAMBUDDY_OIDC_DEFAULT_GROUP", "BAMBUDDY_OIDC_SCOPES", "BAMBUDDY_OIDC_ENABLED"):
+            monkeypatch.delenv(key, raising=False)
+
+        await apply_env_oidc_provider(db_session)
+        row = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "EnvIdP"))).scalar_one()
+        assert row.group_claim == "roles"
+        assert row.group_mapping == {"idp-ops": "Operators"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_env_mapping_unknown_group_refuses_provider(self, db_session: AsyncSession, monkeypatch, caplog):
+        """Unknown names must refuse the whole config (matching DEFAULT_GROUP):
+        no provider row may be created carrying a mapping that never resolves."""
+        monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "EnvIdP-Refused")
+        monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://env2.test.example.com")
+        monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "env-client")
+        monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_SECRET", "env-secret")
+        monkeypatch.setenv("BAMBUDDY_OIDC_GROUP_MAPPING", '{"idp-ops": "NoSuchGroup"}')
+        for key in (
+            "BAMBUDDY_OIDC_DEFAULT_GROUP",
+            "BAMBUDDY_OIDC_SCOPES",
+            "BAMBUDDY_OIDC_ENABLED",
+            "BAMBUDDY_OIDC_GROUP_CLAIM",
+        ):
+            monkeypatch.delenv(key, raising=False)
+
+        await apply_env_oidc_provider(db_session)
+        row = (
+            await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "EnvIdP-Refused"))
+        ).scalar_one_or_none()
+        assert row is None, "provider must not be created when a mapping value matches no group"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_env_mapping_bad_json_refuses_provider(self, db_session: AsyncSession, monkeypatch):
+        monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "EnvIdP-BadJson")
+        monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://env3.test.example.com")
+        monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "env-client")
+        monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_SECRET", "env-secret")
+        monkeypatch.setenv("BAMBUDDY_OIDC_GROUP_MAPPING", "{not json")
+        for key in (
+            "BAMBUDDY_OIDC_DEFAULT_GROUP",
+            "BAMBUDDY_OIDC_SCOPES",
+            "BAMBUDDY_OIDC_ENABLED",
+            "BAMBUDDY_OIDC_GROUP_CLAIM",
+        ):
+            monkeypatch.delenv(key, raising=False)
+
+        await apply_env_oidc_provider(db_session)  # must not raise
+        row = (
+            await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "EnvIdP-BadJson"))
+        ).scalar_one_or_none()
+        assert row is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        "mapping", ['{"staff": null}', '{"staff": 5}', '{"Admins": "Viewers", "admins": "Viewers"}']
+    )
+    async def test_env_mapping_bad_value_is_named_in_the_log(
+        self, db_session: AsyncSession, monkeypatch, caplog, mapping
+    ):
+        """A malformed value is refused by name, not as a bare TypeError from
+        the group lookup it would otherwise reach."""
+        monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "EnvIdP-BadValue")
+        monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://env5.test.example.com")
+        monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "env-client")
+        monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_SECRET", "env-secret")
+        monkeypatch.setenv("BAMBUDDY_OIDC_GROUP_MAPPING", mapping)
+        for key in (
+            "BAMBUDDY_OIDC_DEFAULT_GROUP",
+            "BAMBUDDY_OIDC_SCOPES",
+            "BAMBUDDY_OIDC_ENABLED",
+            "BAMBUDDY_OIDC_GROUP_CLAIM",
+        ):
+            monkeypatch.delenv(key, raising=False)
+
+        with caplog.at_level(logging.ERROR, logger="backend.app.core.oidc_env"):
+            await apply_env_oidc_provider(db_session)
+
+        row = (
+            await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "EnvIdP-BadValue"))
+        ).scalar_one_or_none()
+        assert row is None
+        assert "BAMBUDDY_OIDC_GROUP_MAPPING is invalid" in caplog.text
+        assert "TypeError" not in caplog.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_env_mapping_removed_clears_it(self, db_session: AsyncSession, monkeypatch):
+        """The environment is the whole truth: dropping the variable clears
+        the mapping on the next boot."""
+        await _get_or_make_group(db_session, "Operators")
+        await db_session.commit()
+        env = {
+            "BAMBUDDY_OIDC_NAME": "EnvIdP-Clear",
+            "BAMBUDDY_OIDC_ISSUER_URL": "https://env4.test.example.com",
+            "BAMBUDDY_OIDC_CLIENT_ID": "env-client",
+            "BAMBUDDY_OIDC_CLIENT_SECRET": "env-secret",
+        }
+        for key in (
+            "BAMBUDDY_OIDC_DEFAULT_GROUP",
+            "BAMBUDDY_OIDC_SCOPES",
+            "BAMBUDDY_OIDC_ENABLED",
+            "BAMBUDDY_OIDC_GROUP_CLAIM",
+        ):
+            monkeypatch.delenv(key, raising=False)
+        for k, v in env.items():
+            monkeypatch.setenv(k, v)
+        monkeypatch.setenv("BAMBUDDY_OIDC_GROUP_MAPPING", '{"idp-ops": "Operators"}')
+        await apply_env_oidc_provider(db_session)
+        row = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "EnvIdP-Clear"))).scalar_one()
+        assert row.group_mapping == {"idp-ops": "Operators"}
+
+        monkeypatch.delenv("BAMBUDDY_OIDC_GROUP_MAPPING")
+        await apply_env_oidc_provider(db_session)
+        await db_session.refresh(row)
+        assert row.group_mapping == {}
+
+
+def _mock_oidc_httpx(discovery_doc, token_response, jwks_data):
+    """An httpx.AsyncClient stand-in for oidc_callback: serves the discovery
+    document, the JWKS payload and the token response, nothing else."""
+
+    class _MockResp:
+        def __init__(self, data):
+            self._data = data
+            self.status_code = 200
+            self.is_success = True
+            self.text = str(data)
+
+        def json(self):
+            return self._data
+
+        def raise_for_status(self):
+            pass
+
+    class _MockHttpxClient:
+        def __init__(self, *args, **kwargs):
+            pass
+
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, *args):
+            pass
+
+        async def get(self, url, **kwargs):
+            if "jwks" in url:
+                return _MockResp(jwks_data)
+            return _MockResp(discovery_doc)
+
+        async def post(self, url, **kwargs):
+            return _MockResp(token_response)
+
+    return _MockHttpxClient
+
+
+class TestCallbackAppliesMappingEndToEnd:
+    """oidc_callback with a mapping configured: creation grants the mapped
+    group, a later login applies revocation, and a manual group survives."""
+
+    @staticmethod
+    def _id_token(private_pem, issuer, client_id, nonce, groups, sub, email):
+        now = int(time.time())
+        return pyjwt.encode(
+            {
+                "sub": sub,
+                "iss": issuer,
+                "aud": client_id,
+                "nonce": nonce,
+                "email": email,
+                "email_verified": True,
+                "groups": groups,
+                "iat": now,
+                "exp": now + 300,
+            },
+            private_pem,
+            algorithm="RS256",
+            headers={"kid": "test-kid-1"},
+        )
+
+    async def _run_callback(self, async_client, db_session, provider_id, id_token, nonce, jwks, issuer):
+        from backend.app.models.auth_ephemeral import AuthEphemeralToken
+
+        state = secrets.token_urlsafe(32)
+        db_session.add(
+            AuthEphemeralToken(
+                token=state,
+                token_type="oidc_state",
+                provider_id=provider_id,
+                nonce=nonce,
+                code_verifier=secrets.token_urlsafe(48),
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
+            )
+        )
+        await db_session.commit()
+
+        discovery = {
+            "issuer": issuer,
+            "authorization_endpoint": f"{issuer}/auth",
+            "token_endpoint": f"{issuer}/token",
+            "jwks_uri": f"{issuer}/.well-known/jwks.json",
+        }
+        token_response = {"access_token": "mock", "token_type": "Bearer", "id_token": id_token}
+        client_cls = _mock_oidc_httpx(discovery, token_response, jwks)
+
+        with patch("backend.app.api.routes.mfa.httpx.AsyncClient", client_cls):
+            resp = await async_client.get(
+                f"/api/v1/auth/oidc/callback?code=x&state={state}",
+                follow_redirects=False,
+            )
+        return resp
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_callback_grants_mapped_group_on_creation(self, async_client: AsyncClient, db_session: AsyncSession):
+        operators = await _get_or_make_group(db_session, "Operators")
+        await _get_or_make_group(db_session, "ManualGroup")
+        private_pem, jwks = _make_test_rsa_key()
+        issuer = "https://e2e-gs.test.example.com"
+        nonce = secrets.token_urlsafe(16)
+
+        resp = await _create_provider(
+            async_client,
+            name="E2E-GroupSync-IdP",
+            issuer_url=issuer,
+            client_id="gs-e2e-client",
+            client_secret="sec",
+            group_claim="groups",
+            group_mapping={"idp-ops": "Operators"},
+        )
+        assert resp.status_code == 201, resp.text
+        provider_id = resp.json()["id"]
+
+        id_token = self._id_token(
+            private_pem, issuer, "gs-e2e-client", nonce, ["idp-ops"], "gs-sub-1", "gse2e@example.com"
+        )
+        callback = await self._run_callback(async_client, db_session, provider_id, id_token, nonce, jwks, issuer)
+        assert callback.status_code == 302, callback.text
+        # 302 alone is not enough — the error path also 302s (review #1):
+        # assert the exchange token, i.e. the login actually succeeded.
+        assert "oidc_token=" in callback.headers.get("location", "")
+
+        user = (await db_session.execute(select(User).where(User.email == "gse2e@example.com"))).scalar_one()
+        group_ids = {g.id for g in user.groups}
+        assert operators.id in group_ids, "auto-created user must land in the mapped group"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_second_login_applies_revocation_and_keeps_manual(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """Login 1 grants Operators via the mapping. An admin then adds
+        ManualGroup by hand and the IdP revokes idp-ops. Login 2 must remove
+        Operators and keep ManualGroup — the #1292 contract, end to end."""
+
+        operators = await _get_or_make_group(db_session, "Operators2")
+        manual = await _get_or_make_group(db_session, "ManualGroup2")
+        private_pem, jwks = _make_test_rsa_key()
+        issuer = "https://e2e-gs2.test.example.com"
+        client_id = "gs-e2e-client-2"
+        nonce = secrets.token_urlsafe(16)
+
+        resp = await _create_provider(
+            async_client,
+            name="E2E-GroupSync-IdP-2",
+            issuer_url=issuer,
+            client_id=client_id,
+            client_secret="sec",
+            group_claim="groups",
+            group_mapping={"idp-ops": "Operators2"},
+        )
+        assert resp.status_code == 201, resp.text
+        provider_id = resp.json()["id"]
+
+        # Login 1: IdP says idp-ops -> Operators2 granted at creation.
+        id_token = self._id_token(private_pem, issuer, client_id, nonce, ["idp-ops"], "gs-sub-2", "gse2e2@example.com")
+        cb = await self._run_callback(async_client, db_session, provider_id, id_token, nonce, jwks, issuer)
+        assert cb.status_code == 302
+        assert "oidc_token=" in cb.headers.get("location", "")
+
+        user = (await db_session.execute(select(User).where(User.email == "gse2e2@example.com"))).scalar_one()
+        # Creation assigns the default group (Viewers) per the existing
+        # auto-create path; the sync adds the mapped group on top. Viewers is
+        # NOT in the mapping, so it is a creation-time assignment and must
+        # survive login 2 alongside the manual group.
+        viewers = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
+        assert {g.id for g in user.groups} == {operators.id, viewers.id}
+
+        # Admin assigns ManualGroup2 by hand; IdP revokes idp-ops.
+        manual_group = (await db_session.execute(select(Group).where(Group.name == "ManualGroup2"))).scalar_one()
+        user.groups = list(user.groups) + [manual_group]
+        db_session.add(user)
+        await db_session.commit()
+
+        # Login 2: new nonce, no idp-ops in the claim.
+        nonce2 = secrets.token_urlsafe(16)
+        id_token2 = self._id_token(private_pem, issuer, client_id, nonce2, [], "gs-sub-2", "gse2e2@example.com")
+        cb2 = await self._run_callback(async_client, db_session, provider_id, id_token2, nonce2, jwks, issuer)
+        assert cb2.status_code == 302, cb2.text
+        assert "oidc_token=" in cb2.headers.get("location", ""), (
+            "login 2 must succeed (and carry an exchange token), not error out"
+        )
+
+        await db_session.refresh(user, attribute_names=["groups"])
+        assert {g.id for g in user.groups} == {manual.id, viewers.id}, (
+            "revoked mapped group must be removed; the creation-default group "
+            "and the manual assignment must both survive"
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sync_failure_does_not_block_login(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch
+    ):
+        """Review blocker #1: a mid-sync failure must not take the login down.
+
+        sync_oidc_user_groups catches the exception and calls db.rollback(),
+        which expires every loaded object in the session. If the callback then
+        reads user.username for the exchange token, that lazy-load raises
+        MissingGreenlet and the user lands on ?oidc_error=user_resolution_failed
+        — a failed sync blocking the login after all. The post-sync
+        db.refresh(user) (all attributes) is what prevents it; this test
+        fails with user_resolution_failed if that refresh is narrowed again.
+        Asserts the exchange token in the Location, which a 302 alone cannot
+        distinguish from the error redirect.
+        """
+        operators = await _get_or_make_group(db_session, "Operators")
+        private_pem, jwks = _make_test_rsa_key()
+        issuer = "https://e2e-gsfail.test.example.com"
+        client_id = "gs-e2e-client-fail"
+        nonce = secrets.token_urlsafe(16)
+
+        resp = await _create_provider(
+            async_client,
+            name="E2E-GroupSync-Fail-IdP",
+            issuer_url=issuer,
+            client_id=client_id,
+            client_secret="sec",
+            group_claim="groups",
+            group_mapping={"idp-ops": "Operators"},
+        )
+        assert resp.status_code == 201, resp.text
+        provider_id = resp.json()["id"]
+
+        id_token = self._id_token(
+            private_pem, issuer, client_id, nonce, ["idp-ops"], "gs-sub-fail", "gsfaile@example.com"
+        )
+
+        from backend.app.services import oidc_group_sync as sync_module
+
+        async def _explode(*args, **kwargs):
+            raise RuntimeError("simulated mid-sync failure")
+
+        monkeypatch.setattr(sync_module, "resolve_oidc_group_mapping", _explode)
+        # The callback imports the name lazily inside the function, so patch
+        # the module attribute the callback resolves it from.
+        import backend.app.api.routes.mfa as mfa_module  # noqa: F401  (sanity: module importable)
+
+        callback = await self._run_callback(async_client, db_session, provider_id, id_token, nonce, jwks, issuer)
+
+        location = callback.headers.get("location", "")
+        assert "oidc_error=" not in location, f"a failed sync must not turn into a login error: {location}"
+        assert "oidc_token=" in location, "the login must succeed and issue an exchange token despite the sync failure"
+        # The user exists, and kept the groups they had (none mapped).
+        user = (await db_session.execute(select(User).where(User.email == "gsfaile@example.com"))).scalar_one()
+        assert operators.id not in {g.id for g in user.groups}

+ 234 - 0
frontend/src/__tests__/components/OIDCGroupSyncForm.test.tsx

@@ -0,0 +1,234 @@
+/**
+ * Tests for the OIDC group-sync form (#3107): the row-based mapping editor,
+ * the orphaned-row flagging for deleted groups, and the summary card's
+ * Group Sync line. The scope-mismatch warning was removed in review, and
+ * one test pins that it stays gone.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { OIDCProviderSettings } from '../../components/OIDCProviderSettings';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const baseProvider = {
+  id: 7,
+  name: 'GroupSyncIdP',
+  issuer_url: 'https://gs.example.com',
+  client_id: 'gs-client',
+  scopes: 'openid email profile',
+  is_enabled: true,
+  auto_create_users: false,
+  auto_link_existing_accounts: false,
+  email_claim: 'email',
+  require_email_verified: true,
+  group_claim: 'groups',
+  group_mapping: {},
+  icon_url: null,
+  has_icon: false,
+  default_group_id: null,
+  is_autologin: false,
+  created_at: '2026-01-01T00:00:00Z',
+  updated_at: '2026-01-01T00:00:00Z',
+};
+
+const mockGroups = [
+  { id: 1, name: 'Administrators', description: '', permissions: [], is_system: true },
+  { id: 2, name: 'Operators', description: '', permissions: [], is_system: true },
+];
+
+let savedPayload: Record<string, unknown> | null = null;
+
+function mockCreate() {
+  server.use(
+    http.post('/api/v1/auth/oidc/providers', async ({ request }) => {
+      savedPayload = (await request.json()) as Record<string, unknown>;
+      return HttpResponse.json({ ...baseProvider, id: 99, ...savedPayload }, { status: 201 });
+    })
+  );
+}
+
+beforeEach(() => {
+  savedPayload = null;
+  server.use(
+    http.get('/api/v1/auth/oidc/providers/all', () =>
+      HttpResponse.json([{ ...baseProvider }])
+    ),
+    http.get('/api/v1/groups/', () => HttpResponse.json(mockGroups))
+  );
+});
+
+async function openCreateForm() {
+  await waitFor(() => {
+    expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
+  });
+  await userEvent.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
+}
+
+async function fillRequiredFields() {
+  // The form's labels are not wired with htmlFor, so drive by placeholder
+  // (same approach the existing OIDCProviderSettings test uses).
+  await userEvent.type(screen.getByPlaceholderText('Google'), 'GS-IdP');
+  await userEvent.type(screen.getByPlaceholderText('https://accounts.google.com'), 'https://gs.example.com');
+  await userEvent.type(screen.getByPlaceholderText('your-client-id'), 'gs-client');
+  await userEvent.type(screen.getByPlaceholderText(/new secret/i), 'gs-secret');
+}
+
+describe('OIDC group sync form', () => {
+  it('adds a mapping row, fills the IdP name and picks a Bambuddy group', async () => {
+    mockCreate();
+    render(<OIDCProviderSettings />);
+    await openCreateForm();
+    await fillRequiredFields();
+
+    await userEvent.click(screen.getByRole('button', { name: /Add Mapping/i }));
+    const idpInput = screen.getByPlaceholderText(/Identity provider group name/i);
+    await userEvent.type(idpInput, 'idp-ops');
+
+    const selects = screen.getAllByDisplayValue(/Select Bambuddy group/i);
+    await userEvent.selectOptions(selects[0], 'Operators');
+
+    await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
+    await waitFor(() => {
+      expect(savedPayload).not.toBeNull();
+    });
+    expect(savedPayload!.group_mapping).toEqual({ 'idp-ops': 'Operators' });
+    expect(savedPayload!.group_claim).toBe('groups');
+  });
+
+  it('saves an empty mapping as {} when no rows are added', async () => {
+    mockCreate();
+    render(<OIDCProviderSettings />);
+    await openCreateForm();
+    await fillRequiredFields();
+
+    await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
+    await waitFor(() => {
+      expect(savedPayload).not.toBeNull();
+    });
+    expect(savedPayload!.group_mapping).toEqual({});
+  });
+
+  it('removes a row with its trash button and the mapping is not sent', async () => {
+    mockCreate();
+    render(<OIDCProviderSettings />);
+    await openCreateForm();
+    await fillRequiredFields();
+
+    await userEvent.click(screen.getByRole('button', { name: /Add Mapping/i }));
+    const idpInput = screen.getByPlaceholderText(/Identity provider group name/i);
+    await userEvent.type(idpInput, 'idp-ops');
+
+    // The row's trash button is the button inside the same row container
+    const row = idpInput.closest('div')?.parentElement;
+    const trash = row?.querySelector('button');
+    expect(trash).not.toBeNull();
+    await userEvent.click(trash!);
+
+    await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
+    await waitFor(() => {
+      expect(savedPayload).not.toBeNull();
+    });
+    expect(savedPayload!.group_mapping).toEqual({});
+  });
+
+  it('flags a row with no Bambuddy group and keeps Save disabled instead of dropping it', async () => {
+    mockCreate();
+    render(<OIDCProviderSettings />);
+    await openCreateForm();
+    await fillRequiredFields();
+
+    await userEvent.click(screen.getByRole('button', { name: /Add Mapping/i }));
+    await userEvent.type(screen.getByPlaceholderText(/Identity provider group name/i), 'idp-ops');
+
+    expect(screen.getByText(/pick a Bambuddy group, or remove this mapping/i)).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /^Save$/i })).toBeDisabled();
+
+    await userEvent.selectOptions(screen.getAllByDisplayValue(/Select Bambuddy group/i)[0], 'Operators');
+    expect(screen.queryByText(/pick a Bambuddy group, or remove this mapping/i)).not.toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /^Save$/i })).toBeEnabled();
+  });
+
+  it('flags a second row for the same IdP group, ignoring case', async () => {
+    mockCreate();
+    render(<OIDCProviderSettings />);
+    await openCreateForm();
+    await fillRequiredFields();
+
+    await userEvent.click(screen.getByRole('button', { name: /Add Mapping/i }));
+    await userEvent.click(screen.getByRole('button', { name: /Add Mapping/i }));
+    const idpInputs = screen.getAllByPlaceholderText(/Identity provider group name/i);
+    await userEvent.type(idpInputs[0], 'Admins');
+    await userEvent.type(idpInputs[1], 'admins');
+    const selects = screen.getAllByDisplayValue(/Select Bambuddy group/i);
+    await userEvent.selectOptions(selects[0], 'Administrators');
+    await userEvent.selectOptions(selects[1], 'Operators');
+
+    expect(screen.getAllByText(/already mapped above/i)).toHaveLength(1);
+    expect(screen.getByRole('button', { name: /^Save$/i })).toBeDisabled();
+  });
+
+  it('skips a row that was added but left completely empty', async () => {
+    mockCreate();
+    render(<OIDCProviderSettings />);
+    await openCreateForm();
+    await fillRequiredFields();
+
+    await userEvent.click(screen.getByRole('button', { name: /Add Mapping/i }));
+    await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
+    await waitFor(() => {
+      expect(savedPayload).not.toBeNull();
+    });
+    expect(savedPayload!.group_mapping).toEqual({});
+  });
+
+  it('shows the stale group as a deleted option when the mapping names a removed group', async () => {
+    server.use(
+      http.get('/api/v1/auth/oidc/providers/all', () =>
+        HttpResponse.json([
+          {
+            ...baseProvider,
+            group_mapping: { 'idp-ops': 'DeletedGroup' },
+          },
+        ])
+      )
+    );
+    render(<OIDCProviderSettings />);
+
+    // Summary card shows Group Sync on
+    await waitFor(() => {
+      expect(screen.getByText(/Group Sync/i)).toBeInTheDocument();
+    });
+
+    // Open the edit form: the orphaned row must be flagged
+    const editButton = await screen.findByTestId('edit-provider-7');
+    await userEvent.click(editButton);
+    // The orphaned row shows the stale name as a "(deleted)" option AND the
+    // standalone warning line; assert on the warning's distinctive text.
+    await waitFor(() => {
+      expect(screen.getByText(/has been deleted/i)).toBeInTheDocument();
+    });
+    expect(screen.getByText(/DeletedGroup \(deleted\)/i)).toBeInTheDocument();
+  });
+
+  it('shows Group Sync on the summary card only when a mapping exists', async () => {
+    render(<OIDCProviderSettings />);
+    await waitFor(() => {
+      expect(screen.getByText(/Group Sync/i)).toBeInTheDocument();
+    });
+    // base provider has empty mapping -> the status reads Off, not On
+    expect(screen.queryByText(/\(groups\)/)).not.toBeInTheDocument();
+  });
+
+  it('does not show a scopes warning on the claim fields (removed in review)', async () => {
+    render(<OIDCProviderSettings />);
+    await openCreateForm();
+    // With stock scopes "openid email profile" and claim "groups", the removed
+    // warning must not render anywhere in the form.
+    expect(
+      screen.queryByText(/won't be returned by your identity provider/i)
+    ).not.toBeInTheDocument();
+  });
+});

+ 17 - 1
frontend/src/api/client.ts

@@ -4409,6 +4409,16 @@ export interface TwoFAVerifyRequest {
 export type SameOriginUrl = string & { readonly __brand: 'SameOriginUrl' };
 
 // OIDC interfaces
+/** What the unauthenticated GET /auth/oidc/providers returns (#3107): only
+ *  what the login page renders. The full provider, group sync config
+ *  included, needs the admin-only /auth/oidc/providers/all. */
+export interface OIDCProviderPublic {
+  id: number;
+  name: string;
+  has_icon: boolean;
+  is_autologin: boolean;
+}
+
 export interface OIDCProvider {
   id: number;
   name: string;
@@ -4420,6 +4430,9 @@ export interface OIDCProvider {
   auto_link_existing_accounts: boolean;
   email_claim: string;
   require_email_verified: boolean;
+  // #3107 — group sync. Empty mapping = sync off (default).
+  group_claim?: string;
+  group_mapping?: Record<string, string>;
   icon_url?: string | null;
   default_group_id?: number | null;
   // True when the backend has cached icon bytes for this provider.
@@ -4451,6 +4464,9 @@ export interface OIDCProviderCreate {
   auto_link_existing_accounts?: boolean;
   email_claim?: string;
   require_email_verified?: boolean;
+  // #3107 — group sync. Omit both to leave them unchanged on update.
+  group_claim?: string;
+  group_mapping?: Record<string, string>;
   icon_url?: string | null;
   default_group_id?: number | null;
   is_autologin?: boolean;  // #1589
@@ -4660,7 +4676,7 @@ export const api = {
     request<{ message: string }>(`/auth/2fa/admin/${userId}`, { method: 'DELETE' }),
 
   // OIDC providers (public list)
-  getOIDCProviders: () => request<OIDCProvider[]>('/auth/oidc/providers'),
+  getOIDCProviders: () => request<OIDCProviderPublic[]>('/auth/oidc/providers'),
 
   // OIDC providers (admin)
   getOIDCProvidersAll: () => request<OIDCProvider[]>('/auth/oidc/providers/all'),

+ 136 - 1
frontend/src/components/OIDCProviderSettings.tsx

@@ -21,6 +21,8 @@ const EMPTY_FORM: OIDCProviderCreate = {
   auto_link_existing_accounts: false,
   email_claim: 'email',
   require_email_verified: true,
+  group_claim: 'groups',
+  group_mapping: {},
   icon_url: undefined,
   default_group_id: null,
   is_autologin: false,
@@ -45,6 +47,16 @@ function ProviderForm({
   const { t } = useTranslation();
   const [form, setForm] = useState<OIDCProviderCreate>(initial);
   const [secretChanged, setSecretChanged] = useState(false);
+  // #3107 — each row is an { idpGroup -> bambuddyGroup } pair, edited directly
+  // instead of as JSON. The Bambuddy side is a <select> sourced from `groups`,
+  // so an invalid group name can't be entered in the first place.
+  const [mappingRows, setMappingRows] = useState<{ idpGroup: string; bambuddyGroup: string }[]>(() =>
+    Object.entries(initial.group_mapping ?? {}).map(([idpGroup, bambuddyGroup]) => ({ idpGroup, bambuddyGroup }))
+  );
+  const addMappingRow = () => setMappingRows((prev) => [...prev, { idpGroup: '', bambuddyGroup: '' }]);
+  const removeMappingRow = (i: number) => setMappingRows((prev) => prev.filter((_, idx) => idx !== i));
+  const updateMappingRow = (i: number, patch: Partial<{ idpGroup: string; bambuddyGroup: string }>) =>
+    setMappingRows((prev) => prev.map((row, idx) => (idx === i ? { ...row, ...patch } : row)));
   const set = (key: keyof OIDCProviderCreate, value: unknown) =>
     setForm((prev) => ({ ...prev, [key]: value }));
 
@@ -57,9 +69,42 @@ function ProviderForm({
     if (isEdit && !secretChanged) {
       delete (payload as Partial<OIDCProviderCreate>).client_secret;
     }
+    // #3107 review: default the group claim on save, not on change —
+    // snapping it back mid-edit means the field can only be typed over.
+    if (!payload.group_claim || !payload.group_claim.trim()) {
+      payload.group_claim = 'groups';
+    }
+    // #3107 — blank IdP-group name or unselected Bambuddy group means the row
+    // isn't finished yet; drop it rather than saving a half-filled mapping.
+    const mapping: Record<string, string> = {};
+    for (const row of mappingRows) {
+      const idpGroup = row.idpGroup.trim();
+      if (idpGroup && row.bambuddyGroup) {
+        mapping[idpGroup] = row.bambuddyGroup;
+      }
+    }
+    payload.group_mapping = mapping;
     onSave(payload);
   };
 
+  const groupNames = new Set(groups.map((g) => g.name));
+
+  // A row with only one side filled in is unfinished, and a second row for an
+  // IdP group already mapped above would be collapsed into one entry (the
+  // backend compares IdP names ignoring case). Either way the saved mapping
+  // would differ from what the form shows -- dropping the only row turns
+  // group sync off -- so the row is flagged and Save stays disabled instead.
+  // A row with both sides empty is a fresh "Add Mapping" and is just skipped.
+  const mappingRowErrors = mappingRows.map((row, i) => {
+    const idpGroup = row.idpGroup.trim();
+    if (!idpGroup && !row.bambuddyGroup) return null;
+    if (!idpGroup || !row.bambuddyGroup) return 'incomplete' as const;
+    const key = idpGroup.toLowerCase();
+    if (mappingRows.slice(0, i).some((prev) => prev.idpGroup.trim().toLowerCase() === key)) return 'duplicate' as const;
+    return null;
+  });
+  const hasMappingErrors = mappingRowErrors.some((e) => e !== null);
+
   const autoLinkOn = form.auto_link_existing_accounts === true;
   const emailVerifiedOn = form.require_email_verified ?? true;
   let requireEmailVerifiedDesc: ReactNode;
@@ -188,6 +233,82 @@ function ProviderForm({
         <p className="text-bambu-gray text-xs mt-1">{t('settings.oidc.form.defaultGroupDesc')}</p>
       </div>
 
+      <div>
+        <label className={labelCls}>{t('settings.oidc.form.groupClaim')}</label>
+        <input
+          className={inputCls}
+          value={form.group_claim ?? 'groups'}
+          onChange={(e) => set('group_claim', e.target.value)}
+          placeholder="groups"
+        />
+        <p className="text-bambu-gray text-xs mt-1">{t('settings.oidc.form.groupClaimDesc')}</p>
+      </div>
+
+      <div>
+        <label className={labelCls}>{t('settings.oidc.form.groupMapping')}</label>
+        <div className="space-y-2">
+          {mappingRows.map((row, i) => {
+            // A row can point at a group name that's since been deleted
+            // (deleting a group doesn't touch any provider's mapping — see
+            // #3107 follow-up). Left alone, that value doesn't match any
+            // <option> and the select just renders as if nothing were
+            // chosen, so the broken row looks identical to an unset one.
+            // Injecting the stale name as its own (disabled) option keeps it
+            // visibly selected, and the row is flagged red until the admin
+            // repoints it or removes it.
+            const isOrphaned = row.bambuddyGroup !== '' && !groupNames.has(row.bambuddyGroup);
+            return (
+              <div key={i}>
+                <div className="flex items-center gap-2">
+                  <input
+                    className={inputCls}
+                    value={row.idpGroup}
+                    onChange={(e) => updateMappingRow(i, { idpGroup: e.target.value })}
+                    placeholder={t('settings.oidc.form.groupMappingIdpGroupPlaceholder')}
+                  />
+                  <span className="text-bambu-gray text-sm shrink-0">&rarr;</span>
+                  <select
+                    className={`${inputCls} ${isOrphaned ? 'border-red-700 dark:border-red-400 text-red-700 dark:text-red-400' : ''}`}
+                    value={row.bambuddyGroup}
+                    onChange={(e) => updateMappingRow(i, { bambuddyGroup: e.target.value })}
+                  >
+                    <option value="">{t('settings.oidc.form.groupMappingSelectGroup')}</option>
+                    {isOrphaned && (
+                      <option value={row.bambuddyGroup}>
+                        {t('settings.oidc.form.groupMappingDeletedGroupOption', { group: row.bambuddyGroup })}
+                      </option>
+                    )}
+                    {groups.map((g) => (
+                      <option key={g.id} value={g.name}>{g.name}</option>
+                    ))}
+                  </select>
+                  <Button variant="secondary" size="sm" onClick={() => removeMappingRow(i)} title={t('common.remove')}>
+                    <Trash2 className="w-4 h-4" />
+                  </Button>
+                </div>
+                {isOrphaned && (
+                  <p className="text-red-700 dark:text-red-400 text-xs mt-1">
+                    {t('settings.oidc.form.groupMappingDeletedGroupWarning')}
+                  </p>
+                )}
+                {mappingRowErrors[i] && (
+                  <p className="text-red-700 dark:text-red-400 text-xs mt-1">
+                    {mappingRowErrors[i] === 'incomplete'
+                      ? t('settings.oidc.form.groupMappingIncompleteRow')
+                      : t('settings.oidc.form.groupMappingDuplicateRow')}
+                  </p>
+                )}
+              </div>
+            );
+          })}
+          <Button variant="secondary" size="sm" onClick={addMappingRow} className="inline-flex items-center gap-2">
+            <Plus className="w-4 h-4" />
+            {t('settings.oidc.form.groupMappingAddRow')}
+          </Button>
+        </div>
+        <p className="text-bambu-gray text-xs mt-1">{t('settings.oidc.form.groupMappingDesc')}</p>
+      </div>
+
       <div className="flex gap-3 pt-2">
         <Button variant="secondary" onClick={onCancel} className="flex-1">
           {t('common.cancel')}
@@ -195,7 +316,7 @@ function ProviderForm({
         <Button
           variant="primary"
           className="flex-1"
-          disabled={!form.name || !form.issuer_url || !form.client_id || (!isEdit && !form.client_secret) || (isEdit && secretChanged && !form.client_secret) || isPending}
+          disabled={!form.name || !form.issuer_url || !form.client_id || (!isEdit && !form.client_secret) || (isEdit && secretChanged && !form.client_secret) || hasMappingErrors || isPending}
           onClick={handleSave}
         >
           {isPending ? t('common.saving') : t('common.save')}
@@ -472,6 +593,8 @@ export function OIDCProviderSettings() {
                     auto_link_existing_accounts: provider.auto_link_existing_accounts,
                     email_claim: provider.email_claim,
                     require_email_verified: provider.require_email_verified,
+                    group_claim: provider.group_claim ?? 'groups',
+                    group_mapping: provider.group_mapping ?? {},
                     icon_url: provider.icon_url ?? undefined,
                     default_group_id: provider.default_group_id ?? null,
                     is_autologin: provider.is_autologin,
@@ -511,6 +634,10 @@ export function OIDCProviderSettings() {
                   <dt className="text-bambu-gray">{t('settings.oidc.form.emailClaim')}</dt>
                   <dd className="text-white font-mono">{provider.email_claim}</dd>
                 </div>
+                <div>
+                  <dt className="text-bambu-gray">{t('settings.oidc.form.groupClaim')}</dt>
+                  <dd className="text-white font-mono">{provider.group_claim ?? 'groups'}</dd>
+                </div>
                 <div>
                   <dt className="text-bambu-gray">{t('settings.oidc.form.requireEmailVerified')}</dt>
                   <dd className={provider.require_email_verified ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}>
@@ -525,6 +652,14 @@ export function OIDCProviderSettings() {
                       : t('settings.oidc.form.defaultGroupViewersFallback')}
                   </dd>
                 </div>
+                <div>
+                  <dt className="text-bambu-gray">{t('settings.oidc.form.groupSync')}</dt>
+                  <dd className={provider.group_mapping && Object.keys(provider.group_mapping).length > 0 ? 'text-green-700 dark:text-green-400' : 'text-bambu-gray'}>
+                    {provider.group_mapping && Object.keys(provider.group_mapping).length > 0
+                      ? t('settings.oidc.form.groupSyncOn')
+                      : t('common.off')}
+                  </dd>
+                </div>
               </dl>
             </CardContent>
           )}

+ 13 - 0
frontend/src/i18n/locales/de.ts

@@ -2957,6 +2957,19 @@ export default {
         defaultGroupViewersFallback: 'Viewers (Standard)',
         autologin: 'Automatische Anmeldung',
         autologinDesc: 'Nicht angemeldete Besucher direkt zu diesem Anbieter weiterleiten. Diese Option kann nur für einen Anbieter aktiv sein.',
+        groupClaim: 'Gruppen-Claim',
+        groupClaimDesc: 'JWT-Claim, der die Gruppen des Benutzers beim Identitätsanbieter enthält. Die meisten Anbieter verwenden \'groups\'; der Wert kann ein JSON-Array oder ein String sein.',
+        groupMapping: 'Gruppen-Mapping',
+        groupMappingDesc: 'Gemappte Gruppen werden bei jeder Anmeldung synchronisiert. Gruppen, die hier nicht genannt sind, bleiben unangetastet, damit manuelle Zuweisungen erhalten bleiben. Leer lassen, um die Gruppensynchronisierung zu deaktivieren.',
+        groupSync: 'Gruppen-Sync',
+        groupSyncOn: 'Aktiv',
+        groupMappingIdpGroupPlaceholder: 'Gruppenname beim Identitätsanbieter',
+        groupMappingSelectGroup: 'Bambuddy-Gruppe wählen…',
+        groupMappingAddRow: 'Zuordnung hinzufügen',
+        groupMappingDeletedGroupOption: '{{group}} (gelöscht)',
+        groupMappingDeletedGroupWarning: 'Diese Bambuddy-Gruppe wurde gelöscht. Wählen Sie einen Ersatz oder entfernen Sie diese Zuordnung.',
+        groupMappingIncompleteRow: 'Geben Sie die Gruppe beim Identitätsanbieter ein und wählen Sie eine Bambuddy-Gruppe, oder entfernen Sie diese Zuordnung.',
+        groupMappingDuplicateRow: 'Diese Gruppe des Identitätsanbieters ist oben bereits zugeordnet. Gruppennamen werden ohne Beachtung der Groß-/Kleinschreibung verglichen.',
       },
     },
 

+ 13 - 0
frontend/src/i18n/locales/en.ts

@@ -2978,6 +2978,19 @@ export default {
         defaultGroupViewersFallback: 'Viewers (default)',
         autologin: 'Autologin',
         autologinDesc: 'Redirect unauthenticated visitors straight to this provider. Only one provider can carry this flag.',
+        groupClaim: 'Group Claim',
+        groupClaimDesc: 'JWT claim that holds the user\'s groups at the identity provider. Most providers use \'groups\'; values can be a JSON array or a string.',
+        groupMapping: 'Group Mapping',
+        groupMappingDesc: 'Mapped groups are synced on every login. Groups not named here are left untouched, so manual assignments survive. Leave empty to disable group sync.',
+        groupMappingIdpGroupPlaceholder: 'Identity provider group name',
+        groupMappingSelectGroup: 'Select Bambuddy group…',
+        groupMappingAddRow: 'Add Mapping',
+        groupMappingDeletedGroupOption: '{{group}} (deleted)',
+        groupMappingDeletedGroupWarning: 'This Bambuddy group has been deleted. Pick a replacement or remove this mapping.',
+        groupMappingIncompleteRow: 'Enter the identity provider group and pick a Bambuddy group, or remove this mapping.',
+        groupMappingDuplicateRow: 'This identity provider group is already mapped above. Group names are compared ignoring case.',
+        groupSync: 'Group Sync',
+        groupSyncOn: 'On',
       },
     },
 

+ 13 - 0
frontend/src/i18n/locales/es.ts

@@ -2959,6 +2959,19 @@ export default {
         defaultGroupViewersFallback: 'Visores (predeterminado)',
         autologin: 'Inicio automático',
         autologinDesc: 'Redirigir a los visitantes no autenticados directamente a este proveedor. Solo un proveedor puede llevar esta marca.',
+        groupClaim: 'Claim de grupos',
+        groupClaimDesc: 'Claim JWT que contiene los grupos del usuario en el proveedor de identidad. La mayoría usa \'groups\'; el valor puede ser un array JSON o una cadena.',
+        groupMapping: 'Mapeo de grupos',
+        groupMappingDesc: 'Los grupos mapeados se sincronizan en cada inicio de sesión. Los grupos que no aparezcan aquí no se tocan, de modo que las asignaciones manuales se conservan. Déjalo vacío para desactivar la sincronización de grupos.',
+        groupSync: 'Sincronización de grupos',
+        groupSyncOn: 'Activa',
+        groupMappingIdpGroupPlaceholder: 'Nombre del grupo en el proveedor de identidad',
+        groupMappingSelectGroup: 'Seleccionar grupo de Bambuddy…',
+        groupMappingAddRow: 'Añadir mapeo',
+        groupMappingDeletedGroupOption: '{{group}} (eliminado)',
+        groupMappingDeletedGroupWarning: 'Este grupo de Bambuddy ha sido eliminado. Elige un reemplazo o quita este mapeo.',
+        groupMappingIncompleteRow: 'Escribe el grupo del proveedor de identidad y elige un grupo de Bambuddy, o quita este mapeo.',
+        groupMappingDuplicateRow: 'Este grupo del proveedor de identidad ya está mapeado más arriba. Los nombres de grupo se comparan sin distinguir mayúsculas y minúsculas.',
       },
     },
 

+ 13 - 0
frontend/src/i18n/locales/fr.ts

@@ -2899,6 +2899,19 @@ export default {
         defaultGroupViewersFallback: 'Viewers (par défaut)',
         autologin: 'Connexion automatique',
         autologinDesc: 'Rediriger les visiteurs non authentifiés directement vers ce fournisseur. Un seul fournisseur peut porter cet indicateur.',
+        groupClaim: 'Claim de groupes',
+        groupClaimDesc: 'Claim JWT contenant les groupes de l\'utilisateur auprès du fournisseur d\'identité. La plupart des fournisseurs utilisent \'groups\' ; la valeur peut être un tableau JSON ou une chaîne.',
+        groupMapping: 'Mappage des groupes',
+        groupMappingDesc: 'Les groupes mappés sont synchronisés à chaque connexion. Les groupes non mentionnés ici ne sont pas modifiés, afin que les affectations manuelles soient conservées. Laisser vide pour désactiver la synchronisation des groupes.',
+        groupSync: 'Synchronisation des groupes',
+        groupSyncOn: 'Active',
+        groupMappingIdpGroupPlaceholder: 'Nom du groupe auprès du fournisseur d\'identité',
+        groupMappingSelectGroup: 'Sélectionner un groupe Bambuddy…',
+        groupMappingAddRow: 'Ajouter un mappage',
+        groupMappingDeletedGroupOption: '{{group}} (supprimé)',
+        groupMappingDeletedGroupWarning: 'Ce groupe Bambuddy a été supprimé. Choisissez un remplaçant ou supprimez ce mappage.',
+        groupMappingIncompleteRow: 'Saisissez le groupe du fournisseur d\'identité et choisissez un groupe Bambuddy, ou supprimez ce mappage.',
+        groupMappingDuplicateRow: 'Ce groupe du fournisseur d\'identité est déjà mappé plus haut. Les noms de groupe sont comparés sans tenir compte de la casse.',
       },
     },
 

+ 13 - 0
frontend/src/i18n/locales/it.ts

@@ -2898,6 +2898,19 @@ export default {
         defaultGroupViewersFallback: 'Viewers (predefinito)',
         autologin: 'Accesso automatico',
         autologinDesc: 'Reindirizza i visitatori non autenticati direttamente a questo provider. Solo un provider può avere questo flag.',
+        groupClaim: 'Claim dei gruppi',
+        groupClaimDesc: 'Claim JWT che contiene i gruppi dell\'utente presso il provider di identità. La maggior parte usa \'groups\'; il valore può essere un array JSON o una stringa.',
+        groupMapping: 'Mappatura gruppi',
+        groupMappingDesc: 'I gruppi mappati vengono sincronizzati a ogni accesso. I gruppi non elencati qui restano invariati, così le assegnazioni manuali vengono preservate. Lascia vuoto per disattivare la sincronizzazione dei gruppi.',
+        groupSync: 'Sincronizzazione gruppi',
+        groupSyncOn: 'Attiva',
+        groupMappingIdpGroupPlaceholder: 'Nome del gruppo nel provider di identità',
+        groupMappingSelectGroup: 'Seleziona gruppo Bambuddy…',
+        groupMappingAddRow: 'Aggiungi mappatura',
+        groupMappingDeletedGroupOption: '{{group}} (eliminato)',
+        groupMappingDeletedGroupWarning: 'Questo gruppo Bambuddy è stato eliminato. Scegli un sostituto o rimuovi questa mappatura.',
+        groupMappingIncompleteRow: 'Inserisci il gruppo del provider di identità e scegli un gruppo Bambuddy, oppure rimuovi questa mappatura.',
+        groupMappingDuplicateRow: 'Questo gruppo del provider di identità è già mappato più sopra. I nomi dei gruppi vengono confrontati senza distinguere maiuscole e minuscole.',
       },
     },
 

+ 13 - 0
frontend/src/i18n/locales/ja.ts

@@ -2956,6 +2956,19 @@ export default {
         defaultGroupViewersFallback: 'Viewers(デフォルト)',
         autologin: '自動サインイン',
         autologinDesc: '未認証の訪問者をこのプロバイダーに直接リダイレクトします。このフラグを付けられるプロバイダーは1つだけです。',
+        groupClaim: 'グループClaim',
+        groupClaimDesc: 'IdP でのユーザーのグループを含む JWT クレーム。ほとんどのプロバイダーは \'groups\' を使用します。値は JSON 配列または文字列です。',
+        groupMapping: 'グループマッピング',
+        groupMappingDesc: 'マッピングされたグループはログインのたびに同期されます。ここに記載のないグループは変更されないため、手動割り当ては保持されます。空欄にするとグループ同期は無効になります。',
+        groupSync: 'グループ同期',
+        groupSyncOn: 'オン',
+        groupMappingIdpGroupPlaceholder: 'IdP のグループ名',
+        groupMappingSelectGroup: 'Bambuddy グループを選択…',
+        groupMappingAddRow: 'マッピングを追加',
+        groupMappingDeletedGroupOption: '{{group}}(削除済み)',
+        groupMappingDeletedGroupWarning: 'この Bambuddy グループは削除されました。代替を選ぶか、このマッピングを削除してください。',
+        groupMappingIncompleteRow: 'IdP のグループ名を入力して Bambuddy グループを選ぶか、このマッピングを削除してください。',
+        groupMappingDuplicateRow: 'この IdP グループは上で既にマッピングされています。グループ名は大文字と小文字を区別せずに比較されます。',
       },
     },
 

+ 14 - 1
frontend/src/i18n/locales/ko.ts

@@ -2799,7 +2799,20 @@ export default {
         defaultGroupDesc: '자동 생성된 사용자에게 할당되는 그룹. 설정되지 않으면 Viewers로 대체됩니다.',
         defaultGroupViewersFallback: 'Viewers (기본값)',
         autologin: '자동 로그인',
-        autologinDesc: '인증되지 않은 방문자를 이 공급자로 바로 리디렉션합니다. 이 플래그를 가질 수 있는 공급자는 하나뿐입니다.'
+        autologinDesc: '인증되지 않은 방문자를 이 공급자로 바로 리디렉션합니다. 이 플래그를 가질 수 있는 공급자는 하나뿐입니다.',
+        groupClaim: '그룹 클레임',
+        groupClaimDesc: 'IdP에서 사용자의 그룹을 담는 JWT 클레임입니다. 대부분의 공급자는 \'groups\'를 사용하며, 값은 JSON 배열 또는 문자열일 수 있습니다.',
+        groupMapping: '그룹 매핑',
+        groupMappingDesc: '매핑된 그룹은 로그인할 때마다 동기화됩니다. 여기에 지정되지 않은 그룹은 변경되지 않으므로 수동 할당이 유지됩니다. 비워 두면 그룹 동기화가 비활성화됩니다.',
+        groupSync: '그룹 동기화',
+        groupSyncOn: '켜짐',
+        groupMappingIdpGroupPlaceholder: 'IdP의 그룹 이름',
+        groupMappingSelectGroup: 'Bambuddy 그룹 선택…',
+        groupMappingAddRow: '매핑 추가',
+        groupMappingDeletedGroupOption: '{{group}} (삭제됨)',
+        groupMappingDeletedGroupWarning: '이 Bambuddy 그룹은 삭제되었습니다. 다른 그룹을 선택하거나 이 매핑을 제거하세요.',
+        groupMappingIncompleteRow: 'IdP의 그룹 이름을 입력하고 Bambuddy 그룹을 선택하거나 이 매핑을 제거하세요.',
+        groupMappingDuplicateRow: '이 IdP 그룹은 위에서 이미 매핑되었습니다. 그룹 이름은 대소문자를 구분하지 않고 비교됩니다.',
       },
       refreshIcon: '아이콘 새로고침',
       removeIcon: '아이콘 제거',

+ 13 - 0
frontend/src/i18n/locales/nl.ts

@@ -2978,6 +2978,19 @@ export default {
         defaultGroupViewersFallback: 'Kijkers (standaard)',
         autologin: 'Automatisch inloggen',
         autologinDesc: 'Stuur niet-geauthenticeerde bezoekers rechtstreeks door naar deze provider. Slechts één provider kan deze vlag hebben.',
+        groupClaim: 'Groepsclaim',
+        groupClaimDesc: 'JWT-claim met de groepen van de gebruiker bij de identiteitsprovider. De meeste providers gebruiken \'groups\'; de waarde kan een JSON-array of een string zijn.',
+        groupMapping: 'Groepskoppeling',
+        groupMappingDesc: 'Gekoppelde groepen worden bij elke login gesynchroniseerd. Groepen die hier niet genoemd worden blijven ongewijzigd, zodat handmatige toewijzingen behouden blijven. Laat leeg om groepssynchronisatie uit te schakelen.',
+        groupSync: 'Groepsynchronisatie',
+        groupSyncOn: 'Aan',
+        groupMappingIdpGroupPlaceholder: 'Groepsnaam bij de identiteitsprovider',
+        groupMappingSelectGroup: 'Bambuddy-groep kiezen…',
+        groupMappingAddRow: 'Koppeling toevoegen',
+        groupMappingDeletedGroupOption: '{{group}} (verwijderd)',
+        groupMappingDeletedGroupWarning: 'Deze Bambuddy-groep is verwijderd. Kies een vervanging of verwijder deze koppeling.',
+        groupMappingIncompleteRow: 'Vul de groep bij de identiteitsprovider in en kies een Bambuddy-groep, of verwijder deze koppeling.',
+        groupMappingDuplicateRow: 'Deze groep van de identiteitsprovider is hierboven al gekoppeld. Groepsnamen worden vergeleken zonder op hoofdletters te letten.',
       },
     },
 

+ 13 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2898,6 +2898,19 @@ export default {
         defaultGroupViewersFallback: 'Viewers (padrão)',
         autologin: 'Login automático',
         autologinDesc: 'Redirecionar visitantes não autenticados diretamente para este provedor. Apenas um provedor pode ter esta marcação.',
+        groupClaim: 'Claim de grupos',
+        groupClaimDesc: 'Claim JWT que contém os grupos do usuário no provedor de identidade. A maioria usa \'groups\'; o valor pode ser um array JSON ou uma string.',
+        groupMapping: 'Mapeamento de grupos',
+        groupMappingDesc: 'Os grupos mapeados são sincronizados a cada login. Grupos não listados aqui não são alterados, preservando atribuições manuais. Deixe vazio para desativar a sincronização de grupos.',
+        groupSync: 'Sincronização de grupos',
+        groupSyncOn: 'Ativa',
+        groupMappingIdpGroupPlaceholder: 'Nome do grupo no provedor de identidade',
+        groupMappingSelectGroup: 'Selecionar grupo do Bambuddy…',
+        groupMappingAddRow: 'Adicionar mapeamento',
+        groupMappingDeletedGroupOption: '{{group}} (excluído)',
+        groupMappingDeletedGroupWarning: 'Este grupo do Bambuddy foi excluído. Escolha um substituto ou remova este mapeamento.',
+        groupMappingIncompleteRow: 'Informe o grupo do provedor de identidade e escolha um grupo do Bambuddy, ou remova este mapeamento.',
+        groupMappingDuplicateRow: 'Este grupo do provedor de identidade já está mapeado acima. Os nomes de grupo são comparados sem diferenciar maiúsculas de minúsculas.',
       },
     },
 

+ 13 - 0
frontend/src/i18n/locales/ru.ts

@@ -2808,6 +2808,19 @@ export default {
         defaultGroupViewersFallback: "Наблюдатели (по умолчанию)",
         autologin: "Автоматический вход",
         autologinDesc: "Сразу перенаправлять неавторизованных посетителей к этому провайдеру. Этот флаг может быть установлен только у одного провайдера.",
+        groupClaim: 'Claim групп',
+        groupClaimDesc: 'JWT-claim, содержащий группы пользователя в провайдере идентификации. Большинство провайдеров используют \'groups\'; значение может быть JSON-массивом или строкой.',
+        groupMapping: 'Сопоставление групп',
+        groupMappingDesc: 'Сопоставленные группы синхронизируются при каждом входе. Группы, не указанные здесь, не изменяются, поэтому ручные назначения сохраняются. Оставьте пустым, чтобы отключить синхронизацию групп.',
+        groupSync: 'Синхронизация групп',
+        groupSyncOn: 'Вкл',
+        groupMappingIdpGroupPlaceholder: 'Название группы в провайдере идентификации',
+        groupMappingSelectGroup: 'Выберите группу Bambuddy…',
+        groupMappingAddRow: 'Добавить сопоставление',
+        groupMappingDeletedGroupOption: '{{group}} (удалена)',
+        groupMappingDeletedGroupWarning: 'Эта группа Bambuddy была удалена. Выберите замену или удалите это сопоставление.',
+        groupMappingIncompleteRow: 'Укажите группу провайдера идентификации и выберите группу Bambuddy или удалите это сопоставление.',
+        groupMappingDuplicateRow: 'Эта группа провайдера идентификации уже сопоставлена выше. Названия групп сравниваются без учёта регистра.',
       },
     },
     encryption: {

+ 13 - 0
frontend/src/i18n/locales/sv.ts

@@ -2977,6 +2977,19 @@ export default {
         defaultGroupViewersFallback: 'Åskådare (standard)',
         autologin: 'Autologin',
         autologinDesc: 'Omdirigera icke autentiserade besökare direkt till denna leverantör. Endast en leverantör kan ha denna flagga.',
+        groupClaim: 'Gruppanspråk',
+        groupClaimDesc: 'JWT-anspråk som innehåller användarens grupper hos identitetsleverantören. De flesta leverantörer använder \'groups\'; värdet kan vara en JSON-array eller en sträng.',
+        groupMapping: 'Gruppmappning',
+        groupMappingDesc: 'Mappade grupper synkroniseras vid varje inloggning. Grupper som inte nämns här lämnas orörda, så att manuella tilldelningar bevaras. Lämna tomt för att inaktivera gruppsynkronisering.',
+        groupSync: 'Gruppsynkronisering',
+        groupSyncOn: 'På',
+        groupMappingIdpGroupPlaceholder: 'Gruppnamn hos identitetsleverantören',
+        groupMappingSelectGroup: 'Välj Bambuddy-grupp…',
+        groupMappingAddRow: 'Lägg till mappning',
+        groupMappingDeletedGroupOption: '{{group}} (borttagen)',
+        groupMappingDeletedGroupWarning: 'Denna Bambuddy-grupp har tagits bort. Välj en ersättning eller ta bort denna mappning.',
+        groupMappingIncompleteRow: 'Ange gruppen hos identitetsleverantören och välj en Bambuddy-grupp, eller ta bort denna mappning.',
+        groupMappingDuplicateRow: 'Den här gruppen hos identitetsleverantören är redan mappad ovan. Gruppnamn jämförs utan hänsyn till versaler och gemener.',
       },
     },
 

+ 13 - 0
frontend/src/i18n/locales/tr.ts

@@ -2959,6 +2959,19 @@ export default {
         defaultGroupViewersFallback: 'Viewers (varsayılan)',
         autologin: 'Otomatik oturum açma',
         autologinDesc: 'Kimlik doğrulaması yapılmamış ziyaretçileri doğrudan bu sağlayıcıya yönlendir. Bu işareti yalnızca bir sağlayıcı taşıyabilir.',
+        groupClaim: 'Grup talebi',
+        groupClaimDesc: 'Kimlik sağlayıcıdaki kullanıcının gruplarını içeren JWT talebi. Çoğu sağlayıcı \'groups\' kullanır; değer bir JSON dizisi veya dize olabilir.',
+        groupMapping: 'Grup Eşlemesi',
+        groupMappingDesc: 'Eşlenen gruplar her oturum açmada senkronize edilir. Burada adı geçmeyen gruplara dokunulmaz, böylece manuel atamalar korunur. Grup senkronizasyonunu devre dışı bırakmak için boş bırakın.',
+        groupSync: 'Grup senkronizasyonu',
+        groupSyncOn: 'Açık',
+        groupMappingIdpGroupPlaceholder: 'Kimlik sağlayıcıdaki grup adı',
+        groupMappingSelectGroup: 'Bambuddy grubu seç…',
+        groupMappingAddRow: 'Eşleme ekle',
+        groupMappingDeletedGroupOption: '{{group}} (silindi)',
+        groupMappingDeletedGroupWarning: 'Bu Bambuddy grubu silinmiş. Bir yenisini seçin veya bu eşlemeyi kaldırın.',
+        groupMappingIncompleteRow: 'Kimlik sağlayıcıdaki grubu girin ve bir Bambuddy grubu seçin ya da bu eşlemeyi kaldırın.',
+        groupMappingDuplicateRow: 'Bu kimlik sağlayıcı grubu yukarıda zaten eşlenmiş. Grup adları büyük/küçük harf ayrımı yapılmadan karşılaştırılır.',
       },
     },
 

+ 13 - 0
frontend/src/i18n/locales/uk.ts

@@ -2975,6 +2975,19 @@ export default {
         defaultGroupViewersFallback: "Глядачі (за замовчуванням)",
         autologin: "Автовхід",
         autologinDesc: "Одразу перенаправляти неавтентифікованих відвідувачів до цього постачальника. Автоматичний вхід можна ввімкнути лише для одного постачальника.",
+        groupClaim: 'Заява груп',
+        groupClaimDesc: 'JWT-заява, що містить групи користувача у постачальника ідентичності. Більшість постачальників використовують \'groups\'; значення може бути JSON-масивом або рядком.',
+        groupMapping: 'Зіставлення груп',
+        groupMappingDesc: 'Зіставлені групи синхронізуються при кожному вході. Групи, не вказані тут, не змінюються, тому ручні призначення зберігаються. Залиште порожнім, щоб вимкнути синхронізацію груп.',
+        groupSync: 'Синхронізація груп',
+        groupSyncOn: 'Увімк.',
+        groupMappingIdpGroupPlaceholder: 'Назва групи у постачальника ідентичності',
+        groupMappingSelectGroup: 'Виберіть групу Bambuddy…',
+        groupMappingAddRow: 'Додати зіставлення',
+        groupMappingDeletedGroupOption: '{{group}} (видалено)',
+        groupMappingDeletedGroupWarning: 'Цю групу Bambuddy було видалено. Виберіть заміну або видаліть це зіставлення.',
+        groupMappingIncompleteRow: 'Вкажіть групу постачальника ідентичності та виберіть групу Bambuddy або видаліть це зіставлення.',
+        groupMappingDuplicateRow: 'Цю групу постачальника ідентичності вже зіставлено вище. Назви груп порівнюються без урахування регістру.',
       },
     },
 

+ 13 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -2943,6 +2943,19 @@ export default {
         defaultGroupViewersFallback: 'Viewers(默认)',
         autologin: '自动登录',
         autologinDesc: '将未认证的访问者直接重定向到该提供商。只有一个提供商可以携带此标志。',
+        groupClaim: '组 Claim',
+        groupClaimDesc: '包含用户在身份提供者处所属组的 JWT 声明。大多数提供者使用 \'groups\';值可以是 JSON 数组或字符串。',
+        groupMapping: '组映射',
+        groupMappingDesc: '映射的组会在每次登录时同步。此处未列出的组不会被更改,因此手动分配会保留。留空可禁用组同步。',
+        groupSync: '组同步',
+        groupSyncOn: '开启',
+        groupMappingIdpGroupPlaceholder: '身份提供者中的组名',
+        groupMappingSelectGroup: '选择 Bambuddy 组…',
+        groupMappingAddRow: '添加映射',
+        groupMappingDeletedGroupOption: '{{group}}(已删除)',
+        groupMappingDeletedGroupWarning: '此 Bambuddy 组已被删除。请选择替代组或移除此映射。',
+        groupMappingIncompleteRow: '请输入身份提供者中的组名并选择一个 Bambuddy 组,或移除此映射。',
+        groupMappingDuplicateRow: '此身份提供者组已在上方映射。组名比较时不区分大小写。',
       },
     },
 

+ 13 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -2943,6 +2943,19 @@ export default {
         defaultGroupViewersFallback: 'Viewers(預設)',
         autologin: '自動登入',
         autologinDesc: '將未驗證的訪客直接重新導向至此提供者。此旗標僅能由一個提供者持有。',
+        groupClaim: '群組 Claim',
+        groupClaimDesc: '包含使用者在身分提供者所屬群組的 JWT 宣告。大多數提供者使用 \'groups\';值可以是 JSON 陣列或字串。',
+        groupMapping: '群組對應',
+        groupMappingDesc: '對應的群組會在每次登入時同步。此處未列出的群組不會被變更,因此手動分配會保留。留空可停用群組同步。',
+        groupSync: '群組同步',
+        groupSyncOn: '開啟',
+        groupMappingIdpGroupPlaceholder: '身分提供者中的群組名稱',
+        groupMappingSelectGroup: '選擇 Bambuddy 群組…',
+        groupMappingAddRow: '新增對應',
+        groupMappingDeletedGroupOption: '{{group}}(已刪除)',
+        groupMappingDeletedGroupWarning: '此 Bambuddy 群組已被刪除。請選擇替代群組或移除此對應。',
+        groupMappingIncompleteRow: '請輸入身分提供者中的群組名稱並選擇一個 Bambuddy 群組,或移除此對應。',
+        groupMappingDuplicateRow: '此身分提供者群組已在上方對應。群組名稱比對時不區分大小寫。',
       },
     },
 

+ 2 - 2
frontend/src/pages/LoginPage.tsx

@@ -6,7 +6,7 @@ import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { useTheme } from '../contexts/ThemeContext';
 import { X, Mail, Shield, Smartphone, Key } from 'lucide-react';
-import { api, type LoginResponse, type OIDCProvider, type TokenPersistence } from '../api/client';
+import { api, type LoginResponse, type OIDCProviderPublic, type TokenPersistence } from '../api/client';
 import { Card, CardHeader, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 
@@ -79,7 +79,7 @@ function OIDCProviderButton({
   onClick,
   disabled,
 }: {
-  provider: OIDCProvider;
+  provider: OIDCProviderPublic;
   onClick: () => void;
   disabled: boolean;
 }) {