Browse Source

feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1118)

feat(oidc): add Azure Entra ID support with configurable email claim resolution
Sn0rrii 1 month ago
parent
commit
50382006b3

+ 136 - 18
backend/app/api/routes/mfa.py

@@ -58,6 +58,7 @@ from backend.app.models.user import User
 from backend.app.models.user_otp_code import UserOTPCode
 from backend.app.models.user_totp import UserTOTP
 from backend.app.schemas.auth import (
+    AUTO_LINK_REQUIREMENTS_ERROR,
     AdminDisable2FARequest,
     BackupCodesResponse,
     EmailOTPDisableRequest,
@@ -373,6 +374,126 @@ def _assert_totp_not_replayed(totp_obj: pyotp.TOTP, totp_record: UserTOTP, code:
     totp_record.accept_counter(accepted_counter)
 
 
+# ---------------------------------------------------------------------------
+# OIDC helpers
+# ---------------------------------------------------------------------------
+_EMAIL_SHAPE_RE = re.compile(r"[^\s@]+@[^\s@]+\.[^\s@]+")
+
+
+def _is_valid_email_shaped(value: str | None) -> bool:
+    # SEC-2: shape check for non-standard claims (upn, preferred_username).
+    # Requires local@domain.tld — rejects "@", "x@", "@domain", "x@nodot".
+    if not value or len(value) > 255:
+        return False
+    return _EMAIL_SHAPE_RE.fullmatch(value) is not None
+
+
+def _enforce_auto_link_safety(provider: OIDCProvider) -> None:
+    """Raise HTTP 422 if auto_link_existing_accounts is on without safe email settings.
+
+    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.
+    """
+    if provider.auto_link_existing_accounts and (
+        not provider.require_email_verified or provider.email_claim != "email"
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+            detail=AUTO_LINK_REQUIREMENTS_ERROR,
+        )
+
+
+def _resolve_provider_email(provider: OIDCProvider, claims: dict, provider_sub: str) -> str | None:
+    """Extract and normalise the email address from OIDC ID-token claims.
+
+    Implements three resolution paths (Fall A/B/C):
+      Fall C — custom email_claim (!= "email"): shape-check only, no email_verified gate.
+               Recommended for Azure Entra ID (preferred_username or upn).
+      Fall A — email_claim="email" + require_email_verified=True: strict, email_verified must be True.
+      Fall B — email_claim="email" + require_email_verified=False: permissive, explicit False drops email.
+
+    Returns a lowercase-stripped email string, or None when the claim is absent/invalid.
+    """
+    provider_id = provider.id
+    raw_claim_value = claims.get(provider.email_claim)
+    if raw_claim_value is not None and not isinstance(raw_claim_value, str):
+        # TYPE-GUARD: non-string claim (e.g. list, int) would raise AttributeError on .lower().
+        logger.warning(
+            "OIDC provider %d: email_claim %r has unexpected type %s for sub=%r, ignoring",
+            provider_id,
+            provider.email_claim,
+            type(raw_claim_value).__name__,
+            provider_sub,
+        )
+        raw_claim_value = None
+    raw_email: str | None = raw_claim_value.lower().strip() if raw_claim_value else None
+
+    if provider.email_claim != "email":
+        # Fall C: custom claim (preferred_username, upn, …) — no email_verified check.
+        # SEC-2: _is_valid_email_shaped instead of bare '"@" in value'.
+        # Recommended for Azure Entra ID: set email_claim="preferred_username" or "upn".
+        if raw_email and _is_valid_email_shaped(raw_email):
+            return raw_email
+        if raw_email:
+            logger.warning(
+                "OIDC provider %d: email_claim %r value failed shape check for sub=%r, ignoring",
+                provider_id,
+                provider.email_claim,
+                provider_sub,
+            )
+        return None
+
+    email_verified = claims.get("email_verified")
+    if provider.require_email_verified:
+        # Fall A: standard C1-Guard — fail closed unless email_verified is True.
+        # SEC-2: apply shape check to standard email claim — providers may set
+        # email_verified=True on non-email values (e.g. numeric user IDs).
+        # SEC-3 normalisation applies; existing mixed-case provider_email records
+        # were normalised to lowercase by run_migrations at startup.
+        if raw_email and not _is_valid_email_shaped(raw_email):
+            logger.warning(
+                "OIDC provider %d: email claim failed shape check for sub=%r, ignoring",
+                provider_id,
+                provider_sub,
+            )
+            return None
+        if email_verified is True:
+            return raw_email
+        if raw_email:
+            logger.info(
+                "OIDC provider %d: ignoring email for sub=%r because email_verified=%r",
+                provider_id,
+                provider_sub,
+                email_verified,
+            )
+        return None
+
+    # Fall B: permissive — explicit False drops email, absent/None keeps it.
+    # Required for Azure Entra ID which never sends email_verified.
+    # SEC-2: apply shape check before the email_verified=False drop so malformed
+    # values are rejected regardless of the email_verified claim.
+    if raw_email and not _is_valid_email_shaped(raw_email):
+        logger.warning(
+            "OIDC provider %d: email claim failed shape check for sub=%r, ignoring",
+            provider_id,
+            provider_sub,
+        )
+        return None
+    if email_verified is False:
+        return None
+    if email_verified is not True:
+        # SEC-5: log only when the permissive path actually fires (ev absent/None),
+        # not on every successful login.
+        logger.info(
+            "OIDC provider %r (%d): accepting email for sub=%r without email_verified claim (permissive mode)",
+            provider.name,
+            provider.id,
+            provider_sub,
+        )
+    return raw_email
+
+
 # ---------------------------------------------------------------------------
 # Settings helpers (email 2FA flag)
 # ---------------------------------------------------------------------------
@@ -1056,8 +1177,14 @@ async def create_oidc_provider(
         scopes=body.scopes,
         is_enabled=body.is_enabled,
         auto_create_users=body.auto_create_users,
+        auto_link_existing_accounts=body.auto_link_existing_accounts,
+        email_claim=body.email_claim,
+        require_email_verified=body.require_email_verified,
         icon_url=body.icon_url,
     )
+    # SEC-1 + SEC-6: runtime guard mirrors the OIDCProviderCreate model_validator in schemas/auth.py.
+    # Catches any future path that bypasses Pydantic validation (direct ORM, scripts).
+    _enforce_auto_link_safety(provider)
     db.add(provider)
     await db.commit()
     await db.refresh(provider)
@@ -1082,6 +1209,11 @@ async def update_oidc_provider(
             value = value.rstrip("/")
         setattr(provider, field, value)
 
+    # SEC-1 + SEC-6: Combined-State-Guard after setattr loop.
+    # Checks the final in-memory state (DB values + newly set values combined) to catch
+    # partial updates that each pass schema validation individually but are unsafe together.
+    _enforce_auto_link_safety(provider)
+
     await db.commit()
     await db.refresh(provider)
     return OIDCProviderResponse.model_validate(provider)
@@ -1370,23 +1502,8 @@ async def oidc_callback(
         if not provider_sub:
             return RedirectResponse(url=f"{frontend_error_url}missing_sub_claim", status_code=302)
 
-        # C1: Only trust the email claim when the provider explicitly marks it verified.
-        # Treating absent email_verified as verified enables account-takeover: an attacker
-        # could register an unverified email with an IdP and auto-link to an existing account.
-        # Fail closed: require email_verified == True; absent/False both drop the email.
-        raw_email: str | None = claims.get("email")
-        email_verified = claims.get("email_verified")
-        if email_verified is not True:
-            if raw_email:
-                logger.info(
-                    "OIDC provider %d: ignoring email for sub=%r because email_verified=%r",
-                    provider_id,
-                    provider_sub,
-                    email_verified,
-                )
-            provider_email: str | None = None
-        else:
-            provider_email = raw_email
+        # SEC-3: resolve email via Fall A/B/C logic (see _resolve_provider_email).
+        provider_email = _resolve_provider_email(provider, claims, provider_sub)
 
         # ── Step 4: Resolve / create user ────────────────────────────────────
         try:
@@ -1549,7 +1666,8 @@ async def oidc_callback(
         logger.error("Unexpected error in OIDC callback (%s): %s", type(exc).__name__, exc, exc_info=True)
         try:
             return RedirectResponse(url=f"{frontend_error_url}internal_error", status_code=302)
-        except Exception:
+        except Exception as redirect_exc:
+            logger.error("Failed to construct error redirect in OIDC callback: %s", redirect_exc, exc_info=True)
             raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="OIDC callback failed")
 
 

+ 132 - 19
backend/app/core/database.py

@@ -214,22 +214,56 @@ async def init_db():
 
 
 async def _safe_execute(conn, sql):
-    """Execute a migration statement, ignoring 'already exists' errors.
-
-    Uses a savepoint so that a failed statement doesn't poison the
-    surrounding transaction (required for PostgreSQL).
+    """Execute a DDL migration statement, silently ignoring idempotency errors.
+
+    'already exists', 'duplicate column name' (SQLite ADD COLUMN), 'no such column'
+    (SQLite RENAME COLUMN), 'duplicate key', and the compound
+    'column … does not exist' (PostgreSQL RENAME COLUMN idempotency) are swallowed
+    so that re-running DDL migrations is safe.  The compound check additionally
+    requires the SQL to be a RENAME COLUMN statement so that "does not exist" errors
+    from ADD COLUMN or CREATE INDEX (which would indicate schema corruption, not
+    idempotency) are never silently swallowed.
+    Any other error is logged and re-raised — callers must not assume silent
+    recovery, as a failure will abort the migration sequence and prevent
+    application startup.
+
+    Only use for DDL statements (ALTER TABLE, CREATE INDEX, etc.).
+    For DML backfills (UPDATE, DELETE) use conn.execute() directly inside
+    async with conn.begin_nested() so failures are never silently swallowed.
+
+    Uses a savepoint so that a failed statement doesn't poison the surrounding
+    transaction (required for PostgreSQL).
     """
     from sqlalchemy import text
 
     try:
         async with conn.begin_nested():
             await conn.execute(text(sql))
-    except (OperationalError, ProgrammingError):
-        pass
+    except (OperationalError, ProgrammingError) as exc:
+        msg = str(exc).lower()
+        # Only swallow "column … does not exist" for RENAME COLUMN — not for ADD COLUMN
+        # or CREATE INDEX where it would indicate schema corruption, not idempotency.
+        column_not_exists = "rename column" in sql.lower() and "column" in msg and "does not exist" in msg
+        if (
+            not any(k in msg for k in ("already exists", "duplicate key", "duplicate column name", "no such column"))
+            and not column_not_exists
+        ):
+            logger.error("Migration statement failed: %s | SQL: %.200s", exc, sql)
+            raise
 
 
 async def run_migrations(conn):
-    """Add new columns to existing tables if they don't exist."""
+    """Run all schema migrations and data backfills on startup.
+
+    Includes ALTER TABLE (add columns, rename columns, add constraints),
+    CREATE INDEX, CREATE TRIGGER, data UPDATE backfills, and table recreations
+    for complex SQLite schema changes that ALTER TABLE cannot handle.
+
+    DDL statements are wrapped in _safe_execute for idempotency.
+    DML backfills (UPDATE/DELETE) are executed directly via conn.execute()
+    inside begin_nested() so any failure is always fatal and never silently
+    swallowed.
+    """
     from sqlalchemy import text
 
     # Migration: Add is_favorite column to print_archives
@@ -1397,7 +1431,8 @@ async def run_migrations(conn):
 
     # Migration: Normalize empty printer_ids [] to NULL (global access) on API keys
     # Previously both None and [] meant "all printers"; now [] means "no printers"
-    await _safe_execute(conn, "UPDATE api_keys SET printer_ids = NULL WHERE printer_ids = '[]'")
+    async with conn.begin_nested():
+        await conn.execute(text("UPDATE api_keys SET printer_ids = NULL WHERE printer_ids = '[]'"))
 
     # Migration: Add auth_source column to users for LDAP support (#794)
     await _safe_execute(conn, "ALTER TABLE users ADD COLUMN auth_source VARCHAR(20) DEFAULT 'local' NOT NULL")
@@ -1428,8 +1463,13 @@ async def run_migrations(conn):
                 )
                 await conn.execute(text(f"PRAGMA schema_version = {schema_version + 1}"))
                 await conn.execute(text("PRAGMA writable_schema = OFF"))
-        except (OperationalError, ProgrammingError):
-            pass
+        except (OperationalError, ProgrammingError) as exc:
+            logger.error(
+                "Failed to remove NOT NULL from users.password_hash via writable_schema — "
+                "OIDC/LDAP user creation will fail on this install: %s",
+                exc,
+                exc_info=True,
+            )
     else:
         await _safe_execute(conn, "ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL")
 
@@ -1483,7 +1523,71 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE auth_ephemeral_tokens ADD COLUMN challenge_id VARCHAR(128)")
 
     # Migration: Add auto_link_existing_accounts column to oidc_providers (M-4)
-    await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT 1")
+    # Postgres rejects `DEFAULT 0` for BOOLEAN columns.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(
+            conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT false"
+        )
+
+    # Migration: Azure Entra ID support — configurable email claim and verification requirement
+    await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN email_claim VARCHAR(64) DEFAULT 'email'")
+    # Postgres rejects `DEFAULT 1` for BOOLEAN columns.
+    if is_sqlite():
+        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 safety backfill: reset auto_link on rows where the combined state
+    # is unsafe.  Runs BEFORE the CHECK constraint below so existing installs
+    # that somehow ended up with auto_link=TRUE + unsafe email settings (e.g.
+    # users carrying state from in-progress dev branches) self-heal rather
+    # than failing-loud when ADD CONSTRAINT validates against the data
+    # (PostgreSQL rejects ADD CONSTRAINT with "check constraint is violated
+    # by some row" if any row breaks the predicate).
+    # 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')"
+                )
+            )
+    except Exception as exc:
+        logger.error(
+            "SEC-1 safety backfill FAILED — auto_link_existing_accounts may remain enabled "
+            "on providers with unsafe email settings: %s",
+            exc,
+            exc_info=True,
+        )
+        raise
+
+    # SEC-1/SEC-6: 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.
+    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'))"
+                    )
+                )
+        except (OperationalError, ProgrammingError) as exc:
+            msg = str(exc).lower()
+            if "already exists" not in msg:
+                logger.error(
+                    "Security constraint migration FAILED — auto_link safety constraint may not be enforced: %s",
+                    exc,
+                    exc_info=True,
+                )
+                raise
 
     # Migration: Add password_changed_at to users (M-R7-B)
     # Tracks the last time a user's password was changed/reset.  JWTs whose iat
