Browse Source

fix(oidc): Allow `auto_link_existing_accounts` with custom email claims (Azure Entra ID) (#1142)

chore(i18n): extend parity gate to all locales with strict/info tiers
Sn0rrii 4 weeks ago
parent
commit
78408856cd

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 10 - 9
UPDATING.md

@@ -1,9 +1,8 @@
 # Updating Bambuddy
 
-> **One-time note for 0.2.2.x → 0.2.3:** the in-app **Update** button does not
-> reliably perform this specific migration. Do this one upgrade from the
-> command line using the steps below. Once you're on 0.2.3, the in-app Update
-> button works normally again for all future releases.
+> **0.2.3 note:** the in-app **Update** button is unreliable when upgrading from
+> older releases. Use the commands below instead — they cover every supported
+> install path and are safe to run repeatedly.
 
 Pick the section that matches how Bambuddy was installed.
 
@@ -75,7 +74,10 @@ These installs have no `.git` directory, so neither `update.sh` nor a plain
 
 ```bash
 # 1. Back up your stateful data
-Create and download a backup via Bambuddy Settings -> Backup -> Local Backup
+sudo systemctl stop bambuddy
+sudo tar czf ~/bambuddy-backup.tgz -C /opt/bambuddy \
+  data bambuddy.db bambuddy.db-shm bambuddy.db-wal \
+  virtual_printer archive projects icons .env 2>/dev/null || true
 
 # 2. Remove the old install and reinstall via install.sh
 sudo rm -rf /opt/bambuddy
@@ -83,10 +85,9 @@ curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/insta
   -o /tmp/install.sh && sudo bash /tmp/install.sh --path /opt/bambuddy
 
 # 3. Restore your data
-Restore your backup via Bambuddy -> Settings -> Backup -> Local Backup
-
-# 4. Restart Bambuddy
-sudo systemctl restart bambuddy
+sudo systemctl stop bambuddy
+sudo tar xzf ~/bambuddy-backup.tgz -C /opt/bambuddy
+sudo systemctl start bambuddy
 ```
 
 ---

+ 6 - 7
backend/app/api/routes/mfa.py

@@ -389,15 +389,14 @@ def _is_valid_email_shaped(value: str | None) -> bool:
 
 
 def _enforce_auto_link_safety(provider: OIDCProvider) -> None:
-    """Raise HTTP 422 if auto_link_existing_accounts is on without safe email settings.
+    """Raise HTTP 422 if auto_link_existing_accounts is on with an unsafe combined state.
 
-    SEC-1/SEC-6: auto_link is only safe when require_email_verified=True AND
-    email_claim='email'. Called after ORM construction (create) and after the
-    setattr loop (update) so partial-update bypasses are also caught.
+    SEC-1: only Fall B (email_claim='email' + require_email_verified=False) is unsafe —
+    an attacker-controlled IdP could present an unverified email that matches a local account.
+    Fall C (custom claim) never performs an email_verified check, so auto_link is safe there.
+    Called after ORM construction (create) and after the setattr loop (update).
     """
-    if provider.auto_link_existing_accounts and (
-        not provider.require_email_verified or provider.email_claim != "email"
-    ):
+    if provider.auto_link_existing_accounts and provider.email_claim == "email" and not provider.require_email_verified:
         raise HTTPException(
             status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
             detail=AUTO_LINK_REQUIREMENTS_ERROR,

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

@@ -276,6 +276,93 @@ async def _migrate_normalize_printer_ids(conn) -> None:
             await conn.execute(text("UPDATE api_keys SET printer_ids = NULL WHERE printer_ids::text = '[]'"))
 
 
+async def _migrate_update_auto_link_constraint(conn) -> None:
+    """Update the auto_link CHECK constraint to allow Fall C (custom email claim).
+
+    Old formula: auto_link = FALSE OR (require_ev = TRUE AND email_claim = 'email')
+    New formula: auto_link = FALSE OR email_claim != 'email' OR require_ev = TRUE
+
+    Only Fall B (email_claim='email' + require_ev=False) remains blocked.
+    Fall C (custom claim, e.g. Azure preferred_username/upn) is now allowed.
+
+    PostgreSQL: DROP CONSTRAINT IF EXISTS + ADD new formula via _safe_execute (idempotent).
+    SQLite: table recreation when old formula is detected in sqlite_master (idempotent).
+    """
+    from sqlalchemy import text
+
+    _NEW_FORMULA = "auto_link_existing_accounts = FALSE OR email_claim != 'email' OR require_email_verified = TRUE"
+    _CONSTRAINT_NAME = "ck_auto_link_requires_verified_email_claim"
+
+    if not is_sqlite():
+        await _safe_execute(conn, f"ALTER TABLE oidc_providers DROP CONSTRAINT IF EXISTS {_CONSTRAINT_NAME}")
+        await _safe_execute(
+            conn,
+            f"ALTER TABLE oidc_providers ADD CONSTRAINT {_CONSTRAINT_NAME} CHECK ({_NEW_FORMULA})",
+        )
+    else:
+        row = (
+            await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='oidc_providers'"))
+        ).fetchone()
+        # Only recreate if the old (more restrictive) formula is still present.
+        # Fresh installs created with the new __table_args__ already have the correct formula.
+        # Installs without any constraint (pre-SEC-1 upgrades) are skipped — app-level guards suffice.
+        if row and "require_email_verified = TRUE AND email_claim = 'email'" in row[0]:
+            try:
+                async with conn.begin_nested():
+                    await conn.execute(text("DROP TABLE IF EXISTS oidc_providers_v2"))
+                    await conn.execute(
+                        text(
+                            "CREATE TABLE oidc_providers_v2 ("
+                            "id INTEGER NOT NULL, "
+                            "name VARCHAR(100) NOT NULL, "
+                            "issuer_url VARCHAR(500) NOT NULL, "
+                            "client_id VARCHAR(255) NOT NULL, "
+                            "client_secret VARCHAR(512) NOT NULL, "
+                            "scopes VARCHAR(500), "
+                            "is_enabled BOOLEAN, "
+                            "auto_create_users BOOLEAN, "
+                            "auto_link_existing_accounts BOOLEAN DEFAULT 0, "
+                            "email_claim VARCHAR(64) DEFAULT 'email', "
+                            "require_email_verified BOOLEAN DEFAULT 1, "
+                            "icon_url TEXT, "
+                            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                            "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                            "PRIMARY KEY (id), "
+                            f"UNIQUE (name), "
+                            f"CONSTRAINT {_CONSTRAINT_NAME} CHECK ({_NEW_FORMULA})"
+                            ")"
+                        )
+                    )
+                    await conn.execute(
+                        text(
+                            "INSERT INTO oidc_providers_v2 "
+                            "(id, name, issuer_url, client_id, client_secret, scopes, is_enabled, "
+                            "auto_create_users, auto_link_existing_accounts, email_claim, "
+                            "require_email_verified, icon_url, created_at, updated_at) "
+                            "SELECT id, name, issuer_url, client_id, client_secret, scopes, is_enabled, "
+                            "auto_create_users, auto_link_existing_accounts, email_claim, "
+                            "require_email_verified, icon_url, created_at, updated_at "
+                            "FROM oidc_providers"
+                        )
+                    )
+                    original = (await conn.execute(text("SELECT count(*) FROM oidc_providers"))).scalar_one()
+                    copied = (await conn.execute(text("SELECT count(*) FROM oidc_providers_v2"))).scalar_one()
+                    if copied != original:
+                        raise RuntimeError(
+                            f"auto_link constraint migration: row count mismatch after copy "
+                            f"({original} in source, {copied} in copy)"
+                        )
+                    await conn.execute(text("DROP TABLE oidc_providers"))
+                    await conn.execute(text("ALTER TABLE oidc_providers_v2 RENAME TO oidc_providers"))
+            except Exception as exc:
+                logger.error(
+                    "auto_link constraint update (SQLite table recreation) FAILED: %s",
+                    exc,
+                    exc_info=True,
+                )
+                raise
+
+
 async def run_migrations(conn):
     """Run all schema migrations and data backfills on startup.
 
@@ -1569,20 +1656,19 @@ async def run_migrations(conn):
         await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN require_email_verified BOOLEAN DEFAULT 1")
     else:
         await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN require_email_verified BOOLEAN DEFAULT true")
-    # SEC-1 backfill: reset auto_link on rows where the combined state is unsafe.
-    # Runs BEFORE the CHECK constraint below so existing installs that have
-    # auto_link=TRUE + unsafe email settings self-heal rather than failing when
-    # PostgreSQL validates ADD CONSTRAINT against existing rows ("check constraint
-    # is violated by some row").  On fresh installs the column defaults guarantee
-    # this UPDATE matches zero rows.  TRUE/FALSE literals are accepted by both
-    # SQLite (≥ 3.23) and PostgreSQL — no dialect branch needed.
+    # SEC-1 backfill: reset auto_link only for Fall B (email_claim='email' + require_email_verified=False).
+    # Fall C (custom claim) is now allowed to use auto_link — do NOT reset those rows.
+    # Runs BEFORE the CHECK constraint below so Fall B rows self-heal rather than failing
+    # PostgreSQL's "check constraint is violated by some row" on ADD CONSTRAINT.
+    # On fresh installs the column defaults guarantee this UPDATE matches zero rows.
+    # TRUE/FALSE literals are accepted by both SQLite (≥ 3.23) and PostgreSQL — no dialect branch needed.
     try:
         async with conn.begin_nested():
             await conn.execute(
                 text(
                     "UPDATE oidc_providers SET auto_link_existing_accounts = FALSE "
                     "WHERE auto_link_existing_accounts = TRUE "
-                    "AND (require_email_verified = FALSE OR email_claim != 'email')"
+                    "AND email_claim = 'email' AND require_email_verified = FALSE"
                 )
             )
     except Exception as exc:
@@ -1594,16 +1680,16 @@ async def run_migrations(conn):
         )
         raise
 