@@ -1499,10 +1603,8 @@ async def run_migrations(conn):
     # tokens could never be invalidated via the freshness check.  Setting it to
     # created_at is conservative: any token issued before the account was created
     # is always invalid, so this is a safe lower bound.
-    await _safe_execute(
-        conn,
-        "UPDATE users SET password_changed_at = created_at WHERE password_changed_at IS NULL",
-    )
+    async with conn.begin_nested():
+        await conn.execute(text("UPDATE users SET password_changed_at = created_at WHERE password_changed_at IS NULL"))
 
     # Migration: Provenance columns on library_files for MakerWorld imports.
     # source_url is indexed so "already imported" dedupe lookups stay O(log N)
@@ -1536,12 +1638,23 @@ async def run_migrations(conn):
     # every restart. Dedupe (keep lowest id per key) and add the missing unique index
     # before seeding. Safe/idempotent on both dialects — fresh installs already have
     # no dupes and `create_all` already emits the index.
-    await _safe_execute(
-        conn,
-        "DELETE FROM settings WHERE id NOT IN (SELECT MIN(id) FROM settings GROUP BY key)",
-    )
+    async with conn.begin_nested():
+        await conn.execute(text("DELETE FROM settings WHERE id NOT IN (SELECT MIN(id) FROM settings GROUP BY key)"))
     await _safe_execute(conn, "CREATE UNIQUE INDEX IF NOT EXISTS ix_settings_key ON settings(key)")
 
+    # Migration: Normalise provider_email to lowercase (SEC-3).
+    # Required for Entra ID where UPN/email claims may arrive in mixed case.
+    # LOWER() is supported by both SQLite and PostgreSQL; the UPDATE is idempotent.
+    # Executed directly (not via _safe_execute) so any column-reference failure
+    # is always fatal and never silently swallowed.
+    async with conn.begin_nested():
+        await conn.execute(
+            text(
+                "UPDATE user_oidc_links SET provider_email = LOWER(provider_email) "
+                "WHERE provider_email IS NOT NULL AND provider_email != LOWER(provider_email)"
+            )
+        )
+
     # Seed default settings keys that must exist on fresh install
     default_settings = [
         ("advanced_auth_enabled", "false"),

+ 24 - 1
backend/app/models/oidc_provider.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
+from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -21,6 +21,15 @@ 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.
+        CheckConstraint(
+            "auto_link_existing_accounts = FALSE OR (require_email_verified = TRUE AND email_claim = 'email')",
+            name="ck_auto_link_requires_verified_email_claim",
+        ),
+    )
 
     id: Mapped[int] = mapped_column(primary_key=True)
     # Human-readable name shown on the login button (e.g. "PocketID", "Google")
@@ -50,6 +59,20 @@ class OIDCProvider(Base):
     # operators must explicitly opt-in to prevent an attacker-controlled IdP from
     # silently hijacking local accounts via email matching (M-2 fix).
     auto_link_existing_accounts: Mapped[bool] = mapped_column(Boolean, default=False)
+    # JWT claim name used as the email identity (default "email").
+    # Set to "preferred_username" or "upn" for Azure Entra ID, which does not send
+    # email_verified — using a custom claim skips the email_verified check entirely
+    # and is the recommended Azure configuration.
+    # Has no interaction with require_email_verified when set to a non-"email" value:
+    # custom claims never perform an email_verified check regardless of that setting.
+    email_claim: Mapped[str] = mapped_column(String(64), default="email")
+    # When True (default), the "email" claim is only trusted when email_verified=True.
+    # Set to False to accept the email even when email_verified is absent — required
+    # for providers like Azure Entra ID that never send email_verified and where a
+    # custom claim (email_claim != "email") is not preferred.
+    # Has no effect when email_claim is not "email": the custom-claim path never
+    # performs an email_verified check regardless of this setting.
+    require_email_verified: Mapped[bool] = mapped_column(Boolean, default=True)
     # Optional icon URL (SVG/PNG) shown on the login button
     icon_url: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 56 - 3
backend/app/schemas/auth.py

@@ -1,7 +1,7 @@
 import re
 from typing import Literal
 
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, Field, field_validator, model_validator
 
 
 def _validate_password_complexity(v: str) -> str:
@@ -296,6 +296,19 @@ class AdminDisable2FARequest(BaseModel):
 # ---------------------------------------------------------------------------
 
 
+AUTO_LINK_REQUIREMENTS_ERROR = (
+    "auto_link_existing_accounts requires require_email_verified=True and email_claim='email'"
+)
+
+
+def _validate_email_claim_name(v: str) -> str:
+    # Accepts only alphanumeric/underscore/hyphen claim names starting with a letter —
+    # prevents log injection and limits the attack surface of operator-supplied claim names.
+    if not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9_\-]{0,63}", v):
+        raise ValueError("Invalid claim name")
+    return v
+
+
 def _validate_icon_url(v: str | None) -> str | None:
     """Reject non-HTTPS icon URLs to prevent SSRF / mixed-content issues."""
     if v is None:
@@ -355,27 +368,45 @@ class OIDCProviderCreate(BaseModel):
     is_enabled: bool = True
     auto_create_users: bool = False
     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
     icon_url: str | None = None
 
     @field_validator("issuer_url")
     @classmethod
     def validate_issuer_url(cls, v: str) -> str:
         result = _validate_issuer_url(v)
-        assert result is not None
+        if result is None:
+            raise ValueError("issuer_url is required")
         return result
 
     @field_validator("scopes")
     @classmethod
     def validate_scopes(cls, v: str) -> str:
         result = _validate_scopes(v)
-        assert result is not None
+        if result is None:
+            raise ValueError("scopes is required")
         return result
 
+    @field_validator("email_claim")
+    @classmethod
+    def validate_email_claim(cls, v: str) -> str:
+        return _validate_email_claim_name(v)
+
     @field_validator("icon_url")
     @classmethod
     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.
+    @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"):
+            raise ValueError(AUTO_LINK_REQUIREMENTS_ERROR)
+        return self
+
 
 class OIDCProviderUpdate(BaseModel):
     name: str | None = Field(default=None, max_length=100)
@@ -392,6 +423,8 @@ class OIDCProviderUpdate(BaseModel):
     is_enabled: bool | None = None
     auto_create_users: bool | None = None
     auto_link_existing_accounts: bool | None = None
+    email_claim: str | None = Field(default=None, max_length=64)
+    require_email_verified: bool | None = None
     icon_url: str | None = None
 
     @field_validator("scopes")
@@ -399,11 +432,29 @@ class OIDCProviderUpdate(BaseModel):
     def validate_scopes(cls, v: str | None) -> str | None:
         return _validate_scopes(v)
 
+    @field_validator("email_claim")
+    @classmethod
+    def validate_email_claim(cls, v: str | None) -> str | None:
+        if v is None:
+            return None
+        return _validate_email_claim_name(v)
+
     @field_validator("icon_url")
     @classmethod
     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
+    # 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")
+        ):
+            raise ValueError(AUTO_LINK_REQUIREMENTS_ERROR)
+        return self
+
 
 class OIDCProviderResponse(BaseModel):
     id: int
@@ -414,6 +465,8 @@ class OIDCProviderResponse(BaseModel):
     is_enabled: bool
     auto_create_users: bool
     auto_link_existing_accounts: bool = False
+    email_claim: str = "email"
+    require_email_verified: bool = True
     icon_url: str | None = None
 
     class Config:

+ 777 - 0
backend/tests/integration/test_mfa_api.py

@@ -18,8 +18,11 @@ Tests the full request/response cycle for:
 from __future__ import annotations
 
 import secrets
+import time
 from datetime import datetime, timedelta, timezone
+from unittest.mock import patch
 
+import jwt as pyjwt
 import pyotp
 import pytest
 from httpx import AsyncClient
@@ -3263,3 +3266,777 @@ class TestOIDCCallbackCodeLength:
             follow_redirects=False,
         )
         assert resp.status_code == 422, "2049-char state must be rejected by Pydantic"