-    # SEC-1/SEC-6: Add DB-level CHECK constraint for existing PostgreSQL installs.
+    # SEC-1: Add DB-level CHECK constraint for existing PostgreSQL installs.
     # SQLite does not support ALTER TABLE ADD CONSTRAINT — handled by __table_args__ at creation.
-    # Runs AFTER the backfill so legacy unsafe rows don't fail constraint validation.
+    # Runs AFTER the backfill so Fall B rows don't fail constraint validation.
     if not is_sqlite():
         try:
             async with conn.begin_nested():
                 await conn.execute(
                     text(
                         "ALTER TABLE oidc_providers ADD CONSTRAINT ck_auto_link_requires_verified_email_claim "
-                        "CHECK (auto_link_existing_accounts = FALSE OR (require_email_verified = TRUE AND email_claim = 'email'))"
+                        "CHECK (auto_link_existing_accounts = FALSE OR email_claim != 'email' OR require_email_verified = TRUE)"
                     )
                 )
         except (OperationalError, ProgrammingError) as exc:
@@ -1616,6 +1702,13 @@ async def run_migrations(conn):
                 )
                 raise
 
+    # Migration: Update auto_link CHECK constraint formula (existing installs).
+    # Existing PostgreSQL installs that ran the ADD CONSTRAINT above with the old formula
+    # (or a previous version of this code) need an explicit DROP + ADD to update it.
+    # For SQLite, the table is recreated with the new constraint formula if the old formula
+    # is still present in sqlite_master (SQLite cannot ALTER TABLE DROP/ADD CONSTRAINT).
+    await _migrate_update_auto_link_constraint(conn)
+
     # Migration: Add password_changed_at to users (M-R7-B)
     # Tracks the last time a user's password was changed/reset.  JWTs whose iat
     # predates this timestamp are rejected in all six auth validation paths.

+ 4 - 4
backend/app/models/oidc_provider.py

@@ -22,11 +22,11 @@ class OIDCProvider(Base):
 
     __tablename__ = "oidc_providers"
     __table_args__ = (
-        # DB-level enforcement of SEC-1/SEC-6: auto_link is only safe when
-        # require_email_verified=True AND email_claim='email'. Enforced on new
-        # installations; existing tables get this via the PostgreSQL-only migration.
+        # DB-level enforcement of SEC-1: blocks only Fall B (email_claim='email' + require_ev=False).
+        # Fall C (custom claim) is safe — no email_verified gate on that path.
+        # Enforced on new installations; existing tables updated via _migrate_update_auto_link_constraint.
         CheckConstraint(
-            "auto_link_existing_accounts = FALSE OR (require_email_verified = TRUE AND email_claim = 'email')",
+            "auto_link_existing_accounts = FALSE OR email_claim != 'email' OR require_email_verified = TRUE",
             name="ck_auto_link_requires_verified_email_claim",
         ),
     )

+ 12 - 9
backend/app/schemas/auth.py

@@ -297,7 +297,7 @@ class AdminDisable2FARequest(BaseModel):
 
 
 AUTO_LINK_REQUIREMENTS_ERROR = (
-    "auto_link_existing_accounts requires require_email_verified=True and email_claim='email'"
+    "auto_link_existing_accounts requires require_email_verified=True when email_claim='email'"
 )
 
 
@@ -398,12 +398,12 @@ class OIDCProviderCreate(BaseModel):
     def validate_icon_url(cls, v: str | None) -> str | None:
         return _validate_icon_url(v)
 