+
+
+# ---------------------------------------------------------------------------
+# Helpers shared by TestOIDCEmailClaimResolution
+# ---------------------------------------------------------------------------
+
+
+async def _run_oidc_callback(
+    async_client: AsyncClient,
+    db_session: AsyncSession,
+    *,
+    provider_id: int,
+    claims: dict,
+    private_pem: bytes,
+    jwks_data: dict,
+    issuer: str,
+    client_id: str,
+) -> str:
+    """Run a full OIDC callback flow and return the redirect location."""
+    nonce = secrets.token_urlsafe(16)
+    now = int(time.time())
+    token_claims = {
+        "sub": claims.get("sub", f"sub-{secrets.token_hex(8)}"),
+        "iss": issuer,
+        "aud": client_id,
+        "nonce": nonce,
+        "iat": now,
+        "exp": now + 300,
+        **{k: v for k, v in claims.items() if k not in ("sub",)},
+    }
+    id_token = pyjwt.encode(token_claims, private_pem, algorithm="RS256", headers={"kid": "test-kid-1"})
+
+    state = secrets.token_urlsafe(32)
+    code_verifier = secrets.token_urlsafe(48)
+    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=5),
+        )
+    )
+    await db_session.commit()
+
+    discovery_doc = {
+        "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-access", "token_type": "Bearer", "id_token": id_token}
+
+    class _R:
+        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 _C:
+        def __init__(self, *a, **kw):
+            pass
+
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, *a):
+            pass
+
+        async def get(self, url, **kw):
+            return _R(jwks_data if "jwks" in url else discovery_doc)
+
+        async def post(self, url, **kw):
+            return _R(token_response)
+
+    with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _C):
+        resp = await async_client.get(
+            f"/api/v1/auth/oidc/callback?code=test-code&state={state}",
+            follow_redirects=False,
+        )
+    return resp.headers.get("location", "")
+
+
+class TestOIDCEmailClaimResolution:
+    """Three-case email resolution logic: Fall A / Fall B / Fall C."""
+
+    # ── shared helpers ────────────────────────────────────────────────────────
+
+    async def _create_provider(
+        self,
+        async_client: AsyncClient,
+        admin_token: str,
+        issuer: str,
+        client_id: str,
+        *,
+        email_claim: str = "email",
+        require_email_verified: bool = True,
+        auto_link_existing_accounts: bool = False,
+        suffix: str = "",
+    ) -> int:
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": f"TestIdP-{suffix or secrets.token_hex(4)}",
+                "issuer_url": issuer,
+                "client_id": client_id,
+                "client_secret": "sec",
+                "scopes": "openid email profile",
+                "is_enabled": True,
+                "auto_create_users": True,
+                "auto_link_existing_accounts": auto_link_existing_accounts,
+                "email_claim": email_claim,
+                "require_email_verified": require_email_verified,
+            },
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert resp.status_code == 201, resp.text
+        return resp.json()["id"]
+
+    async def _get_oidc_link(self, db_session: AsyncSession, provider_id: int, sub: str):
+        from sqlalchemy import select
+
+        from backend.app.models.oidc_provider import UserOIDCLink
+
+        result = await db_session.execute(
+            select(UserOIDCLink)
+            .where(UserOIDCLink.provider_id == provider_id)
+            .where(UserOIDCLink.provider_user_id == sub)
+        )
+        return result.scalar_one_or_none()
+
+    # ── Parametrized matrix: Fall A / Fall B / Fall C ─────────────────────────
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        "email_claim,require_ev,claims,expected",
+        [
+            # Fall A: standard claim + require_ev=True (default)
+            ("email", True, {"email": "fa@example.com", "email_verified": True}, "fa@example.com"),
+            ("email", True, {"email": "fa@example.com", "email_verified": False}, None),
+            ("email", True, {"email": "fa@example.com"}, None),  # Azure Entra with default config
+            # Fall A + SEC-2: malformed email claim rejected even when email_verified=True
+            ("email", True, {"email": "notanemail", "email_verified": True}, None),
+            # Fall B: standard claim + require_ev=False (Azure Entra permissive)
+            ("email", False, {"email": "fb@example.com", "email_verified": True}, "fb@example.com"),
+            ("email", False, {"email": "fb@example.com", "email_verified": False}, None),
+            ("email", False, {"email": "azure@company.com"}, "azure@company.com"),  # ev absent → kept
+            # Fall B + SEC-2: malformed email claim rejected in permissive mode (ev absent)
+            ("email", False, {"email": "user@nodot"}, None),
+            # Fall B + SEC-2: shape check fires before email_verified=False drop
+            ("email", False, {"email": "notanemail", "email_verified": False}, None),
+            # Fall C: custom claim (preferred_username) — no email_verified check
+            ("preferred_username", True, {"preferred_username": "User@Company.COM"}, "user@company.com"),
+            ("preferred_username", True, {"preferred_username": "  User@EXAMPLE.COM  "}, "user@example.com"),
+            ("preferred_username", True, {"preferred_username": "justausername"}, None),
+            ("preferred_username", True, {"preferred_username": "@"}, None),  # SEC-2: "@" only
+            ("preferred_username", True, {"preferred_username": "@domain.com"}, None),  # SEC-2: empty local
+            ("preferred_username", True, {"preferred_username": "user@"}, None),  # SEC-2: empty domain
+            ("preferred_username", True, {"preferred_username": "user@nodot"}, None),  # SEC-2: no dot in domain
+            ("preferred_username", True, {}, None),  # claim absent
+            # Fall C: email_verified=False present alongside custom claim — must NOT suppress the email
+            ("preferred_username", True, {"preferred_username": "user@co.com", "email_verified": False}, "user@co.com"),
+        ],
+        ids=[
+            "fall-a-ev-true",
+            "fall-a-ev-false",
+            "fall-a-ev-absent",
+            "fall-a-malformed-email",
+            "fall-b-ev-true",
+            "fall-b-ev-false",
+            "fall-b-ev-absent",
+            "fall-b-malformed-email",
+            "fall-b-malformed-email-ev-false",
+            "fall-c-valid-upn",
+            "fall-c-lowercase-strip",
+            "fall-c-no-at",
+            "fall-c-at-only",
+            "fall-c-empty-local",
+            "fall-c-empty-domain",
+            "fall-c-no-dot-in-domain",
+            "fall-c-claim-absent",
+            "fall-c-ev-false-ignored",
+        ],
+    )
+    async def test_email_resolution_matrix(
+        self,
+        async_client: AsyncClient,
+        db_session: AsyncSession,
+        email_claim: str,
+        require_ev: bool,
+        claims: dict,
+        expected: str | None,
+    ):
+        """C4: Verify link exists AND check provider_email — avoids false-passing on callback failure."""
+        issuer = "https://matrix.test"
+        client_id = "matrix-client"
+        admin_token = await _setup_and_login(async_client, "matrix_adm", "Matrix123!")
+        private_pem, jwks_data = _make_test_rsa_key()
+        provider_id = await self._create_provider(
+            async_client,
+            admin_token,
+            issuer,
+            client_id,
+            email_claim=email_claim,
+            require_email_verified=require_ev,
+            suffix="matrix",
+        )
+        sub = f"sub-matrix-{secrets.token_hex(6)}"
+        await _run_oidc_callback(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            claims={"sub": sub, **claims},
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+            issuer=issuer,
+            client_id=client_id,
+        )
+        db_session.expire_all()
+        link = await self._get_oidc_link(db_session, provider_id, sub)
+        assert link is not None, "UserOIDCLink must be created even when email is dropped"
+        assert link.provider_email == expected
+
+    # ── Security: auto_link guards (CREATE endpoint) ──────────────────────────
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auto_link_blocked_with_require_ev_false(self, async_client: AsyncClient):
+        """SEC-1: auto_link + require_email_verified=False must be rejected at schema level (422)."""
+        admin_token = await _setup_and_login(async_client, "sec1_adm", "Sec1Adm123!")
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "SEC1-Test",
+                "issuer_url": "https://sec1.test",
+                "client_id": "sec1-client",
+                "client_secret": "sec",
+                "scopes": "openid email profile",
+                "auto_link_existing_accounts": True,
+                "require_email_verified": False,
+            },
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert resp.status_code == 422
+
+    @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)."""
+        admin_token = await _setup_and_login(async_client, "sec6c_adm", "Sec6CAdm123!")
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "SEC6-Create-Test",
+                "issuer_url": "https://sec6c.test",
+                "client_id": "sec6c-client",
+                "client_secret": "sec",
+                "scopes": "openid email profile",
+                "auto_link_existing_accounts": True,
+                "email_claim": "upn",
+            },
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert resp.status_code == 422
+
+    @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)."""
+        admin_token = await _setup_and_login(async_client, "sec6u_adm", "Sec6UAdm123!")
+        create_resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "SEC6-Update-Test",
+                "issuer_url": "https://sec6u.test",
+                "client_id": "sec6u-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"]
+        resp = await async_client.put(
+            f"/api/v1/auth/oidc/providers/{provider_id}",
+            json={"auto_link_existing_accounts": True, "email_claim": "upn"},
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert resp.status_code == 422
+
+    # ── Combined-State-Guard (partial updates across two requests) ─────────────
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_partial_update_guard_require_ev(self, async_client: AsyncClient):
+        """SEC-1 Combined-State-Guard: require_ev=False then auto_link=True → 422 (T1 require_ev path)."""
+        admin_token = await _setup_and_login(async_client, "pg_rev_adm", "PgRev123!")
+        create_resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "PG-RequireEV-Test",
+                "issuer_url": "https://pg-rev.test",
+                "client_id": "pg-rev-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={"require_email_verified": False},
+            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={"auto_link_existing_accounts": True},
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert upd2.status_code == 422
+
+    @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)."""
+        admin_token = await _setup_and_login(async_client, "pg_ec_adm", "PgEc123!")
+        create_resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "PG-EmailClaim-Test",
+                "issuer_url": "https://pg-ec.test",
+                "client_id": "pg-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={"email_claim": "upn"},
+            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={"auto_link_existing_accounts": True},
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert upd2.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_partial_update_guard_inverse_order(self, async_client: AsyncClient):
+        """T2: auto_link=True first (valid), then require_ev=False → Combined-State-Guard fires (422)."""
+        admin_token = await _setup_and_login(async_client, "pg_inv_adm", "PgInv123!")
+        create_resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "PG-Inverse-Test",
+                "issuer_url": "https://pg-inv.test",
+                "client_id": "pg-inv-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"]
+
+        # auto_link=True is safe when require_ev=True (default)
+        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
+
+        # Disabling require_ev with auto_link already on → unsafe combined state
+        upd2 = await async_client.put(
+            f"/api/v1/auth/oidc/providers/{provider_id}",
+            json={"require_email_verified": False},
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert upd2.status_code == 422
+
+    # ── Low5: Response fields verified ───────────────────────────────────────
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_response_includes_new_fields(self, async_client: AsyncClient):
+        """Low5: OIDCProviderResponse must include email_claim and require_email_verified."""
+        admin_token = await _setup_and_login(async_client, "resp_adm", "Resp123!")
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "ResponseFields-Test",
+                "issuer_url": "https://resp.test",
+                "client_id": "resp-client",
+                "client_secret": "sec",
+                "scopes": "openid email profile",
+                "email_claim": "preferred_username",
+                "require_email_verified": False,
+            },
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert resp.status_code == 201
+        data = resp.json()
+        assert data["email_claim"] == "preferred_username"
+        assert data["require_email_verified"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_response_reflects_new_fields(self, async_client: AsyncClient):
+        """Low5: PUT response must reflect updated email_claim and require_email_verified."""
+        admin_token = await _setup_and_login(async_client, "upd_resp_adm", "UpdResp123!")
+        create_resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "UpdateResponse-Test",
+                "issuer_url": "https://upd-resp.test",
+                "client_id": "upd-resp-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"]
+
+        upd = await async_client.put(
+            f"/api/v1/auth/oidc/providers/{provider_id}",
+            json={"email_claim": "upn", "require_email_verified": True},
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert upd.status_code == 200
+        data = upd.json()
+        assert data["email_claim"] == "upn"
+        assert data["require_email_verified"] is True
+
+
+# ===========================================================================
+# TestOIDCEmailClaimValidation — T2: email_claim field validator coverage
+# ===========================================================================
+
+
+class TestOIDCEmailClaimValidation:
+    """Schema-level validation for the email_claim field."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_invalid_claim_name_dot_rejected(self, async_client: AsyncClient):
+        """email_claim with a dot (log-injection risk) must be rejected."""
+        admin_token = await _setup_and_login(async_client, "ecv_adm1", "Ecv123!")
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "ECVTest1",
+                "issuer_url": "https://ecv1.test",
+                "client_id": "ecv1",
+                "client_secret": "sec",
+                "scopes": "openid email",
+                "email_claim": "email.address",
+            },
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_invalid_claim_name_starts_with_digit_rejected(self, async_client: AsyncClient):
+        admin_token = await _setup_and_login(async_client, "ecv_adm2", "Ecv123!")
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "ECVTest2",
+                "issuer_url": "https://ecv2.test",
+                "client_id": "ecv2",
+                "client_secret": "sec",
+                "scopes": "openid email",
+                "email_claim": "1invalid",
+            },
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_invalid_claim_name_newline_rejected(self, async_client: AsyncClient):
+        """T2 regex-bug guard: re.fullmatch must reject trailing newline."""
+        admin_token = await _setup_and_login(async_client, "ecv_adm3", "Ecv123!")
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "ECVTest3",
+                "issuer_url": "https://ecv3.test",
+                "client_id": "ecv3",
+                "client_secret": "sec",
+                "scopes": "openid email",
+                "email_claim": "email\n",
+            },
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_claim_name_65_chars_rejected(self, async_client: AsyncClient):
+        """email_claim longer than 64 characters must be rejected."""
+        admin_token = await _setup_and_login(async_client, "ecv_adm4", "Ecv123!")
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "ECVTest4",
+                "issuer_url": "https://ecv4.test",
+                "client_id": "ecv4",
+                "client_secret": "sec",
+                "scopes": "openid email",
+                "email_claim": "a" * 65,
+            },
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_valid_claim_name_accepted(self, async_client: AsyncClient):
+        """Valid claim names like preferred_username and upn must be accepted."""
+        admin_token = await _setup_and_login(async_client, "ecv_adm5", "Ecv123!")
+        for claim in ("preferred_username", "upn", "email", "emailAddress"):
+            resp = await async_client.post(
+                "/api/v1/auth/oidc/providers",
+                json={
+                    "name": f"ECVTest-{claim[:8]}",
+                    "issuer_url": f"https://ecv-{claim[:8]}.test",
+                    "client_id": f"ecv-{claim[:8]}",
+                    "client_secret": "sec",
+                    "scopes": "openid email",
+                    "email_claim": claim,
+                },
+                headers={"Authorization": f"Bearer {admin_token}"},
+            )
+            assert resp.status_code == 201, f"claim {claim!r} was rejected: {resp.text}"
+
+
+# ===========================================================================
+# TestOIDCEmailResolutionExtra — T1 / T3 / T4 additional coverage
+# ===========================================================================
+
+
+async def _create_provider_via_api(
+    async_client: AsyncClient,
+    admin_token: str,
+    issuer: str,
+    client_id: str,
+    *,
+    email_claim: str = "email",
+    require_email_verified: bool = True,
+    suffix: str = "",
+) -> int:
+    resp = await async_client.post(
+        "/api/v1/auth/oidc/providers",
+        json={
+            "name": f"TestIdP-extra-{suffix or secrets.token_hex(4)}",
+            "issuer_url": issuer,
+            "client_id": client_id,
+            "client_secret": "sec",
+            "scopes": "openid email profile",
+            "is_enabled": True,
+            "auto_create_users": True,
+            "email_claim": email_claim,
+            "require_email_verified": require_email_verified,
+        },
+        headers={"Authorization": f"Bearer {admin_token}"},
+    )
+    assert resp.status_code == 201, resp.text
+    return resp.json()["id"]
+
+
+class TestOIDCEmailResolutionExtra:
+    """T1: isinstance guard, T3: SEC-3 normalisation for Fall A/B, T4: inverse Combined-State-Guard."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_non_string_claim_value_drops_email(
+        self,
+        async_client: AsyncClient,
+        db_session: AsyncSession,
+    ):
+        """T1: A non-string email_claim value (list) must be silently dropped — no crash."""
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://nonstring-test.example"
+        client_id = "nonstring-client"
+
+        admin_token = await _setup_and_login(async_client, "nonstr_adm", "Nonstr123!")
+        provider_id = await _create_provider_via_api(
+            async_client,
+            admin_token,
+            issuer,
+            client_id,
+            email_claim="preferred_username",
+            require_email_verified=False,
+            suffix="nonstr",
+        )
+
+        from sqlalchemy import select
+
+        from backend.app.models.oidc_provider import UserOIDCLink
+
+        # IdP sends preferred_username as a list (non-string) — must not crash
+        location = await _run_oidc_callback(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            claims={"sub": "nonstr-sub-1", "preferred_username": ["user@example.com"]},
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+            issuer=issuer,
+            client_id=client_id,
+        )
+        assert "internal_error" not in location, f"Unexpected error redirect: {location}"
+        link_result = await db_session.execute(
+            select(UserOIDCLink)
+            .where(UserOIDCLink.provider_id == provider_id)
+            .where(UserOIDCLink.provider_user_id == "nonstr-sub-1")
+        )
+        link = link_result.scalar_one_or_none()
+        assert link is not None
+        assert link.provider_email is None  # list value dropped
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_fall_a_sec3_normalisation(
+        self,
+        async_client: AsyncClient,
+        db_session: AsyncSession,
+    ):
+        """T3: Fall A — uppercase + whitespace in email claim must be normalised to lowercase."""
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://sec3a-test.example"
+        client_id = "sec3a-client"
+
+        admin_token = await _setup_and_login(async_client, "sec3a_adm", "Sec3a123!")
+        provider_id = await _create_provider_via_api(
+            async_client,
+            admin_token,
+            issuer,
+            client_id,
+            email_claim="email",
+            require_email_verified=True,
+            suffix="sec3a",
+        )
+
+        from sqlalchemy import select
+
+        from backend.app.models.oidc_provider import UserOIDCLink
+
+        location = await _run_oidc_callback(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            claims={"sub": "sec3a-sub-1", "email": "  USER@EXAMPLE.COM  ", "email_verified": True},
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+            issuer=issuer,
+            client_id=client_id,
+        )
+        assert "internal_error" not in location
+        link_result = await db_session.execute(
+            select(UserOIDCLink)
+            .where(UserOIDCLink.provider_id == provider_id)
+            .where(UserOIDCLink.provider_user_id == "sec3a-sub-1")
+        )
+        link = link_result.scalar_one_or_none()
+        assert link is not None
+        assert link.provider_email == "user@example.com"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_fall_b_sec3_normalisation(
+        self,
+        async_client: AsyncClient,
+        db_session: AsyncSession,
+    ):
+        """T3: Fall B — uppercase + whitespace in email claim must be normalised to lowercase."""
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://sec3b-test.example"
+        client_id = "sec3b-client"
+
+        admin_token = await _setup_and_login(async_client, "sec3b_adm", "Sec3b123!")
+        provider_id = await _create_provider_via_api(
+            async_client,
+            admin_token,
+            issuer,
+            client_id,
+            email_claim="email",
+            require_email_verified=False,
+            suffix="sec3b",
+        )
+
+        from sqlalchemy import select
+
+        from backend.app.models.oidc_provider import UserOIDCLink
+
+        location = await _run_oidc_callback(
+            async_client,
+            db_session,
+            provider_id=provider_id,
+            claims={"sub": "sec3b-sub-1", "email": "  USER@EXAMPLE.COM  "},
+            private_pem=private_pem,
+            jwks_data=jwks_data,
+            issuer=issuer,
+            client_id=client_id,
+        )
+        assert "internal_error" not in location
+        link_result = await db_session.execute(
+            select(UserOIDCLink)
+            .where(UserOIDCLink.provider_id == provider_id)
+            .where(UserOIDCLink.provider_user_id == "sec3b-sub-1")
+        )
+        link = link_result.scalar_one_or_none()
+        assert link is not None
+        assert link.provider_email == "user@example.com"
+
+    @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."""
+        admin_token = await _setup_and_login(async_client, "inv_ec_adm", "InvEc123!")
+        create_resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "InvEcTest",
+                "issuer_url": "https://inv-ec.test",
+                "client_id": "inv-ec",
+                "client_secret": "sec",
+                "scopes": "openid email",
+            },
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert create_resp.status_code == 201
+        provider_id = create_resp.json()["id"]
+
+        # First: enable auto_link (safe — 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},
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert upd1.status_code == 200
+
+        # Second: switch email_claim to custom while auto_link is on → unsafe combined state
+        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

+ 313 - 4
backend/tests/unit/test_db_dialect.py

@@ -163,13 +163,11 @@ class TestSafeExecutePattern:
         """Verify _safe_execute catches both OperationalError and ProgrammingError."""
         from sqlalchemy.exc import OperationalError, ProgrammingError
 
-        # These are the exception types _safe_execute must catch
-        # (verified by reading the source — actual integration tested by 1509 unit tests)
         for exc_type in (OperationalError, ProgrammingError):
             try:
                 raise exc_type("test", [], Exception("column already exists"))
             except (OperationalError, ProgrammingError):
-                pass  # This is what _safe_execute does
+                pass
 
     def test_safe_execute_would_not_catch_integrity_error(self):
         """IntegrityError should NOT be caught by _safe_execute."""
@@ -179,4 +177,315 @@ class TestSafeExecutePattern:
             try:
                 raise IntegrityError("test", [], Exception("unique violation"))
             except (OperationalError, ProgrammingError):
-                pass  # _safe_execute only catches these two
+                pass
+
+    @pytest.mark.asyncio
+    async def test_safe_execute_reraises_non_idempotency_errors(self):
+        """Non-idempotency errors must propagate so startup fails loudly."""
+        from sqlalchemy.exc import OperationalError
+        from sqlalchemy.ext.asyncio import create_async_engine
+
+        from backend.app.core.database import _safe_execute
+
+        engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+        async with engine.begin() as conn:
+            with pytest.raises(OperationalError):
+                await _safe_execute(conn, "SELECT * FROM nonexistent_table_xyz")
+        await engine.dispose()
+
+    @pytest.mark.asyncio
+    async def test_safe_execute_swallows_already_exists(self):
+        """Idempotency errors (already exists) must be silently ignored."""
+        from sqlalchemy import text
+        from sqlalchemy.ext.asyncio import create_async_engine
+
+        from backend.app.core.database import _safe_execute
+
+        engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+        async with engine.begin() as conn:
+            await conn.execute(text("CREATE TABLE t (id INTEGER)"))
+            # Second CREATE must not raise
+            await _safe_execute(conn, "CREATE TABLE t (id INTEGER)")
+        await engine.dispose()
+
+    @pytest.mark.asyncio
+    async def test_provider_email_lowercasing_migration(self):
+        """SEC-3: provider_email normalisation lowers mixed-case values, leaves NULL intact.
+
+        The production migration runs this UPDATE directly (not via _safe_execute)
+        so any failure is always fatal and visible at startup.
+        """
+        from sqlalchemy import text
+        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 user_oidc_links (id INTEGER PRIMARY KEY, provider_email TEXT)"))
+            await conn.execute(text("INSERT INTO user_oidc_links VALUES (1, 'User@Example.COM')"))
+            await conn.execute(text("INSERT INTO user_oidc_links VALUES (2, 'already@lower.com')"))
+            await conn.execute(text("INSERT INTO user_oidc_links VALUES (3, NULL)"))
+
+            async with conn.begin_nested():
+                await conn.execute(
+                    text(
+                        "UPDATE user_oidc_links SET provider_email = LOWER(provider_email) "
+                        "WHERE provider_email IS NOT NULL AND provider_email != LOWER(provider_email)"
+                    )
+                )
+
+            result = await conn.execute(text("SELECT provider_email FROM user_oidc_links ORDER BY id"))
+            rows = [r[0] for r in result.fetchall()]
+        await engine.dispose()
+
+        assert rows[0] == "user@example.com"
+        assert rows[1] == "already@lower.com"
+        assert rows[2] is None
+
+    @pytest.mark.asyncio
+    async def test_safe_execute_swallows_no_such_column_for_rename(self):
+        """'no such column' is swallowed for RENAME COLUMN idempotency.
+
+        When a column has already been renamed, re-running the RENAME COLUMN
+        migration raises 'no such column' — that must be silently swallowed.
+        DML safety is guaranteed by never passing DML through _safe_execute.
+        """
+        from sqlalchemy.ext.asyncio import create_async_engine
+
+        from backend.app.core.database import _safe_execute
+
+        engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+        async with engine.begin() as conn:
+            await conn.execute(__import__("sqlalchemy").text("CREATE TABLE t (id INTEGER, new_col INTEGER)"))
+            # Column 'old_col' does not exist — simulates re-running a RENAME COLUMN migration
+            # Must NOT raise.
+            await _safe_execute(conn, "ALTER TABLE t RENAME COLUMN old_col TO new_col")
+        await engine.dispose()
+
+    @pytest.mark.asyncio
+    async def test_safe_execute_swallows_does_not_exist_for_rename_postgres(self):
+        """'does not exist' (PostgreSQL UndefinedColumnError) is swallowed for RENAME COLUMN idempotency."""
+        from unittest.mock import AsyncMock, MagicMock
+
+        from sqlalchemy.exc import ProgrammingError
+
+        from backend.app.core.database import _safe_execute
+
+        fake_exc = ProgrammingError('column "quantity_printed" does not exist', [], Exception())
+
+        nested_cm = MagicMock()
+        nested_cm.__aenter__ = AsyncMock(return_value=nested_cm)
+        nested_cm.execute = AsyncMock(side_effect=fake_exc)
+        nested_cm.__aexit__ = AsyncMock(return_value=False)
+
+        mock_conn = MagicMock()
+        mock_conn.begin_nested.return_value = nested_cm
+        mock_conn.execute = AsyncMock(side_effect=fake_exc)
+
+        # Must NOT raise — "does not exist" is in the swallow-list
+        await _safe_execute(
+            mock_conn, "ALTER TABLE project_bom_items RENAME COLUMN quantity_printed TO quantity_acquired"
+        )
+
+    @pytest.mark.asyncio
+    async def test_safe_execute_swallows_duplicate_key(self):
+        """'duplicate key' errors (PostgreSQL unique-constraint violations on re-run)
+        must be silently swallowed for idempotent DDL migrations."""
+        from unittest.mock import AsyncMock, MagicMock
+
+        from sqlalchemy.exc import OperationalError
+
+        from backend.app.core.database import _safe_execute
+
+        fake_exc = OperationalError("duplicate key value violates unique constraint", [], Exception())
+
+        # begin_nested() is called synchronously (not awaited) and returns an
+        # async context manager. Use MagicMock so the call returns a regular
+        # object, then attach __aenter__/__aexit__ for the async with protocol.
+        nested_cm = MagicMock()
+        nested_cm.__aenter__ = AsyncMock(return_value=nested_cm)
+        # Raise on execute inside the context, simulating PG duplicate key
+        nested_cm.execute = AsyncMock(side_effect=fake_exc)
+        nested_cm.__aexit__ = AsyncMock(return_value=False)
+
+        mock_conn = MagicMock()
+        mock_conn.begin_nested.return_value = nested_cm
+        mock_conn.execute = AsyncMock(side_effect=fake_exc)
+
+        # Must NOT raise — "duplicate key" is in the swallow-list
+        await _safe_execute(mock_conn, "CREATE UNIQUE INDEX ...")
+
+    @pytest.mark.asyncio
+    async def test_check_constraint_false_true_on_sqlite(self):
+        """CheckConstraint with FALSE/TRUE literals is enforced on SQLite (3.23+)."""
+        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 ck_test (
+                    id INTEGER PRIMARY KEY,
+                    auto_link BOOLEAN,
+                    require_ev BOOLEAN,
+                    email_claim TEXT,
+                    CHECK (auto_link = FALSE OR (require_ev = TRUE AND email_claim = 'email'))
+                )
+            """)
+            )
+            # Valid: auto_link=0 (FALSE)
+            await conn.execute(text("INSERT INTO ck_test VALUES (1, 0, 0, 'upn')"))
+            # Valid: auto_link=1, require_ev=1, email_claim='email'
+            await conn.execute(text("INSERT INTO ck_test VALUES (2, 1, 1, 'email')"))
+
+        async with engine.begin() as conn:
+            # Invalid: auto_link=1 but conditions not met
+            with pytest.raises(IntegrityError):
+                await conn.execute(text("INSERT INTO ck_test VALUES (3, 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.
+
+        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)
+        """
+        from sqlalchemy import text
+        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 ("
+                    "id INTEGER PRIMARY KEY, "
+                    "auto_link_existing_accounts BOOLEAN, "
+                    "require_email_verified BOOLEAN, "
+                    "email_claim TEXT"
+                    ")"
+                )
+            )
+            # Row 1: unsafe — require_ev=FALSE
+            await conn.execute(text("INSERT INTO oidc_providers VALUES (1, 1, 0, 'email')"))
+            # Row 2: unsafe — custom claim
+            await conn.execute(text("INSERT INTO oidc_providers VALUES (2, 1, 1, 'preferred_username')"))
+            # Row 3: safe — require_ev=TRUE + standard claim
+            await conn.execute(text("INSERT INTO oidc_providers VALUES (3, 1, 1, 'email')"))
+
+            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')"
+                    )
+                )
+
+            result = await conn.execute(text("SELECT id, auto_link_existing_accounts FROM oidc_providers ORDER BY id"))
+            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"
+
+    @pytest.mark.asyncio
+    async def test_safe_execute_reraises_does_not_exist_without_column(self):
+        """'does not exist' without 'column' in the message must NOT be swallowed.
+
+        This verifies that the narrowing from the broad 'does not exist' substring
+        to the compound RENAME-COLUMN-only guard works correctly.  A missing-relation
+        error must propagate so the operator sees a startup failure rather than a
+        silent schema gap.
+        """
+        from unittest.mock import AsyncMock, MagicMock
+
+        from sqlalchemy.exc import ProgrammingError
+
+        from backend.app.core.database import _safe_execute
+
+        # PostgreSQL error for a missing relation — contains "does not exist" but NOT "column"
+        fake_exc = ProgrammingError('relation "oidc_providers" does not exist', [], Exception())
+
+        nested_cm = MagicMock()
+        nested_cm.__aenter__ = AsyncMock(return_value=nested_cm)
+        nested_cm.execute = AsyncMock(side_effect=fake_exc)
+        nested_cm.__aexit__ = AsyncMock(return_value=False)
+
+        mock_conn = MagicMock()
+        mock_conn.begin_nested.return_value = nested_cm
+        mock_conn.execute = AsyncMock(side_effect=fake_exc)
+
+        # Must RAISE — "column" is absent so this is not RENAME COLUMN idempotency
+        with pytest.raises(ProgrammingError):
+            await _safe_execute(
+                mock_conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT false"
+            )
+
+    @pytest.mark.asyncio
+    async def test_oidc_boolean_default_migrations_sqlite_defaults(self):
+        """auto_link defaults to 0 (FALSE) and require_email_verified defaults to 1 (TRUE) on SQLite.
+
+        Verifies that the SQLite branch of the BOOLEAN DEFAULT dialect-branch uses
+        the correct integer literals so new rows get safe defaults without explicit values.
+        """
+        from sqlalchemy import text
+        from sqlalchemy.ext.asyncio import create_async_engine
+
+        from backend.app.core.database import _safe_execute
+
+        engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+        async with engine.begin() as conn:
+            await conn.execute(text("CREATE TABLE oidc_providers (id INTEGER PRIMARY KEY, name TEXT)"))
+            await _safe_execute(
+                conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT 0"
+            )
+            await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN require_email_verified BOOLEAN DEFAULT 1")
+            await conn.execute(text("INSERT INTO oidc_providers (id, name) VALUES (1, 'test')"))
+            result = await conn.execute(
+                text("SELECT auto_link_existing_accounts, require_email_verified FROM oidc_providers WHERE id = 1")
+            )
+            row = result.fetchone()
+        await engine.dispose()
+
+        assert row[0] == 0, "auto_link_existing_accounts must default to 0 (FALSE) on SQLite"
+        assert row[1] == 1, "require_email_verified must default to 1 (TRUE) on SQLite"
+
+    @pytest.mark.asyncio
+    async def test_safe_execute_column_not_exists_only_swallowed_for_rename(self):
+        """'column … does not exist' is swallowed only when the SQL is RENAME COLUMN.
+
+        The compound guard must NOT swallow the same error pattern when the SQL is
+        an ADD COLUMN statement — that would indicate schema corruption, not idempotency.
+        """
+        from unittest.mock import AsyncMock, MagicMock
+
+        from sqlalchemy.exc import ProgrammingError
+
+        from backend.app.core.database import _safe_execute
+
+        fake_exc = ProgrammingError('column "auto_link_existing_accounts" does not exist', [], Exception())
+
+        nested_cm = MagicMock()
+        nested_cm.__aenter__ = AsyncMock(return_value=nested_cm)
+        nested_cm.execute = AsyncMock(side_effect=fake_exc)
+        nested_cm.__aexit__ = AsyncMock(return_value=False)
+
+        mock_conn = MagicMock()
+        mock_conn.begin_nested.return_value = nested_cm
+        mock_conn.execute = AsyncMock(side_effect=fake_exc)
+
+        # ADD COLUMN statement — must RAISE even though message contains "column" + "does not exist"
+        with pytest.raises(ProgrammingError):
+            await _safe_execute(
+                mock_conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT false"
+            )
+
+        # RENAME COLUMN statement — must NOT raise (idempotency)
+        await _safe_execute(
+            mock_conn, "ALTER TABLE oidc_providers RENAME COLUMN auto_link_existing_accounts TO auto_link"
+        )