-    # SEC-1 + SEC-6: auto_link requires both require_email_verified=True AND email_claim="email".
-    # Fall B (require_email_verified=False) accepts absent email_verified → account-takeover risk.
-    # Fall C (custom claim) skips email_verified entirely → same risk.
+    # SEC-1: auto_link with email_claim='email' requires require_email_verified=True.
+    # Fall B (require_email_verified=False + email_claim='email') accepts absent email_verified → account-takeover risk.
+    # Fall C (custom claim != 'email') is safe: no email_verified gate on that path regardless of require_email_verified.
     @model_validator(mode="after")
     def check_auto_link_requires_verified(self) -> "OIDCProviderCreate":
-        if self.auto_link_existing_accounts and (not self.require_email_verified or self.email_claim != "email"):
+        if self.auto_link_existing_accounts and self.email_claim == "email" and not self.require_email_verified:
             raise ValueError(AUTO_LINK_REQUIREMENTS_ERROR)
         return self
 
@@ -444,13 +444,16 @@ class OIDCProviderUpdate(BaseModel):
     def validate_icon_url(cls, v: str | None) -> str | None:
         return _validate_icon_url(v)
 
-    # SEC-1 + SEC-6 (schema-level): blocks only when both conflicting fields arrive
-    # in the same request. Partial updates spanning two requests are caught by the
+    # SEC-1 (schema-level): blocks only when auto_link=True + email_claim='email' + require_email_verified=False
+    # arrive in the same request. email_claim=None means the request leaves it unchanged (still 'email' by default),
+    # so that is also treated as 'email'. Partial updates spanning two requests are caught by the
     # Combined-State-Guard in the route handler after the setattr loop.
     @model_validator(mode="after")
     def check_auto_link_requires_verified(self) -> "OIDCProviderUpdate":
-        if self.auto_link_existing_accounts is True and (
-            self.require_email_verified is False or (self.email_claim is not None and self.email_claim != "email")
+        if (
+            self.auto_link_existing_accounts is True
+            and self.require_email_verified is False
+            and (self.email_claim is None or self.email_claim == "email")
         ):
             raise ValueError(AUTO_LINK_REQUIREMENTS_ERROR)
         return self

+ 213 - 13
backend/tests/integration/test_mfa_api.py

@@ -3522,8 +3522,12 @@ class TestOIDCEmailClaimResolution:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_auto_link_blocked_with_custom_claim_create(self, async_client: AsyncClient):
-        """SEC-6: auto_link + email_claim!='email' must be rejected at schema level on CREATE (422)."""
+    async def test_auto_link_allowed_with_custom_claim_create(self, async_client: AsyncClient):
+        """Fall C: auto_link + email_claim!='email' must be accepted on CREATE (201).
+
+        Custom claims (e.g. Azure preferred_username/upn) never perform an email_verified
+        check, so auto_link is safe regardless of require_email_verified.
+        """
         admin_token = await _setup_and_login(async_client, "sec6c_adm", "Sec6CAdm123!")
         resp = await async_client.post(
             "/api/v1/auth/oidc/providers",
@@ -3538,12 +3542,17 @@ class TestOIDCEmailClaimResolution:
             },
             headers={"Authorization": f"Bearer {admin_token}"},
         )
-        assert resp.status_code == 422
+        assert resp.status_code == 201
+        assert resp.json()["auto_link_existing_accounts"] is True
+        assert resp.json()["email_claim"] == "upn"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_auto_link_blocked_with_custom_claim_update(self, async_client: AsyncClient):
-        """SEC-6: auto_link=True + email_claim='upn' in same UPDATE request → 422 (T4)."""
+    async def test_auto_link_allowed_with_custom_claim_update(self, async_client: AsyncClient):
+        """Fall C: auto_link=True + email_claim='upn' in same UPDATE request → 200.
+
+        Custom claims never perform an email_verified check, so auto_link is safe.
+        """
         admin_token = await _setup_and_login(async_client, "sec6u_adm", "Sec6UAdm123!")
         create_resp = await async_client.post(
             "/api/v1/auth/oidc/providers",
@@ -3563,7 +3572,9 @@ class TestOIDCEmailClaimResolution:
             json={"auto_link_existing_accounts": True, "email_claim": "upn"},
             headers={"Authorization": f"Bearer {admin_token}"},
         )
-        assert resp.status_code == 422
+        assert resp.status_code == 200
+        assert resp.json()["auto_link_existing_accounts"] is True
+        assert resp.json()["email_claim"] == "upn"
 
     # ── Combined-State-Guard (partial updates across two requests) ─────────────
 
@@ -3602,8 +3613,8 @@ class TestOIDCEmailClaimResolution:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_partial_update_guard_email_claim(self, async_client: AsyncClient):
-        """SEC-6 Combined-State-Guard: email_claim='upn' then auto_link=True → 422 (T1 email_claim path)."""
+    async def test_partial_update_custom_claim_then_auto_link_allowed(self, async_client: AsyncClient):
+        """Fall C: email_claim='upn' first, then auto_link=True → both 200 (custom claim is safe)."""
         admin_token = await _setup_and_login(async_client, "pg_ec_adm", "PgEc123!")
         create_resp = await async_client.post(
             "/api/v1/auth/oidc/providers",
@@ -3631,7 +3642,44 @@ class TestOIDCEmailClaimResolution:
             json={"auto_link_existing_accounts": True},
             headers={"Authorization": f"Bearer {admin_token}"},
         )