+ 82 - 0
docs/authentication/entra-id.md

@@ -0,0 +1,82 @@
+# Azure Entra ID (Azure AD) OIDC Setup
+
+This guide shows how to configure BamBuddy's OIDC integration with **Microsoft Azure Entra ID** (formerly Azure Active Directory).
+
+---
+
+## Prerequisites
+
+- An Azure account with permission to register applications in Entra ID.
+- BamBuddy ≥ 1.0 with OIDC enabled (Settings → Authentication → OIDC Providers).
+
+---
+
+## 1. Register an Application in Azure
+
+1. Open the [Azure Portal](https://portal.azure.com) and navigate to **Entra ID → App registrations → New registration**.
+2. Set a display name (e.g. `BamBuddy`).
+3. Under **Supported account types**, select the option that matches your organisation.
+4. Add a **Redirect URI** of type **Web**:
+   ```
+   https://<your-bambuddy-host>/api/v1/auth/oidc/callback
+   ```
+5. Click **Register**.
+
+---
+
+## 2. Create a Client Secret
+
+1. In your app registration, go to **Certificates & secrets → Client secrets → New client secret**.
+2. Choose an expiry and click **Add**.
+3. **Copy the secret value immediately** — it is only shown once.
+
+---
+
+## 3. Gather the Required Values
+
+| Value | Where to find it |
+|---|---|
+| **Issuer URL** | **Overview → Endpoints** — copy the *OpenID Connect metadata document* URL and strip `/.well-known/openid-configuration` from the end. It looks like `https://login.microsoftonline.com/<tenant-id>/v2.0`. |
+| **Client ID** | **Overview → Application (client) ID** |
+| **Client Secret** | The secret value you copied above |
+
+---
+
+## 4. Add the Provider in BamBuddy
+
+Go to **Settings → Authentication → OIDC Providers → Add Provider** and fill in:
+
+| Field | Value |
+|---|---|
+| Name | `Azure Entra ID` (or any label you prefer) |
+| Issuer URL | `https://login.microsoftonline.com/<tenant-id>/v2.0` |
+| Client ID | Your Application (client) ID |
+| Client Secret | The secret you created |
+| Scopes | `openid email profile` |
+| **Email Claim** | `preferred_username` ← **important for Entra ID** |
+| Require Email Verified | **Off** ← Entra ID never sends `email_verified` |
+| Auto-link existing accounts | Keep **Off** unless you fully trust the IdP and have verified all existing user emails |
+
+### Why `preferred_username`?
+
+Azure Entra ID does **not** include an `email_verified` claim in its ID tokens. Using the standard `email` claim with *Require Email Verified* enabled would block every login. Two safe alternatives exist:
+
+- **`preferred_username`** (recommended) — Entra ID always populates this with the UPN (e.g. `user@contoso.com`). BamBuddy treats it as an email-shaped identifier and skips the `email_verified` check entirely.
+- **`email`** with *Require Email Verified* disabled — works but accepts the claim unconditionally; only appropriate when the Entra ID tenant is fully under your control.
+
+---
+
+## 5. (Optional) Token Lifetime
+
+Azure Entra ID issues access tokens that expire after 1 hour by default. BamBuddy exchanges the OIDC code for its own JWT at callback time, so the Entra token lifetime does not affect the BamBuddy session length. You can adjust BamBuddy's `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` env var independently.
+
+---
+
+## 6. Troubleshooting
+
+| Symptom | Likely cause |
+|---|---|
+| Login redirects to Entra but returns "OIDC login failed" | Redirect URI mismatch — check the URI registered in Azure exactly matches BamBuddy's callback URL, including scheme and trailing path. |
+| User created but email is empty | The `preferred_username` claim was not populated by Entra. Try the `email` claim with *Require Email Verified* off. |
+| "Invalid client" error from Azure | Client secret has expired or was copied incorrectly. Rotate the secret in Azure and update the provider in BamBuddy. |
+| Login works but wrong user is linked | `auto_link_existing_accounts` should remain **Off** until all local user emails are verified to match the Entra UPNs. |

+ 56 - 0
frontend/src/__tests__/api/client.test.ts

@@ -33,6 +33,8 @@ beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
 afterEach(() => {
   server.resetHandlers();
   sessionStorageMock.clear();
+  vi.mocked(localStorage.setItem).mockClear();
+  vi.mocked(localStorage.removeItem).mockClear();
   setAuthToken(null);
 });
 afterAll(() => server.close());
@@ -50,6 +52,60 @@ describe('Auth Token Management', () => {
     expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
     expect(getAuthToken()).toBeNull();
   });
+
+  it("setAuthToken('persistent') writes to both sessionStorage and localStorage", () => {
+    setAuthToken('persist-token', 'persistent');
+    expect(sessionStorageMock.setItem).toHaveBeenCalledWith('auth_token', 'persist-token');
+    expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'persist-token');
+    expect(getAuthToken()).toBe('persist-token');
+  });
+
+  it("setAuthToken('session') writes only to sessionStorage, not localStorage", () => {
+    setAuthToken('session-token', 'session');
+    expect(sessionStorageMock.setItem).toHaveBeenCalledWith('auth_token', 'session-token');
+    expect(vi.mocked(localStorage.setItem)).not.toHaveBeenCalledWith('auth_token', expect.any(String));
+  });
+
+  it('setAuthToken(null) removes from both storages regardless of previous persistence', () => {
+    setAuthToken('some-token', 'persistent');
+    vi.mocked(localStorage.setItem).mockClear();
+    setAuthToken(null);
+    expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
+    expect(vi.mocked(localStorage.removeItem)).toHaveBeenCalledWith('auth_token');
+    expect(getAuthToken()).toBeNull();
+  });
+
+  it('setAuthToken keeps in-memory token when sessionStorage throws', () => {
+    sessionStorageMock.setItem.mockImplementationOnce(() => {
+      throw new DOMException('QuotaExceededError');
+    });
+    // Should not throw even when storage is unavailable
+    expect(() => setAuthToken('fallback-token')).not.toThrow();
+    // In-memory token must still be set
+    expect(getAuthToken()).toBe('fallback-token');
+  });
+
+  it('setAuthToken(null) removes from sessionStorage even when localStorage.removeItem throws', () => {
+    setAuthToken('some-token', 'persistent');
+    vi.mocked(localStorage.removeItem).mockImplementationOnce(() => {
+      throw new DOMException('SecurityError');
+    });
+    // Must not throw — localStorage failure must not abort the sessionStorage removal
+    expect(() => setAuthToken(null)).not.toThrow();
+    expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
+    expect(getAuthToken()).toBeNull();
+  });
+
+  it('setAuthToken(null) removes from localStorage even when sessionStorage.removeItem throws', () => {
+    setAuthToken('some-token', 'persistent');
+    sessionStorageMock.removeItem.mockImplementationOnce(() => {
+      throw new DOMException('SecurityError');
+    });
+    // Must not throw — sessionStorage failure must not abort the localStorage removal
+    expect(() => setAuthToken(null)).not.toThrow();
+    expect(vi.mocked(localStorage.removeItem)).toHaveBeenCalledWith('auth_token');
+    expect(getAuthToken()).toBeNull();
+  });
 });
 
 describe('API Client Auth Header', () => {

+ 125 - 0
frontend/src/__tests__/components/OIDCProviderSettings.test.tsx

@@ -0,0 +1,125 @@
+/**
+ * Tests for OIDCProviderSettings — focused on the auto_link / require_email_verified
+ * toggle interaction (SEC-1/SEC-6 UI enforcement).
+ */
+
+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 mockProviders = [
+  {
+    id: 1,
+    name: 'TestIdP',
+    issuer_url: 'https://idp.example.com',
+    client_id: 'test-client',
+    scopes: 'openid email profile',
+    is_enabled: true,
+    auto_create_users: false,
+    auto_link_existing_accounts: false,
+    email_claim: 'email',
+    require_email_verified: true,
+    icon_url: null,
+    created_at: '2026-01-01T00:00:00Z',
+    updated_at: '2026-01-01T00:00:00Z',
+  },
+];
+
+beforeEach(() => {
+  server.use(
+    http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json(mockProviders))
+  );
+});
+
+describe('OIDCProviderSettings', () => {
+  describe('ProviderForm — require_email_verified description logic', () => {
+    it('shows standard description when require_email_verified is on and auto_link is off', async () => {
+      server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
+      render(<OIDCProviderSettings />);
+
+      await waitFor(() => {
+        expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
+      });
+      await userEvent.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
+
+      await waitFor(() => {
+        // Default state: require_email_verified=true, auto_link=false → standard description
+        expect(
+          screen.getByText(/only.*accept.*email.*verified/i)
+        ).toBeInTheDocument();
+      });
+    });
+
+    it('shows "Disable auto-link first" description when auto_link is enabled', 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();
+      });
+
+      // Find the Auto Link switch by aria-label or by position
+      const switches = screen.getAllByRole('switch');
+      // Switches order in form: Enabled, AutoCreate, AutoLink, RequireEmailVerified
+      // AutoLink is the 3rd switch (index 2)
+      const autoLinkSwitch = switches[2];
+      await user.click(autoLinkSwitch);
+
+      await waitFor(() => {
+        expect(
+          screen.getByText(/disable auto.?link first/i)
+        ).toBeInTheDocument();
+      });
+    });
+
+    it('shows warning text when require_email_verified is toggled off', 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(/Require Email Verified/i)).toBeInTheDocument();
+      });
+
+      // RequireEmailVerified is the 4th switch (index 3)
+      const switches = screen.getAllByRole('switch');
+      const reqEvSwitch = switches[3];
+      await user.click(reqEvSwitch);
+
+      await waitFor(() => {
+        expect(
+          screen.getByText(/warning.*accept.*without.*verif/i)
+        ).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('Provider info view', () => {
+    it('renders email_claim and require_email_verified fields in provider details', async () => {
+      render(<OIDCProviderSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('TestIdP')).toBeInTheDocument();
+      });
+
+      // The provider card shows field labels in the details section
+      expect(screen.getByText(/Email Claim/i)).toBeInTheDocument();
+      expect(screen.getByText(/Require Email Verified/i)).toBeInTheDocument();
+    });
+  });
+});

+ 328 - 1
frontend/src/__tests__/pages/LoginPage.test.tsx

@@ -2,7 +2,7 @@
  * Tests for the LoginPage component.
  */
 
-import { describe, it, expect, beforeEach } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
 import { screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
@@ -300,4 +300,331 @@ describe('LoginPage', () => {
       });
     });
   });
+
+  describe('Remember Me', () => {
+    const mockUser = {
+      id: 1,
+      username: 'testuser',
+      role: 'admin' as const,
+      is_active: true,
+      created_at: new Date().toISOString(),
+    };
+
+    beforeEach(() => {
+      vi.mocked(localStorage.setItem).mockClear();
+      sessionStorage.clear();
+      server.use(
+        http.post('/api/v1/auth/login', () =>
+          HttpResponse.json({
+            access_token: 'test-token',
+            token_type: 'bearer',
+            user: mockUser,
+          })
+        ),
+        // Prevent checkAuthStatus from clearing the token when getCurrentUser is called
+        http.get('/api/v1/auth/me', () => HttpResponse.json(mockUser))
+      );
+    });
+
+    it('renders Remember Me checkbox on credentials step', async () => {
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByLabelText(/Remember Me/i)).toBeInTheDocument();
+      });
+
+      expect(screen.getByRole('checkbox', { name: /Remember Me/i })).not.toBeChecked();
+    });
+
+    it('does not persist token to localStorage when unchecked (default)', async () => {
+      const user = userEvent.setup();
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
+      });
+
+      await user.type(screen.getByLabelText(/Username/i), 'testuser');
+      await user.type(screen.getByLabelText(/Password/i), 'testpassword');
+      await user.click(screen.getByRole('button', { name: /Sign in/i }));
+
+      // Token must be in sessionStorage (tab-only) but not in localStorage
+      await waitFor(() => {
+        expect(vi.mocked(localStorage.setItem)).not.toHaveBeenCalledWith('auth_token', expect.any(String));
+        expect(sessionStorage.getItem('auth_token')).toBe('test-token');
+      });
+    });
+
+    it('persists token to localStorage when Remember Me is checked', async () => {
+      const user = userEvent.setup();
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByRole('checkbox', { name: /Remember Me/i }));
+      await user.type(screen.getByLabelText(/Username/i), 'testuser');
+      await user.type(screen.getByLabelText(/Password/i), 'testpassword');
+      await user.click(screen.getByRole('button', { name: /Sign in/i }));
+
+      await waitFor(() => {
+        expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'test-token');
+      });
+    });
+
+    it('carries Remember Me through 2FA verification', async () => {
+      server.use(
+        http.post('/api/v1/auth/login', () =>
+          HttpResponse.json({
+            requires_2fa: true,
+            pre_auth_token: 'pre-token',
+            two_fa_methods: ['totp'],
+          })
+        ),
+        http.post('/api/v1/auth/2fa/verify', () =>
+          HttpResponse.json({
+            access_token: 'final-token',
+            token_type: 'bearer',
+            user: mockUser,
+          })
+        )
+      );
+
+      const user = userEvent.setup();
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
+      });
+
+      // Check Remember Me before submitting credentials
+      await user.click(screen.getByRole('checkbox', { name: /Remember Me/i }));
+      await user.type(screen.getByLabelText(/Username/i), 'testuser');
+      await user.type(screen.getByLabelText(/Password/i), 'testpassword');
+      await user.click(screen.getByRole('button', { name: /Sign in/i }));
+
+      // Now on 2FA step — enter code and verify
+      await waitFor(() => {
+        expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
+      });
+
+      await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456');
+      await user.click(screen.getByRole('button', { name: /Verify/i }));
+
+      // Token must be persisted to localStorage because Remember Me was checked
+      await waitFor(() => {
+        expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'final-token');
+      });
+    });
+
+    it('checkbox is not shown on 2FA step', async () => {
+      server.use(
+        http.post('/api/v1/auth/login', () =>
+          HttpResponse.json({
+            requires_2fa: true,
+            pre_auth_token: 'pre-token',
+            two_fa_methods: ['totp'],
+          })
+        )
+      );
+
+      const user = userEvent.setup();
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
+      });
+
+      await user.type(screen.getByLabelText(/Username/i), 'testuser');
+      await user.type(screen.getByLabelText(/Password/i), 'testpassword');
+      await user.click(screen.getByRole('button', { name: /Sign in/i }));
+
+      await waitFor(() => {
+        expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
+      });
+
+      expect(screen.queryByLabelText(/Remember Me/i)).not.toBeInTheDocument();
+    });
+  });
+
+  describe('OIDC with Remember Me', () => {
+    const mockUser = {
+      id: 1,
+      username: 'oidcuser',
+      role: 'admin' as const,
+      is_active: true,
+      created_at: new Date().toISOString(),
+    };
+
+    beforeEach(() => {
+      vi.mocked(localStorage.setItem).mockClear();
+      sessionStorage.clear();
+    });
+
+    afterEach(() => {
+      window.location.hash = '';
+      window.history.pushState({}, '', '/login');
+      sessionStorage.clear();
+    });
+
+    it('persists token to localStorage after OIDC redirect when Remember Me was set', async () => {
+      sessionStorage.setItem('auth_remember_me', '1');
+      server.use(
+        http.post('/api/v1/auth/oidc/exchange', () =>
+          HttpResponse.json({
+            access_token: 'oidc-token',
+            token_type: 'bearer',
+            user: mockUser,
+          })
+        )
+      );
+
+      window.location.hash = '#oidc_token=test-exchange-token';
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'oidc-token');
+      });
+      expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
+    });
+
+    it('carries Remember Me through OIDC + 2FA flow', async () => {
+      sessionStorage.setItem('auth_remember_me', '1');
+      server.use(
+        http.post('/api/v1/auth/oidc/exchange', () =>
+          HttpResponse.json({
+            requires_2fa: true,
+            pre_auth_token: 'oidc-pre-token',
+            two_fa_methods: ['totp'],
+          })
+        ),
+        http.post('/api/v1/auth/2fa/verify', () =>
+          HttpResponse.json({
+            access_token: 'oidc-2fa-token',
+            token_type: 'bearer',
+            user: mockUser,
+          })
+        )
+      );
+
+      window.location.hash = '#oidc_token=test-exchange-token';
+      const user = userEvent.setup();
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
+      });
+      // Flag consumed on mount — no stale value for future flows
+      expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
+
+      await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456');
+      await user.click(screen.getByRole('button', { name: /Verify/i }));
+
+      await waitFor(() => {
+        expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'oidc-2fa-token');
+      });
+    });
+
+    it('cleans up auth_remember_me flag when OIDC returns an error', async () => {
+      sessionStorage.setItem('auth_remember_me', '1');
+      window.history.pushState({}, '', '/login?oidc_error=invalid_state');
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
+      });
+    });
+
+    it('does not persist token to localStorage after OIDC redirect when Remember Me was not set', async () => {
+      // No auth_remember_me flag set — token must stay session-only
+      server.use(
+        http.post('/api/v1/auth/oidc/exchange', () =>
+          HttpResponse.json({
+            access_token: 'oidc-session-token',
+            token_type: 'bearer',
+            user: mockUser,
+          })
+        )
+      );
+
+      window.location.hash = '#oidc_token=test-exchange-token';
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(sessionStorage.getItem('auth_token')).toBe('oidc-session-token');
+      });
+      expect(vi.mocked(localStorage.setItem)).not.toHaveBeenCalledWith('auth_token', expect.any(String));
+    });
+
+    it('shows error toast when OIDC exchange returns unexpected response shape', async () => {
+      sessionStorage.setItem('auth_remember_me', '1');
+      server.use(
+        // Response is missing both access_token and requires_2fa — hits the else branch
+        http.post('/api/v1/auth/oidc/exchange', () =>
+          HttpResponse.json({ token_type: 'bearer' })
+        )
+      );
+
+      window.location.hash = '#oidc_token=test-exchange-token';
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/Login.*failed|failed.*login/i)).toBeInTheDocument();
+      });
+      // Flag must still be cleaned up even on malformed response
+      expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
+    });
+
+    it('writes auth_remember_me flag to sessionStorage before OIDC provider redirect', async () => {
+      server.use(
+        http.get('/api/v1/auth/oidc/providers', () =>
+          HttpResponse.json([
+            {
+              id: 42,
+              name: 'FlagIdP',
+              issuer_url: 'https://flag.test',
+              client_id: 'c',
+              is_enabled: true,
+              icon_url: null,
+              email_claim: 'email',
+              require_email_verified: true,
+              auto_create_users: false,
+              auto_link_existing_accounts: false,
+            },
+          ])
+        ),
+        http.get('/api/v1/auth/oidc/authorize/42', () =>
+          HttpResponse.json({ auth_url: 'https://flag.test/authorize?state=abc' })
+        )
+      );
+
+      const user = userEvent.setup();
+      render(<LoginPage />);
+
+      // Tick "Remember Me"
+      await waitFor(() => {
+        expect(screen.getByRole('checkbox', { name: /Remember Me/i })).toBeInTheDocument();
+      });
+      await user.click(screen.getByRole('checkbox', { name: /Remember Me/i }));
+
+      // Wait for OIDC provider button to appear
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: /FlagIdP/i })).toBeInTheDocument();
+      });
+
+      // Stub window.location so the OIDC redirect doesn't actually navigate.
+      // Keep href valid so relative fetch URLs resolve correctly.
+      Object.defineProperty(window, 'location', {
+        writable: true,
+        value: { ...window.location, href: 'http://localhost:3000/' },
+      });
+
+      await user.click(screen.getByRole('button', { name: /FlagIdP/i }));
+
+      await waitFor(() => {
+        expect(sessionStorage.getItem('auth_remember_me')).toBe('1');
+      });
+    });
+  });
 });