-        assert upd2.status_code == 422
+        assert upd2.status_code == 200
+        assert upd2.json()["auto_link_existing_accounts"] is True
+        assert upd2.json()["email_claim"] == "upn"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_partial_update_auto_link_then_custom_claim_allowed(self, async_client: AsyncClient):
+        """Fall C: auto_link=True first (email_claim='email', safe), then email_claim='upn' → both 200."""
+        admin_token = await _setup_and_login(async_client, "pg_al_ec_adm", "PgAlEc123!")
+        create_resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "PG-AutoLink-Claim-Test",
+                "issuer_url": "https://pg-al-ec.test",
+                "client_id": "pg-al-ec-client",
+                "client_secret": "sec",
+                "scopes": "openid email profile",
+            },
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert create_resp.status_code == 201
+        provider_id = create_resp.json()["id"]
+
+        upd1 = await async_client.put(
+            f"/api/v1/auth/oidc/providers/{provider_id}",
+            json={"auto_link_existing_accounts": True},
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert upd1.status_code == 200
+
+        upd2 = await async_client.put(
+            f"/api/v1/auth/oidc/providers/{provider_id}",
+            json={"email_claim": "preferred_username"},
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert upd2.status_code == 200
+        assert upd2.json()["auto_link_existing_accounts"] is True
+        assert upd2.json()["email_claim"] == "preferred_username"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -4009,7 +4057,11 @@ class TestOIDCEmailResolutionExtra:
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_combined_state_guard_email_claim_inverse_order(self, async_client: AsyncClient):
-        """T4: Combined-State-Guard — set auto_link=True first, then switch email_claim to custom."""
+        """Fall C: auto_link=True first, then switch email_claim to custom → both 200 (now allowed).
+
+        Custom claims never perform an email_verified check, so switching to a custom claim
+        while auto_link is on transitions from Fall A to Fall C — both are safe.
+        """
         admin_token = await _setup_and_login(async_client, "inv_ec_adm", "InvEc123!")
         create_resp = await async_client.post(
             "/api/v1/auth/oidc/providers",
@@ -4025,7 +4077,7 @@ class TestOIDCEmailResolutionExtra:
         assert create_resp.status_code == 201
         provider_id = create_resp.json()["id"]
 
-        # First: enable auto_link (safe — email_claim="email", require_ev=True)
+        # First: enable auto_link (Fall A — email_claim='email', require_ev=True)
         upd1 = await async_client.put(
             f"/api/v1/auth/oidc/providers/{provider_id}",
             json={"auto_link_existing_accounts": True},
@@ -4033,10 +4085,158 @@ class TestOIDCEmailResolutionExtra:
         )
         assert upd1.status_code == 200
 
-        # Second: switch email_claim to custom while auto_link is on → unsafe combined state
+        # Second: switch to custom claim → Fall C, still safe
         upd2 = await async_client.put(
             f"/api/v1/auth/oidc/providers/{provider_id}",
             json={"email_claim": "preferred_username"},
             headers={"Authorization": f"Bearer {admin_token}"},
         )
-        assert upd2.status_code == 422
+        assert upd2.status_code == 200
+        assert upd2.json()["auto_link_existing_accounts"] is True
+        assert upd2.json()["email_claim"] == "preferred_username"
+
+
+# ===========================================================================
+# E2E: Fall C (custom email claim) auto-link actually links existing user
+# ===========================================================================
+
+
+class TestOIDCFallCAutoLinkE2E:
+    """OIDC callback with email_claim='preferred_username' (Fall C / Azure Entra ID)
+    must auto-link an existing local user when auto_link_existing_accounts=True.
+
+    This test exercises _resolve_provider_email Fall C and the auto-link path in
+    oidc_callback — a regression in either would silently drop the link without
+    being caught by the configuration-layer tests.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_fall_c_auto_link_links_existing_user_via_callback(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        from unittest.mock import AsyncMock, MagicMock, patch
+
+        from sqlalchemy import select as sa_select
+
+        from backend.app.core.auth import get_password_hash
+        from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
+
+        issuer = "https://entra.fallc.example.com"
+        nonce = secrets.token_urlsafe(32)
+        code_verifier = secrets.token_urlsafe(48)
+
+        # ── 1. Local user that should be linked ──────────────────────────────
+        alice = User(
+            username="fallc_alice",
+            email="alice.fallc@example.com",
+            password_hash=get_password_hash(secrets.token_urlsafe(16)),
+            role="user",
+            is_active=True,
+        )
+        db_session.add(alice)
+        await db_session.flush()
+
+        # ── 2. Provider: Fall C config (preferred_username, no email_verified) ─
+        provider = OIDCProvider(
+            name="AzureEntraFallC",
+            issuer_url=issuer,
+            client_id="azure-client",
+            _client_secret_enc="azure-secret",
+            scopes="openid profile",
+            is_enabled=True,
+            auto_link_existing_accounts=True,
+            auto_create_users=False,
+            email_claim="preferred_username",
+            require_email_verified=False,
+        )
+        db_session.add(provider)
+        await db_session.flush()
+
+        # ── 3. OIDC state token ───────────────────────────────────────────────
+        state = secrets.token_urlsafe(32)
+        db_session.add(
+            AuthEphemeralToken(
+                token=state,
+                token_type="oidc_state",
+                provider_id=provider.id,
+                nonce=nonce,
+                code_verifier=code_verifier,
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
+            )
+        )
+        await db_session.commit()
+
+        # ── 4. Mock HTTP + JWT ────────────────────────────────────────────────
+        fake_discovery = {
+            "issuer": issuer,
+            "token_endpoint": f"{issuer}/token",
+            "jwks_uri": f"{issuer}/.well-known/jwks.json",
+        }
+        fake_token = {"access_token": "acc_tok", "id_token": "fake.id.token"}
+        # Fall C: preferred_username carries the email; no email_verified key at all
+        fake_claims = {
+            "sub": "azure-sub-alice",
+            "preferred_username": "alice.fallc@example.com",
+            "iss": issuer,
+            "aud": "azure-client",
+            "nonce": nonce,
+            "exp": 9_999_999_999,
+        }
+
+        disc_resp = AsyncMock()
+        disc_resp.raise_for_status = MagicMock()
+        disc_resp.json = MagicMock(return_value=fake_discovery)
+
+        token_resp = AsyncMock()
+        token_resp.json = MagicMock(return_value=fake_token)
+
+        jwks_resp = AsyncMock()
+        jwks_resp.raise_for_status = MagicMock()
+        jwks_resp.json = MagicMock(return_value={})
+
+        mock_http = AsyncMock()
+        mock_http.get = AsyncMock(side_effect=[disc_resp, jwks_resp])
+        mock_http.post = AsyncMock(return_value=token_resp)
+
+        mock_signing_key = MagicMock()
+        mock_signing_key.key = "fake_key"
+
+        with (
+            patch("backend.app.api.routes.mfa.httpx.AsyncClient") as mock_httpx_cls,
+            patch("backend.app.api.routes.mfa.jwt.decode", return_value=fake_claims),
+            patch("backend.app.api.routes.mfa.PyJWKClient") as mock_jwks_cls,
+        ):
+            mock_httpx_cls.return_value.__aenter__ = AsyncMock(return_value=mock_http)
+            mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=False)
+            mock_jwks_cls.return_value.get_signing_key_from_jwt.return_value = mock_signing_key
+
+            callback_resp = await async_client.get(
+                f"/api/v1/auth/oidc/callback?code=fake_code&state={state}",
+                follow_redirects=False,
+            )
+
+        assert callback_resp.status_code == 302, callback_resp.text
+        location = callback_resp.headers.get("location", "")
+        assert "oidc_token=" in location, f"Expected oidc_token in redirect, got: {location}"
+
+        # ── 5. Exchange token → full JWT ──────────────────────────────────────
+        oidc_exchange_token = location.split("oidc_token=")[1].split("&")[0].split("#")[-1]
+        exchange_resp = await async_client.post(
+            "/api/v1/auth/oidc/exchange",
+            json={"oidc_token": oidc_exchange_token},
+        )
+        assert exchange_resp.status_code == 200
+        assert exchange_resp.json()["user"]["username"] == "fallc_alice"
+
+        # ── 6. Verify UserOIDCLink was created in DB ──────────────────────────
+        async with db_session as s:
+            result = await s.execute(
+                sa_select(UserOIDCLink).where(
+                    UserOIDCLink.user_id == alice.id,
+                    UserOIDCLink.provider_id == provider.id,
+                )
+            )
+            link = result.scalar_one_or_none()
+        assert link is not None, "UserOIDCLink must have been created by auto-link"
+        assert link.provider_user_id == "azure-sub-alice"

+ 235 - 17
backend/tests/unit/test_db_dialect.py

@@ -316,7 +316,12 @@ class TestSafeExecutePattern:
 
     @pytest.mark.asyncio
     async def test_check_constraint_false_true_on_sqlite(self):
-        """CheckConstraint with FALSE/TRUE literals is enforced on SQLite (3.23+)."""
+        """New constraint formula is enforced on SQLite (3.23+).
+
+        New: auto_link = FALSE OR email_claim != 'email' OR require_ev = TRUE
+        Blocks Fall B (auto_link=1 + email_claim='email' + require_ev=0).
+        Allows Fall A (email_claim='email' + require_ev=1) and Fall C (custom claim).
+        """
         from sqlalchemy import text
         from sqlalchemy.exc import IntegrityError
         from sqlalchemy.ext.asyncio import create_async_engine
@@ -330,29 +335,32 @@ class TestSafeExecutePattern:
                     auto_link BOOLEAN,
                     require_ev BOOLEAN,
                     email_claim TEXT,
-                    CHECK (auto_link = FALSE OR (require_ev = TRUE AND email_claim = 'email'))
+                    CHECK (auto_link = FALSE OR email_claim != 'email' OR require_ev = TRUE)
                 )
             """)
             )
-            # Valid: auto_link=0 (FALSE)
+            # Valid: auto_link=0 (FALSE) — any combo allowed
             await conn.execute(text("INSERT INTO ck_test VALUES (1, 0, 0, 'upn')"))
-            # Valid: auto_link=1, require_ev=1, email_claim='email'
+            # Valid: Fall A — auto_link=1, require_ev=1, email_claim='email'
             await conn.execute(text("INSERT INTO ck_test VALUES (2, 1, 1, 'email')"))
+            # Valid: Fall C — auto_link=1, email_claim='upn' (require_ev irrelevant)
+            await conn.execute(text("INSERT INTO ck_test VALUES (3, 1, 0, 'upn')"))
+            await conn.execute(text("INSERT INTO ck_test VALUES (4, 1, 1, 'upn')"))
 
         async with engine.begin() as conn:
-            # Invalid: auto_link=1 but conditions not met
+            # Invalid: Fall B — auto_link=1 + email_claim='email' + require_ev=0
             with pytest.raises(IntegrityError):
-                await conn.execute(text("INSERT INTO ck_test VALUES (3, 1, 0, 'email')"))
+                await conn.execute(text("INSERT INTO ck_test VALUES (5, 1, 0, 'email')"))
         await engine.dispose()
 
     @pytest.mark.asyncio
     async def test_auto_link_sec1_backfill_resets_unsafe_rows(self):
-        """SEC-1 backfill resets auto_link=TRUE on rows with unsafe combined state.
+        """SEC-1 backfill resets auto_link=TRUE only for Fall B (email_claim='email' + require_ev=FALSE).
 
         Three cases:
-          1. auto_link=TRUE + require_ev=FALSE → reset to FALSE (unsafe: permissive mode)
-          2. auto_link=TRUE + custom claim → reset to FALSE (unsafe: no email_verified gate)
-          3. auto_link=TRUE + require_ev=TRUE + standard claim → unchanged (safe)
+          1. auto_link=TRUE + email_claim='email' + require_ev=FALSE → reset to FALSE (Fall B, unsafe)
+          2. auto_link=TRUE + custom claim + require_ev=TRUE → unchanged (Fall C, now allowed)
+          3. auto_link=TRUE + email_claim='email' + require_ev=TRUE → unchanged (Fall A, safe)
         """
         from sqlalchemy import text
         from sqlalchemy.ext.asyncio import create_async_engine
@@ -369,11 +377,11 @@ class TestSafeExecutePattern:
                     ")"
                 )
             )
-            # Row 1: unsafe — require_ev=FALSE
+            # Row 1: Fall B — email_claim='email' + require_ev=FALSE → must be reset
             await conn.execute(text("INSERT INTO oidc_providers VALUES (1, 1, 0, 'email')"))
-            # Row 2: unsafe — custom claim
+            # Row 2: Fall C — custom claim → must NOT be reset (now allowed)
             await conn.execute(text("INSERT INTO oidc_providers VALUES (2, 1, 1, 'preferred_username')"))
-            # Row 3: safe — require_ev=TRUE + standard claim
+            # Row 3: Fall A — email_claim='email' + require_ev=TRUE → must NOT be reset (always safe)
             await conn.execute(text("INSERT INTO oidc_providers VALUES (3, 1, 1, 'email')"))
 
             async with conn.begin_nested():
@@ -381,7 +389,7 @@ class TestSafeExecutePattern:
                     text(
                         "UPDATE oidc_providers SET auto_link_existing_accounts = FALSE "
                         "WHERE auto_link_existing_accounts = TRUE "
-                        "AND (require_email_verified = FALSE OR email_claim != 'email')"
+                        "AND email_claim = 'email' AND require_email_verified = FALSE"
                     )
                 )
 
@@ -389,9 +397,9 @@ class TestSafeExecutePattern:
             rows = {r[0]: r[1] for r in result.fetchall()}
         await engine.dispose()
 
-        assert rows[1] == 0, "unsafe (require_ev=FALSE) row must be reset to FALSE"
-        assert rows[2] == 0, "unsafe (custom claim) row must be reset to FALSE"
-        assert rows[3] == 1, "safe row must remain TRUE"
+        assert rows[1] == 0, "Fall B (require_ev=FALSE) must be reset to FALSE"
+        assert rows[2] == 1, "Fall C (custom claim) must remain TRUE"
+        assert rows[3] == 1, "Fall A (require_ev=TRUE) must remain TRUE"
 
     @pytest.mark.asyncio
     async def test_safe_execute_reraises_does_not_exist_without_column(self):
@@ -534,3 +542,213 @@ class TestSafeExecutePattern:
         sql = mock_conn.execute.call_args[0][0].text
         assert "::text = '[]'" in sql, f"Expected ::text cast in SQL, got: {sql}"
         assert "printer_ids" in sql
+
+
+class TestAutoLinkConstraintMigration:
+    """Tests for _migrate_update_auto_link_constraint (Fall C / Azure support)."""
+
+    @pytest.mark.asyncio
+    async def test_new_constraint_allows_fall_c_sqlite(self):
+        """New formula allows auto_link=TRUE with a custom claim (Fall C)."""
+        from sqlalchemy import text
+        from sqlalchemy.exc import IntegrityError
+        from sqlalchemy.ext.asyncio import create_async_engine
+
+        engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+        async with engine.begin() as conn:
+            await conn.execute(
+                text(
+                    "CREATE TABLE oidc_providers_ck ("
+                    "id INTEGER PRIMARY KEY, "
+                    "auto_link BOOLEAN, "
+                    "require_ev BOOLEAN, "
+                    "email_claim TEXT, "
+                    "CHECK (auto_link = FALSE OR email_claim != 'email' OR require_ev = TRUE)"
+                    ")"
+                )
+            )
+            # Fall C: custom claim + auto_link + require_ev=FALSE must pass
+            await conn.execute(text("INSERT INTO oidc_providers_ck VALUES (1, 1, 0, 'upn')"))
+            # Fall C: custom claim + auto_link + require_ev=TRUE must pass
+            await conn.execute(text("INSERT INTO oidc_providers_ck VALUES (2, 1, 1, 'preferred_username')"))
+        await engine.dispose()
+
+    @pytest.mark.asyncio
+    async def test_new_constraint_blocks_fall_b_sqlite(self):
+        """New formula still blocks Fall B (email_claim='email' + require_ev=FALSE + auto_link=TRUE)."""
+        from sqlalchemy import text
+        from sqlalchemy.exc import IntegrityError
+        from sqlalchemy.ext.asyncio import create_async_engine
+
+        engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+        async with engine.begin() as conn:
+            await conn.execute(
+                text(
+                    "CREATE TABLE oidc_providers_ck ("
+                    "id INTEGER PRIMARY KEY, "
+                    "auto_link BOOLEAN, "
+                    "require_ev BOOLEAN, "
+                    "email_claim TEXT, "
+                    "CHECK (auto_link = FALSE OR email_claim != 'email' OR require_ev = TRUE)"
+                    ")"
+                )
+            )
+        async with engine.begin() as conn:
+            with pytest.raises(IntegrityError):
+                await conn.execute(text("INSERT INTO oidc_providers_ck VALUES (1, 1, 0, 'email')"))
+        await engine.dispose()
+
+    @pytest.mark.asyncio
+    async def test_constraint_migration_sqlite_recreates_table(self):
+        """SQLite path recreates oidc_providers with new constraint when old formula is present."""
+        from sqlalchemy import text
+        from sqlalchemy.ext.asyncio import create_async_engine
+
+        from backend.app.core.database import _migrate_update_auto_link_constraint
+
+        # Create table with old constraint formula
+        engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+        async with engine.begin() as conn:
+            await conn.execute(
+                text(
+                    "CREATE TABLE oidc_providers ("
+                    "id INTEGER NOT NULL PRIMARY KEY, "
+                    "name VARCHAR(100) NOT NULL UNIQUE, "
+                    "issuer_url VARCHAR(500) NOT NULL, "
+                    "client_id VARCHAR(255) NOT NULL, "
+                    "client_secret VARCHAR(512) NOT NULL, "
+                    "scopes VARCHAR(500), "
+                    "is_enabled BOOLEAN, "
+                    "auto_create_users BOOLEAN, "
+                    "auto_link_existing_accounts BOOLEAN DEFAULT 0, "
+                    "email_claim VARCHAR(64) DEFAULT 'email', "
+                    "require_email_verified BOOLEAN DEFAULT 1, "
+                    "icon_url TEXT, "
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                    "CONSTRAINT ck_auto_link_requires_verified_email_claim "
+                    "CHECK (auto_link_existing_accounts = FALSE OR "
+                    "(require_email_verified = TRUE AND email_claim = 'email'))"
+                    ")"
+                )
+            )
+            await conn.execute(
+                text(
+                    "INSERT INTO oidc_providers (id, name, issuer_url, client_id, client_secret, "
+                    "scopes, is_enabled, auto_create_users, auto_link_existing_accounts, "
+                    "email_claim, require_email_verified, icon_url, created_at, updated_at) "
+                    "VALUES (1, 'TestIdP', 'https://idp.test', 'cid', 'secret', "
+                    "'openid email', 1, 0, 0, 'email', 1, NULL, "
+                    "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
+                )
+            )
+
+        async with engine.begin() as conn:
+            with patch("backend.app.core.database.is_sqlite", return_value=True):
+                await _migrate_update_auto_link_constraint(conn)
+
+            # Verify data survived
+            result = await conn.execute(text("SELECT id, name FROM oidc_providers"))
+            rows = result.fetchall()
+            assert len(rows) == 1
+            assert rows[0][0] == 1
+
+            # Verify new constraint: Fall C (auto_link=TRUE + custom claim) must now be insertable
+            await conn.execute(
+                text(
+                    "INSERT INTO oidc_providers (id, name, issuer_url, client_id, client_secret, "
+                    "scopes, is_enabled, auto_create_users, auto_link_existing_accounts, "
+                    "email_claim, require_email_verified, icon_url, created_at, updated_at) "
+                    "VALUES (2, 'AzureIdP', 'https://azure.test', 'cid2', 'secret', "
+                    "'openid', 1, 0, 1, 'upn', 1, NULL, "
+                    "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
+                )
+            )
+
+            # Verify schema has new formula
+            schema = (
+                await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='oidc_providers'"))
+            ).fetchone()[0]
+            assert "require_email_verified = TRUE AND email_claim = 'email'" not in schema
+            assert "email_claim != 'email'" in schema
+
+        await engine.dispose()
+
+    @pytest.mark.asyncio
+    async def test_constraint_migration_postgres_drops_and_recreates(self):
+        """PostgreSQL path calls DROP CONSTRAINT IF EXISTS then ADD CONSTRAINT with new formula."""
+        from unittest.mock import AsyncMock, MagicMock, call
+
+        from backend.app.core.database import _migrate_update_auto_link_constraint
+
+        # Track all SQL statements passed to _safe_execute by capturing conn.execute calls
+        executed_sqls: list[str] = []
+
+        async def fake_safe_execute(conn, sql):
+            executed_sqls.append(sql)
+
+        nested_cm = MagicMock()
+        nested_cm.__aenter__ = AsyncMock(return_value=nested_cm)
+        nested_cm.__aexit__ = AsyncMock(return_value=False)
+        nested_cm.execute = AsyncMock()
+
+        mock_conn = MagicMock()
+        mock_conn.begin_nested.return_value = nested_cm
+        mock_conn.execute = AsyncMock()
+
+        with (
+            patch("backend.app.core.database.is_sqlite", return_value=False),
+            patch("backend.app.core.database._safe_execute", side_effect=fake_safe_execute),
+        ):
+            await _migrate_update_auto_link_constraint(mock_conn)
+
+        assert len(executed_sqls) == 2
+        drop_sql, add_sql = executed_sqls
+        assert "DROP CONSTRAINT IF EXISTS" in drop_sql.upper()
+        assert "ck_auto_link_requires_verified_email_claim" in drop_sql
+        assert "ADD CONSTRAINT" in add_sql.upper()
+        assert "email_claim != 'email'" in add_sql
+        assert "require_email_verified = TRUE AND email_claim = 'email'" not in add_sql
+
+    @pytest.mark.asyncio
+    async def test_constraint_migration_sqlite_count_guard_raises_on_mismatch(self):
+        """RuntimeError is raised when the copied row count doesn't match the source."""
+        from unittest.mock import AsyncMock, MagicMock, patch
+
+        import pytest
+
+        from backend.app.core.database import _migrate_update_auto_link_constraint
+
+        _OLD_SQL = (
+            "CREATE TABLE oidc_providers (id INTEGER NOT NULL, "
+            "CONSTRAINT ck_auto_link_requires_verified_email_claim "
+            "CHECK (auto_link_existing_accounts = FALSE OR "
+            "(require_email_verified = TRUE AND email_claim = 'email')))"
+        )
+
+        async def fake_execute(stmt):
+            sql = str(stmt)
+            result = MagicMock()
+            if "sqlite_master" in sql:
+                result.fetchone.return_value = (_OLD_SQL,)
+            elif "count(*)" in sql.lower() and "oidc_providers_v2" not in sql:
+                result.scalar_one.return_value = 2  # source has 2 rows
+            elif "count(*)" in sql.lower() and "oidc_providers_v2" in sql:
+                result.scalar_one.return_value = 1  # copy only has 1 — mismatch
+            else:
+                result.fetchone.return_value = None
+            return result
+
+        nested_cm = MagicMock()
+        nested_cm.__aenter__ = AsyncMock(return_value=None)
+        nested_cm.__aexit__ = AsyncMock(return_value=False)  # don't suppress exceptions
+
+        mock_conn = MagicMock()
+        mock_conn.execute = AsyncMock(side_effect=fake_execute)
+        mock_conn.begin_nested.return_value = nested_cm
+
+        with (
+            patch("backend.app.core.database.is_sqlite", return_value=True),
+            pytest.raises(RuntimeError, match="mismatch"),
+        ):
+            await _migrate_update_auto_link_constraint(mock_conn)

+ 1 - 14
frontend/scripts/check-i18n-parity.mjs

@@ -207,20 +207,7 @@ if (isMainModule) {
   const infoReports = reports.filter((r) => !strictSet.has(codeOf(r.label)));
 
   printReports(strictReports, '=== STRICT locales (failures below fail CI) ===');
-  // Informational locales: show per-category drift counts only, not the
-  // full key lists — the leaf-count table below already gives the overall
-  // picture. Flip VERBOSE_INFO=1 to dump the full missing-key/placeholder
-  // reports when actually working on translations.
-  if (infoReports.length) {
-    if (process.env.VERBOSE_INFO === '1') {
-      printReports(infoReports, '=== INFORMATIONAL locales (drift shown, does not fail CI) ===');
-    } else {
-      console.error('\n=== INFORMATIONAL locales (drift summary; VERBOSE_INFO=1 for detail) ===');
-      for (const { label, items } of infoReports) {
-        console.error(`  ${label}: ${items.length}`);
-      }
-    }
-  }
+  printReports(infoReports, '=== INFORMATIONAL locales (drift shown, does not fail CI) ===');
 
   console.log('\nLocale leaf counts:');
   for (const [code, map] of Object.entries(locales)) {

+ 28 - 1
frontend/src/__tests__/components/OIDCProviderSettings.test.tsx

@@ -4,7 +4,7 @@
  */
 
 import { describe, it, expect, beforeEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { OIDCProviderSettings } from '../../components/OIDCProviderSettings';
@@ -107,6 +107,33 @@ describe('OIDCProviderSettings', () => {
         ).toBeInTheDocument();
       });
     });
+
+    it('shows security warning when auto_link is enabled with a custom email claim', async () => {
+      server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
+      const user = userEvent.setup();
+      render(<OIDCProviderSettings />);
+
+      await waitFor(() => {
+        expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
+      });
+      await user.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
+
+      await waitFor(() => {
+        expect(screen.getByText(/Auto.*Link/i)).toBeInTheDocument();
+      });
+
+      // Enable auto_link (switch index 2)
+      const autoLinkSwitch = screen.getAllByRole('switch')[2];
+      await user.click(autoLinkSwitch);
+
+      // Change email claim to a custom value via fireEvent to bypass the onChange fallback
+      const emailClaimInput = screen.getByPlaceholderText('email');
+      fireEvent.change(emailClaimInput, { target: { value: 'preferred_username' } });
+
+      await waitFor(() => {
+        expect(screen.getByText(/tenant-administered/i)).toBeInTheDocument();
+      });
+    });
   });
 
   describe('Provider info view', () => {

+ 5 - 2
frontend/src/components/OIDCProviderSettings.tsx

@@ -123,14 +123,14 @@ function ProviderForm({
             <p className="text-bambu-gray text-xs">{t('settings.oidc.form.autoCreateDesc')}</p>
           </div>
         </label>
-        <label className="flex items-center gap-3 cursor-pointer">
+        <label className="flex items-center gap-3 cursor-pointer w-full">
           <Toggle checked={form.auto_link_existing_accounts ?? false} onChange={(v) => set('auto_link_existing_accounts', v)} />
           <div>
             <p className="text-white text-sm">{t('settings.oidc.form.autoLink')}</p>
             <p className="text-bambu-gray text-xs">{t('settings.oidc.form.autoLinkDesc')}</p>
           </div>
         </label>
-        <label className="flex items-center gap-3 cursor-pointer">
+        <label className="flex items-center gap-3 cursor-pointer w-full">
           <Toggle
             checked={emailVerifiedOn}
             onChange={(v) => set('require_email_verified', v)}
@@ -152,6 +152,9 @@ function ProviderForm({
           placeholder={t('settings.oidc.form.emailClaimPlaceholder')}
         />
         <p className="text-bambu-gray text-xs mt-1">{t('settings.oidc.form.emailClaimDesc')}</p>
+        {autoLinkOn && form.email_claim !== 'email' && (
+          <p className="text-yellow-400 text-xs mt-1">{t('settings.oidc.form.emailClaimCustomClaimAutoLinkWarning')}</p>
+        )}
       </div>
 
       <div className="flex gap-3 pt-2">

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

@@ -2214,6 +2214,7 @@ export default {
         emailClaim: 'E-Mail-Claim',
         emailClaimDesc: "JWT-Claim für die E-Mail-Identität. Für Azure Entra ID 'preferred_username' oder 'upn' verwenden (sendet kein email_verified). Nur vertrauenswürdige Claim-Namen verwenden.",
         emailClaimPlaceholder: 'email',
+        emailClaimCustomClaimAutoLinkWarning: "Benutzerdefinierte Claims sind für die Auto-Verknüpfung nur sicher, wenn der Wert vom Mandanten verwaltet wird (z. B. Azure Entra ID upn / preferred_username). Aktiviere Auto-Verknüpfung nicht, wenn dein IdP Benutzern erlaubt, diesen Claim selbst zu setzen.",
         requireEmailVerified: 'E-Mail-Verifizierung erforderlich',
         requireEmailVerifiedDesc: 'E-Mail-Claim nur akzeptieren, wenn der Provider ihn als verifiziert markiert.',
         requireEmailVerifiedWarning: 'Warnung: E-Mail wird auch ohne Verifizierung akzeptiert. Nur bei vertrauenswürdigen Providern verwenden.',

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

@@ -2217,6 +2217,7 @@ export default {
         emailClaim: 'Email Claim',
         emailClaimDesc: "JWT claim used as email identity. Use 'preferred_username' or 'upn' for Azure Entra ID (which does not send email_verified). Only use trusted claim names.",
         emailClaimPlaceholder: 'email',
+        emailClaimCustomClaimAutoLinkWarning: "Custom claims are safe for auto-link only when the value is tenant-administered (e.g. Azure Entra ID upn / preferred_username). Do not enable auto-link if your IdP allows users to self-assert this claim.",
         requireEmailVerified: 'Require email verified',
         requireEmailVerifiedDesc: 'Only accept the email claim when the provider marks it as verified.',
         requireEmailVerifiedWarning: 'Warning: email will be accepted even without verification. Use only with trusted providers.',

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

@@ -2150,6 +2150,7 @@ export default {
         emailClaim: 'Claim e-mail',
         emailClaimDesc: "Claim JWT utilisé comme identité e-mail. Utiliser 'preferred_username' ou 'upn' pour Azure Entra ID (qui n'envoie pas email_verified). Utiliser uniquement des noms de claims de confiance.",
         emailClaimPlaceholder: 'email',
+        emailClaimCustomClaimAutoLinkWarning: "Custom claims are safe for auto-link only when the value is tenant-administered (e.g. Azure Entra ID upn / preferred_username). Do not enable auto-link if your IdP allows users to self-assert this claim.",
         requireEmailVerified: 'Exiger la vérification e-mail',
         requireEmailVerifiedDesc: "N'accepter le claim e-mail que si le fournisseur le marque comme vérifié.",
         requireEmailVerifiedWarning: "Avertissement : l'e-mail sera accepté sans vérification. À utiliser uniquement avec des fournisseurs de confiance.",

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

@@ -2149,6 +2149,7 @@ export default {
         emailClaim: 'Claim email',
         emailClaimDesc: "Claim JWT usato come identità email. Usare 'preferred_username' o 'upn' per Azure Entra ID (che non invia email_verified). Usare solo nomi di claim affidabili.",
         emailClaimPlaceholder: 'email',
+        emailClaimCustomClaimAutoLinkWarning: "Custom claims are safe for auto-link only when the value is tenant-administered (e.g. Azure Entra ID upn / preferred_username). Do not enable auto-link if your IdP allows users to self-assert this claim.",
         requireEmailVerified: 'Richiedi verifica email',
         requireEmailVerifiedDesc: "Accetta il claim email solo se il provider lo contrassegna come verificato.",
         requireEmailVerifiedWarning: 'Attenzione: l\'email sarà accettata senza verifica. Usare solo con provider affidabili.',

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

@@ -2188,6 +2188,7 @@ export default {
         emailClaim: 'メールクレーム',
         emailClaimDesc: "メールIDとして使用するJWTクレーム。Azure Entra IDには'preferred_username'または'upn'を使用(email_verifiedを送信しない)。信頼できるクレーム名のみ使用してください。",
         emailClaimPlaceholder: 'email',
+        emailClaimCustomClaimAutoLinkWarning: "Custom claims are safe for auto-link only when the value is tenant-administered (e.g. Azure Entra ID upn / preferred_username). Do not enable auto-link if your IdP allows users to self-assert this claim.",
         requireEmailVerified: 'メール確認を要求',
         requireEmailVerifiedDesc: 'プロバイダーが確認済みとしてマークした場合にのみメールクレームを受け入れます。',
         requireEmailVerifiedWarning: '警告:確認なしでメールが受け入れられます。信頼できるプロバイダーのみで使用してください。',

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

@@ -2149,6 +2149,7 @@ export default {
         emailClaim: 'Claim de e-mail',
         emailClaimDesc: "Claim JWT usado como identidade de e-mail. Use 'preferred_username' ou 'upn' para Azure Entra ID (que não envia email_verified). Use apenas nomes de claim confiáveis.",
         emailClaimPlaceholder: 'email',
+        emailClaimCustomClaimAutoLinkWarning: "Custom claims are safe for auto-link only when the value is tenant-administered (e.g. Azure Entra ID upn / preferred_username). Do not enable auto-link if your IdP allows users to self-assert this claim.",
         requireEmailVerified: 'Exigir e-mail verificado',
         requireEmailVerifiedDesc: 'Aceitar o claim de e-mail apenas quando o provedor o marcar como verificado.',
         requireEmailVerifiedWarning: 'Aviso: o e-mail será aceito sem verificação. Use apenas com provedores confiáveis.',

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

@@ -2201,6 +2201,7 @@ export default {
         emailClaim: '邮箱声明',
         emailClaimDesc: "用作邮箱身份的 JWT 声明。Azure Entra ID 请使用 'preferred_username' 或 'upn'(不发送 email_verified)。仅使用可信的声明名称。",
         emailClaimPlaceholder: 'email',
+        emailClaimCustomClaimAutoLinkWarning: "Custom claims are safe for auto-link only when the value is tenant-administered (e.g. Azure Entra ID upn / preferred_username). Do not enable auto-link if your IdP allows users to self-assert this claim.",
         requireEmailVerified: '要求邮箱已验证',
         requireEmailVerifiedDesc: '仅在提供商将邮箱声明标记为已验证时才接受。',
         requireEmailVerifiedWarning: '警告:将在未经验证的情况下接受邮箱。仅对受信任的提供商使用。',

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

@@ -2199,6 +2199,7 @@ export default {
         emailClaim: '電子郵件聲明',
         emailClaimDesc: "用作電子郵件身份的 JWT 聲明。Azure Entra ID 請使用 'preferred_username' 或 'upn'(不發送 email_verified)。僅使用可信的聲明名稱。",
         emailClaimPlaceholder: 'email',
+        emailClaimCustomClaimAutoLinkWarning: "Custom claims are safe for auto-link only when the value is tenant-administered (e.g. Azure Entra ID upn / preferred_username). Do not enable auto-link if your IdP allows users to self-assert this claim.",
         requireEmailVerified: '要求電子郵件已驗證',
         requireEmailVerifiedDesc: '僅在提供商將電子郵件聲明標記為已驗證時才接受。',
         requireEmailVerifiedWarning: '警告:將在未經驗證的情況下接受電子郵件。僅對受信任的提供商使用。',

Some files were not shown because too many files changed in this diff