+ 25 - 11
frontend/src/api/client.ts

@@ -2,23 +2,33 @@ import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/
 
 const API_BASE = '/api/v1';
 
-// Auth token storage
-// By default tokens are stored in sessionStorage (tab-scoped, cleared on close).
-// When the token originates from the ?token= URL param (kiosk bootstrap), it is
-// additionally persisted in localStorage so the kiosk survives page reloads.
+// 'persistent' also writes to localStorage so the token survives tab close
+// (used by Remember Me and the ?token= kiosk bootstrap).
 let authToken: string | null =
   sessionStorage.getItem('auth_token') ?? localStorage.getItem('auth_token');
 
-export function setAuthToken(token: string | null, persist = false) {
+export type TokenPersistence = 'session' | 'persistent';
+
+export function setAuthToken(token: string | null, persistence: TokenPersistence = 'session') {
   authToken = token;
-  if (token) {
-    sessionStorage.setItem('auth_token', token);
-    if (persist) {
+  try {
+    if (token) {
+      sessionStorage.setItem('auth_token', token);
+    } else {
+      sessionStorage.removeItem('auth_token');
+    }
+  } catch (err) {
+    // Storage unavailable (quota exceeded, private mode): in-memory token still works for this tab.
+    console.warn('setAuthToken: sessionStorage unavailable, token kept in-memory only', err);
+  }
+  try {
+    if (!token) {
+      localStorage.removeItem('auth_token');
+    } else if (persistence === 'persistent') {
       localStorage.setItem('auth_token', token);
     }
-  } else {
-    sessionStorage.removeItem('auth_token');
-    localStorage.removeItem('auth_token');
+  } catch (err) {
+    console.warn('setAuthToken: localStorage operation failed', err);
   }
 }
 
@@ -2518,6 +2528,8 @@ export interface OIDCProvider {
   is_enabled: boolean;
   auto_create_users: boolean;
   auto_link_existing_accounts: boolean;
+  email_claim: string;
+  require_email_verified: boolean;
   icon_url?: string | null;
 }
 
@@ -2530,6 +2542,8 @@ export interface OIDCProviderCreate {
   is_enabled?: boolean;
   auto_create_users?: boolean;
   auto_link_existing_accounts?: boolean;
+  email_claim?: string;
+  require_email_verified?: boolean;
   icon_url?: string | null;
 }
 

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

@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState, type ReactNode } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
@@ -19,6 +19,8 @@ const EMPTY_FORM: OIDCProviderCreate = {
   is_enabled: true,
   auto_create_users: false,
   auto_link_existing_accounts: false,
+  email_claim: 'email',
+  require_email_verified: true,
   icon_url: undefined,
 };
 
@@ -54,6 +56,19 @@ function ProviderForm({
     onSave(payload);
   };
 
+  const autoLinkOn = form.auto_link_existing_accounts === true;
+  const emailVerifiedOn = form.require_email_verified ?? true;
+  let requireEmailVerifiedDesc: ReactNode;
+  if (autoLinkOn) {
+    requireEmailVerifiedDesc = t('settings.oidc.form.requireEmailVerifiedAutoLink');
+  } else if (emailVerifiedOn) {
+    requireEmailVerifiedDesc = t('settings.oidc.form.requireEmailVerifiedDesc');
+  } else {
+    requireEmailVerifiedDesc = (
+      <span className="text-red-400">{t('settings.oidc.form.requireEmailVerifiedWarning')}</span>
+    );
+  }
+
   return (
     <div className="space-y-4">
       <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -115,6 +130,28 @@ function ProviderForm({
             <p className="text-bambu-gray text-xs">{t('settings.oidc.form.autoLinkDesc')}</p>
           </div>
         </label>
+        <label className="flex items-center gap-3 cursor-pointer">
+          <Toggle
+            checked={emailVerifiedOn}
+            onChange={(v) => set('require_email_verified', v)}
+            disabled={autoLinkOn}
+          />
+          <div>
+            <p className="text-white text-sm">{t('settings.oidc.form.requireEmailVerified')}</p>
+            <p className="text-bambu-gray text-xs">{requireEmailVerifiedDesc}</p>
+          </div>
+        </label>
+      </div>
+
+      <div>
+        <label className={labelCls}>{t('settings.oidc.form.emailClaim')}</label>
+        <input
+          className={inputCls}
+          value={form.email_claim}
+          onChange={(e) => set('email_claim', e.target.value || 'email')}
+          placeholder={t('settings.oidc.form.emailClaimPlaceholder')}
+        />
+        <p className="text-bambu-gray text-xs mt-1">{t('settings.oidc.form.emailClaimDesc')}</p>
       </div>
 
       <div className="flex gap-3 pt-2">
@@ -304,6 +341,8 @@ export function OIDCProviderSettings() {
                     is_enabled: provider.is_enabled,
                     auto_create_users: provider.auto_create_users,
                     auto_link_existing_accounts: provider.auto_link_existing_accounts,
+                    email_claim: provider.email_claim,
+                    require_email_verified: provider.require_email_verified,
                     icon_url: provider.icon_url ?? undefined,
                   }}
                   onSave={(data) => updateMutation.mutate({ id: provider.id, data })}
@@ -337,6 +376,16 @@ export function OIDCProviderSettings() {
                     {provider.auto_link_existing_accounts ? t('common.yes') : t('common.no')}
                   </dd>
                 </div>
+                <div>
+                  <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.requireEmailVerified')}</dt>
+                  <dd className={provider.require_email_verified ? 'text-green-400' : 'text-red-400'}>
+                    {provider.require_email_verified ? t('common.yes') : t('common.no')}
+                  </dd>
+                </div>
               </dl>
             </CardContent>
           )}

+ 9 - 9
frontend/src/contexts/AuthContext.tsx

@@ -1,6 +1,6 @@
 import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
 import { api, getAuthToken, setAuthToken } from '../api/client';
-import type { LoginResponse, Permission, UserResponse } from '../api/client';
+import type { LoginResponse, Permission, TokenPersistence, UserResponse } from '../api/client';
 
 interface AuthContextType {
   user: UserResponse | null;
@@ -9,9 +9,9 @@ interface AuthContextType {
   loading: boolean;
   isAdmin: boolean;
   /** Login with username/password. Returns LoginResponse (may include requires_2fa). */
-  login: (username: string, password: string) => Promise<LoginResponse>;
+  login: (username: string, password: string, persistence?: TokenPersistence) => Promise<LoginResponse>;
   /** Finalise login after 2FA or OIDC — store token and set user directly. */
-  loginWithToken: (token: string, user: UserResponse) => void;
+  loginWithToken: (token: string, user: UserResponse, persistence?: TokenPersistence) => void;
   logout: () => void;
   refreshUser: () => Promise<void>;
   refreshAuth: () => Promise<void>;
@@ -41,7 +41,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
       const urlParams = new URLSearchParams(window.location.search);
       const urlToken = urlParams.get('token');
       if (urlToken) {
-        setAuthToken(urlToken, false); // session-only until server confirms it's valid
+        setAuthToken(urlToken, 'session'); // session-only until server confirms it's valid
         urlParams.delete('token');
         const cleanSearch = urlParams.toString();
         const cleanUrl = window.location.pathname
@@ -64,7 +64,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
             setUser(currentUser);
             // Persist kiosk token only after the server confirms it is valid.
             if (urlToken && token === urlToken) {
-              setAuthToken(urlToken, true);
+              setAuthToken(urlToken, 'persistent');
             }
           } catch {
             // Token invalid, clear it (removes from both sessionStorage and localStorage)
@@ -116,17 +116,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     }
   }, [loading, requiresSetup, authEnabled]);
 
-  const login = async (username: string, password: string): Promise<LoginResponse> => {
+  const login = async (username: string, password: string, persistence: TokenPersistence = 'session'): Promise<LoginResponse> => {
     const response = await api.login({ username, password });
     if (!response.requires_2fa && response.access_token) {
-      setAuthToken(response.access_token);
+      setAuthToken(response.access_token, persistence);
       await checkAuthStatus();
     }
     return response;
   };
 
-  const loginWithToken = (token: string, userObj: UserResponse) => {
-    setAuthToken(token);
+  const loginWithToken = (token: string, userObj: UserResponse, persistence: TokenPersistence = 'session') => {
+    setAuthToken(token, persistence);
     setUser(userObj);
     setAuthEnabled(true);
   };

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

@@ -2202,6 +2202,13 @@ export default {
         autoLinkDesc: 'Verknüpft beim ersten Login vorhandene lokale Konten anhand der E-Mail-Adresse.',
         secretHint: 'leer lassen zum Beibehalten',
         secretPlaceholder: 'neues Secret',
+        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',
+        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.',
+        requireEmailVerifiedAutoLink: 'Auto-Verknüpfung zuerst deaktivieren, um diese Einstellung zu ändern.',
       },
     },
 
@@ -2331,6 +2338,7 @@ export default {
     passwordPlaceholder: 'Passwort eingeben',
     signIn: 'Anmelden',
     signingIn: 'Anmeldung läuft...',
+    rememberMe: 'Angemeldet bleiben',
     forgotPassword: 'Passwort vergessen?',
     loginSuccess: 'Erfolgreich angemeldet',
     loginFailed: 'Anmeldung fehlgeschlagen',

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

@@ -2205,6 +2205,13 @@ export default {
         autoLinkDesc: 'Link existing local accounts by matching email on first login.',
         secretHint: 'leave blank to keep current',
         secretPlaceholder: 'new secret',
+        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',
+        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.',
+        requireEmailVerifiedAutoLink: 'Disable auto-link first to change this setting.',
       },
     },
 
@@ -2334,6 +2341,7 @@ export default {
     passwordPlaceholder: 'Enter your password',
     signIn: 'Sign in',
     signingIn: 'Logging in...',
+    rememberMe: 'Remember Me',
     forgotPassword: 'Forgot your password?',
     loginSuccess: 'Logged in successfully',
     loginFailed: 'Login failed',

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

@@ -2138,6 +2138,13 @@ export default {
         autoLinkDesc: 'Lie les comptes locaux existants par e-mail lors de la première connexion.',
         secretHint: 'laisser vide pour conserver',
         secretPlaceholder: 'nouveau secret',
+        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',
+        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.",
+        requireEmailVerifiedAutoLink: 'Désactiver le lien automatique d\'abord pour modifier ce paramètre.',
       },
     },
 
@@ -2267,6 +2274,7 @@ export default {
     passwordPlaceholder: 'Entrez votre mot de passe',
     signIn: 'Se connecter',
     signingIn: 'Connexion...',
+    rememberMe: 'Se souvenir de moi',
     forgotPassword: 'Mot de passe oublié ?',
     loginSuccess: 'Connecté avec succès',
     loginFailed: 'Échec de connexion',

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

@@ -2137,6 +2137,13 @@ export default {
         autoLinkDesc: 'Collega gli account locali esistenti tramite email al primo accesso.',
         secretHint: 'lascia vuoto per mantenere',
         secretPlaceholder: 'nuovo segreto',
+        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',
+        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.',
+        requireEmailVerifiedAutoLink: 'Disabilitare prima il collegamento automatico per modificare questa impostazione.',
       },
     },
 
@@ -2266,6 +2273,7 @@ export default {
     passwordPlaceholder: 'Inserisci la password',
     signIn: 'Accedi',
     signingIn: 'Accesso in corso...',
+    rememberMe: 'Ricordami',
     forgotPassword: 'Hai dimenticato la password?',
     loginSuccess: 'Accesso riuscito',
     loginFailed: 'Accesso fallito',

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

@@ -2176,6 +2176,13 @@ export default {
         autoLinkDesc: '初回ログイン時にメールアドレスで既存のローカルアカウントにリンクします。',
         secretHint: '空白のままで現在のものを維持',
         secretPlaceholder: '新しいシークレット',
+        emailClaim: 'メールクレーム',
+        emailClaimDesc: "メールIDとして使用するJWTクレーム。Azure Entra IDには'preferred_username'または'upn'を使用(email_verifiedを送信しない)。信頼できるクレーム名のみ使用してください。",
+        emailClaimPlaceholder: 'email',
+        requireEmailVerified: 'メール確認を要求',
+        requireEmailVerifiedDesc: 'プロバイダーが確認済みとしてマークした場合にのみメールクレームを受け入れます。',
+        requireEmailVerifiedWarning: '警告:確認なしでメールが受け入れられます。信頼できるプロバイダーのみで使用してください。',
+        requireEmailVerifiedAutoLink: 'この設定を変更するには、まず自動リンクを無効にしてください。',
       },
     },
 
@@ -2305,6 +2312,7 @@ export default {
     passwordPlaceholder: 'パスワードを入力',
     signIn: 'サインイン',
     signingIn: 'ログイン中...',
+    rememberMe: 'ログイン状態を保持する',
     forgotPassword: 'パスワードをお忘れですか?',
     loginSuccess: 'ログインしました',
     loginFailed: 'ログインに失敗しました',

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

@@ -2137,6 +2137,13 @@ export default {
         autoLinkDesc: 'Vincula contas locais existentes por e-mail no primeiro login.',
         secretHint: 'deixe em branco para manter',
         secretPlaceholder: 'novo segredo',
+        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',
+        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.',
+        requireEmailVerifiedAutoLink: 'Desabilite o vínculo automático primeiro para alterar esta configuração.',
       },
     },
 
@@ -2266,6 +2273,7 @@ export default {
     passwordPlaceholder: 'Digite sua senha',
     signIn: 'Entrar',
     signingIn: 'Entrando...',
+    rememberMe: 'Lembrar de mim',
     forgotPassword: 'Esqueceu sua senha?',
     loginSuccess: 'Login realizado com sucesso',
     loginFailed: 'Falha no login',

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

@@ -2189,6 +2189,13 @@ export default {
         autoLinkDesc: '首次登录时通过邮箱匹配现有本地账户并自动关联。',
         secretHint: '留空以保留当前',
         secretPlaceholder: '新密钥',
+        emailClaim: '邮箱声明',
+        emailClaimDesc: "用作邮箱身份的 JWT 声明。Azure Entra ID 请使用 'preferred_username' 或 'upn'(不发送 email_verified)。仅使用可信的声明名称。",
+        emailClaimPlaceholder: 'email',
+        requireEmailVerified: '要求邮箱已验证',
+        requireEmailVerifiedDesc: '仅在提供商将邮箱声明标记为已验证时才接受。',
+        requireEmailVerifiedWarning: '警告:将在未经验证的情况下接受邮箱。仅对受信任的提供商使用。',
+        requireEmailVerifiedAutoLink: '请先禁用自动关联以更改此设置。',
       },
     },
 
@@ -2318,6 +2325,7 @@ export default {
     passwordPlaceholder: '输入您的密码',
     signIn: '登录',
     signingIn: '登录中...',
+    rememberMe: '记住我',
     forgotPassword: '忘记密码?',
     loginSuccess: '登录成功',
     loginFailed: '登录失败',

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

@@ -2187,6 +2187,13 @@ export default {
         autoCreateDesc: '首次登入時自動建立本機帳戶。',
         autoLink: '自動連結已有帳戶',
         autoLinkDesc: '首次登入時透過信箱匹配現有本機帳戶並自動連結。',
+        emailClaim: '電子郵件聲明',
+        emailClaimDesc: "用作電子郵件身份的 JWT 聲明。Azure Entra ID 請使用 'preferred_username' 或 'upn'(不發送 email_verified)。僅使用可信的聲明名稱。",
+        emailClaimPlaceholder: 'email',
+        requireEmailVerified: '要求電子郵件已驗證',
+        requireEmailVerifiedDesc: '僅在提供商將電子郵件聲明標記為已驗證時才接受。',
+        requireEmailVerifiedWarning: '警告:將在未經驗證的情況下接受電子郵件。僅對受信任的提供商使用。',
+        requireEmailVerifiedAutoLink: '請先停用自動連結以變更此設定。',
         secretHint: '留空以保留目前',
         secretPlaceholder: '新金鑰',
       },
@@ -2318,6 +2325,7 @@ export default {
     passwordPlaceholder: '輸入您的密碼',
     signIn: '登入',
     signingIn: '登入中...',
+    rememberMe: '記住我',
     forgotPassword: '忘記密碼?',
     loginSuccess: '登入成功',
     loginFailed: '登入失敗',

+ 60 - 7
frontend/src/pages/LoginPage.tsx

@@ -6,12 +6,32 @@ 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 } from '../api/client';
+import { api, type LoginResponse, type TokenPersistence } from '../api/client';
 import { Card, CardHeader, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 
 type LoginStep = 'credentials' | '2fa' | 'reset-password';
 
+// sessionStorage survives the OIDC provider round-trip; React state does not.
+// Read + remove in one try so all branches in the OIDC useEffect see the same
+// value and a subsequent page load does not replay the flag.
+const REMEMBER_ME_KEY = 'auth_remember_me';
+
+function toPersistence(remember: boolean): TokenPersistence {
+  return remember ? 'persistent' : 'session';
+}
+
+function consumeSavedRememberMe(): boolean {
+  try {
+    const saved = sessionStorage.getItem(REMEMBER_ME_KEY) === '1';
+    sessionStorage.removeItem(REMEMBER_ME_KEY);
+    return saved;
+  } catch (err) {
+    console.warn('consumeSavedRememberMe: sessionStorage unavailable, Remember Me preference lost across OIDC redirect', err);
+    return false;
+  }
+}
+
 export function LoginPage() {
   const navigate = useNavigate();
   const [searchParams] = useSearchParams();
@@ -35,6 +55,8 @@ export function LoginPage() {
   const [emailOTPSent, setEmailOTPSent] = useState(false);
   const twoFAInputRef = useRef<HTMLInputElement>(null);
 
+  const [rememberMe, setRememberMe] = useState(false);
+
   // H-6: Password reset step state
   const [resetToken, setResetToken] = useState('');
   const [newPassword, setNewPassword] = useState('');
@@ -74,6 +96,10 @@ export function LoginPage() {
     const oidcToken = hash.startsWith('#oidc_token=') ? hash.slice('#oidc_token='.length) : null;
     const oidcError = searchParams.get('oidc_error');
 
+    if (!oidcToken && !oidcError) return;
+
+    const savedRememberMe = consumeSavedRememberMe();
+
     if (oidcError) {
       // L-3: Whitelist known OIDC error codes so provider-controlled text is never
       // shown verbatim. Any unknown code falls back to a generic message.
@@ -100,7 +126,6 @@ export function LoginPage() {
       const errorMsg = KNOWN_OIDC_ERRORS[oidcError]
         ?? (oidcError.startsWith('token_exchange_') ? t('login.oidcErrors.tokenExchangeFailed') : t('login.oidcLoginFailed'));
       showToast(errorMsg, 'error');
-      // Remove query params from URL cleanly
       navigate('/login', { replace: true });
       return;
     }
@@ -109,6 +134,7 @@ export function LoginPage() {
       api.exchangeOIDCToken(oidcToken).then((resp: LoginResponse) => {
         if (resp.requires_2fa && resp.pre_auth_token) {
           // OIDC user has 2FA enabled — redirect to 2FA step
+          setRememberMe(savedRememberMe);
           setPreAuthToken(resp.pre_auth_token);
           const methods = resp.two_fa_methods ?? [];
           setTwoFAMethods(methods);
@@ -119,12 +145,16 @@ export function LoginPage() {
           // Remove oidc_token from URL so page refresh doesn't re-trigger exchange
           navigate('/login', { replace: true });
         } else if (resp.access_token && resp.user) {
-          loginWithToken(resp.access_token, resp.user);
+          loginWithToken(resp.access_token, resp.user, toPersistence(savedRememberMe));
           showToast(t('login.loginSuccess'));
           navigate('/', { replace: true });
+        } else {
+          showToast(t('login.oidcLoginFailed'), 'error');
+          navigate('/login', { replace: true });
         }
-      }).catch((err: Error) => {
-        showToast(err.message || t('login.oidcLoginFailed'), 'error');
+      }).catch((err: unknown) => {
+        console.error('OIDC token exchange failed', err);
+        showToast(t('login.oidcLoginFailed'), 'error');
         navigate('/login', { replace: true });
       });
     }
@@ -132,7 +162,7 @@ export function LoginPage() {
 
   // --- Step 1: Credentials login ---
   const loginMutation = useMutation({
-    mutationFn: () => login(username, password),
+    mutationFn: () => login(username, password, toPersistence(rememberMe)),
     onSuccess: (resp: LoginResponse) => {
       if (resp.requires_2fa && resp.pre_auth_token) {
         // 2FA required — switch to verification step
@@ -200,9 +230,12 @@ export function LoginPage() {
       api.verify2FA({ pre_auth_token: preAuthToken, code: twoFACode, method: twoFAMethod }),
     onSuccess: (resp: LoginResponse) => {
       if (resp.access_token && resp.user) {
-        loginWithToken(resp.access_token, resp.user);
+        loginWithToken(resp.access_token, resp.user, toPersistence(rememberMe));
         showToast(t('login.loginSuccess'));
         navigate('/');
+      } else {
+        console.error('2FA verify: unexpected response shape', resp);
+        showToast(t('login.loginFailed'), 'error');
       }
     },
     onError: (error: Error) => {
@@ -215,6 +248,13 @@ export function LoginPage() {
   const oidcLoginMutation = useMutation({
     mutationFn: (providerId: number) => api.getOIDCAuthorizeUrl(providerId),
     onSuccess: (data) => {
+      if (rememberMe) {
+        try {
+          sessionStorage.setItem(REMEMBER_ME_KEY, '1');
+        } catch (err) {
+          console.warn('setItem auth_remember_me failed, Remember Me will not carry through OIDC redirect', err);
+        }
+      }
       window.location.href = data.auth_url;
     },
     onError: (error: Error) => {
@@ -568,6 +608,19 @@ export function LoginPage() {
             </div>
           </div>
 
+          <div className="flex items-center gap-2">
+            <input
+              id="remember-me"
+              type="checkbox"
+              checked={rememberMe}
+              onChange={(e) => setRememberMe(e.target.checked)}
+              className="h-4 w-4 rounded border-bambu-dark-tertiary bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green/50 cursor-pointer"
+            />
+            <label htmlFor="remember-me" className="text-sm text-bambu-gray cursor-pointer">
+              {t('login.rememberMe')}
+            </label>
+          </div>
+
           <div>
             <button
               type="submit"