Browse Source

feat: Two-Factor Authentication (TOTP, Email OTP) and OIDC/SSO – full implementation with admin UI (#933)

feat: Two-Factor Authentication (TOTP, Email OTP) and OIDC/SSO – full implementation with admin UI (#933)
Sn0rrii 1 month ago
parent
commit
ba1c97c808
44 changed files with 10473 additions and 290 deletions
  1. 4 4
      backend/app/api/routes/archives.py
  2. 428 57
      backend/app/api/routes/auth.py
  3. 1 1
      backend/app/api/routes/camera.py
  4. 2 2
      backend/app/api/routes/library.py
  5. 1690 0
      backend/app/api/routes/mfa.py
  6. 43 7
      backend/app/api/routes/users.py
  7. 256 64
      backend/app/core/auth.py
  8. 44 0
      backend/app/core/database.py
  9. 88 0
      backend/app/core/encryption.py
  10. 158 4
      backend/app/main.py
  11. 10 0
      backend/app/models/__init__.py
  12. 199 0
      backend/app/models/auth_ephemeral.py
  13. 93 0
      backend/app/models/oidc_provider.py
  14. 5 1
      backend/app/models/user.py
  15. 55 0
      backend/app/models/user_otp_code.py
  16. 84 0
      backend/app/models/user_totp.py
  17. 344 16
      backend/app/schemas/auth.py
  18. 67 4
      backend/app/services/email_service.py
  19. 4 0
      backend/tests/conftest.py
  20. 111 27
      backend/tests/integration/test_advanced_auth_api.py
  21. 88 34
      backend/tests/integration/test_auth_api.py
  22. 130 0
      backend/tests/integration/test_client_ip.py
  23. 3016 0
      backend/tests/integration/test_mfa_api.py
  24. 9 9
      backend/tests/integration/test_ownership_permissions.py
  25. 796 0
      backend/tests/integration/test_security.py
  26. 49 0
      backend/tests/unit/test_mfa_helpers.py
  27. 3 0
      frontend/index.html
  28. 14 14
      frontend/src/__tests__/api/client.test.ts
  29. 141 0
      frontend/src/__tests__/pages/LoginPage.test.tsx
  30. 173 6
      frontend/src/api/client.ts
  31. 344 0
      frontend/src/components/OIDCProviderSettings.tsx
  32. 547 0
      frontend/src/components/TwoFactorSettings.tsx
  33. 29 9
      frontend/src/contexts/AuthContext.tsx
  34. 136 0
      frontend/src/i18n/locales/de.ts
  35. 136 0
      frontend/src/i18n/locales/en.ts
  36. 122 0
      frontend/src/i18n/locales/fr.ts
  37. 122 0
      frontend/src/i18n/locales/it.ts
  38. 122 0
      frontend/src/i18n/locales/ja.ts
  39. 136 0
      frontend/src/i18n/locales/pt-BR.ts
  40. 122 0
      frontend/src/i18n/locales/zh-CN.ts
  41. 502 29
      frontend/src/pages/LoginPage.tsx
  42. 40 2
      frontend/src/pages/SettingsPage.tsx
  43. 6 0
      pyproject.toml
  44. 4 0
      requirements.txt

+ 4 - 4
backend/app/api/routes/archives.py

@@ -1514,7 +1514,7 @@ async def create_archive_slicer_token(
     if not archive:
     if not archive:
         raise HTTPException(404, "Archive not found")
         raise HTTPException(404, "Archive not found")
 
 
-    token = create_slicer_download_token("archive", archive_id)
+    token = await create_slicer_download_token("archive", archive_id)
     return {"token": token}
     return {"token": token}
 
 
 
 
@@ -1533,7 +1533,7 @@ async def download_archive_for_slicer(
     """
     """
     from backend.app.core.auth import verify_slicer_download_token
     from backend.app.core.auth import verify_slicer_download_token
 
 
-    if not verify_slicer_download_token(token, "archive", archive_id):
+    if not await verify_slicer_download_token(token, "archive", archive_id):
         raise HTTPException(403, "Invalid or expired download token")
         raise HTTPException(403, "Invalid or expired download token")
 
 
     service = ArchiveService(db)
     service = ArchiveService(db)
@@ -3512,7 +3512,7 @@ async def create_source_slicer_token(
     if not archive.source_3mf_path:
     if not archive.source_3mf_path:
         raise HTTPException(404, "No source 3MF attached to this archive")
         raise HTTPException(404, "No source 3MF attached to this archive")
 
 
-    token = create_slicer_download_token("source", archive_id)
+    token = await create_slicer_download_token("source", archive_id)
     return {"token": token}
     return {"token": token}
 
 
 
 
@@ -3530,7 +3530,7 @@ async def download_source_3mf_for_slicer_with_token(
     """
     """
     from backend.app.core.auth import verify_slicer_download_token
     from backend.app.core.auth import verify_slicer_download_token
 
 
-    if not verify_slicer_download_token(token, "source", archive_id):
+    if not await verify_slicer_download_token(token, "source", archive_id):
         raise HTTPException(403, "Invalid or expired download token")
         raise HTTPException(403, "Invalid or expired download token")
 
 
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))

+ 428 - 57
backend/app/api/routes/auth.py

@@ -1,9 +1,14 @@
-from datetime import timedelta
+import logging
+import os
+import secrets
+from datetime import datetime, timedelta, timezone
 from typing import Annotated
 from typing import Annotated
 
 
-from fastapi import APIRouter, Depends, Header, HTTPException, status
+import jwt as _jwt
+from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, Response, status
 from fastapi.security import HTTPAuthorizationCredentials
 from fastapi.security import HTTPAuthorizationCredentials
-from sqlalchemy import select
+from jwt.exceptions import PyJWTError
+from sqlalchemy import delete, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
@@ -14,6 +19,7 @@ from backend.app.core.auth import (
     SECRET_KEY,
     SECRET_KEY,
     Permission,
     Permission,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
+    _is_token_fresh,
     _validate_api_key,
     _validate_api_key,
     authenticate_user,
     authenticate_user,
     authenticate_user_by_email,
     authenticate_user_by_email,
@@ -22,14 +28,18 @@ from backend.app.core.auth import (
     get_password_hash,
     get_password_hash,
     get_user_by_email,
     get_user_by_email,
     get_user_by_username,
     get_user_by_username,
+    is_jti_revoked,
+    revoke_jti,
     security,
     security,
 )
 )
-from backend.app.core.database import get_db
+from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import ALL_PERMISSIONS
 from backend.app.core.permissions import ALL_PERMISSIONS
+from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
 from backend.app.models.group import Group
 from backend.app.models.group import Group
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.auth import (
 from backend.app.schemas.auth import (
+    ForgotPasswordConfirmRequest,
     ForgotPasswordRequest,
     ForgotPasswordRequest,
     ForgotPasswordResponse,
     ForgotPasswordResponse,
     GroupBrief,
     GroupBrief,
@@ -45,13 +55,14 @@ from backend.app.schemas.auth import (
     UserResponse,
     UserResponse,
 )
 )
 from backend.app.services.email_service import (
 from backend.app.services.email_service import (
-    create_password_reset_email_from_template,
-    generate_secure_password,
+    create_password_reset_link_email_from_template,
     get_smtp_settings,
     get_smtp_settings,
     save_smtp_settings,
     save_smtp_settings,
     send_email,
     send_email,
 )
 )
 
 
+_logger = logging.getLogger(__name__)
+
 
 
 def _user_to_response(user: User) -> UserResponse:
 def _user_to_response(user: User) -> UserResponse:
     """Convert a User model to UserResponse schema."""
     """Convert a User model to UserResponse schema."""
@@ -84,6 +95,50 @@ def _api_key_to_user_response(api_key) -> UserResponse:
     )
     )
 
 
 
 
+# ---------------------------------------------------------------------------
+# M-R9-A: Real client IP resolution for rate limiting behind reverse proxies.
+# Set TRUSTED_PROXY_IPS (comma-separated) to enable X-Forwarded-For trust.
+# Without this env var client.host is used directly (safe default).
+# ---------------------------------------------------------------------------
+_TRUSTED_PROXY_IPS: frozenset[str] = frozenset(
+    ip.strip() for ip in os.environ.get("TRUSTED_PROXY_IPS", "").split(",") if ip.strip()
+)
+
+
+def _get_client_ip(request: Request) -> str:
+    """Return the real client IP for rate-limiting purposes.
+
+    When TRUSTED_PROXY_IPS is configured and the direct TCP peer is a trusted
+    proxy, X-Forwarded-For is evaluated right-to-left: the rightmost IP that is
+    NOT itself a trusted proxy is the true client address (M-R10-A fix).
+
+    Standard nginx with proxy_add_x_forwarded_for *appends* the client IP, so
+    the rightmost entry is always the one added by the last trusted proxy —
+    i.e. the real client. Walking right-to-left and skipping known proxies is
+    safe for multi-hop chains as well.
+
+    Falls back to request.client.host when TRUSTED_PROXY_IPS is unset (direct
+    deployment without a reverse proxy).
+    """
+    # I5: Use a per-request unique token instead of "unknown" when the transport
+    # layer provides no client address.  This prevents all such requests from
+    # sharing one rate-limit bucket, and avoids collision with a literal username
+    # "unknown".  The token is not stable across requests, which is intentional:
+    # we cannot track the IP so we also cannot rate-limit by it meaningfully.
+    direct_ip = request.client.host if request.client else f"__no_ip_{secrets.token_hex(8)}__"
+    if _TRUSTED_PROXY_IPS and direct_ip in _TRUSTED_PROXY_IPS:
+        forwarded_for = request.headers.get("X-Forwarded-For", "")
+        ips = [ip.strip() for ip in forwarded_for.split(",") if ip.strip()]
+        # Walk right-to-left; skip IPs that belong to trusted proxies.
+        for ip in reversed(ips):
+            if ip not in _TRUSTED_PROXY_IPS:
+                return ip
+        # Edge case: every entry is a trusted proxy — fall back to leftmost.
+        if ips:
+            return ips[0]
+    return direct_ip
+
+
 router = APIRouter(prefix="/auth", tags=["authentication"])
 router = APIRouter(prefix="/auth", tags=["authentication"])
 
 
 
 
@@ -206,7 +261,7 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
                     logger.error("Failed to create admin user: %s", e, exc_info=True)
                     logger.error("Failed to create admin user: %s", e, exc_info=True)
                     raise HTTPException(
                     raise HTTPException(
                         status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                         status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-                        detail=f"Failed to create admin user: {str(e)}",
+                        detail="Failed to create admin user",
                     )
                     )
 
 
         # Set auth enabled and mark setup as completed
         # Set auth enabled and mark setup as completed
@@ -227,7 +282,7 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
         await db.rollback()
         await db.rollback()
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-            detail=f"Setup failed: {str(e)}",
+            detail="Setup failed",
         )
         )
 
 
 
 
@@ -272,15 +327,20 @@ async def disable_auth(
         logger.error("Failed to disable authentication: %s", e, exc_info=True)
         logger.error("Failed to disable authentication: %s", e, exc_info=True)
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-            detail=f"Failed to disable authentication: {str(e)}",
+            detail="Failed to disable authentication",
         )
         )
 
 
 
 
 @router.post("/login", response_model=LoginResponse)
 @router.post("/login", response_model=LoginResponse)
-async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
+async def login(raw_request: Request, request: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
     """Login and get access token.
     """Login and get access token.
 
 
     Supports username or email-based login. Username lookup is case-insensitive.
     Supports username or email-based login. Username lookup is case-insensitive.
+
+    When 2FA is enabled for the user the response contains ``requires_2fa=True``
+    and a short-lived ``pre_auth_token`` instead of the final JWT.  The client
+    must then call ``POST /auth/2fa/verify`` (or first ``POST /auth/2fa/email/send``
+    to trigger an email OTP) to obtain the real access token.
     """
     """
     # Check if auth is enabled
     # Check if auth is enabled
     auth_enabled = await is_auth_enabled(db)
     auth_enabled = await is_auth_enabled(db)
@@ -290,6 +350,16 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
             detail="Authentication is not enabled",
             detail="Authentication is not enabled",
         )
         )
 
 
+    # Rate-limit repeated login failures — two independent buckets (M-R5-B / M-R6-A):
+    #   1. Per-username (10/15 min): prevents password brute-force on a known account.
+    #   2. Per-IP     (20/15 min): prevents an attacker from locking out arbitrary accounts
+    #      (DoS) by sending failures for many usernames from a single address.
+    from backend.app.api.routes.mfa import MAX_LOGIN_ATTEMPTS, check_rate_limit, record_failed_attempt
+
+    await check_rate_limit(db, request.username, event_type=EventType.LOGIN_ATTEMPT, max_attempts=MAX_LOGIN_ATTEMPTS)
+    client_ip = _get_client_ip(raw_request)
+    await check_rate_limit(db, client_ip, event_type=EventType.LOGIN_IP, max_attempts=20)
+
     # Check if LDAP is enabled
     # Check if LDAP is enabled
     ldap_user = None
     ldap_user = None
     ldap_settings = await _get_ldap_settings(db)
     ldap_settings = await _get_ldap_settings(db)
@@ -338,6 +408,8 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
             user = await authenticate_user_by_email(db, request.username, request.password)
             user = await authenticate_user_by_email(db, request.username, request.password)
 
 
     if not user:
     if not user:
+        await record_failed_attempt(db, request.username, event_type=EventType.LOGIN_ATTEMPT)
+        await record_failed_attempt(db, client_ip, event_type=EventType.LOGIN_IP)
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
             status_code=status.HTTP_401_UNAUTHORIZED,
             detail="Incorrect username or password",
             detail="Incorrect username or password",
@@ -348,6 +420,64 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
     result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
     result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
     user = result.scalar_one()
     user = result.scalar_one()
 
 
+    # L-R6-A: Password was correct — reset login failure counters for both buckets
+    from backend.app.api.routes.mfa import clear_failed_attempts
+
+    await clear_failed_attempts(db, user.username, event_type=EventType.LOGIN_ATTEMPT)
+    await clear_failed_attempts(db, client_ip, event_type=EventType.LOGIN_IP)
+
+    # --- 2FA check ---
+    # Determine which 2FA methods are active for this user.
+
+    from backend.app.models.settings import Settings as _Settings
+    from backend.app.models.user_totp import UserTOTP
+
+    totp_result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == user.id))
+    user_totp = totp_result.scalar_one_or_none()
+    totp_enabled = user_totp is not None and user_totp.is_enabled
+
+    email_2fa_result = await db.execute(select(_Settings).where(_Settings.key == f"user_{user.id}_email_2fa_enabled"))
+    email_2fa_setting = email_2fa_result.scalar_one_or_none()
+    email_otp_enabled = (
+        email_2fa_setting is not None and email_2fa_setting.value.lower() == "true" and user.email is not None
+    )
+
+    if totp_enabled or email_otp_enabled:
+        # Import here to avoid circular imports
+        from backend.app.api.routes.mfa import create_pre_auth_token
+
+        # Bind the pre_auth_token to an HttpOnly cookie so XSS cannot steal the
+        # token from JS memory and complete 2FA from a different client.
+        challenge_id = secrets.token_urlsafe(32)
+        pre_auth_token = await create_pre_auth_token(db, user.username, challenge_id=challenge_id)
+        response.set_cookie(
+            key="2fa_challenge",
+            value=challenge_id,
+            httponly=True,
+            # H-1: only transmit over HTTPS so the binding cookie can't be intercepted
+            # on mixed-content deployments.  Falls back to False on plain HTTP so tests
+            # and local development still work (the client wouldn't send it otherwise).
+            secure=raw_request.url.scheme == "https",
+            samesite="lax",
+            max_age=300,
+            path="/api/v1/auth/2fa",
+        )
+        methods: list[str] = []
+        if totp_enabled:
+            methods.append("totp")
+        if email_otp_enabled:
+            methods.append("email")
+        # Backup codes are always available when TOTP is set up
+        if totp_enabled:
+            methods.append("backup")
+
+        return LoginResponse(
+            requires_2fa=True,
+            pre_auth_token=pre_auth_token,
+            two_fa_methods=methods,
+        )
+
+    # No 2FA — issue full token immediately
     access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
     access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
     access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
     access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
 
 
@@ -403,6 +533,14 @@ async def get_current_user_info(
                     detail="Could not validate credentials",
                     detail="Could not validate credentials",
                     headers={"WWW-Authenticate": "Bearer"},
                     headers={"WWW-Authenticate": "Bearer"},
                 )
                 )
+            jti: str | None = payload.get("jti")
+            if not jti or await is_jti_revoked(jti):  # B1: logout bypass fix
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+            iat: int | float | None = payload.get("iat")
         except JWTError:
         except JWTError:
             raise HTTPException(
             raise HTTPException(
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 status_code=status.HTTP_401_UNAUTHORIZED,
@@ -420,6 +558,13 @@ async def get_current_user_info(
         # Reload with groups for proper permission calculation
         # Reload with groups for proper permission calculation
         result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
         result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
         user = result.scalar_one()
         user = result.scalar_one()
+        # L-R8-A: reject tokens issued before the last password change
+        if not _is_token_fresh(iat, user):
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Could not validate credentials",
+                headers={"WWW-Authenticate": "Bearer"},
+            )
         return _user_to_response(user)
         return _user_to_response(user)
 
 
     # No credentials provided
     # No credentials provided
@@ -431,8 +576,44 @@ async def get_current_user_info(
 
 
 
 
 @router.post("/logout")
 @router.post("/logout")
-async def logout():
-    """Logout (client should discard token)."""
+async def logout(
+    raw_request: Request,
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+):
+    """Logout — revokes the current JWT so it cannot be reused after logout."""
+    if credentials is not None:
+        raw_token = credentials.credentials
+        # Nit2: Verify signature before revoking to prevent DoS-revoke attacks
+        # (an attacker crafting a token with an arbitrary jti cannot force
+        # revocation of a legitimate token because the signature check rejects it).
+        # Expired tokens are still accepted — the user is logging out and their
+        # token may have just expired; we still want to record the revocation.
+        try:
+            verified = _jwt.decode(
+                raw_token,
+                SECRET_KEY,
+                algorithms=[ALGORITHM],
+                options={"verify_exp": False},  # allow expired tokens at logout
+            )
+            jti: str | None = verified.get("jti")
+            exp = verified.get("exp")
+            username: str | None = verified.get("sub")
+            if jti and exp:
+                expires_at = datetime.fromtimestamp(exp, tz=timezone.utc)
+                try:
+                    await revoke_jti(jti, expires_at, username)
+                except Exception as exc:
+                    _logger.error("Failed to revoke JTI on logout for user %s: %s", username, exc)
+        except PyJWTError:
+            client_ip = _get_client_ip(raw_request)
+            ua = raw_request.headers.get("user-agent", "<unknown>")
+            _logger.error(
+                "Logout received token that failed signature verification — skipping revocation "
+                "(possible tamper attempt; ip=%s ua=%s)",
+                client_ip,
+                ua,
+            )
+
     return {"message": "Logged out successfully"}
     return {"message": "Logged out successfully"}
 
 
 
 
@@ -467,8 +648,8 @@ async def test_smtp_connection(
         logger.info(f"Test email sent successfully to {test_request.test_recipient}")
         logger.info(f"Test email sent successfully to {test_request.test_recipient}")
         return TestSMTPResponse(success=True, message="Test email sent successfully")
         return TestSMTPResponse(success=True, message="Test email sent successfully")
     except Exception as e:
     except Exception as e:
-        logger.error(f"Failed to send test email: {e}")
-        return TestSMTPResponse(success=False, message=f"Failed to send test email: {str(e)}")
+        logger.error("Failed to send test email: %s", e)
+        return TestSMTPResponse(success=False, message="Failed to send test email")
 
 
 
 
 @router.get("/smtp", response_model=SMTPSettings | None)
 @router.get("/smtp", response_model=SMTPSettings | None)
@@ -502,10 +683,10 @@ async def save_smtp_config(
         return {"message": "SMTP settings saved successfully"}
         return {"message": "SMTP settings saved successfully"}
     except Exception as e:
     except Exception as e:
         await db.rollback()
         await db.rollback()
-        logger.error(f"Failed to save SMTP settings: {e}")
+        logger.error("Failed to save SMTP settings: %s", e)
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-            detail=f"Failed to save SMTP settings: {str(e)}",
+            detail="Failed to save SMTP settings",
         )
         )
 
 
 
 
@@ -547,10 +728,10 @@ async def enable_advanced_auth(
         return {"message": "Advanced authentication enabled successfully", "advanced_auth_enabled": True}
         return {"message": "Advanced authentication enabled successfully", "advanced_auth_enabled": True}
     except Exception as e:
     except Exception as e:
         await db.rollback()
         await db.rollback()
-        logger.error(f"Failed to enable advanced authentication: {e}")
+        logger.error("Failed to enable advanced authentication: %s", e)
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-            detail=f"Failed to enable advanced authentication: {str(e)}",
+            detail="Failed to enable advanced authentication",
         )
         )
 
 
 
 
@@ -581,10 +762,10 @@ async def disable_advanced_auth(
         return {"message": "Advanced authentication disabled successfully", "advanced_auth_enabled": False}
         return {"message": "Advanced authentication disabled successfully", "advanced_auth_enabled": False}
     except Exception as e:
     except Exception as e:
         await db.rollback()
         await db.rollback()
-        logger.error(f"Failed to disable advanced authentication: {e}")
+        logger.error("Failed to disable advanced authentication: %s", e)
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-            detail=f"Failed to disable advanced authentication: {str(e)}",
+            detail="Failed to disable advanced authentication",
         )
         )
 
 
 
 
@@ -599,13 +780,68 @@ async def get_advanced_auth_status(db: AsyncSession = Depends(get_db)):
     }
     }
 
 
 
 
-@router.post("/forgot-password", response_model=ForgotPasswordResponse)
-async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
-    """Request password reset via email (advanced auth only)."""
-    import logging
+# TTL for password-reset tokens (H-6)
+_RESET_TOKEN_TTL = timedelta(hours=1)
+
+# Rate-limit for password-reset email sends per identifier (M-A)
+_MAX_PWD_RESET_SENDS = 3
+_PWD_RESET_SEND_WINDOW = timedelta(minutes=15)
+# L-NEW-6: per-IP cap to prevent mass-reset flooding across many addresses
+_MAX_PWD_RESET_SENDS_PER_IP = 10
 
 
-    logger = logging.getLogger(__name__)
 
 
+async def _send_reset_email_or_delete_token(
+    reset_token: str,
+    smtp_settings,
+    to_email: str,
+    subject: str,
+    text_body: str,
+    html_body: str,
+    log_label: str,
+) -> None:
+    """Background task: send a password-reset email and delete the token on failure.
+
+    C1: FastAPI silently swallows BackgroundTask exceptions.  This wrapper
+    catches send failures, deletes the single-use token so it cannot be used
+    (user is not locked out forever — they can request a new link), and logs at
+    ERROR so operators are alerted without leaking details to the caller.
+    """
+    try:
+        send_email(smtp_settings, to_email, subject, text_body, html_body)
+        _logger.info("Password reset email sent (%s) to %s", log_label, to_email)
+    except Exception as exc:
+        _logger.error(
+            "Password reset email failed (%s) to %s — deleting token to unblock re-request: %s",
+            log_label,
+            to_email,
+            exc,
+        )
+        try:
+            async with async_session() as db:
+                await db.execute(
+                    delete(AuthEphemeralToken).where(
+                        AuthEphemeralToken.token == reset_token,
+                        AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
+                    )
+                )
+                await db.commit()
+        except Exception as db_exc:
+            _logger.error("Failed to delete reset token after send failure: %s", db_exc)
+
+
+@router.post("/forgot-password", response_model=ForgotPasswordResponse)
+async def forgot_password(
+    request: ForgotPasswordRequest,
+    background_tasks: BackgroundTasks,
+    raw_request: Request,
+    db: AsyncSession = Depends(get_db),
+):
+    """Request password reset via email (advanced auth only).
+
+    H-6: Issues a short-lived single-use reset token and emails the user a
+    secure link instead of a plaintext temporary password.  The new password is
+    set only when the user clicks the link and POSTs to /forgot-password/confirm.
+    """
     # Check if advanced auth is enabled
     # Check if advanced auth is enabled
     advanced_auth = await is_advanced_auth_enabled(db)
     advanced_auth = await is_advanced_auth_enabled(db)
     if not advanced_auth:
     if not advanced_auth:
@@ -614,6 +850,47 @@ async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Dep
             detail="Advanced authentication is not enabled",
             detail="Advanced authentication is not enabled",
         )
         )
 
 
+    # M-A: Rate-limit by normalised email to prevent reset-email flooding.
+    # Apply unconditionally (before the user lookup) so unknown emails are also
+    # throttled — this prevents both flooding and timing-based enumeration.
+    identifier = request.email.lower()
+    cutoff = datetime.now(timezone.utc) - _PWD_RESET_SEND_WINDOW
+    rate_result = await db.execute(
+        select(AuthRateLimitEvent).where(
+            AuthRateLimitEvent.username == identifier,
+            AuthRateLimitEvent.event_type == EventType.PASSWORD_RESET_SEND,
+            AuthRateLimitEvent.occurred_at > cutoff,
+        )
+    )
+    if len(rate_result.scalars().all()) >= _MAX_PWD_RESET_SENDS:
+        raise HTTPException(
+            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+            detail=f"Too many password reset requests. Please wait {_PWD_RESET_SEND_WINDOW.seconds // 60} minutes.",
+        )
+
+    # L-NEW-6: per-IP rate limit — prevents mass-reset flooding across many
+    # different email addresses from a single source IP.
+    client_ip = _get_client_ip(raw_request)
+    ip_rate_result = await db.execute(
+        select(AuthRateLimitEvent).where(
+            AuthRateLimitEvent.username == client_ip,
+            AuthRateLimitEvent.event_type == EventType.PASSWORD_RESET_IP,
+            AuthRateLimitEvent.occurred_at > cutoff,
+        )
+    )
+    if len(ip_rate_result.scalars().all()) >= _MAX_PWD_RESET_SENDS_PER_IP:
+        raise HTTPException(
+            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+            detail=f"Too many password reset requests. Please wait {_PWD_RESET_SEND_WINDOW.seconds // 60} minutes.",
+        )
+
+    # Nit7: Always record the IP-level event (prevents spray attacks across many
+    # different email addresses from one IP).  The email-level event is only
+    # recorded when we actually send an email to a local user — LDAP/OIDC users
+    # do not consume a slot because this flow is a no-op for them.
+    db.add(AuthRateLimitEvent(username=client_ip, event_type=EventType.PASSWORD_RESET_IP))
+    await db.commit()
+
     # Get SMTP settings
     # Get SMTP settings
     smtp_settings = await get_smtp_settings(db)
     smtp_settings = await get_smtp_settings(db)
     if not smtp_settings:
     if not smtp_settings:
@@ -622,47 +899,116 @@ async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Dep
             detail="Email service is not configured",
             detail="Email service is not configured",
         )
         )
 
 
-    # Find user by email
+    # Find user by email — always return success to prevent email enumeration.
     user = await get_user_by_email(db, request.email)
     user = await get_user_by_email(db, request.email)
 
 
-    # Always return success message to prevent email enumeration
-    # but only send email if user exists and is not an LDAP user
-    if user and user.is_active and user.auth_source != "ldap":
+    # M-1: exclude LDAP and OIDC users — they must use their respective provider.
+    if user and user.is_active and user.auth_source not in ("ldap", "oidc"):
         try:
         try:
-            # Generate new password
-            new_password = generate_secure_password()
-            user.password_hash = get_password_hash(new_password)
+            # Record email-level slot only for local users who will actually receive
+            # the reset email (Nit7: don't waste the user's quota for LDAP/OIDC no-ops).
+            db.add(AuthRateLimitEvent(username=identifier, event_type=EventType.PASSWORD_RESET_SEND))
+
+            now = datetime.now(timezone.utc)
+            # Prune any outstanding reset tokens for this user before issuing a new one.
+            await db.execute(
+                delete(AuthEphemeralToken).where(
+                    AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
+                    AuthEphemeralToken.username == user.username,
+                )
+            )
+            reset_token = secrets.token_urlsafe(32)
+            db.add(
+                AuthEphemeralToken(
+                    token=reset_token,
+                    token_type=TokenType.PASSWORD_RESET,
+                    username=user.username,
+                    expires_at=now + _RESET_TOKEN_TTL,
+                )
+            )
             await db.commit()
             await db.commit()
 
 
             login_url = await get_external_login_url(db)
             login_url = await get_external_login_url(db)
+            # M-B: Deliver token in the URL fragment so it never reaches the server
+            # in access-logs or Referer headers (mirrors H-4 for the OIDC token).
+            reset_url = f"{login_url}#reset_token={reset_token}"
 
 
-            # Send password reset email
-            subject, text_body, html_body = await create_password_reset_email_from_template(
-                db, user.username, new_password, login_url
+            subject, text_body, html_body = await create_password_reset_link_email_from_template(
+                db, user.username, reset_url
             )
             )
-            send_email(smtp_settings, user.email, subject, text_body, html_body)
-
-            logger.info(f"Password reset email sent to {user.email}")
+            # L-R9-B: send asynchronously so response time is independent of
+            # whether the user exists (prevents email-existence timing oracle).
+            # C1: wrapper deletes the token if SMTP fails so the user can re-request.
+            background_tasks.add_task(
+                _send_reset_email_or_delete_token,
+                reset_token,
+                smtp_settings,
+                user.email,
+                subject,
+                text_body,
+                html_body,
+                "forgot_password",
+            )
+            _logger.info("Password reset email queued for %s", user.email)
         except Exception as e:
         except Exception as e:
-            logger.error(f"Failed to send password reset email: {e}")
-            # Don't reveal error to user for security
+            _logger.error("Failed to send password reset email: %s", e)
+            # Don't reveal error to caller for security
 
 
     return ForgotPasswordResponse(
     return ForgotPasswordResponse(
         message="If the email address is associated with an account, a password reset email has been sent."
         message="If the email address is associated with an account, a password reset email has been sent."
     )
     )
 
 
 
 
+@router.post("/forgot-password/confirm", response_model=ForgotPasswordResponse)
+async def forgot_password_confirm(request: ForgotPasswordConfirmRequest, db: AsyncSession = Depends(get_db)):
+    """Complete a password reset by supplying the token from the reset email.
+
+    H-6: Atomically consumes the single-use token (DELETE…RETURNING) and sets
+    the new password.  Expired or already-used tokens are silently rejected with
+    the same response to prevent oracle attacks.
+    """
+    now = datetime.now(timezone.utc)
+    result = await db.execute(
+        delete(AuthEphemeralToken)
+        .where(
+            AuthEphemeralToken.token == request.token,
+            AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
+        )
+        .returning(AuthEphemeralToken.username, AuthEphemeralToken.expires_at)
+    )
+    row = result.one_or_none()
+    await db.commit()
+    if row is None:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
+
+    username, expires_at = row
+    # SQLite returns naive datetimes; treat them as UTC.
+    if expires_at.tzinfo is None:
+        expires_at = expires_at.replace(tzinfo=timezone.utc)
+    if now > expires_at:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
+
+    user = await get_user_by_username(db, username)
+    # M-1: block LDAP/OIDC users — they authenticate via their provider, not local password.
+    if not user or not user.is_active or user.auth_source in ("ldap", "oidc"):
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
+
+    user.password_hash = get_password_hash(request.new_password)
+    user.password_changed_at = now  # M-R7-B: invalidate all prior JWTs
+    await db.commit()
+    _logger.info("Password reset completed for user '%s'", username)
+
+    return ForgotPasswordResponse(message="Password has been reset successfully.")
+
+
 @router.post("/reset-password", response_model=ResetPasswordResponse)
 @router.post("/reset-password", response_model=ResetPasswordResponse)
 async def reset_user_password(
 async def reset_user_password(
     request: ResetPasswordRequest,
     request: ResetPasswordRequest,
+    background_tasks: BackgroundTasks,
     current_user: User = Depends(get_current_active_user),
     current_user: User = Depends(get_current_active_user),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Reset a user's password and send them an email (admin only, advanced auth only)."""
     """Reset a user's password and send them an email (admin only, advanced auth only)."""
-    import logging
-
-    logger = logging.getLogger(__name__)
-
     # Reload user with groups for proper is_admin check
     # Reload user with groups for proper is_admin check
     result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
     result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
     admin_user = result.scalar_one()
     admin_user = result.scalar_one()
@@ -698,10 +1044,11 @@ async def reset_user_password(
             detail="User not found",
             detail="User not found",
         )
         )
 
 
-    if user.auth_source == "ldap":
+    # M-1: block LDAP/OIDC users — passwords are managed by their respective providers.
+    if user.auth_source in ("ldap", "oidc"):
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
             status_code=status.HTTP_400_BAD_REQUEST,
-            detail="Cannot reset password for LDAP users — passwords are managed by the LDAP server",
+            detail="Cannot reset password for LDAP/OIDC users — authentication is managed by their provider",
         )
         )
 
 
     if not user.email:
     if not user.email:
@@ -711,27 +1058,51 @@ async def reset_user_password(
         )
         )
 
 
     try:
     try:
-        # Generate new password
-        new_password = generate_secure_password()
-        user.password_hash = get_password_hash(new_password)
+        # H-B: Issue a single-use reset link instead of generating a plaintext password.
+        # The admin never sees the credential — the user sets their own password.
+        now = datetime.now(timezone.utc)
+        await db.execute(
+            delete(AuthEphemeralToken).where(
+                AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
+                AuthEphemeralToken.username == user.username,
+            )
+        )
+        reset_token = secrets.token_urlsafe(32)
+        db.add(
+            AuthEphemeralToken(
+                token=reset_token,
+                token_type=TokenType.PASSWORD_RESET,
+                username=user.username,
+                expires_at=now + _RESET_TOKEN_TTL,
+            )
+        )
         await db.commit()
         await db.commit()
 
 
         login_url = await get_external_login_url(db)
         login_url = await get_external_login_url(db)
+        reset_url = f"{login_url}#reset_token={reset_token}"
 
 
-        # Send password reset email
-        subject, text_body, html_body = await create_password_reset_email_from_template(
-            db, user.username, new_password, login_url
+        subject, text_body, html_body = await create_password_reset_link_email_from_template(
+            db, user.username, reset_url
+        )
+        background_tasks.add_task(
+            _send_reset_email_or_delete_token,
+            reset_token,
+            smtp_settings,
+            user.email,
+            subject,
+            text_body,
+            html_body,
+            "admin_reset",
         )
         )
-        send_email(smtp_settings, user.email, subject, text_body, html_body)
 
 
-        logger.info(f"Password reset by admin {admin_user.username} for user {user.username}")
-        return ResetPasswordResponse(message=f"Password reset email sent to {user.email}")
+        _logger.info("Admin password reset link queued for user '%s' by admin '%s'", user.username, admin_user.username)
+        return ResetPasswordResponse(message=f"Password reset link sent to {user.email}")
     except Exception as e:
     except Exception as e:
         await db.rollback()
         await db.rollback()
-        logger.error(f"Failed to reset password for user {user.username}: {e}")
+        _logger.error("Failed to send admin password reset for user '%s': %s", user.username, e)
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-            detail=f"Failed to reset password: {str(e)}",
+            detail="Failed to send password reset link. Check server logs.",  # L-R7-B: no internal details
         )
         )
 
 
 
 

+ 1 - 1
backend/app/api/routes/camera.py

@@ -525,7 +525,7 @@ async def create_stream_token(
     Returns a token valid for 60 minutes that can be appended as ?token=xxx
     Returns a token valid for 60 minutes that can be appended as ?token=xxx
     to camera stream/snapshot URLs loaded via <img> tags.
     to camera stream/snapshot URLs loaded via <img> tags.
     """
     """
-    return {"token": create_camera_stream_token()}
+    return {"token": await create_camera_stream_token()}
 
 
 
 
 @router.get("/{printer_id}/camera/stream")
 @router.get("/{printer_id}/camera/stream")

+ 2 - 2
backend/app/api/routes/library.py

@@ -2499,7 +2499,7 @@ async def create_library_slicer_token(
     if not file:
     if not file:
         raise HTTPException(status_code=404, detail="File not found")
         raise HTTPException(status_code=404, detail="File not found")
 
 
-    token = create_slicer_download_token("library", file_id)
+    token = await create_slicer_download_token("library", file_id)
     return {"token": token}
     return {"token": token}
 
 
 
 
@@ -2518,7 +2518,7 @@ async def download_library_file_for_slicer(
     """
     """
     from backend.app.core.auth import verify_slicer_download_token
     from backend.app.core.auth import verify_slicer_download_token
 
 
-    if not verify_slicer_download_token(token, "library", file_id):
+    if not await verify_slicer_download_token(token, "library", file_id):
         raise HTTPException(status_code=403, detail="Invalid or expired download token")
         raise HTTPException(status_code=403, detail="Invalid or expired download token")
 
 
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))

+ 1690 - 0
backend/app/api/routes/mfa.py

@@ -0,0 +1,1690 @@
+"""2FA (TOTP + Email OTP) and OIDC authentication routes.
+
+Security model
+--------------
+* Pre-auth tokens  : secrets.token_urlsafe(32) stored in-memory with a 5-minute TTL.
+  They are single-use and do NOT grant access to any protected resource.
+* TOTP codes       : verified with pyotp (30-second window, ±1 step tolerance).
+* Email OTP codes  : 6-digit numeric, hashed with pbkdf2_sha256, 10-minute TTL,
+  max 5 failed attempts per code before invalidation.
+* Backup codes     : 10 × 8-char alphanumeric codes, each stored as pbkdf2_sha256 hash,
+  single-use.
+* OIDC state       : secrets.token_urlsafe(32) bound to provider_id + nonce, 10-minute TTL.
+* OIDC exchange    : secrets.token_urlsafe(32), 2-minute TTL, single-use.
+* Rate limiting    : max 5 failed 2FA verification attempts per user within 15 minutes.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import io
+import logging
+import os
+import re
+import secrets
+import string
+import urllib.parse
+from datetime import datetime, timedelta, timezone
+
+import httpx
+import jwt
+import pyotp
+from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response, status
+from fastapi.responses import RedirectResponse
+from jwt import PyJWKClient
+from passlib.context import CryptContext
+from sqlalchemy import delete, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.api.routes.settings import get_setting, set_setting
+from backend.app.core.auth import (
+    ACCESS_TOKEN_EXPIRE_MINUTES,
+    RequirePermissionIfAuthEnabled,
+    create_access_token,
+    get_current_active_user,
+    get_user_by_email,
+    get_user_by_username,
+    is_auth_enabled,
+    verify_password,
+)
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
+from backend.app.models.group import Group
+from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
+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 (
+    AdminDisable2FARequest,
+    BackupCodesResponse,
+    EmailOTPDisableRequest,
+    EmailOTPEnableConfirmRequest,
+    EmailOTPSendRequest,
+    GroupBrief,
+    LoginResponse,
+    OIDCAuthorizeResponse,
+    OIDCExchangeRequest,
+    OIDCLinkResponse,
+    OIDCProviderCreate,
+    OIDCProviderResponse,
+    OIDCProviderUpdate,
+    TOTPDisableRequest,
+    TOTPEnableRequest,
+    TOTPEnableResponse,
+    TOTPSetupRequest,
+    TOTPSetupResponse,
+    TwoFAStatusResponse,
+    TwoFAVerifyRequest,
+    TwoFAVerifyResponse,
+    UserResponse,
+)
+from backend.app.services.email_service import get_smtp_settings, send_email
+
+logger = logging.getLogger(__name__)
+
+
+def _as_utc(dt: datetime) -> datetime:
+    """Return *dt* with UTC timezone attached.
+
+    SQLite/aiosqlite strips timezone info when reading DateTime(timezone=True)
+    columns back – the stored value is always UTC, so we just re-attach the
+    info when doing Python-level comparisons.
+    """
+    return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
+
+
+# ---------------------------------------------------------------------------
+# Passlib context (same scheme as auth.py)
+# ---------------------------------------------------------------------------
+pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
+
+# ---------------------------------------------------------------------------
+# TTL / rate-limit constants
+# ---------------------------------------------------------------------------
+MAX_2FA_ATTEMPTS = 5
+MAX_LOGIN_ATTEMPTS = 10
+LOCKOUT_WINDOW = timedelta(minutes=15)
+MAX_EMAIL_OTP_SENDS = 3
+EMAIL_OTP_SEND_WINDOW = timedelta(minutes=10)
+PRE_AUTH_TOKEN_TTL = timedelta(minutes=5)
+OIDC_STATE_TTL = timedelta(minutes=10)
+OIDC_EXCHANGE_TTL = timedelta(minutes=2)
+
+# ---------------------------------------------------------------------------
+# Router
+# ---------------------------------------------------------------------------
+router = APIRouter(prefix="/auth", tags=["2fa", "oidc"])
+
+
+# ---------------------------------------------------------------------------
+# Helper: user response
+# ---------------------------------------------------------------------------
+def _user_to_response(user: User) -> UserResponse:
+    return UserResponse(
+        id=user.id,
+        username=user.username,
+        email=user.email,
+        role=user.role,
+        is_active=user.is_active,
+        is_admin=user.is_admin,
+        groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
+        permissions=sorted(user.get_permissions()),
+        created_at=user.created_at.isoformat(),
+    )
+
+
+# ---------------------------------------------------------------------------
+# Helper: QR code generation
+# ---------------------------------------------------------------------------
+def _generate_totp_qr_b64(provisioning_uri: str) -> str:
+    """Generate a base64-encoded PNG QR code for the given TOTP provisioning URI."""
+    import qrcode  # type: ignore
+
+    qr = qrcode.QRCode(box_size=6, border=2)
+    qr.add_data(provisioning_uri)
+    qr.make(fit=True)
+    img = qr.make_image(fill_color="black", back_color="white")
+    buf = io.BytesIO()
+    img.save(buf, format="PNG")
+    return base64.b64encode(buf.getvalue()).decode()
+
+
+# ---------------------------------------------------------------------------
+# Helper: backup code generation
+# ---------------------------------------------------------------------------
+def _generate_backup_codes() -> tuple[list[str], list[str]]:
+    """Return (plain_codes, hashed_codes) — 10 codes of 8 alphanumeric chars each."""
+    alphabet = string.ascii_uppercase + string.digits
+    plain = ["".join(secrets.choice(alphabet) for _ in range(8)) for _ in range(10)]
+    hashed = [pwd_context.hash(c) for c in plain]
+    return plain, hashed
+
+
+# ---------------------------------------------------------------------------
+# DB-backed pre-auth token helpers
+# ---------------------------------------------------------------------------
+async def create_pre_auth_token(db: AsyncSession, username: str, challenge_id: str | None = None) -> str:
+    """Create a single-use pre-auth token stored in the DB.
+
+    Pass ``challenge_id`` (from the HttpOnly 2fa_challenge cookie) to bind the
+    token to the originating browser session.  The same value must be present as
+    a cookie on every subsequent call that consumes this token.
+    """
+    now = datetime.now(timezone.utc)
+    # Prune expired tokens opportunistically (keep table small)
+    await db.execute(
+        delete(AuthEphemeralToken).where(
+            AuthEphemeralToken.token_type == TokenType.PRE_AUTH,
+            AuthEphemeralToken.expires_at < now,
+        )
+    )
+    token = secrets.token_urlsafe(32)
+    db.add(
+        AuthEphemeralToken(
+            token=token,
+            token_type=TokenType.PRE_AUTH,
+            username=username,
+            challenge_id=challenge_id,
+            expires_at=now + PRE_AUTH_TOKEN_TTL,
+        )
+    )
+    await db.commit()
+    return token
+
+
+async def consume_pre_auth_token(db: AsyncSession, token: str, challenge_id: str | None = None) -> str | None:
+    """Atomically validate and consume a pre-auth token. Returns username or None.
+
+    Uses DELETE...RETURNING so two concurrent requests with the same token cannot
+    both succeed — only the first DELETE finds the row.
+
+    M5: When challenge_id is provided, also enforces the cookie-binding constraint
+    so a stolen token cannot be replayed from a different browser session.
+    """
+    now = datetime.now(timezone.utc)
+    result = await db.execute(
+        delete(AuthEphemeralToken)
+        .where(
+            AuthEphemeralToken.token == token,
+            AuthEphemeralToken.token_type == TokenType.PRE_AUTH,
+            AuthEphemeralToken.expires_at > now,
+        )
+        .returning(AuthEphemeralToken.username, AuthEphemeralToken.challenge_id)
+    )
+    row = result.one_or_none()
+    if row is None:
+        return None
+    username, stored_challenge_id = row
+    # Enforce client binding: if the token was issued with a challenge_id,
+    # the caller must supply the matching value.
+    if stored_challenge_id is not None and stored_challenge_id != challenge_id:
+        await db.rollback()
+        return None
+    await db.commit()
+    return username
+
+
+async def peek_pre_auth_token(db: AsyncSession, token: str, challenge_id: str | None = None) -> str | None:
+    """Validate a pre-auth token and return the username WITHOUT consuming it.
+
+    When the stored token has a ``challenge_id`` (client-binding cookie), the
+    caller must supply the matching value.  A mismatch is treated as an invalid
+    token — no information leakage about whether the token itself exists.
+    """
+    now = datetime.now(timezone.utc)
+    result = await db.execute(
+        select(AuthEphemeralToken).where(
+            AuthEphemeralToken.token == token,
+            AuthEphemeralToken.token_type == TokenType.PRE_AUTH,
+            AuthEphemeralToken.expires_at > now,
+        )
+    )
+    eph = result.scalar_one_or_none()
+    if eph is None:
+        return None
+    # Enforce client binding: if the token was issued with a challenge_id the
+    # cookie must match.  Treat a mismatch as if the token doesn't exist.
+    if eph.challenge_id is not None and eph.challenge_id != challenge_id:
+        return None
+    return eph.username
+
+
+# ---------------------------------------------------------------------------
+# DB-backed rate-limiting helpers
+# ---------------------------------------------------------------------------
+async def check_rate_limit(
+    db: AsyncSession,
+    username: str,
+    event_type: str = EventType.TWO_FA_ATTEMPT,
+    max_attempts: int = MAX_2FA_ATTEMPTS,
+) -> None:
+    """Raise HTTP 429 if the user has exceeded the failed attempt limit.
+
+    The username is normalised to lower-case so case-variant attempts
+    (which all resolve to the same user) share the same rate-limit bucket.
+
+    L-2: Known TOCTOU — the SELECT (count) and the subsequent INSERT
+    (record_failed_attempt) are not atomic.  Two concurrent requests can both
+    read a count below the threshold and both proceed.  This is an inherent
+    trade-off of the event-log rate-limit pattern: fixing it would require
+    a serialising lock (SELECT FOR UPDATE on a dedicated counter row), which
+    adds contention and is not worth it for a soft rate-limit whose window is
+    already measured in minutes.  In practice the race window is microseconds
+    and the limit can be slightly exceeded only under precise concurrent timing.
+    """
+    username_key = username.lower()
+    now = datetime.now(timezone.utc)
+    cutoff = now - LOCKOUT_WINDOW
+    result = await db.execute(
+        select(AuthRateLimitEvent).where(
+            AuthRateLimitEvent.username == username_key,
+            AuthRateLimitEvent.event_type == event_type,
+            AuthRateLimitEvent.occurred_at > cutoff,
+        )
+    )
+    recent_count = len(result.scalars().all())
+    if recent_count >= max_attempts:
+        raise HTTPException(
+            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+            detail="Too many failed attempts. Please try again later.",
+        )
+
+
+async def record_failed_attempt(db: AsyncSession, username: str, event_type: str = EventType.TWO_FA_ATTEMPT) -> None:
+    """Record a failed attempt for rate-limiting purposes."""
+    db.add(AuthRateLimitEvent(username=username.lower(), event_type=event_type))
+    await db.commit()
+
+
+async def clear_failed_attempts(db: AsyncSession, username: str, event_type: str = EventType.TWO_FA_ATTEMPT) -> None:
+    """Delete all recorded failed attempts for a user on successful verification."""
+    await db.execute(
+        delete(AuthRateLimitEvent).where(
+            AuthRateLimitEvent.username == username.lower(),
+            AuthRateLimitEvent.event_type == event_type,
+        )
+    )
+    await db.commit()
+
+
+async def check_email_otp_send_rate(db: AsyncSession, username: str) -> None:
+    """Raise HTTP 429 if the user has requested too many OTP emails recently.
+
+    I1: This function only *checks* the limit.  The caller is responsible for
+    recording the slot via ``record_email_otp_send`` **after** the email has
+    been sent successfully.  This prevents failed sends from consuming a slot
+    (wasting the user's quota) and makes it impossible to farm rate-limit events
+    without actually triggering a send.
+    """
+    username_key = username.lower()
+    now = datetime.now(timezone.utc)
+    cutoff = now - EMAIL_OTP_SEND_WINDOW
+    result = await db.execute(
+        select(AuthRateLimitEvent).where(
+            AuthRateLimitEvent.username == username_key,
+            AuthRateLimitEvent.event_type == EventType.EMAIL_SEND,
+            AuthRateLimitEvent.occurred_at > cutoff,
+        )
+    )
+    recent_count = len(result.scalars().all())
+    if recent_count >= MAX_EMAIL_OTP_SENDS:
+        raise HTTPException(
+            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+            detail=f"Too many OTP email requests. Please wait {EMAIL_OTP_SEND_WINDOW.seconds // 60} minutes.",
+        )
+
+
+async def record_email_otp_send(db: AsyncSession, username: str) -> None:
+    """Record a successful OTP email send for rate-limiting purposes (I1).
+
+    Must be called *after* the email has been sent successfully so that failed
+    sends do not consume a slot from the user's quota.
+    """
+    db.add(AuthRateLimitEvent(username=username.lower(), event_type=EventType.EMAIL_SEND))
+    await db.commit()
+
+
+# ---------------------------------------------------------------------------
+# TOTP replay-protection helper
+# ---------------------------------------------------------------------------
+def _assert_totp_not_replayed(totp_obj: pyotp.TOTP, totp_record: UserTOTP, code: str) -> None:
+    """Raise HTTP 400 if this TOTP code was already accepted in its time window.
+
+    M3 fix: store the counter of the *accepted* code rather than the current
+    wall-clock counter.  With valid_window=1, pyotp accepts codes from the
+    previous 30-second step.  Using timecode(now) would store the wrong counter
+    when the previous-window code is accepted, allowing immediate replay.
+    """
+    # Determine which time-step the accepted code belongs to.
+    now = datetime.now(timezone.utc)
+    accepted_counter: int | None = None
+    for offset in (0, -1):  # current window first, then previous
+        candidate_time = now.timestamp() + offset * totp_obj.interval
+        candidate_counter = totp_obj.timecode(datetime.fromtimestamp(candidate_time, tz=timezone.utc))
+        if totp_obj.at(candidate_counter) == code:
+            accepted_counter = candidate_counter
+            break
+    if accepted_counter is None:
+        accepted_counter = totp_obj.timecode(now)  # fallback (should not happen after verify())
+
+    totp_record.accept_counter(accepted_counter)
+
+
+# ---------------------------------------------------------------------------
+# Settings helpers (email 2FA flag)
+# ---------------------------------------------------------------------------
+async def _get_email_2fa_enabled(db: AsyncSession, user_id: int) -> bool:
+    val = await get_setting(db, f"user_{user_id}_email_2fa_enabled")
+    return val == "true"
+
+
+async def _set_email_2fa_enabled(db: AsyncSession, user_id: int, enabled: bool) -> None:
+    await set_setting(db, f"user_{user_id}_email_2fa_enabled", "true" if enabled else "false")
+
+
+# ===========================================================================
+# 2FA Endpoints
+# ===========================================================================
+
+
+@router.get("/2fa/status", response_model=TwoFAStatusResponse)
+async def get_2fa_status(
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> TwoFAStatusResponse:
+    """Return the current 2FA configuration for the authenticated user."""
+    result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == current_user.id))
+    totp_record = result.scalar_one_or_none()
+
+    totp_enabled = totp_record is not None and totp_record.is_enabled
+    backup_codes_remaining = len(totp_record.backup_code_hashes) if totp_record else 0
+    email_otp_enabled = await _get_email_2fa_enabled(db, current_user.id)
+
+    return TwoFAStatusResponse(
+        totp_enabled=totp_enabled,
+        email_otp_enabled=email_otp_enabled,
+        backup_codes_remaining=backup_codes_remaining,
+    )
+
+
+@router.post("/2fa/totp/setup", response_model=TOTPSetupResponse)
+async def setup_totp(
+    body: TOTPSetupRequest | None = Body(default=None),
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> TOTPSetupResponse:
+    """Initiate TOTP setup: generates a new secret and QR code.
+
+    Creates (or replaces) a pending UserTOTP record with is_enabled=False.
+    The caller must confirm with POST /auth/2fa/totp/enable.
+
+    M-R7-A: If an *active* TOTP is already configured, the caller must supply
+    the current TOTP code in the request body to confirm intent before the
+    secret is overwritten (prevents silently locking out the real user).
+    """
+    if not await is_auth_enabled(db):
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is not enabled")
+
+    # Upsert a pending TOTP record (is_enabled=False)
+    existing = (await db.execute(select(UserTOTP).where(UserTOTP.user_id == current_user.id))).scalar_one_or_none()
+
+    # M-R7-A: Guard against silent TOTP replacement when one is already active.
+    if existing and existing.is_enabled:
+        await check_rate_limit(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
+        supplied_code = (body.code if body else None) or ""
+        if not pyotp.TOTP(existing.secret).verify(supplied_code, valid_window=1):
+            await record_failed_attempt(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Current TOTP code required to replace an active authenticator",
+            )
+        await clear_failed_attempts(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
+        _assert_totp_not_replayed(pyotp.TOTP(existing.secret), existing, supplied_code)
+        await db.flush()  # L-3: persist last_totp_counter immediately to block replay
+
+    secret = pyotp.random_base32()
+    totp = pyotp.TOTP(secret)
+    provisioning_uri = totp.provisioning_uri(name=current_user.username, issuer_name="Bambuddy")
+    qr_b64 = _generate_totp_qr_b64(provisioning_uri)
+
+    if existing:
+        existing.secret = secret
+        existing.is_enabled = False
+        existing.backup_code_hashes = []
+    else:
+        db.add(UserTOTP(user_id=current_user.id, secret=secret, is_enabled=False))
+
+    await db.commit()
+
+    return TOTPSetupResponse(secret=secret, qr_code_b64=qr_b64, issuer="Bambuddy")
+
+
+@router.post("/2fa/totp/enable", response_model=TOTPEnableResponse)
+async def enable_totp(
+    body: TOTPEnableRequest,
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> TOTPEnableResponse:
+    """Confirm TOTP setup by verifying a code from the authenticator app.
+
+    On success, enables TOTP and returns 10 single-use backup codes (shown once).
+    L-R7-A: Rate-limited to prevent brute-forcing the 6-digit confirmation code.
+    """
+    # L-R7-A: Rate-limit the enable step to prevent brute-forcing the 6-digit code.
+    await check_rate_limit(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
+
+    result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == current_user.id))
+    totp_record = result.scalar_one_or_none()
+
+    if not totp_record:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP setup not initiated. Call /auth/2fa/totp/setup first."
+        )
+
+    if not pyotp.TOTP(totp_record.secret).verify(body.code, valid_window=1):
+        await record_failed_attempt(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid TOTP code")
+
+    await clear_failed_attempts(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
+    plain_codes, hashed_codes = _generate_backup_codes()
+    totp_record.is_enabled = True
+    totp_record.backup_code_hashes = hashed_codes
+    await db.commit()
+
+    return TOTPEnableResponse(
+        message="TOTP enabled successfully. Store your backup codes in a safe place.",
+        backup_codes=plain_codes,
+    )
+
+
+@router.post("/2fa/totp/disable")
+async def disable_totp(
+    body: TOTPDisableRequest,
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Disable TOTP by verifying a valid TOTP code or a backup code.
+
+    I10: Rate-limited to prevent backup-code brute-forcing from a hijacked session.
+    """
+    await check_rate_limit(db, current_user.username)
+
+    result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == current_user.id))
+    totp_record = result.scalar_one_or_none()
+
+    if not totp_record or not totp_record.is_enabled:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP is not enabled")
+
+    # Accept either a valid TOTP code or a valid backup code
+    totp_obj = pyotp.TOTP(totp_record.secret)
+    code_valid = totp_obj.verify(body.code, valid_window=1)
+    if code_valid:
+        _assert_totp_not_replayed(totp_obj, totp_record, body.code)
+        await db.flush()  # L-3: persist last_totp_counter immediately to block replay
+    else:
+        # Check backup codes — always iterate all entries (L-R9-A: no early break
+        # to avoid timing oracle based on code position in the list).
+        for hashed in totp_record.backup_code_hashes:
+            if pwd_context.verify(body.code, hashed):
+                code_valid = True
+
+    if not code_valid:
+        await record_failed_attempt(db, current_user.username)
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid code")
+
+    await db.execute(delete(UserTOTP).where(UserTOTP.user_id == current_user.id))
+    await db.commit()
+    return {"message": "TOTP disabled"}
+
+
+@router.post("/2fa/totp/regenerate-backup-codes", response_model=BackupCodesResponse)
+async def regenerate_backup_codes(
+    body: TOTPDisableRequest,
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> BackupCodesResponse:
+    """Generate 10 new backup codes. Requires a valid TOTP code OR a backup code.
+
+    M10: Accepts backup codes for consistency with disable_totp — users who have
+    lost their authenticator app but still have backup codes can regenerate.
+    Rate-limited to prevent brute-forcing from a hijacked session.
+    """
+    await check_rate_limit(db, current_user.username)
+
+    result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == current_user.id))
+    totp_record = result.scalar_one_or_none()
+
+    if not totp_record or not totp_record.is_enabled:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP is not enabled")
+
+    totp_obj = pyotp.TOTP(totp_record.secret)
+    code_valid = totp_obj.verify(body.code, valid_window=1)
+    if code_valid:
+        _assert_totp_not_replayed(totp_obj, totp_record, body.code)
+        await db.flush()  # L-3: persist last_totp_counter immediately to block replay
+    else:
+        # Accept a backup code as an alternative (M10)
+        matched_index: int | None = None
+        for idx, hashed in enumerate(totp_record.backup_code_hashes):
+            if pwd_context.verify(body.code, hashed) and matched_index is None:
+                matched_index = idx
+        if matched_index is None:
+            await record_failed_attempt(db, current_user.username)
+            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid TOTP or backup code")
+        # Remove the used backup code
+        totp_record.backup_code_hashes = [c for i, c in enumerate(totp_record.backup_code_hashes) if i != matched_index]
+
+    plain_codes, hashed_codes = _generate_backup_codes()
+    totp_record.backup_code_hashes = hashed_codes
+    await db.commit()
+
+    return BackupCodesResponse(
+        backup_codes=plain_codes,
+        message="Backup codes regenerated. Store them safely — they will not be shown again.",
+    )
+
+
+@router.post("/2fa/email/enable")
+async def enable_email_otp(
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Step 1 of email OTP enable: send a verification code to the user's email.
+
+    C5: Proof of possession — the user must prove they control the registered email
+    address before email 2FA is activated.  Returns a ``setup_token`` that must be
+    passed to POST /auth/2fa/email/enable/confirm together with the received code.
+    H-3: Rate-limited to prevent email flooding via repeated calls to this endpoint.
+    """
+    await check_email_otp_send_rate(db, current_user.username)
+    if not current_user.email:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="You must have an email address configured to enable email OTP 2FA",
+        )
+
+    smtp_settings = await get_smtp_settings(db)
+    if not smtp_settings:
+        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Email service is not configured")
+
+    # Generate and store the setup token (reuse AuthEphemeralToken with type "email_otp_setup")
+    now = datetime.now(timezone.utc)
+    # Prune any existing pending setup tokens for this user
+    await db.execute(
+        delete(AuthEphemeralToken).where(
+            AuthEphemeralToken.token_type == TokenType.EMAIL_OTP_SETUP,
+            AuthEphemeralToken.username == current_user.username,
+        )
+    )
+
+    code = str(secrets.randbelow(1_000_000)).zfill(6)
+    code_hash = pwd_context.hash(code)
+    setup_token = secrets.token_urlsafe(32)
+
+    db.add(
+        AuthEphemeralToken(
+            token=setup_token,
+            token_type=TokenType.EMAIL_OTP_SETUP,
+            username=current_user.username,
+            # Reuse the nonce field to store the code hash
+            nonce=code_hash,
+            expires_at=now + timedelta(minutes=10),
+        )
+    )
+    await db.commit()
+
+    try:
+        send_email(
+            smtp_settings=smtp_settings,
+            to_email=current_user.email,
+            subject="Verify your Bambuddy email address for 2FA",
+            body_text=(
+                f"Your Bambuddy email 2FA setup code is: {code}\n\n"
+                "Enter this code to confirm email-based two-factor authentication.\n"
+                "The code expires in 10 minutes."
+            ),
+            body_html=(
+                "<p>To enable <strong>email-based two-factor authentication</strong> on your Bambuddy account, "
+                "enter the code below:</p>"
+                f"<h2 style='letter-spacing:4px'>{code}</h2>"
+                "<p>The code expires in <strong>10 minutes</strong>. "
+                "If you did not request this, you can safely ignore this email.</p>"
+            ),
+        )
+        await record_email_otp_send(db, current_user.username)
+    except Exception as exc:
+        logger.error("Failed to send email OTP setup code to user_id=%d: %s", current_user.id, exc)
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to send verification email"
+        )
+
+    return {"message": "Verification code sent to your email address", "setup_token": setup_token}
+
+
+@router.post("/2fa/email/enable/confirm")
+async def confirm_enable_email_otp(
+    body: EmailOTPEnableConfirmRequest,
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Step 2 of email OTP enable: verify the code and activate email 2FA.
+
+    H-2 fix: Uses peek-then-consume so a wrong code does NOT burn the setup token.
+    The token is only deleted after successful code verification, allowing retries
+    up to the rate limit (5 attempts / 15 min).
+    M4: Rate-limited to prevent brute-forcing the 6-digit setup code.
+    """
+    await check_rate_limit(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
+    now = datetime.now(timezone.utc)
+
+    # --- Peek: validate token without consuming ---
+    peek_result = await db.execute(
+        select(AuthEphemeralToken).where(
+            AuthEphemeralToken.token == body.setup_token,
+            AuthEphemeralToken.token_type == TokenType.EMAIL_OTP_SETUP,
+            AuthEphemeralToken.username == current_user.username,
+            AuthEphemeralToken.expires_at > now,
+        )
+    )
+    eph = peek_result.scalar_one_or_none()
+    if eph is None:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired setup token")
+
+    code_hash = eph.nonce  # code hash stored in the nonce field
+
+    # --- Verify code before consuming the token ---
+    if not pwd_context.verify(body.code, code_hash):
+        await record_failed_attempt(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code")
+
+    # --- Atomically consume the token now that the code is correct ---
+    # DELETE...RETURNING prevents a concurrent request from using the same token.
+    del_result = await db.execute(
+        delete(AuthEphemeralToken)
+        .where(
+            AuthEphemeralToken.token == body.setup_token,
+            AuthEphemeralToken.token_type == TokenType.EMAIL_OTP_SETUP,
+            AuthEphemeralToken.username == current_user.username,
+        )
+        .returning(AuthEphemeralToken.id)
+    )
+    if del_result.one_or_none() is None:
+        # Concurrent request consumed it between peek and delete — treat as invalid.
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired setup token")
+
+    await clear_failed_attempts(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
+    await _set_email_2fa_enabled(db, current_user.id, True)
+    await db.commit()
+    return {"message": "Email OTP 2FA enabled"}
+
+
+@router.post("/2fa/email/disable")
+async def disable_email_otp(
+    body: EmailOTPDisableRequest,
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Disable email-based OTP 2FA for the current user.
+
+    C6: Re-authentication required — the caller must supply their account password
+    to prevent a hijacked session from silently removing a second factor.
+    LDAP/OIDC-only users (no local password) are exempt from this check.
+    H-2: Rate-limited to prevent brute-forcing the password via this endpoint.
+    """
+    await check_rate_limit(db, current_user.username)
+    if current_user.password_hash:
+        if not verify_password(body.password, current_user.password_hash):
+            await record_failed_attempt(db, current_user.username)
+            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password")
+    await _set_email_2fa_enabled(db, current_user.id, False)
+    await db.commit()
+    return {"message": "Email OTP 2FA disabled"}
+
+
+@router.post("/2fa/email/send")
+async def send_email_otp(
+    request: Request,
+    body: EmailOTPSendRequest,
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Send a 6-digit OTP code to the user's email address.
+
+    Requires a valid pre_auth_token obtained during the login flow.
+    """
+    # Peek (validate without consuming) first so a rate-limit rejection does not
+    # permanently burn the caller's pre-auth token.
+    challenge_id = request.cookies.get("2fa_challenge")
+    username = await peek_pre_auth_token(db, body.pre_auth_token, challenge_id=challenge_id)
+    if not username:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired pre-auth token")
+
+    # Enforce rate limit BEFORE consuming the token to prevent OTP email flooding.
+    await check_email_otp_send_rate(db, username)
+
+    user = await get_user_by_username(db, username)
+    if not user or not user.is_active:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
+
+    if not user.email:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User has no email address configured")
+
+    smtp_settings = await get_smtp_settings(db)
+    if not smtp_settings:
+        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Email service is not configured")
+
+    # Invalidate all existing unused OTP codes for this user (staged, not yet committed)
+    await db.execute(
+        UserOTPCode.__table__.update()  # type: ignore[attr-defined]
+        .where(UserOTPCode.user_id == user.id)
+        .where(UserOTPCode.used.is_(False))
+        .values(used=True)
+    )
+
+    # Generate a 6-digit code and stage the record (not committed yet)
+    code = str(secrets.randbelow(1_000_000)).zfill(6)
+    code_hash = pwd_context.hash(code)
+    expires_at = datetime.now(timezone.utc) + timedelta(minutes=UserOTPCode.OTP_TTL_MINUTES)
+
+    otp_record = UserOTPCode(
+        user_id=user.id,
+        code_hash=code_hash,
+        attempts=0,
+        used=False,
+        expires_at=expires_at,
+    )
+    db.add(otp_record)
+
+    # M2: Send the email BEFORE consuming the pre-auth token.
+    # If the send fails we raise an exception here; the session is uncommitted so
+    # the OTP record is discarded and the original token remains valid for retry.
+    try:
+        send_email(
+            smtp_settings=smtp_settings,
+            to_email=user.email,
+            subject="Your Bambuddy verification code",
+            body_text=f"Your Bambuddy login code is: {code}\n\nThis code expires in {UserOTPCode.OTP_TTL_MINUTES} minutes and can only be used once.",
+            body_html=(
+                f"<p>Your <strong>Bambuddy</strong> login verification code is:</p>"
+                f"<h2 style='letter-spacing:4px'>{code}</h2>"
+                f"<p>This code expires in <strong>{UserOTPCode.OTP_TTL_MINUTES} minutes</strong> and can only be used once.</p>"
+                f"<p>If you did not request this code, you can safely ignore this email.</p>"
+            ),
+        )
+        await record_email_otp_send(db, username)
+    except Exception as exc:
+        logger.error("Failed to send OTP email to user_id=%d: %s", user.id, exc)
+        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to send OTP email")
+
+    # Email sent — now atomically consume the old token (this also commits the
+    # staged OTP record) and issue a fresh token for the verify step.
+    consumed = await consume_pre_auth_token(db, body.pre_auth_token, challenge_id=challenge_id)
+    if not consumed:
+        # Raced with another request or token just expired — treat as invalid.
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired pre-auth token")
+
+    # Re-issue a fresh pre-auth token bound to the same cookie so the binding
+    # carries forward through the email → verify step.
+    fresh_token = await create_pre_auth_token(db, username, challenge_id=challenge_id)
+
+    # Return the fresh pre-auth token so the frontend can proceed to verify
+    return {"message": "Code sent to your email address", "pre_auth_token": fresh_token}
+
+
+@router.post("/2fa/verify", response_model=TwoFAVerifyResponse)
+async def verify_2fa(
+    request: Request,
+    body: TwoFAVerifyRequest,
+    db: AsyncSession = Depends(get_db),
+) -> TwoFAVerifyResponse:
+    """Verify a 2FA code and exchange the pre_auth_token for a full JWT.
+
+    Accepted methods: ``totp``, ``email``, ``backup``.
+
+    The pre_auth_token is NOT consumed on failed verification attempts so the
+    user can retry without restarting the login flow.  It is only consumed once
+    verification succeeds, preventing token replay after success.
+    """
+    # Peek without consuming — bad codes must not burn the session token.
+    # Pass the HttpOnly challenge cookie so the binding check is enforced.
+    challenge_id = request.cookies.get("2fa_challenge")
+    username = await peek_pre_auth_token(db, body.pre_auth_token, challenge_id=challenge_id)
+    if not username:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired pre-auth token")
+
+    await check_rate_limit(db, username)
+
+    user = await get_user_by_username(db, username)
+    if not user or not user.is_active:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
+
+    method = body.method
+
+    if method == "totp":
+        result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == user.id))
+        totp_record = result.scalar_one_or_none()
+        if not totp_record or not totp_record.is_enabled:
+            await record_failed_attempt(db, username)
+            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP is not enabled for this user")
+        totp_obj = pyotp.TOTP(totp_record.secret)
+        if not totp_obj.verify(body.code, valid_window=1):
+            await record_failed_attempt(db, username)
+            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid TOTP code")
+        _assert_totp_not_replayed(totp_obj, totp_record, body.code)
+        await db.flush()  # L-3: persist last_totp_counter immediately to block replay
+
+    elif method == "email":
+        now = datetime.now(timezone.utc)
+        result = await db.execute(
+            select(UserOTPCode)
+            .where(UserOTPCode.user_id == user.id)
+            .where(UserOTPCode.used.is_(False))
+            .where(UserOTPCode.expires_at > now)
+            .order_by(UserOTPCode.created_at.desc())
+        )
+        otp_record = result.scalar_one_or_none()
+        if not otp_record:
+            await record_failed_attempt(db, username)
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED, detail="No valid OTP code found. Request a new one."
+            )
+
+        if otp_record.attempts >= UserOTPCode.MAX_ATTEMPTS:
+            otp_record.consume()
+            await db.commit()
+            await record_failed_attempt(db, username)
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED, detail="OTP code has been invalidated after too many attempts"
+            )
+
+        if not pwd_context.verify(body.code, otp_record.code_hash):
+            otp_record.attempts += 1
+            await db.commit()
+            await record_failed_attempt(db, username)
+            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid OTP code")
+
+        otp_record.consume()
+        await db.commit()
+
+    else:  # method == "backup"
+        result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == user.id))
+        totp_record = result.scalar_one_or_none()
+        if not totp_record or not totp_record.is_enabled:
+            await record_failed_attempt(db, username)
+            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP is not enabled for this user")
+
+        # Always iterate all codes — no early break (L-R9-A: constant iteration
+        # count prevents timing oracle based on used-code position in the list).
+        matched_index: int | None = None
+        for idx, hashed in enumerate(totp_record.backup_code_hashes):
+            if pwd_context.verify(body.code, hashed) and matched_index is None:
+                matched_index = idx
+
+        if matched_index is None:
+            await record_failed_attempt(db, username)
+            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid backup code")
+
+        # M1: Consume the pre-auth token FIRST (atomic single-use enforcement).
+        # Only if that succeeds do we remove the backup code — this prevents a race
+        # where two concurrent requests both pass code verification but only one
+        # should be granted a session.
+        consumed_username = await consume_pre_auth_token(db, body.pre_auth_token, challenge_id=challenge_id)
+        if not consumed_username:
+            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired pre-auth token")
+
+        # Remove the used backup code now that the token is atomically consumed.
+        updated_codes = [c for i, c in enumerate(totp_record.backup_code_hashes) if i != matched_index]
+        totp_record.backup_code_hashes = updated_codes
+        await db.commit()
+        await clear_failed_attempts(db, username)
+
+        access_token = create_access_token(
+            data={"sub": user.username},
+            expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
+        )
+        result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
+        user = result.scalar_one()
+        return TwoFAVerifyResponse(access_token=access_token, token_type="bearer", user=_user_to_response(user))
+
+    # Verification succeeded (TOTP or email) — consume the pre-auth token.
+    # C-1: Check the return value; if None the token was already consumed by a
+    # concurrent request (race condition) — reject to prevent double-use.
+    consumed_username = await consume_pre_auth_token(db, body.pre_auth_token, challenge_id=challenge_id)
+    if not consumed_username:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired pre-auth token")
+    await clear_failed_attempts(db, username)
+
+    access_token = create_access_token(
+        data={"sub": user.username},
+        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
+    )
+
+    # Reload with groups for permission calculation
+    result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
+    user = result.scalar_one()
+
+    return TwoFAVerifyResponse(
+        access_token=access_token,
+        token_type="bearer",
+        user=_user_to_response(user),
+    )
+
+
+@router.delete("/2fa/admin/{user_id}")
+async def admin_disable_2fa(
+    user_id: int,
+    body: AdminDisable2FARequest = Body(default_factory=AdminDisable2FARequest),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Admin endpoint: disable all 2FA for a given user.
+
+    Nit 3: Requires the admin's own password as a re-auth step (matching how
+    disable_email_otp protects a user's own 2FA removal). OIDC/LDAP-only admins
+    (no local password_hash) are exempt.
+    """
+    # Nit 3: Re-auth — admin must supply their own password.
+    if current_user and current_user.password_hash:
+        if not body.admin_password or not verify_password(body.admin_password, current_user.password_hash):
+            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin password required")
+
+    # Delete TOTP record
+    await db.execute(delete(UserTOTP).where(UserTOTP.user_id == user_id))
+
+    # Disable email 2FA setting
+    await _set_email_2fa_enabled(db, user_id, False)
+
+    # Invalidate all OTP codes
+    await db.execute(
+        UserOTPCode.__table__.update()  # type: ignore[attr-defined]
+        .where(UserOTPCode.user_id == user_id)
+        .values(used=True)
+    )
+
+    # I2: Invalidate existing JWTs for the target user by bumping password_changed_at.
+    # Without this, a stolen token remains valid after 2FA removal.
+    target_user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
+    if target_user:
+        target_user.password_changed_at = datetime.now(timezone.utc)
+
+    await db.commit()
+    actor = current_user.username if current_user else "anonymous"
+    logger.info("Admin %s disabled all 2FA for user_id=%d", actor, user_id)
+    return {"message": "2FA disabled for user"}
+
+
+# ===========================================================================
+# OIDC Endpoints
+# ===========================================================================
+
+
+@router.get("/oidc/providers", response_model=list[OIDCProviderResponse])
+async def list_oidc_providers(
+    db: AsyncSession = Depends(get_db),
+) -> list[OIDCProviderResponse]:
+    """List all enabled OIDC providers (public)."""
+    result = await db.execute(select(OIDCProvider).where(OIDCProvider.is_enabled.is_(True)))
+    providers = result.scalars().all()
+    return [OIDCProviderResponse.model_validate(p) for p in providers]
+
+
+@router.get("/oidc/providers/all", response_model=list[OIDCProviderResponse])
+async def list_all_oidc_providers(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    db: AsyncSession = Depends(get_db),
+) -> list[OIDCProviderResponse]:
+    """List ALL OIDC providers including disabled ones (admin only)."""
+    result2 = await db.execute(select(OIDCProvider))
+    providers = result2.scalars().all()
+    return [OIDCProviderResponse.model_validate(p) for p in providers]
+
+
+@router.post("/oidc/providers", response_model=OIDCProviderResponse, status_code=status.HTTP_201_CREATED)
+async def create_oidc_provider(
+    body: OIDCProviderCreate,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+) -> OIDCProviderResponse:
+    """Create a new OIDC provider (admin only)."""
+    provider = OIDCProvider(
+        name=body.name,
+        issuer_url=body.issuer_url.rstrip("/"),
+        client_id=body.client_id,
+        client_secret=body.client_secret,
+        scopes=body.scopes,
+        is_enabled=body.is_enabled,
+        auto_create_users=body.auto_create_users,
+        icon_url=body.icon_url,
+    )
+    db.add(provider)
+    await db.commit()
+    await db.refresh(provider)
+    return OIDCProviderResponse.model_validate(provider)
+
+
+@router.put("/oidc/providers/{provider_id}", response_model=OIDCProviderResponse)
+async def update_oidc_provider(
+    provider_id: int,
+    body: OIDCProviderUpdate,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+) -> OIDCProviderResponse:
+    """Update an existing OIDC provider (admin only)."""
+    result2 = await db.execute(select(OIDCProvider).where(OIDCProvider.id == provider_id))
+    provider = result2.scalar_one_or_none()
+    if not provider:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+
+    for field, value in body.model_dump(exclude_none=True).items():
+        if field == "issuer_url" and value:
+            value = value.rstrip("/")
+        setattr(provider, field, value)
+
+    await db.commit()
+    await db.refresh(provider)
+    return OIDCProviderResponse.model_validate(provider)
+
+
+@router.delete("/oidc/providers/{provider_id}")
+async def delete_oidc_provider(
+    provider_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Delete an OIDC provider and all its user links (admin only)."""
+    result2 = await db.execute(select(OIDCProvider).where(OIDCProvider.id == provider_id))
+    provider = result2.scalar_one_or_none()
+    if not provider:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+
+    await db.delete(provider)
+    await db.commit()
+    return {"message": "Provider deleted"}
+
+
+@router.get("/oidc/authorize/{provider_id}", response_model=OIDCAuthorizeResponse)
+async def oidc_authorize(
+    provider_id: int,
+    db: AsyncSession = Depends(get_db),
+) -> OIDCAuthorizeResponse:
+    """Return the OIDC authorization URL for the given provider."""
+    result = await db.execute(
+        select(OIDCProvider).where(OIDCProvider.id == provider_id).where(OIDCProvider.is_enabled.is_(True))
+    )
+    provider = result.scalar_one_or_none()
+    if not provider:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found or not enabled")
+
+    # Fetch discovery document
+    discovery_url = f"{provider.issuer_url}/.well-known/openid-configuration"
+    try:
+        async with httpx.AsyncClient(timeout=10) as client:
+            resp = await client.get(discovery_url)
+            resp.raise_for_status()
+            discovery = resp.json()
+    except Exception as exc:
+        logger.error("Failed to fetch OIDC discovery for provider %d: %s", provider_id, exc)
+        raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Failed to fetch OIDC discovery document")
+
+    authorization_endpoint = discovery.get("authorization_endpoint")
+    if not authorization_endpoint:
+        raise HTTPException(
+            status_code=status.HTTP_502_BAD_GATEWAY, detail="OIDC discovery document missing authorization_endpoint"
+        )
+    # B2: SSRF guard — reject non-HTTP(S) schemes in the authorization endpoint
+    if not authorization_endpoint.startswith(("https://", "http://")):
+        logger.warning("OIDC discovery authorization_endpoint has invalid scheme: %s", authorization_endpoint)
+        raise HTTPException(
+            status_code=status.HTTP_502_BAD_GATEWAY,
+            detail="OIDC discovery document contains invalid authorization_endpoint",
+        )
+
+    external_url = await _get_base_external_url(db)
+    redirect_uri = f"{external_url}/api/v1/auth/oidc/callback"
+
+    now = datetime.now(timezone.utc)
+    # Prune expired OIDC states from the DB
+    await db.execute(
+        delete(AuthEphemeralToken).where(
+            AuthEphemeralToken.token_type == TokenType.OIDC_STATE,
+            AuthEphemeralToken.expires_at < now,
+        )
+    )
+    state = secrets.token_urlsafe(32)
+    nonce = secrets.token_urlsafe(32)
+
+    # PKCE (S256) – required by PocketID and recommended for all OIDC flows
+    code_verifier = secrets.token_urlsafe(48)  # 64-char URL-safe string
+    code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=").decode()
+
+    db.add(
+        AuthEphemeralToken(
+            token=state,
+            token_type=TokenType.OIDC_STATE,
+            provider_id=provider_id,
+            nonce=nonce,
+            code_verifier=code_verifier,
+            expires_at=now + OIDC_STATE_TTL,
+        )
+    )
+    await db.commit()
+
+    params = urllib.parse.urlencode(
+        {
+            "response_type": "code",
+            "client_id": provider.client_id,
+            "redirect_uri": redirect_uri,
+            "scope": provider.scopes,
+            "state": state,
+            "nonce": nonce,
+            "code_challenge": code_challenge,
+            "code_challenge_method": "S256",
+        }
+    )
+    auth_url = f"{authorization_endpoint}?{params}"
+    return OIDCAuthorizeResponse(auth_url=auth_url)
+
+
+@router.get("/oidc/callback")
+async def oidc_callback(
+    code: str | None = Query(default=None, max_length=512),
+    state: str | None = Query(default=None, max_length=512),
+    error: str | None = Query(default=None, max_length=256),
+    db: AsyncSession = Depends(get_db),
+) -> RedirectResponse:
+    """Handle the OIDC authorization code callback from the identity provider."""
+    external_url = await _get_base_external_url(db)
+    frontend_error_url = f"{external_url}/?oidc_error="
+
+    try:
+        if error:
+            logger.warning("OIDC callback received error: %s", error)
+            return RedirectResponse(url=f"{frontend_error_url}oidc_provider_error", status_code=302)
+
+        if not code or not state:
+            return RedirectResponse(url=f"{frontend_error_url}missing_parameters", status_code=302)
+
+        # Atomically validate and consume OIDC state from DB (I6: single-use enforcement).
+        # DELETE...RETURNING ensures concurrent callbacks with the same state token
+        # cannot both succeed — only the first DELETE finds the row.
+        now = datetime.now(timezone.utc)
+        state_del = await db.execute(
+            delete(AuthEphemeralToken)
+            .where(
+                AuthEphemeralToken.token == state,
+                AuthEphemeralToken.token_type == TokenType.OIDC_STATE,
+                AuthEphemeralToken.expires_at > now,  # reject expired tokens atomically
+            )
+            .returning(
+                AuthEphemeralToken.provider_id,
+                AuthEphemeralToken.nonce,
+                AuthEphemeralToken.code_verifier,
+            )
+        )
+        state_row = state_del.one_or_none()
+        if state_row is None:
+            await db.rollback()
+            return RedirectResponse(url=f"{frontend_error_url}invalid_state", status_code=302)
+
+        provider_id, nonce, code_verifier = state_row
+        await db.commit()
+
+        # Load provider
+        result = await db.execute(select(OIDCProvider).where(OIDCProvider.id == provider_id))
+        provider = result.scalar_one_or_none()
+        if not provider:
+            return RedirectResponse(url=f"{frontend_error_url}provider_not_found", status_code=302)
+
+        redirect_uri = f"{external_url}/api/v1/auth/oidc/callback"
+
+        # ── Step 1: Fetch discovery document ────────────────────────────────
+        discovery_url = f"{provider.issuer_url}/.well-known/openid-configuration"
+        try:
+            async with httpx.AsyncClient(timeout=10) as client:
+                disc_resp = await client.get(discovery_url)
+                disc_resp.raise_for_status()
+                discovery = disc_resp.json()
+        except Exception as exc:
+            logger.error("OIDC discovery fetch failed for provider %d: %s", provider_id, exc)
+            return RedirectResponse(url=f"{frontend_error_url}discovery_failed", status_code=302)
+
+        token_endpoint = discovery.get("token_endpoint")
+        jwks_uri = discovery.get("jwks_uri")
+        if not token_endpoint or not jwks_uri:
+            return RedirectResponse(url=f"{frontend_error_url}invalid_discovery_document", status_code=302)
+        # L-R7-C: Reject non-HTTP(S) URLs in the discovery document to prevent
+        # SSRF via crafted responses (e.g. file://, gopher://, internal schemes).
+        if not token_endpoint.startswith(("https://", "http://")) or not jwks_uri.startswith(("https://", "http://")):
+            logger.warning(
+                "OIDC discovery document contains non-HTTP URL(s): token=%s jwks=%s", token_endpoint, jwks_uri
+            )
+            return RedirectResponse(url=f"{frontend_error_url}invalid_discovery_document", status_code=302)
+
+        # ── Step 2: Exchange authorization code for tokens ───────────────────
+        token_form: dict[str, str] = {
+            "grant_type": "authorization_code",
+            "code": code,
+            "redirect_uri": redirect_uri,
+            "client_id": provider.client_id,
+        }
+        if provider.client_secret:
+            token_form["client_secret"] = provider.client_secret
+        if code_verifier:
+            token_form["code_verifier"] = code_verifier
+
+        try:
+            async with httpx.AsyncClient(timeout=15) as client:
+                token_resp = await client.post(
+                    token_endpoint,
+                    data=token_form,
+                    headers={"Accept": "application/json"},
+                )
+        except Exception as exc:
+            logger.error("OIDC token exchange request failed for provider %d: %s", provider_id, exc)
+            return RedirectResponse(url=f"{frontend_error_url}token_exchange_network_error", status_code=302)
+
+        if not token_resp.is_success:
+            try:
+                err_body = token_resp.json()
+                oidc_err = err_body.get("error", "")
+                oidc_desc = err_body.get("error_description", "")
+            except Exception:
+                oidc_err = ""
+                oidc_desc = token_resp.text[:200]
+            logger.error(
+                "OIDC token exchange HTTP %d for provider %d. redirect_uri=%r error=%r desc=%r",
+                token_resp.status_code,
+                provider_id,
+                redirect_uri,
+                oidc_err,
+                oidc_desc,
+            )
+            # Encode the OIDC error code into the redirect so the user sees it in the toast.
+            # URL-encode the value to prevent query-parameter injection from provider responses.
+            raw_err = oidc_err[:40] if oidc_err else str(token_resp.status_code)
+            safe_err = urllib.parse.quote(raw_err, safe="")
+            return RedirectResponse(
+                url=f"{frontend_error_url}token_exchange_{safe_err}",
+                status_code=302,
+            )
+
+        try:
+            token_data = token_resp.json()
+        except Exception as exc:
+            logger.error("OIDC token exchange non-JSON response for provider %d: %s", provider_id, exc)
+            return RedirectResponse(url=f"{frontend_error_url}token_exchange_bad_response", status_code=302)
+
+        id_token = token_data.get("id_token")
+        if not id_token:
+            # Only log the keys present — values may contain secrets (access_token, etc.)
+            logger.error(
+                "OIDC token response missing id_token for provider %d; keys present: %s",
+                provider_id,
+                list(token_data.keys()),
+            )
+            return RedirectResponse(url=f"{frontend_error_url}no_id_token", status_code=302)
+
+        # ── Step 3: Fetch JWKS and validate ID token ─────────────────────────
+        # Use the issuer from the discovery document as the canonical value (OIDC Core
+        # §3.1.3.7 requires iss == discovery issuer exactly).  We strip trailing slashes
+        # from both sides because some providers (e.g. older PocketID versions) are
+        # inconsistent between the discovery issuer and the JWT iss claim.
+        discovery_issuer: str = discovery.get("issuer", provider.issuer_url).rstrip("/")
+        try:
+            async with httpx.AsyncClient(timeout=10) as jwks_http:
+                jwks_resp = await jwks_http.get(jwks_uri)
+                jwks_resp.raise_for_status()
+                jwks_data = jwks_resp.json()
+
+            jwks_client = PyJWKClient(jwks_uri)
+            jwks_client.fetch_data = lambda: jwks_data  # type: ignore[method-assign]
+            signing_key = jwks_client.get_signing_key_from_jwt(id_token)
+
+            # M-3: Use PyJWT native issuer validation (issuer= parameter) instead of
+            # decoding with verify_iss=False and checking manually.  PyJWT will raise
+            # InvalidIssuerError when iss != discovery_issuer, which is caught below.
+            claims = jwt.decode(
+                id_token,
+                signing_key.key,
+                algorithms=["RS256", "ES256", "RS384", "ES384", "RS512"],
+                audience=provider.client_id,
+                issuer=discovery_issuer,
+            )
+        except Exception as exc:
+            logger.error("OIDC JWT validation failed for provider %d: %s", provider_id, exc, exc_info=True)
+            return RedirectResponse(url=f"{frontend_error_url}token_validation_failed", status_code=302)
+
+        # Verify nonce — fail closed: we always send a nonce, so the provider must echo it.
+        # Skipping the check when nonce is absent would allow CSRF on non-nonce providers.
+        token_nonce = claims.get("nonce")
+        if token_nonce is None or token_nonce != nonce:
+            logger.warning("OIDC nonce mismatch for provider %d (present=%r)", provider_id, token_nonce is not None)
+            return RedirectResponse(url=f"{frontend_error_url}nonce_mismatch", status_code=302)
+
+        provider_sub: str = claims.get("sub", "")
+        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
+
+        # ── Step 4: Resolve / create user ────────────────────────────────────
+        try:
+            # 1. Look up existing OIDC link
+            link_result = await db.execute(
+                select(UserOIDCLink)
+                .where(UserOIDCLink.provider_id == provider_id)
+                .where(UserOIDCLink.provider_user_id == provider_sub)
+            )
+            link = link_result.scalar_one_or_none()
+
+            user: User | None = None
+
+            if link:
+                # Existing link → load the linked user
+                user_result = await db.execute(
+                    select(User).where(User.id == link.user_id).options(selectinload(User.groups))
+                )
+                user = user_result.scalar_one_or_none()
+            else:
+                # 2. No OIDC link yet — check for an existing user with the same email.
+                # Use case-insensitive matching (func.lower) so that "User@Example.com"
+                # and "user@example.com" are treated as the same identity, preventing
+                # an attacker-controlled IdP from bypassing the auto-link guard by
+                # registering the target email with different casing.
+                email_user: User | None = None
+                if provider_email:
+                    email_user = await get_user_by_email(db, provider_email)
+
+                if email_user and provider.auto_link_existing_accounts:
+                    # M-4: Only auto-link when the provider has auto_link_existing_accounts
+                    # enabled.  Operators can disable this to require explicit account linking,
+                    # preventing an attacker-controlled IdP from hijacking local accounts.
+                    #
+                    # M-NEW-6: Refuse auto-link if the target user already has any OIDC
+                    # link (to any provider).  Without this guard an attacker who controls
+                    # a second OIDC provider with auto_link enabled could add themselves as
+                    # a second IdP for a user that already authenticates via a legitimate
+                    # provider, effectively taking over the account.
+                    existing_links_result = await db.execute(
+                        select(UserOIDCLink).where(UserOIDCLink.user_id == email_user.id)
+                    )
+                    has_existing_oidc_link = existing_links_result.scalar_one_or_none() is not None
+                    if has_existing_oidc_link:
+                        logger.warning(
+                            "Auto-link rejected for user '%s': already linked to another OIDC provider",
+                            email_user.username,
+                        )
+                        return RedirectResponse(url=f"{frontend_error_url}no_linked_account", status_code=302)
+                    db.add(
+                        UserOIDCLink(
+                            user_id=email_user.id,
+                            provider_id=provider_id,
+                            provider_user_id=provider_sub,
+                            provider_email=provider_email,
+                        )
+                    )
+                    await db.commit()
+                    user = email_user
+                    logger.info(
+                        "Auto-linked existing user '%s' to OIDC provider %d via email match",
+                        email_user.username,
+                        provider_id,
+                    )
+                elif provider.auto_create_users:
+                    # 3. No existing user — create one
+                    if provider_email:
+                        raw = provider_email.split("@")[0]
+                    else:
+                        raw = provider_sub[:30]
+                    candidate = re.sub(r"[^a-zA-Z0-9._-]", "", raw)[:30] or "oidcuser"
+
+                    username = candidate
+                    counter = 1
+                    while True:
+                        existing = await get_user_by_username(db, username)
+                        if not existing:
+                            break
+                        username = f"{candidate}{counter}"
+                        counter += 1
+
+                    # I9: Assign new OIDC users to the default "Viewers" group so they
+                    # have read-only access rather than starting with no permissions.
+                    # Fetch the group BEFORE creating the user so we can set the
+                    # relationship before flush — accessing new_user.groups after a
+                    # flush triggers a lazy-load which fails in async context.
+                    viewers_result = await db.execute(select(Group).where(Group.name == "Viewers"))
+                    viewers_group = viewers_result.scalar_one_or_none()
+
+                    new_user = User(
+                        username=username,
+                        email=provider_email,
+                        # M-1: auth_source="oidc" prevents local password-reset flow
+                        # for users who should only authenticate via OIDC.
+                        auth_source="oidc",
+                        password_hash=None,  # OIDC users never use password auth
+                        role="user",
+                        is_active=True,
+                        groups=[viewers_group] if viewers_group else [],
+                    )
+                    db.add(new_user)
+                    await db.flush()
+
+                    db.add(
+                        UserOIDCLink(
+                            user_id=new_user.id,
+                            provider_id=provider_id,
+                            provider_user_id=provider_sub,
+                            provider_email=provider_email,
+                        )
+                    )
+                    await db.commit()
+
+                    user_result = await db.execute(
+                        select(User).where(User.id == new_user.id).options(selectinload(User.groups))
+                    )
+                    user = user_result.scalar_one()
+                    logger.info("Auto-created user '%s' via OIDC provider %d", username, provider_id)
+                else:
+                    return RedirectResponse(url=f"{frontend_error_url}no_linked_account", status_code=302)
+
+            if not user or not user.is_active:
+                return RedirectResponse(url=f"{frontend_error_url}account_inactive", status_code=302)
+
+            # Issue an OIDC exchange token (short-lived, single-use) stored in DB.
+            # I7: Opportunistically prune expired exchange tokens to keep the table small.
+            now2 = datetime.now(timezone.utc)
+            await db.execute(
+                delete(AuthEphemeralToken).where(
+                    AuthEphemeralToken.token_type == TokenType.OIDC_EXCHANGE,
+                    AuthEphemeralToken.expires_at < now2,
+                )
+            )
+            exchange_token = secrets.token_urlsafe(32)
+            db.add(
+                AuthEphemeralToken(
+                    token=exchange_token,
+                    token_type=TokenType.OIDC_EXCHANGE,
+                    username=user.username,
+                    expires_at=now2 + OIDC_EXCHANGE_TTL,
+                )
+            )
+            await db.commit()
+
+            # H-4: Use a URL fragment (#) instead of a query parameter so the exchange
+            # token is never sent to the server in the Referer header or server logs.
+            return RedirectResponse(url=f"{external_url}/login#oidc_token={exchange_token}", status_code=302)
+
+        except Exception as exc:
+            logger.error("OIDC user resolution failed for provider %d: %s", provider_id, exc, exc_info=True)
+            try:
+                await db.rollback()
+            except Exception as rb_exc:
+                logger.error("DB rollback failed after OIDC user-resolution error: %s", rb_exc, exc_info=True)
+            return RedirectResponse(url=f"{frontend_error_url}user_resolution_failed", status_code=302)
+
+    except Exception as exc:
+        # L-1: Log the exception class name internally but never expose it in the
+        # redirect URL — leaking exception names aids attacker reconnaissance.
+        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:
+            raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="OIDC callback failed")
+
+
+@router.post("/oidc/exchange", response_model=LoginResponse)
+async def oidc_exchange(
+    body: OIDCExchangeRequest,
+    raw_request: Request,
+    response: Response,
+    db: AsyncSession = Depends(get_db),
+) -> LoginResponse:
+    """Exchange an OIDC exchange token (from the callback redirect) for a full JWT.
+
+    C4: If the resolved user has 2FA enabled the exchange returns a pre_auth_token
+    (requires_2fa=True) instead of a full JWT.  The frontend must then complete the
+    2FA step exactly as it would after a password-based login.
+    """
+    now = datetime.now(timezone.utc)
+    # Atomically consume the exchange token (DELETE...RETURNING prevents replay).
+    consume_result = await db.execute(
+        delete(AuthEphemeralToken)
+        .where(
+            AuthEphemeralToken.token == body.oidc_token,
+            AuthEphemeralToken.token_type == TokenType.OIDC_EXCHANGE,
+            AuthEphemeralToken.expires_at > now,  # reject expired tokens atomically
+        )
+        .returning(AuthEphemeralToken.username)
+    )
+    row = consume_result.one_or_none()
+    if row is None:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired OIDC exchange token")
+
+    (username,) = row
+    await db.commit()
+
+    user = await get_user_by_username(db, username)
+    if not user or not user.is_active:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
+
+    # Reload with groups
+    result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
+    user = result.scalar_one()
+
+    # C4: Check whether the user has any 2FA method enabled.
+    totp_result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == user.id))
+    totp_record = totp_result.scalar_one_or_none()
+    totp_enabled = totp_record is not None and totp_record.is_enabled
+    email_2fa_enabled = await _get_email_2fa_enabled(db, user.id)
+
+    if totp_enabled or email_2fa_enabled:
+        # User has 2FA — issue a pre_auth_token bound to this browser session via
+        # an HttpOnly cookie (H-A: mirrors the cookie-binding done in auth.py:login).
+        two_fa_methods: list[str] = []
+        if totp_enabled:
+            two_fa_methods.append("totp")
+        if email_2fa_enabled:
+            two_fa_methods.append("email")
+        if totp_enabled:
+            two_fa_methods.append("backup")
+        challenge_id = secrets.token_urlsafe(32)
+        pre_auth_token = await create_pre_auth_token(db, user.username, challenge_id=challenge_id)
+        response.set_cookie(
+            key="2fa_challenge",
+            value=challenge_id,
+            httponly=True,
+            secure=raw_request.url.scheme == "https",
+            samesite="lax",
+            max_age=300,
+            path="/api/v1/auth/2fa",
+        )
+        return LoginResponse(
+            requires_2fa=True,
+            pre_auth_token=pre_auth_token,
+            two_fa_methods=two_fa_methods,
+            user=_user_to_response(user),
+        )
+
+    access_token = create_access_token(
+        data={"sub": user.username},
+        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
+    )
+
+    return LoginResponse(
+        access_token=access_token,
+        token_type="bearer",
+        user=_user_to_response(user),
+        requires_2fa=False,
+    )
+
+
+@router.get("/oidc/links", response_model=list[OIDCLinkResponse])
+async def list_oidc_links(
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> list[OIDCLinkResponse]:
+    """List all OIDC provider links for the current user."""
+    result = await db.execute(
+        select(UserOIDCLink).where(UserOIDCLink.user_id == current_user.id).options(selectinload(UserOIDCLink.provider))
+    )
+    links = result.scalars().all()
+    return [
+        OIDCLinkResponse(
+            id=link.id,
+            provider_id=link.provider_id,
+            provider_name=link.provider.name,
+            provider_email=link.provider_email,
+            created_at=link.created_at.isoformat(),
+        )
+        for link in links
+    ]
+
+
+@router.delete("/oidc/links/{provider_id}")
+async def remove_oidc_link(
+    provider_id: int,
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Remove the OIDC link between the current user and a provider."""
+    result = await db.execute(
+        select(UserOIDCLink)
+        .where(UserOIDCLink.user_id == current_user.id)
+        .where(UserOIDCLink.provider_id == provider_id)
+    )
+    link = result.scalar_one_or_none()
+    if not link:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="OIDC link not found")
+
+    await db.delete(link)
+    await db.commit()
+    return {"message": "OIDC link removed"}
+
+
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+async def _get_base_external_url(db: AsyncSession) -> str:
+    """Return the base external URL (no trailing slash, no /login suffix)."""
+    external_url = await get_setting(db, "external_url")
+    if external_url:
+        return external_url.rstrip("/")
+    return os.environ.get("APP_URL", "http://localhost:5173").rstrip("/")

+ 43 - 7
backend/app/api/routes/users.py

@@ -1,13 +1,22 @@
+from datetime import datetime, timezone
+from typing import Annotated
+
+import jwt as _jwt
 from fastapi import APIRouter, Depends, HTTPException, Query, status
 from fastapi import APIRouter, Depends, HTTPException, Query, status
+from fastapi.security import HTTPAuthorizationCredentials
 from sqlalchemy import delete, func, select
 from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
 from backend.app.api.routes.settings import get_external_login_url
 from backend.app.api.routes.settings import get_external_login_url
 from backend.app.core.auth import (
 from backend.app.core.auth import (
+    ALGORITHM,
+    SECRET_KEY,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     get_current_user_optional,
     get_current_user_optional,
     get_password_hash,
     get_password_hash,
+    revoke_jti,
+    security,
     verify_password,
     verify_password,
 )
 )
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
@@ -398,6 +407,7 @@ async def delete_user(
 @router.post("/me/change-password", response_model=dict)
 @router.post("/me/change-password", response_model=dict)
 async def change_own_password(
 async def change_own_password(
     password_data: ChangePasswordRequest,
     password_data: ChangePasswordRequest,
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
     current_user: User | None = Depends(get_current_user_optional),
     current_user: User | None = Depends(get_current_user_optional),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
@@ -421,19 +431,19 @@ async def change_own_password(
             status_code=status.HTTP_400_BAD_REQUEST,
             status_code=status.HTTP_400_BAD_REQUEST,
             detail="Account has no local password set",
             detail="Account has no local password set",
         )
         )
+
+    # Rate-limit failed password-change attempts (H-R5-A)
+    from backend.app.api.routes.mfa import MAX_2FA_ATTEMPTS, check_rate_limit, record_failed_attempt
+
+    await check_rate_limit(db, current_user.username, event_type="password_change", max_attempts=MAX_2FA_ATTEMPTS)
+
     if not verify_password(password_data.current_password, current_user.password_hash):
     if not verify_password(password_data.current_password, current_user.password_hash):
+        await record_failed_attempt(db, current_user.username, event_type="password_change")
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
             status_code=status.HTTP_400_BAD_REQUEST,
             detail="Current password is incorrect",
             detail="Current password is incorrect",
         )
         )
 
 
-    # Validate new password
-    if len(password_data.new_password) < 6:
-        raise HTTPException(
-            status_code=status.HTTP_400_BAD_REQUEST,
-            detail="New password must be at least 6 characters",
-        )
-
     # Fetch user from this session to ensure changes are persisted
     # Fetch user from this session to ensure changes are persisted
     result = await db.execute(select(User).where(User.id == current_user.id))
     result = await db.execute(select(User).where(User.id == current_user.id))
     user = result.scalar_one_or_none()
     user = result.scalar_one_or_none()
@@ -445,6 +455,32 @@ async def change_own_password(
 
 
     # Update password
     # Update password
     user.password_hash = get_password_hash(password_data.new_password)
     user.password_hash = get_password_hash(password_data.new_password)
+    user.password_changed_at = datetime.now(timezone.utc)  # M-R7-B: invalidate all prior JWTs
     await db.commit()
     await db.commit()
 
 
+    # L-R6-A: Password verified successfully — reset the failure counter
+    from backend.app.api.routes.mfa import clear_failed_attempts
+
+    await clear_failed_attempts(db, user.username, event_type="password_change")
+
+    # Revoke the current session token so the caller must re-authenticate (M-R5-A)
+    if credentials is not None:
+        try:
+            payload = _jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
+            jti = payload.get("jti")
+            exp = payload.get("exp")
+            if jti and exp:
+                try:
+                    await revoke_jti(jti, datetime.fromtimestamp(exp, tz=timezone.utc), user.username)
+                except Exception as exc:
+                    # B4: log so operators know revocation is broken; password was
+                    # already changed so the token will fail freshness checks anyway.
+                    import logging
+
+                    logging.getLogger(__name__).error(
+                        "Failed to revoke JTI after password change for user %s: %s", user.username, exc
+                    )
+        except Exception:
+            pass  # Decode failure is harmless — token is already invalidated by password_changed_at
+
     return {"message": "Password changed successfully"}
     return {"message": "Password changed successfully"}

+ 256 - 64
backend/app/core/auth.py

@@ -12,13 +12,14 @@ from fastapi import Depends, Header, HTTPException, status
 from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
 from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
 from jwt.exceptions import PyJWTError as JWTError
 from jwt.exceptions import PyJWTError as JWTError
 from passlib.context import CryptContext
 from passlib.context import CryptContext
-from sqlalchemy import func, select
+from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
 from backend.app.core.database import async_session, get_db
 from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
+from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user import User
 
 
@@ -93,79 +94,118 @@ def _get_jwt_secret() -> str:
 # JWT settings
 # JWT settings
 SECRET_KEY = _get_jwt_secret()
 SECRET_KEY = _get_jwt_secret()
 ALGORITHM = "HS256"
 ALGORITHM = "HS256"
-ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7  # 7 days
+ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24  # 24 hours (M-2: reduced from 7 days)
 
 
 # HTTP Bearer token
 # HTTP Bearer token
 security = HTTPBearer(auto_error=False)
 security = HTTPBearer(auto_error=False)
 
 
 # --- Slicer download tokens ---
 # --- Slicer download tokens ---
-# Short-lived tokens for slicer protocol handlers that can't send auth headers.
-# Maps token → (resource_key, expiry). resource_key = "archive:{id}" or "library:{id}".
-_slicer_tokens: dict[str, tuple[str, datetime]] = {}
+# Short-lived, single-use tokens for slicer protocol handlers that can't send
+# auth headers.  Stored in AuthEphemeralToken (token_type=TokenType.SLICER_DOWNLOAD)
+# so they survive server restarts and work in multi-worker deployments (M-3).
 SLICER_TOKEN_EXPIRE_MINUTES = 5
 SLICER_TOKEN_EXPIRE_MINUTES = 5
 
 
 
 
-def create_slicer_download_token(resource_type: str, resource_id: int) -> str:
-    """Create a short-lived download token for slicer protocol handlers."""
-    # Cleanup expired tokens
+async def create_slicer_download_token(resource_type: str, resource_id: int) -> str:
+    """Create a short-lived, single-use download token for slicer protocol handlers."""
     now = datetime.now(timezone.utc)
     now = datetime.now(timezone.utc)
-    expired = [k for k, (_, exp) in _slicer_tokens.items() if exp < now]
-    for k in expired:
-        del _slicer_tokens[k]
-
+    expires_at = now + timedelta(minutes=SLICER_TOKEN_EXPIRE_MINUTES)
     token = secrets.token_urlsafe(24)
     token = secrets.token_urlsafe(24)
     resource_key = f"{resource_type}:{resource_id}"
     resource_key = f"{resource_type}:{resource_id}"
-    _slicer_tokens[token] = (resource_key, now + timedelta(minutes=SLICER_TOKEN_EXPIRE_MINUTES))
+    async with async_session() as db:
+        # Prune expired tokens opportunistically
+        await db.execute(
+            delete(AuthEphemeralToken).where(
+                AuthEphemeralToken.token_type == TokenType.SLICER_DOWNLOAD,
+                AuthEphemeralToken.expires_at < now,
+            )
+        )
+        db.add(
+            AuthEphemeralToken(
+                token=token,
+                token_type=TokenType.SLICER_DOWNLOAD,
+                nonce=resource_key,
+                expires_at=expires_at,
+            )
+        )
+        await db.commit()
     return token
     return token
 
 
 
 
-def verify_slicer_download_token(token: str, resource_type: str, resource_id: int) -> bool:
-    """Verify a slicer download token is valid for the given resource."""
-    entry = _slicer_tokens.get(token)
-    if not entry:
-        return False
-    resource_key, expiry = entry
-    if datetime.now(timezone.utc) > expiry:
-        del _slicer_tokens[token]
-        return False
+async def verify_slicer_download_token(token: str, resource_type: str, resource_id: int) -> bool:
+    """Verify and atomically consume a slicer download token.
+
+    Returns True only if the token is valid, unexpired, and bound to the given resource.
+    DELETE...RETURNING ensures the token is single-use even under concurrent requests.
+
+    M-NEW-1 fix: nonce (resource key) is included in the WHERE clause so the DELETE
+    only succeeds when the token is presented to the *correct* resource endpoint.
+    Previously the token was consumed (committed) even when stored_key != expected_key,
+    permanently invalidating it while returning False to the caller.
+    """
     expected_key = f"{resource_type}:{resource_id}"
     expected_key = f"{resource_type}:{resource_id}"
-    if resource_key != expected_key:
-        return False
-    # Token is single-use
-    del _slicer_tokens[token]
-    return True
+    now = datetime.now(timezone.utc)
+    async with async_session() as db:
+        result = await db.execute(
+            delete(AuthEphemeralToken)
+            .where(
+                AuthEphemeralToken.token == token,
+                AuthEphemeralToken.token_type == TokenType.SLICER_DOWNLOAD,
+                AuthEphemeralToken.nonce == expected_key,
+                AuthEphemeralToken.expires_at > now,
+            )
+            .returning(AuthEphemeralToken.id)
+        )
+        if result.one_or_none() is None:
+            return False
+        await db.commit()
+        return True
 
 
 
 
 # --- Camera stream tokens ---
 # --- Camera stream tokens ---
-# Reusable tokens for camera stream/snapshot endpoints loaded via <img> tags.
-# Unlike slicer tokens, these are NOT single-use (streams reconnect on errors)
-# and have a longer expiry. Maps token → expiry.
-_camera_stream_tokens: dict[str, datetime] = {}
+# Reusable tokens for camera stream/snapshot endpoints loaded via <img>/<video>
+# tags (these cannot send Authorization headers).  Unlike slicer tokens they are
+# NOT single-use — streams reconnect on errors.  Stored in AuthEphemeralToken
+# (token_type="camera_stream") for multi-worker compatibility (M-3).
 CAMERA_STREAM_TOKEN_EXPIRE_MINUTES = 60
 CAMERA_STREAM_TOKEN_EXPIRE_MINUTES = 60
 
 
 
 
-def create_camera_stream_token() -> str:
+async def create_camera_stream_token() -> str:
     """Create a reusable token for camera stream/snapshot access."""
     """Create a reusable token for camera stream/snapshot access."""
     now = datetime.now(timezone.utc)
     now = datetime.now(timezone.utc)
-    # Cleanup expired tokens
-    expired = [k for k, exp in _camera_stream_tokens.items() if exp < now]
-    for k in expired:
-        del _camera_stream_tokens[k]
-
+    expires_at = now + timedelta(minutes=CAMERA_STREAM_TOKEN_EXPIRE_MINUTES)
     token = secrets.token_urlsafe(24)
     token = secrets.token_urlsafe(24)
-    _camera_stream_tokens[token] = now + timedelta(minutes=CAMERA_STREAM_TOKEN_EXPIRE_MINUTES)
+    async with async_session() as db:
+        # Prune expired tokens opportunistically
+        await db.execute(
+            delete(AuthEphemeralToken).where(
+                AuthEphemeralToken.token_type == "camera_stream",
+                AuthEphemeralToken.expires_at < now,
+            )
+        )
+        db.add(
+            AuthEphemeralToken(
+                token=token,
+                token_type="camera_stream",
+                expires_at=expires_at,
+            )
+        )
+        await db.commit()
     return token
     return token
 
 
 
 
-def verify_camera_stream_token(token: str) -> bool:
-    """Verify a camera stream token is valid."""
-    expiry = _camera_stream_tokens.get(token)
-    if not expiry:
-        return False
-    if datetime.now(timezone.utc) > expiry:
-        del _camera_stream_tokens[token]
-        return False
-    return True
+async def verify_camera_stream_token(token: str) -> bool:
+    """Verify a camera stream token is valid (reusable — does not consume it)."""
+    now = datetime.now(timezone.utc)
+    async with async_session() as db:
+        result = await db.execute(
+            select(AuthEphemeralToken).where(
+                AuthEphemeralToken.token == token,
+                AuthEphemeralToken.token_type == "camera_stream",
+                AuthEphemeralToken.expires_at > now,
+            )
+        )
+        return result.scalar_one_or_none() is not None
 
 
 
 
 def verify_password(plain_password: str, hashed_password: str) -> bool:
 def verify_password(plain_password: str, hashed_password: str) -> bool:
@@ -185,17 +225,73 @@ def get_password_hash(password: str) -> str:
 
 
 
 
 def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
 def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
-    """Create a JWT access token."""
+    """Create a JWT access token with jti (revocation) and iat (freshness) claims."""
     to_encode = data.copy()
     to_encode = data.copy()
+    now = datetime.now(timezone.utc)
     if expires_delta:
     if expires_delta:
-        expire = datetime.now(timezone.utc) + expires_delta
+        expire = now + expires_delta
     else:
     else:
-        expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
-    to_encode.update({"exp": expire})
+        expire = now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
+    jti = secrets.token_hex(16)
+    to_encode.update({"exp": expire, "jti": jti, "iat": now})
     encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
     encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
     return encoded_jwt
     return encoded_jwt
 
 
 
 
+def _is_token_fresh(iat: int | float | None, user: User) -> bool:
+    """Return False if the token was issued before the user's last password change.
+
+    Used to invalidate all sessions after a password reset/change (M-R7-B).
+    All tokens without an iat claim are unconditionally rejected — every token
+    issued by this server carries iat, so absence means the token is forged or
+    from a pre-iat code path whose max TTL (24 h) has long since expired.
+    """
+    if iat is None:
+        return False
+    if not hasattr(user, "password_changed_at") or user.password_changed_at is None:
+        return True  # No password change recorded yet (I2 migration handles this)
+    token_issued_at = datetime.fromtimestamp(iat, tz=timezone.utc)
+    pca = user.password_changed_at
+    if pca.tzinfo is None:
+        pca = pca.replace(tzinfo=timezone.utc)
+    # JWT iat is whole seconds; truncate pca so tokens issued in the same second pass.
+    pca = pca.replace(microsecond=0)
+    return token_issued_at >= pca
+
+
+async def revoke_jti(jti: str, expires_at: datetime, username: str | None = None) -> None:
+    """Store a revoked JWT jti so it is rejected on future requests.
+
+    Silently ignores duplicate inserts (e.g. double-logout with the same token).
+    """
+    from sqlalchemy.exc import IntegrityError
+
+    async with async_session() as db:
+        revoked = AuthEphemeralToken(
+            token=jti,
+            token_type="revoked_jti",
+            username=username,
+            expires_at=expires_at,
+        )
+        db.add(revoked)
+        try:
+            await db.commit()
+        except IntegrityError:
+            await db.rollback()  # jti already revoked — desired state, ignore
+
+
+async def is_jti_revoked(jti: str) -> bool:
+    """Return True if the given jti has been revoked."""
+    async with async_session() as db:
+        result = await db.execute(
+            select(AuthEphemeralToken).where(
+                AuthEphemeralToken.token == jti,
+                AuthEphemeralToken.token_type == "revoked_jti",
+            )
+        )
+        return result.scalar_one_or_none() is not None
+
+
 async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
 async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
     """Get a user by username (case-insensitive) with groups loaded for permission checks."""
     """Get a user by username (case-insensitive) with groups loaded for permission checks."""
     result = await db.execute(
     result = await db.execute(
@@ -216,12 +312,13 @@ async def authenticate_user(db: AsyncSession, username: str, password: str) -> U
     """Authenticate a user by username and password.
     """Authenticate a user by username and password.
 
 
     Username lookup is case-insensitive. Password is case-sensitive.
     Username lookup is case-insensitive. Password is case-sensitive.
+    LDAP and OIDC users must authenticate via their respective providers.
     """
     """
     user = await get_user_by_username(db, username)
     user = await get_user_by_username(db, username)
     if not user:
     if not user:
         return None
         return None
-    if getattr(user, "auth_source", "local") == "ldap":
-        return None  # LDAP users authenticate via LDAP, not local password
+    if getattr(user, "auth_source", "local") in ("ldap", "oidc"):
+        return None  # LDAP/OIDC users must authenticate via their provider
     if not user.password_hash or not verify_password(password, user.password_hash):
     if not user.password_hash or not verify_password(password, user.password_hash):
         return None
         return None
     if not user.is_active:
     if not user.is_active:
@@ -233,12 +330,13 @@ async def authenticate_user_by_email(db: AsyncSession, email: str, password: str
     """Authenticate a user by email and password.
     """Authenticate a user by email and password.
 
 
     Email lookup is case-insensitive. Password is case-sensitive.
     Email lookup is case-insensitive. Password is case-sensitive.
+    LDAP and OIDC users must authenticate via their respective providers.
     """
     """
     user = await get_user_by_email(db, email)
     user = await get_user_by_email(db, email)
     if not user:
     if not user:
         return None
         return None
-    if getattr(user, "auth_source", "local") == "ldap":
-        return None
+    if getattr(user, "auth_source", "local") in ("ldap", "oidc"):
+        return None  # LDAP/OIDC users must authenticate via their provider
     if not user.password_hash or not verify_password(password, user.password_hash):
     if not user.password_hash or not verify_password(password, user.password_hash):
         return None
         return None
     if not user.is_active:
     if not user.is_active:
@@ -262,10 +360,23 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
 async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | None:
 async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | None:
     """Validate an API key and return the APIKey object if valid, None otherwise.
     """Validate an API key and return the APIKey object if valid, None otherwise.
 
 
-    This is an internal helper used by auth functions to check API keys.
+    L-1: Pre-filter by key_prefix (first 8 chars) before running pbkdf2 so only
+    O(1) candidate rows are hashed instead of the full key table.  The prefix is
+    not secret (it is shown in the admin UI), so this does not reduce security.
     """
     """
     try:
     try:
-        result = await db.execute(select(APIKey).where(APIKey.enabled.is_(True)))
+        # key_prefix is stored as "<first-8-chars>..." (e.g. "bb_Abc12...").
+        # Matching on the first 8 chars of the submitted key reduces the scan to
+        # at most one row in practice (2^40 collision space for 5 base64 chars).
+        key_lookup = api_key_value[:8] if len(api_key_value) >= 8 else api_key_value
+        result = await db.execute(
+            select(APIKey).where(
+                APIKey.enabled.is_(True),
+                APIKey.key_prefix.like(
+                    key_lookup.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%", escape="\\"
+                ),
+            )
+        )
         api_keys = result.scalars().all()
         api_keys = result.scalars().all()
 
 
         for api_key in api_keys:
         for api_key in api_keys:
@@ -289,23 +400,40 @@ async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | No
 async def get_current_user_optional(
 async def get_current_user_optional(
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
 ) -> User | None:
 ) -> User | None:
-    """Get the current authenticated user from JWT token, or None if not authenticated."""
+    """Get the current authenticated user from JWT token, or None if not authenticated.
+
+    Returns None only when NO credentials are supplied.  If a token is supplied
+    but invalid/revoked, raises 401 — a revoked token must not grant anonymous
+    access (I6).
+    """
     if credentials is None:
     if credentials is None:
         return None
         return None
 
 
+    _unauthorized = HTTPException(
+        status_code=status.HTTP_401_UNAUTHORIZED,
+        detail="Could not validate credentials",
+        headers={"WWW-Authenticate": "Bearer"},
+    )
+
     try:
     try:
         token = credentials.credentials
         token = credentials.credentials
         payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
         payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
         username: str = payload.get("sub")
         username: str = payload.get("sub")
         if username is None:
         if username is None:
-            return None
+            raise _unauthorized
+        jti: str | None = payload.get("jti")
+        if not jti or await is_jti_revoked(jti):
+            raise _unauthorized  # I6: revoked token → 401, not anonymous
+        iat: int | float | None = payload.get("iat")
     except JWTError:
     except JWTError:
-        return None
+        raise _unauthorized
 
 
     async with async_session() as db:
     async with async_session() as db:
         user = await get_user_by_username(db, username)
         user = await get_user_by_username(db, username)
         if user is None or not user.is_active:
         if user is None or not user.is_active:
-            return None
+            raise _unauthorized
+        if not _is_token_fresh(iat, user):
+            raise _unauthorized
         return user
         return user
 
 
 
 
@@ -326,6 +454,10 @@ async def get_current_user(
         username: str = payload.get("sub")
         username: str = payload.get("sub")
         if username is None:
         if username is None:
             raise credentials_exception
             raise credentials_exception
+        jti: str | None = payload.get("jti")
+        if not jti or await is_jti_revoked(jti):
+            raise credentials_exception
+        iat: int | float | None = payload.get("iat")
     except JWTError:
     except JWTError:
         raise credentials_exception
         raise credentials_exception
 
 
@@ -338,6 +470,8 @@ async def get_current_user(
                 status_code=status.HTTP_403_FORBIDDEN,
                 status_code=status.HTTP_403_FORBIDDEN,
                 detail="User account is disabled",
                 detail="User account is disabled",
             )
             )
+        if not _is_token_fresh(iat, user):
+            raise credentials_exception
         return user
         return user
 
 
 
 
@@ -390,6 +524,14 @@ async def require_auth_if_enabled(
                         detail="Could not validate credentials",
                         detail="Could not validate credentials",
                         headers={"WWW-Authenticate": "Bearer"},
                         headers={"WWW-Authenticate": "Bearer"},
                     )
                     )
+                jti: str | None = payload.get("jti")
+                if not jti or await is_jti_revoked(jti):
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+                iat: int | float | None = payload.get("iat")
             except JWTError:
             except JWTError:
                 raise HTTPException(
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED,
                     status_code=status.HTTP_401_UNAUTHORIZED,
@@ -404,6 +546,12 @@ async def require_auth_if_enabled(
                     detail="Could not validate credentials",
                     detail="Could not validate credentials",
                     headers={"WWW-Authenticate": "Bearer"},
                     headers={"WWW-Authenticate": "Bearer"},
                 )
                 )
+            if not _is_token_fresh(iat, user):
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
             return user
             return user
 
 
         # No credentials provided
         # No credentials provided
@@ -483,8 +631,18 @@ async def get_api_key(
             detail="API key required. Provide 'X-API-Key' header or 'Authorization: Bearer <key>'",
             detail="API key required. Provide 'X-API-Key' header or 'Authorization: Bearer <key>'",
         )
         )
 
 
-    # Get all API keys and check them
-    result = await db.execute(select(APIKey).where(APIKey.enabled.is_(True)))
+    # M-NEW-2: Pre-filter by key_prefix (first 8 chars) to avoid O(n) pbkdf2 over all
+    # enabled keys — same fix as in _validate_api_key (L-1 from previous review).
+    key_lookup = api_key_value[:8] if len(api_key_value) >= 8 else api_key_value
+    result = await db.execute(
+        select(APIKey).where(
+            APIKey.enabled.is_(True),
+            APIKey.key_prefix.like(
+                key_lookup.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%",
+                escape="\\",
+            ),
+        )
+    )
     api_keys = result.scalars().all()
     api_keys = result.scalars().all()
 
 
     for api_key in api_keys:
     for api_key in api_keys:
@@ -627,12 +785,18 @@ def require_permission(*permissions: str | Permission):
                 username: str = payload.get("sub")
                 username: str = payload.get("sub")
                 if username is None:
                 if username is None:
                     raise credentials_exception
                     raise credentials_exception
+                jti: str | None = payload.get("jti")
+                if not jti or await is_jti_revoked(jti):
+                    raise credentials_exception
+                iat: int | float | None = payload.get("iat")
             except JWTError:
             except JWTError:
                 raise credentials_exception
                 raise credentials_exception
 
 
             user = await get_user_by_username(db, username)
             user = await get_user_by_username(db, username)
             if user is None or not user.is_active:
             if user is None or not user.is_active:
                 raise credentials_exception
                 raise credentials_exception
+            if not _is_token_fresh(iat, user):
+                raise credentials_exception
 
 
             if not user.has_all_permissions(*perm_strings):
             if not user.has_all_permissions(*perm_strings):
                 raise HTTPException(
                 raise HTTPException(
@@ -699,6 +863,14 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
                             detail="Could not validate credentials",
                             detail="Could not validate credentials",
                             headers={"WWW-Authenticate": "Bearer"},
                             headers={"WWW-Authenticate": "Bearer"},
                         )
                         )
+                    jti: str | None = payload.get("jti")
+                    if not jti or await is_jti_revoked(jti):
+                        raise HTTPException(
+                            status_code=status.HTTP_401_UNAUTHORIZED,
+                            detail="Could not validate credentials",
+                            headers={"WWW-Authenticate": "Bearer"},
+                        )
+                    iat: int | float | None = payload.get("iat")
                 except JWTError:
                 except JWTError:
                     raise HTTPException(
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         status_code=status.HTTP_401_UNAUTHORIZED,
@@ -713,6 +885,12 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
                         detail="Could not validate credentials",
                         detail="Could not validate credentials",
                         headers={"WWW-Authenticate": "Bearer"},
                         headers={"WWW-Authenticate": "Bearer"},
                     )
                     )
+                if not _is_token_fresh(iat, user):
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
 
 
                 if not user.has_all_permissions(*perm_strings):
                 if not user.has_all_permissions(*perm_strings):
                     raise HTTPException(
                     raise HTTPException(
@@ -753,7 +931,7 @@ def require_camera_stream_token_if_auth_enabled():
         async with async_session() as db:
         async with async_session() as db:
             if not await is_auth_enabled(db):
             if not await is_auth_enabled(db):
                 return  # Auth disabled, allow access
                 return  # Auth disabled, allow access
-        if not token or not verify_camera_stream_token(token):
+        if not token or not await verify_camera_stream_token(token):
             raise HTTPException(
             raise HTTPException(
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 detail="Valid camera stream token required. Obtain one from POST /api/v1/printers/camera/stream-token",
                 detail="Valid camera stream token required. Obtain one from POST /api/v1/printers/camera/stream-token",
@@ -828,6 +1006,14 @@ def require_ownership_permission(
                             detail="Could not validate credentials",
                             detail="Could not validate credentials",
                             headers={"WWW-Authenticate": "Bearer"},
                             headers={"WWW-Authenticate": "Bearer"},
                         )
                         )
+                    jti: str | None = payload.get("jti")
+                    if not jti or await is_jti_revoked(jti):
+                        raise HTTPException(
+                            status_code=status.HTTP_401_UNAUTHORIZED,
+                            detail="Could not validate credentials",
+                            headers={"WWW-Authenticate": "Bearer"},
+                        )
+                    iat: int | float | None = payload.get("iat")
                 except JWTError:
                 except JWTError:
                     raise HTTPException(
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         status_code=status.HTTP_401_UNAUTHORIZED,
@@ -842,6 +1028,12 @@ def require_ownership_permission(
                         detail="Could not validate credentials",
                         detail="Could not validate credentials",
                         headers={"WWW-Authenticate": "Bearer"},
                         headers={"WWW-Authenticate": "Bearer"},
                     )
                     )
+                if not _is_token_fresh(iat, user):
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
 
 
                 if user.has_permission(all_perm):
                 if user.has_permission(all_perm):
                     return user, True
                     return user, True

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

@@ -156,6 +156,7 @@ async def init_db():
         ams_label,
         ams_label,
         api_key,
         api_key,
         archive,
         archive,
+        auth_ephemeral,
         bug_report,
         bug_report,
         color_catalog,
         color_catalog,
         external_link,
         external_link,
@@ -168,6 +169,7 @@ async def init_db():
         maintenance,
         maintenance,
         notification,
         notification,
         notification_template,
         notification_template,
+        oidc_provider,
         orca_base_cache,
         orca_base_cache,
         pending_upload,
         pending_upload,
         print_batch,
         print_batch,
@@ -188,6 +190,8 @@ async def init_db():
         spoolbuddy_device,
         spoolbuddy_device,
         user,
         user,
         user_email_pref,
         user_email_pref,
+        user_otp_code,
+        user_totp,
         virtual_printer,
         virtual_printer,
     )
     )
 
 
@@ -306,6 +310,19 @@ async def run_migrations(conn):
     except (OperationalError, ProgrammingError):
     except (OperationalError, ProgrammingError):
         pass  # Already applied
         pass  # Already applied
 
 
+    # Migration: Enforce uniqueness on user_oidc_links for existing rows.
+    # create_all() is idempotent and does not add constraints to existing tables,
+    # so we create covering unique indexes explicitly here.
+    await _safe_execute(
+        conn,
+        "CREATE UNIQUE INDEX IF NOT EXISTS uq_oidc_link_provider_sub"
+        " ON user_oidc_links (provider_id, provider_user_id)",
+    )
+    await _safe_execute(
+        conn,
+        "CREATE UNIQUE INDEX IF NOT EXISTS uq_oidc_link_user_provider ON user_oidc_links (user_id, provider_id)",
+    )
+
     # Migration: Create FTS5 virtual table for archive full-text search (SQLite only)
     # Migration: Create FTS5 virtual table for archive full-text search (SQLite only)
     # PostgreSQL uses tsvector + GIN index instead (set up in archives.py search route)
     # PostgreSQL uses tsvector + GIN index instead (set up in archives.py search route)
     if is_sqlite():
     if is_sqlite():
@@ -1439,6 +1456,33 @@ async def run_migrations(conn):
         "ON smart_plug_energy_snapshots(plug_id, recorded_at)",
         "ON smart_plug_energy_snapshots(plug_id, recorded_at)",
     )
     )
 
 
+    # Migration: Add PKCE code_verifier column to auth_ephemeral_tokens
+    await _safe_execute(conn, "ALTER TABLE auth_ephemeral_tokens ADD COLUMN code_verifier VARCHAR(128)")
+
+    # Migration: Add TOTP replay-protection counter to user_totp
+    await _safe_execute(conn, "ALTER TABLE user_totp ADD COLUMN last_totp_counter BIGINT")
+
+    # Migration: Add challenge_id for pre-auth token client binding (HttpOnly cookie)
+    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")
+
+    # Migration: Add password_changed_at to users (M-R7-B)
+    # Tracks the last time a user's password was changed/reset.  JWTs whose iat
+    # predates this timestamp are rejected in all six auth validation paths.
+    await _safe_execute(conn, "ALTER TABLE users ADD COLUMN password_changed_at DATETIME")
+
+    # Migration: Back-fill password_changed_at = created_at for existing users (I2).
+    # Users who never changed their password would have NULL here, meaning old
+    # 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",
+    )
+
     # Seed default settings keys that must exist on fresh install
     # Seed default settings keys that must exist on fresh install
     default_settings = [
     default_settings = [
         ("advanced_auth_enabled", "false"),
         ("advanced_auth_enabled", "false"),

+ 88 - 0
backend/app/core/encryption.py

@@ -0,0 +1,88 @@
+"""At-rest encryption for high-value secrets (TOTP keys, OIDC client_secret).
+
+Set the ``MFA_ENCRYPTION_KEY`` environment variable to a URL-safe base64-encoded
+32-byte key (generate with ``python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"``)
+to enable Fernet symmetric encryption.
+
+When the key is not set, values are stored as plaintext and a warning is emitted.
+Existing plaintext values are read back correctly even after the key is added
+(values without the ``fernet:`` prefix are treated as legacy plaintext).
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+
+logger = logging.getLogger(__name__)
+
+_FERNET_PREFIX = "fernet:"
+_fernet_instance = None
+_warn_shown = False
+
+
+def _get_fernet():
+    global _fernet_instance, _warn_shown
+
+    if _fernet_instance is not None:
+        return _fernet_instance
+
+    key = os.environ.get("MFA_ENCRYPTION_KEY")
+    if key:
+        from cryptography.fernet import Fernet
+
+        _fernet_instance = Fernet(key.encode() if isinstance(key, str) else key)
+        return _fernet_instance
+
+    if not _warn_shown:
+        logger.warning(
+            "MFA_ENCRYPTION_KEY is not set — TOTP secrets and OIDC client_secrets are "
+            "stored in plaintext. Generate a key with: "
+            'python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
+        )
+        _warn_shown = True
+    return None
+
+
+def mfa_encrypt(plaintext: str) -> str:
+    """Encrypt a secret value. Returns the ciphertext with a ``fernet:`` prefix,
+    or the original plaintext if ``MFA_ENCRYPTION_KEY`` is not configured."""
+    f = _get_fernet()
+    if f is None:
+        return plaintext
+    return _FERNET_PREFIX + f.encrypt(plaintext.encode()).decode()
+
+
+def mfa_decrypt(value: str) -> str:
+    """Decrypt a value previously encrypted with ``mfa_encrypt``.
+
+    Values without the ``fernet:`` prefix are returned as-is (legacy plaintext).
+    Raises ``RuntimeError`` if the prefix is present but no key is configured.
+    """
+    if not value.startswith(_FERNET_PREFIX):
+        # Nit6: Warn when a key IS configured but the stored value is plaintext.
+        # This surfaces rows that were written before encryption was enabled so
+        # operators know they need a migration / re-enroll cycle.
+        if _get_fernet() is not None:
+            logger.warning(
+                "mfa_decrypt: MFA_ENCRYPTION_KEY is set but the stored value has no "
+                "'fernet:' prefix — returning legacy plaintext. Consider re-enrolling "
+                "this secret to store it encrypted."
+            )
+        return value  # Legacy plaintext — backward compatible
+
+    f = _get_fernet()
+    if f is None:
+        raise RuntimeError(
+            "MFA_ENCRYPTION_KEY must be set to decrypt MFA secrets that were stored with encryption enabled."
+        )
+    from cryptography.fernet import InvalidToken
+
+    try:
+        return f.decrypt(value[len(_FERNET_PREFIX) :].encode()).decode()
+    except InvalidToken:
+        raise RuntimeError(
+            "MFA secret was encrypted under a different MFA_ENCRYPTION_KEY. "
+            "Key rotation is not currently supported — restore the previous key "
+            "or have users re-enroll."
+        )

+ 158 - 4
backend/app/main.py

@@ -32,6 +32,7 @@ from backend.app.api.routes import (
     local_presets,
     local_presets,
     maintenance,
     maintenance,
     metrics,
     metrics,
+    mfa,
     notification_templates,
     notification_templates,
     notifications,
     notifications,
     obico,
     obico,
@@ -3741,6 +3742,101 @@ def stop_expected_prints_cleanup() -> None:
         logging.getLogger(__name__).info("Expected prints cleanup stopped")
         logging.getLogger(__name__).info("Expected prints cleanup stopped")
 
 
 
 
+# ---------------------------------------------------------------------------
+# L-2: Periodic auth-token cleanup (stale TOTP + expired revoked JTIs)
+# ---------------------------------------------------------------------------
+
+_auth_cleanup_task: asyncio.Task | None = None
+_AUTH_CLEANUP_INTERVAL = 3600  # seconds (hourly)
+
+
+async def _run_auth_cleanup() -> None:
+    """Single cleanup pass: remove stale TOTP records, expired revoked JTIs, and old rate-limit events."""
+    from backend.app.core.database import async_session
+    from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent
+    from backend.app.models.user_totp import UserTOTP
+
+    now = datetime.now(timezone.utc)
+
+    # Remove unconfirmed (is_enabled=False) TOTP records older than 1 hour.
+    try:
+        async with async_session() as db:
+            stale_cutoff = now - timedelta(hours=1)
+            result = await db.execute(
+                select(UserTOTP).where(
+                    UserTOTP.is_enabled.is_(False),
+                    UserTOTP.created_at < stale_cutoff,
+                )
+            )
+            stale_records = result.scalars().all()
+            if stale_records:
+                for rec in stale_records:
+                    await db.delete(rec)
+                await db.commit()
+                logging.info("Auth cleanup: removed %d stale unconfirmed TOTP record(s)", len(stale_records))
+    except Exception as e:
+        logging.warning("Auth cleanup: failed to purge stale TOTP records: %s", e)
+
+    # Remove expired revoked-JTI entries (they are no longer needed once the
+    # original token's exp has passed — the token would be rejected by JWT
+    # signature verification regardless).
+    try:
+        async with async_session() as db:
+            await db.execute(
+                delete(AuthEphemeralToken).where(
+                    AuthEphemeralToken.token_type == "revoked_jti",
+                    AuthEphemeralToken.expires_at < now,
+                )
+            )
+            await db.commit()
+    except Exception as e:
+        logging.warning("Auth cleanup: failed to purge expired revoked JTIs: %s", e)
+
+    # L-R6-B: Purge AuthRateLimitEvent rows older than the lockout window (15 min).
+    # Events outside this window can never affect rate-limit decisions — they only
+    # consume DB space.  Use the same window constant as the rate limiter so the
+    # two are always in sync.
+    try:
+        from backend.app.api.routes.mfa import LOCKOUT_WINDOW
+
+        async with async_session() as db:
+            await db.execute(
+                delete(AuthRateLimitEvent).where(
+                    AuthRateLimitEvent.occurred_at < now - LOCKOUT_WINDOW,
+                )
+            )
+            await db.commit()
+    except Exception as e:
+        logging.warning("Auth cleanup: failed to purge stale rate-limit events: %s", e)
+
+
+async def _auth_cleanup_loop() -> None:
+    """Periodic background task: run auth cleanup every hour."""
+    while True:
+        try:
+            await _run_auth_cleanup()
+        except asyncio.CancelledError:
+            break
+        except Exception as e:
+            logging.warning("Auth cleanup loop error: %s", e)
+        await asyncio.sleep(_AUTH_CLEANUP_INTERVAL)
+
+
+def start_auth_cleanup() -> None:
+    global _auth_cleanup_task
+    if _auth_cleanup_task is None:
+        _auth_cleanup_task = asyncio.create_task(_auth_cleanup_loop())
+        logging.getLogger(__name__).info("Auth periodic cleanup started")
+
+
+def stop_auth_cleanup() -> None:
+    global _auth_cleanup_task
+    if _auth_cleanup_task:
+        _auth_cleanup_task.cancel()
+        _auth_cleanup_task = None
+        logging.getLogger(__name__).info("Auth periodic cleanup stopped")
+
+
 @asynccontextmanager
 @asynccontextmanager
 async def lifespan(app: FastAPI):
 async def lifespan(app: FastAPI):
     # Startup
     # Startup
@@ -3942,6 +4038,9 @@ async def lifespan(app: FastAPI):
     # registered but on_print_start never fires)
     # registered but on_print_start never fires)
     start_expected_prints_cleanup()
     start_expected_prints_cleanup()
 
 
+    # L-2: Start periodic auth cleanup (stale TOTP + expired revoked JTIs)
+    start_auth_cleanup()
+
     # Initialize virtual printer manager and sync from DB
     # Initialize virtual printer manager and sync from DB
     from backend.app.services.virtual_printer import virtual_printer_manager
     from backend.app.services.virtual_printer import virtual_printer_manager
 
 
@@ -3967,6 +4066,7 @@ async def lifespan(app: FastAPI):
     stop_spoolbuddy_watchdog()
     stop_spoolbuddy_watchdog()
     stop_camera_cleanup()
     stop_camera_cleanup()
     stop_expected_prints_cleanup()
     stop_expected_prints_cleanup()
+    stop_auth_cleanup()
     printer_manager.disconnect_all()
     printer_manager.disconnect_all()
     await close_spoolman_client()
     await close_spoolman_client()
 
 
@@ -4010,6 +4110,14 @@ PUBLIC_API_ROUTES = {
     # Advanced auth status needed for login page
     # Advanced auth status needed for login page
     "/api/v1/auth/advanced-auth/status",
     "/api/v1/auth/advanced-auth/status",
     "/api/v1/auth/forgot-password",  # Password reset for advanced auth
     "/api/v1/auth/forgot-password",  # Password reset for advanced auth
+    "/api/v1/auth/forgot-password/confirm",  # Complete password reset with token (H-6)
+    # 2FA routes that are called BEFORE a JWT is issued (pre-auth flow)
+    "/api/v1/auth/2fa/verify",  # Exchange pre_auth_token + 2FA code for JWT
+    "/api/v1/auth/2fa/email/send",  # Send OTP email (pre_auth_token based)
+    # OIDC routes that must be reachable without a JWT
+    "/api/v1/auth/oidc/providers",  # Public list of enabled providers
+    "/api/v1/auth/oidc/callback",  # Redirect target from OIDC provider
+    "/api/v1/auth/oidc/exchange",  # Exchange short-lived OIDC token for JWT
     # Version check for updates (no sensitive data)
     # Version check for updates (no sensitive data)
     "/api/v1/updates/version",
     "/api/v1/updates/version",
     # Metrics endpoint handles its own prometheus_token authentication
     # Metrics endpoint handles its own prometheus_token authentication
@@ -4020,6 +4128,8 @@ PUBLIC_API_ROUTES = {
 PUBLIC_API_PREFIXES = [
 PUBLIC_API_PREFIXES = [
     # WebSocket connections handle their own auth
     # WebSocket connections handle their own auth
     "/api/v1/ws",
     "/api/v1/ws",
+    # OIDC authorize redirects — include provider_id in path
+    "/api/v1/auth/oidc/authorize/",
 ]
 ]
 
 
 # Route patterns that are public (read-only display data)
 # Route patterns that are public (read-only display data)
@@ -4053,6 +4163,27 @@ async def security_headers_middleware(request, call_next):
     response.headers["X-Content-Type-Options"] = "nosniff"
     response.headers["X-Content-Type-Options"] = "nosniff"
     response.headers["X-Frame-Options"] = "SAMEORIGIN"
     response.headers["X-Frame-Options"] = "SAMEORIGIN"
     response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
     response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
+    # Content-Security-Policy for the React SPA.
+    # Notes:
+    #   - 'unsafe-inline' for style-src: React and UI libs inject inline styles at runtime.
+    #   - connect-src ws:/wss:: MQTT/printer WebSocket connections.
+    #   - img-src data: / blob:: base64 thumbnails and Blob-URL timelapse previews.
+    #   - media-src blob:: timelapse video player uses Blob URLs.
+    #   - font-src data:: some icon fonts are embedded as data URIs.
+    response.headers["Content-Security-Policy"] = (
+        "default-src 'self'; "
+        "script-src 'self'; "
+        "style-src 'self' 'unsafe-inline'; "
+        "img-src 'self' data: blob:; "
+        "media-src 'self' blob:; "
+        "connect-src 'self' ws: wss:; "
+        "font-src 'self' data:; "
+        "object-src 'none'; "
+        "base-uri 'self'; "
+        "frame-ancestors 'none';"
+    )
+    if request.url.scheme == "https":
+        response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
     return response
     return response
 
 
 
 
@@ -4121,18 +4252,34 @@ async def auth_middleware(request, call_next):
     import jwt
     import jwt
 
 
     try:
     try:
-        from backend.app.core.auth import ALGORITHM, SECRET_KEY
+        from backend.app.core.auth import (
+            ALGORITHM,
+            SECRET_KEY,
+            _is_token_fresh,
+            get_user_by_username,
+            is_jti_revoked,
+        )
 
 
         token = auth_header.replace("Bearer ", "")
         token = auth_header.replace("Bearer ", "")
         payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
         payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
         username = payload.get("sub")
         username = payload.get("sub")
         if not username:
         if not username:
             raise ValueError("No username in token")
             raise ValueError("No username in token")
+        jti = payload.get("jti")
+        if not jti:
+            raise ValueError("No jti in token")
+        iat = payload.get("iat")
+
+        # Reject revoked tokens (defense-in-depth gateway check)
+        if await is_jti_revoked(jti):
+            return JSONResponse(
+                status_code=401,
+                content={"detail": "Token has been revoked"},
+                headers={"WWW-Authenticate": "Bearer"},
+            )
 
 
-        # Verify user exists and is active
+        # Verify user exists, is active, and token is still fresh (L-R8-A)
         async with async_session() as db:
         async with async_session() as db:
-            from backend.app.core.auth import get_user_by_username
-
             user = await get_user_by_username(db, username)
             user = await get_user_by_username(db, username)
             if not user or not user.is_active:
             if not user or not user.is_active:
                 return JSONResponse(
                 return JSONResponse(
@@ -4140,6 +4287,12 @@ async def auth_middleware(request, call_next):
                     content={"detail": "User not found or inactive"},
                     content={"detail": "User not found or inactive"},
                     headers={"WWW-Authenticate": "Bearer"},
                     headers={"WWW-Authenticate": "Bearer"},
                 )
                 )
+            if not _is_token_fresh(iat, user):
+                return JSONResponse(
+                    status_code=401,
+                    content={"detail": "Token no longer valid"},
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
     except jwt.ExpiredSignatureError:
     except jwt.ExpiredSignatureError:
         return JSONResponse(
         return JSONResponse(
             status_code=401,
             status_code=401,
@@ -4158,6 +4311,7 @@ async def auth_middleware(request, call_next):
 
 
 # API routes
 # API routes
 app.include_router(auth.router, prefix=app_settings.api_prefix)
 app.include_router(auth.router, prefix=app_settings.api_prefix)
+app.include_router(mfa.router, prefix=app_settings.api_prefix)
 app.include_router(bug_report.router, prefix=app_settings.api_prefix)
 app.include_router(bug_report.router, prefix=app_settings.api_prefix)
 app.include_router(users.router, prefix=app_settings.api_prefix)
 app.include_router(users.router, prefix=app_settings.api_prefix)
 app.include_router(groups.router, prefix=app_settings.api_prefix)
 app.include_router(groups.router, prefix=app_settings.api_prefix)

+ 10 - 0
backend/app/models/__init__.py

@@ -2,6 +2,7 @@ from backend.app.models.ams_history import AMSSensorHistory
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
+from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent
 from backend.app.models.color_catalog import ColorCatalogEntry
 from backend.app.models.color_catalog import ColorCatalogEntry
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
@@ -12,6 +13,7 @@ from backend.app.models.local_preset import LocalPreset
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.notification import NotificationLog
 from backend.app.models.notification import NotificationLog
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.notification_template import NotificationTemplate
+from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
 from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.print_batch import PrintBatch
 from backend.app.models.print_batch import PrintBatch
@@ -28,6 +30,8 @@ from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.models.user_email_pref import UserEmailPreference
 from backend.app.models.user_email_pref import UserEmailPreference
+from backend.app.models.user_otp_code import UserOTPCode
+from backend.app.models.user_totp import UserTOTP
 
 
 __all__ = [
 __all__ = [
     "Printer",
     "Printer",
@@ -56,6 +60,8 @@ __all__ = [
     "GitHubBackupConfig",
     "GitHubBackupConfig",
     "GitHubBackupLog",
     "GitHubBackupLog",
     "LocalPreset",
     "LocalPreset",
+    "OIDCProvider",
+    "UserOIDCLink",
     "OrcaBaseProfile",
     "OrcaBaseProfile",
     "Spool",
     "Spool",
     "SpoolKProfile",
     "SpoolKProfile",
@@ -65,4 +71,8 @@ __all__ = [
     "ColorCatalogEntry",
     "ColorCatalogEntry",
     "SpoolBuddyDevice",
     "SpoolBuddyDevice",
     "UserEmailPreference",
     "UserEmailPreference",
+    "UserOTPCode",
+    "UserTOTP",
+    "AuthEphemeralToken",
+    "AuthRateLimitEvent",
 ]
 ]

+ 199 - 0
backend/app/models/auth_ephemeral.py

@@ -0,0 +1,199 @@
+"""Ephemeral authentication tokens and rate-limit events.
+
+These tables replace the module-level in-memory dicts in mfa.py, making
+the 2FA / OIDC flow compatible with multi-worker deployments and persistent
+across server restarts.
+
+Tables
+------
+AuthEphemeralToken
+    Short-lived, single-use tokens for:
+    - pre_auth   : issued after password check, consumed when 2FA is verified
+    - oidc_state : CSRF nonce for the OIDC authorization-code flow
+    - oidc_exchange : short bridge token from the OIDC callback to the SPA
+
+AuthRateLimitEvent
+    Timestamped events used for sliding-window rate limiting:
+    - 2fa_attempt  : each failed 2FA verification attempt
+    - email_send   : each OTP email sent (prevents email flooding)
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from enum import Enum
+
+from sqlalchemy import DateTime, Integer, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class TokenType(str, Enum):
+    """T3: Enumerated token types for AuthEphemeralToken.token_type.
+
+    Using str-based Enum keeps the stored values human-readable and
+    backward-compatible with existing rows.
+    """
+
+    PRE_AUTH = "pre_auth"
+    OIDC_STATE = "oidc_state"
+    OIDC_EXCHANGE = "oidc_exchange"
+    PASSWORD_RESET = "password_reset"
+    EMAIL_OTP_SETUP = "email_otp_setup"
+    SLICER_DOWNLOAD = "slicer_download"
+
+
+class EventType(str, Enum):
+    """T3: Enumerated event types for AuthRateLimitEvent.event_type.
+
+    Using str-based Enum keeps the stored values human-readable and
+    backward-compatible with existing rows.
+    """
+
+    TWO_FA_ATTEMPT = "2fa_attempt"
+    EMAIL_SEND = "email_send"
+    LOGIN_ATTEMPT = "login_attempt"
+    LOGIN_IP = "login_ip"
+    PASSWORD_RESET_SEND = "password_reset_send"
+    PASSWORD_RESET_IP = "password_reset_ip"
+
+
+class AuthEphemeralToken(Base):
+    """Single-use, time-limited token for pre-auth / OIDC flows."""
+
+    __tablename__ = "auth_ephemeral_tokens"
+
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+    token: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
+    token_type: Mapped[str] = mapped_column(String(20), nullable=False)  # 'pre_auth' | 'oidc_state' | 'oidc_exchange'
+
+    # pre_auth + oidc_exchange: which user this session belongs to
+    username: Mapped[str | None] = mapped_column(String(150), nullable=True)
+
+    # oidc_state: which provider initiated the flow
+    provider_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+
+    # oidc_state: replay-protection nonce embedded in the ID token
+    nonce: Mapped[str | None] = mapped_column(String(128), nullable=True)
+
+    # oidc_state: PKCE code verifier (S256 method)
+    code_verifier: Mapped[str | None] = mapped_column(String(128), nullable=True)
+
+    # pre_auth: HttpOnly cookie value bound to this token to prevent token theft
+    # (XSS can read JS memory but cannot read HttpOnly cookies).
+    challenge_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
+
+    expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        default=lambda: datetime.now(timezone.utc),
+    )
+
+    # ------------------------------------------------------------------
+    # T1: Classmethod factories — enforce required fields per token type
+    # and prevent accidentally leaving optional fields at their defaults.
+    # ------------------------------------------------------------------
+
+    @classmethod
+    def new_pre_auth(
+        cls,
+        token: str,
+        username: str,
+        expires_at: datetime,
+        challenge_id: str | None = None,
+    ) -> AuthEphemeralToken:
+        """Create a pre-auth token (issued after password check, before 2FA)."""
+        return cls(
+            token=token,
+            token_type=TokenType.PRE_AUTH,
+            username=username,
+            expires_at=expires_at,
+            challenge_id=challenge_id,
+        )
+
+    @classmethod
+    def new_oidc_state(
+        cls,
+        token: str,
+        provider_id: int,
+        nonce: str,
+        code_verifier: str,
+        expires_at: datetime,
+    ) -> AuthEphemeralToken:
+        """Create an OIDC state token (CSRF protection + PKCE for authorize redirect)."""
+        return cls(
+            token=token,
+            token_type=TokenType.OIDC_STATE,
+            provider_id=provider_id,
+            nonce=nonce,
+            code_verifier=code_verifier,
+            expires_at=expires_at,
+        )
+
+    @classmethod
+    def new_oidc_exchange(
+        cls,
+        token: str,
+        username: str,
+        expires_at: datetime,
+    ) -> AuthEphemeralToken:
+        """Create an OIDC exchange token (bridge from callback to SPA)."""
+        return cls(
+            token=token,
+            token_type=TokenType.OIDC_EXCHANGE,
+            username=username,
+            expires_at=expires_at,
+        )
+
+    @classmethod
+    def new_password_reset(
+        cls,
+        token: str,
+        username: str,
+        expires_at: datetime,
+    ) -> AuthEphemeralToken:
+        """Create a password-reset token (single-use link emailed to the user)."""
+        return cls(
+            token=token,
+            token_type=TokenType.PASSWORD_RESET,
+            username=username,
+            expires_at=expires_at,
+        )
+
+    @classmethod
+    def new_email_otp_setup(
+        cls,
+        token: str,
+        username: str,
+        code_hash: str,
+        expires_at: datetime,
+    ) -> AuthEphemeralToken:
+        """Create an email-OTP setup token.
+
+        The ``code_hash`` is stored in the ``nonce`` column (field reuse
+        documented inline in the enable_email_otp endpoint).
+        """
+        return cls(
+            token=token,
+            token_type=TokenType.EMAIL_OTP_SETUP,
+            username=username,
+            nonce=code_hash,
+            expires_at=expires_at,
+        )
+
+
+class AuthRateLimitEvent(Base):
+    """Timestamped events used for sliding-window rate limiting."""
+
+    __tablename__ = "auth_rate_limit_events"
+
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+    username: Mapped[str] = mapped_column(String(150), nullable=False, index=True)
+    event_type: Mapped[str] = mapped_column(String(20), nullable=False)  # '2fa_attempt' | 'email_send'
+    occurred_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True),
+        nullable=False,
+        default=lambda: datetime.now(timezone.utc),
+    )

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

@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+from backend.app.core.encryption import mfa_decrypt, mfa_encrypt
+
+
+class OIDCProvider(Base):
+    """OpenID Connect provider configuration.
+
+    Supports any standards-compliant OIDC provider such as PocketID,
+    Authentik, Keycloak, Authelia, Google, etc.
+
+    The issuer_url must point to the root issuer (e.g. ``https://id.example.com``).
+    The OIDC discovery document is fetched from
+    ``{issuer_url}/.well-known/openid-configuration`` at runtime.
+    """
+
+    __tablename__ = "oidc_providers"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    # Human-readable name shown on the login button (e.g. "PocketID", "Google")
+    name: Mapped[str] = mapped_column(String(100), unique=True)
+    # Full OIDC issuer URL (e.g. "https://id.example.com")
+    issuer_url: Mapped[str] = mapped_column(String(500))
+    client_id: Mapped[str] = mapped_column(String(255))
+    # Encrypted at rest when MFA_ENCRYPTION_KEY is set.
+    # Use .client_secret / .client_secret setter rather than _client_secret_enc directly.
+    _client_secret_enc: Mapped[str] = mapped_column("client_secret", String(512))
+
+    @property
+    def client_secret(self) -> str:
+        return mfa_decrypt(self._client_secret_enc)
+
+    @client_secret.setter
+    def client_secret(self, value: str) -> None:
+        self._client_secret_enc = mfa_encrypt(value)
+
+    # Space-separated scopes; must include "openid"
+    scopes: Mapped[str] = mapped_column(String(500), default="openid email profile")
+    is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
+    # When True, a new local user is created automatically on first OIDC login
+    auto_create_users: Mapped[bool] = mapped_column(Boolean, default=False)
+    # When True, an existing local user whose email matches the OIDC claim is
+    # automatically linked on first SSO login.  Default is False (conservative):
+    # 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)
+    # 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())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    # Relationship to linked user accounts
+    user_links: Mapped[list[UserOIDCLink]] = relationship(
+        "UserOIDCLink",
+        back_populates="provider",
+        cascade="all, delete-orphan",
+    )
+
+    def __repr__(self) -> str:
+        return f"<OIDCProvider {self.name!r}>"
+
+
+class UserOIDCLink(Base):
+    """Links a local Bambuddy user account to an identity at an OIDC provider."""
+
+    __tablename__ = "user_oidc_links"
+    __table_args__ = (
+        # T2: Prevent duplicate OIDC identities and duplicate provider links.
+        # (provider_id, provider_user_id) — one OIDC sub per provider maps to at most one local user.
+        UniqueConstraint("provider_id", "provider_user_id", name="uq_oidc_link_provider_sub"),
+        # (user_id, provider_id) — one local user can link to each provider at most once.
+        UniqueConstraint("user_id", "provider_id", name="uq_oidc_link_user_provider"),
+    )
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), index=True)
+    provider_id: Mapped[int] = mapped_column(Integer, ForeignKey("oidc_providers.id", ondelete="CASCADE"), index=True)
+    # The "sub" claim from the OIDC ID token — stable identifier for the user
+    provider_user_id: Mapped[str] = mapped_column(String(500))
+    # Email returned by the provider (informational; may differ from local email)
+    provider_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    provider: Mapped[OIDCProvider] = relationship("OIDCProvider", back_populates="user_links")
+
+    def __repr__(self) -> str:
+        return f"<UserOIDCLink user_id={self.user_id} provider_id={self.provider_id}>"

+ 5 - 1
backend/app/models/user.py

@@ -30,11 +30,15 @@ class User(Base):
     role: Mapped[str] = mapped_column(
     role: Mapped[str] = mapped_column(
         String(20), default="user"
         String(20), default="user"
     )  # "admin" or "user" (legacy, kept for backward compat)
     )  # "admin" or "user" (legacy, kept for backward compat)
-    auth_source: Mapped[str] = mapped_column(String(20), default="local")  # "local" or "ldap"
+    auth_source: Mapped[str] = mapped_column(String(20), default="local")  # "local", "ldap", or "oidc"
     is_active: Mapped[bool] = mapped_column(default=True)
     is_active: Mapped[bool] = mapped_column(default=True)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
 
+    # Set whenever the local password is changed/reset — used to invalidate JWTs
+    # issued before the change (M-R7-B).  NULL means no password change recorded yet.
+    password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
     # Per-user Bambu Cloud credentials (when auth is enabled, each user has their own)
     # Per-user Bambu Cloud credentials (when auth is enabled, each user has their own)
     cloud_token: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None)
     cloud_token: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None)
     cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
     cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)

+ 55 - 0
backend/app/models/user_otp_code.py

@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class UserOTPCode(Base):
+    """Temporary email OTP (One-Time Password) code for 2FA verification.
+
+    Each record represents a single sent OTP code.  Codes expire after
+    OTP_TTL_MINUTES and are invalidated after MAX_ATTEMPTS failed attempts
+    or after successful verification.
+    """
+
+    __tablename__ = "user_otp_codes"
+
+    OTP_TTL_MINUTES = 10
+    MAX_ATTEMPTS = 5
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), index=True)
+    # pbkdf2_sha256 hash of the 6-digit code
+    code_hash: Mapped[str] = mapped_column(String(255))
+    # Number of failed verification attempts for this code
+    attempts: Mapped[int] = mapped_column(Integer, default=0)
+    # True once the code has been successfully used or explicitly invalidated
+    used: Mapped[bool] = mapped_column(Boolean, default=False)
+    expires_at: Mapped[datetime] = mapped_column(DateTime)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    def consume(self) -> None:
+        """T4: Mark this OTP as used, enforcing preconditions.
+
+        Raises ``ValueError`` if the code is already used or expired so callers
+        cannot silently re-use an invalidated code.  The caller is responsible
+        for flushing/committing the change to the DB.
+        """
+        now = datetime.now(timezone.utc)
+        exp = self.expires_at
+        if exp.tzinfo is None:
+            from datetime import timezone as _tz
+
+            exp = exp.replace(tzinfo=_tz.utc)
+        if self.used:
+            raise ValueError("OTP code has already been used")
+        if exp < now:
+            raise ValueError("OTP code has expired")
+        self.used = True
+
+    def __repr__(self) -> str:
+        return f"<UserOTPCode user_id={self.user_id} used={self.used}>"

+ 84 - 0
backend/app/models/user_totp.py

@@ -0,0 +1,84 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime
+
+from fastapi import HTTPException, status
+from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+from backend.app.core.encryption import mfa_decrypt, mfa_encrypt
+
+
+class UserTOTP(Base):
+    """TOTP (Time-based One-Time Password) secret for a user.
+
+    Stores the TOTP secret used by authenticator apps (Google Authenticator,
+    Proton Authenticator, Aegis, etc.). One record per user; is_enabled=False
+    while the setup is pending confirmation.
+    """
+
+    __tablename__ = "user_totp"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True)
+    # TOTP secret — encrypted at rest when MFA_ENCRYPTION_KEY is set.
+    # Use .secret / .set_secret() rather than accessing _secret_enc directly.
+    _secret_enc: Mapped[str] = mapped_column("secret", String(512))
+    is_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
+    # Hashed backup codes stored as JSON array of strings
+    # Each entry is a hashed one-time-use recovery code
+    backup_codes_json: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
+    # TOTP replay protection: stores the 30-second time-step counter of the last
+    # accepted code so the same code cannot be used twice within one window.
+    last_totp_counter: Mapped[int | None] = mapped_column(BigInteger, nullable=True, default=None)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    @property
+    def secret(self) -> str:
+        """Return the decrypted TOTP secret."""
+        return mfa_decrypt(self._secret_enc)
+
+    @secret.setter
+    def secret(self, value: str) -> None:
+        """Store the TOTP secret, encrypting it when MFA_ENCRYPTION_KEY is set."""
+        self._secret_enc = mfa_encrypt(value)
+
+    @property
+    def backup_code_hashes(self) -> list[str]:
+        """T5: Get stored backup-code hashes as a list.
+
+        The name makes clear that these are *hashes*, not plaintext codes,
+        so callers know they must verify with a password-hashing library
+        rather than compare directly.
+        """
+        if not self.backup_codes_json:
+            return []
+        return json.loads(self.backup_codes_json)
+
+    @backup_code_hashes.setter
+    def backup_code_hashes(self, hashes: list[str]) -> None:
+        """Persist backup-code hashes as a JSON array."""
+        self.backup_codes_json = json.dumps(hashes)
+
+    def accept_counter(self, new_counter: int) -> None:
+        """T4: Record an accepted TOTP time-step counter, rejecting backward movement.
+
+        Raises ``HTTPException(400)`` if ``new_counter`` is not strictly greater
+        than ``last_totp_counter``, preventing counter roll-back attacks (e.g. an
+        attacker who replays a previously accepted code after the counter wraps or
+        the clock is skewed backward).
+
+        The caller is responsible for flushing/committing the change to the DB.
+        """
+        if self.last_totp_counter is not None and new_counter <= self.last_totp_counter:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="TOTP code already used",
+            )
+        self.last_totp_counter = new_counter
+
+    def __repr__(self) -> str:
+        return f"<UserTOTP user_id={self.user_id} enabled={self.is_enabled}>"

+ 344 - 16
backend/app/schemas/auth.py

@@ -1,4 +1,24 @@
-from pydantic import BaseModel
+import re
+from typing import Literal
+
+from pydantic import BaseModel, Field, field_validator
+
+
+def _validate_password_complexity(v: str) -> str:
+    """Enforce minimum password complexity (M-C).
+
+    Requires at least one uppercase letter, one lowercase letter, one digit,
+    and one special character in addition to the min_length=8 Field constraint.
+    """
+    if not re.search(r"[A-Z]", v):
+        raise ValueError("Password must contain at least one uppercase letter")
+    if not re.search(r"[a-z]", v):
+        raise ValueError("Password must contain at least one lowercase letter")
+    if not re.search(r"\d", v):
+        raise ValueError("Password must contain at least one digit")
+    if not re.search(r"[^A-Za-z0-9]", v):
+        raise ValueError("Password must contain at least one special character")
+    return v
 
 
 
 
 class GroupBrief(BaseModel):
 class GroupBrief(BaseModel):
@@ -12,32 +32,50 @@ class GroupBrief(BaseModel):
 
 
 
 
 class LoginRequest(BaseModel):
 class LoginRequest(BaseModel):
-    username: str
-    password: str
+    username: str = Field(..., max_length=150)
+    password: str = Field(..., max_length=256)
 
 
 
 
 class LoginResponse(BaseModel):
 class LoginResponse(BaseModel):
-    access_token: str
+    access_token: str | None = None
     token_type: str = "bearer"
     token_type: str = "bearer"
-    user: "UserResponse"
+    user: "UserResponse | None" = None
+    # Set when 2FA is required; the frontend must call /auth/2fa/verify
+    requires_2fa: bool = False
+    pre_auth_token: str | None = None
+    two_fa_methods: list[str] = []
 
 
 
 
 class UserCreate(BaseModel):
 class UserCreate(BaseModel):
-    username: str
-    password: str | None = None  # Optional when advanced auth is enabled
-    email: str | None = None
+    username: str = Field(..., max_length=150)
+    password: str | None = Field(default=None, max_length=256)  # M-NEW-4: cap before pbkdf2
+    email: str | None = Field(default=None, max_length=254)  # L-NEW-5: RFC 5321 max
     role: str = "user"
     role: str = "user"
     group_ids: list[int] | None = None
     group_ids: list[int] | None = None
 
 
+    @field_validator("password")
+    @classmethod
+    def validate_password(cls, v: str | None) -> str | None:
+        if v is not None:
+            _validate_password_complexity(v)
+        return v
+
 
 
 class UserUpdate(BaseModel):
 class UserUpdate(BaseModel):
-    username: str | None = None
-    password: str | None = None
-    email: str | None = None
+    username: str | None = Field(default=None, max_length=150)
+    password: str | None = Field(default=None, max_length=256)  # M-NEW-4: cap before pbkdf2
+    email: str | None = Field(default=None, max_length=254)  # L-NEW-5: RFC 5321 max
     role: str | None = None
     role: str | None = None
     is_active: bool | None = None
     is_active: bool | None = None
     group_ids: list[int] | None = None
     group_ids: list[int] | None = None
 
 
+    @field_validator("password")
+    @classmethod
+    def validate_password(cls, v: str | None) -> str | None:
+        if v is not None:
+            _validate_password_complexity(v)
+        return v
+
 
 
 class UserResponse(BaseModel):
 class UserResponse(BaseModel):
     id: int
     id: int
@@ -56,14 +94,26 @@ class UserResponse(BaseModel):
 
 
 
 
 class ChangePasswordRequest(BaseModel):
 class ChangePasswordRequest(BaseModel):
-    current_password: str
-    new_password: str
+    current_password: str = Field(..., max_length=256)  # M-NEW-3: cap before pbkdf2
+    new_password: str = Field(..., min_length=8, max_length=256)
+
+    @field_validator("new_password")
+    @classmethod
+    def validate_new_password(cls, v: str) -> str:
+        return _validate_password_complexity(v)
 
 
 
 
 class SetupRequest(BaseModel):
 class SetupRequest(BaseModel):
     auth_enabled: bool
     auth_enabled: bool
-    admin_username: str | None = None
-    admin_password: str | None = None
+    admin_username: str | None = Field(default=None, max_length=150)
+    admin_password: str | None = Field(default=None, max_length=256)
+
+    @field_validator("admin_password")
+    @classmethod
+    def validate_admin_password(cls, v: str | None) -> str | None:
+        if v is not None:
+            _validate_password_complexity(v)
+        return v
 
 
 
 
 class SetupResponse(BaseModel):
 class SetupResponse(BaseModel):
@@ -72,7 +122,17 @@ class SetupResponse(BaseModel):
 
 
 
 
 class ForgotPasswordRequest(BaseModel):
 class ForgotPasswordRequest(BaseModel):
-    email: str
+    email: str = Field(..., max_length=254)  # L-NEW-1: RFC 5321 max; caps memory/CPU before lookup
+
+
+class ForgotPasswordConfirmRequest(BaseModel):
+    token: str = Field(..., max_length=128)
+    new_password: str = Field(..., min_length=8, max_length=256)
+
+    @field_validator("new_password")
+    @classmethod
+    def validate_new_password(cls, v: str) -> str:
+        return _validate_password_complexity(v)
 
 
 
 
 class ForgotPasswordResponse(BaseModel):
 class ForgotPasswordResponse(BaseModel):
@@ -107,3 +167,271 @@ class TestSMTPRequest(BaseModel):
 class TestSMTPResponse(BaseModel):
 class TestSMTPResponse(BaseModel):
     success: bool
     success: bool
     message: str
     message: str
+
+
+# ---------------------------------------------------------------------------
+# 2FA / MFA schemas
+# ---------------------------------------------------------------------------
+
+
+class TwoFAStatusResponse(BaseModel):
+    totp_enabled: bool
+    email_otp_enabled: bool
+    backup_codes_remaining: int
+
+
+class TOTPSetupResponse(BaseModel):
+    """Returned when a user initiates TOTP setup.  The frontend should display
+    the QR code image (base64 PNG) and ask the user to scan it, then call
+    /auth/2fa/totp/enable with a valid code to confirm."""
+
+    secret: str  # base32 secret (shown as fallback text)
+    qr_code_b64: str  # base64-encoded PNG of the QR code
+    issuer: str
+
+
+class TOTPSetupRequest(BaseModel):
+    """Optional body for POST /auth/2fa/totp/setup.
+
+    Only required when re-initialising setup while an active TOTP record exists.
+    Provide the current TOTP code (from the existing authenticator app) to
+    confirm intent — mirrors the verification requirement in disable_totp.
+    """
+
+    code: str | None = Field(default=None, max_length=8)  # L-NEW-2: bound before pyotp
+
+
+class TOTPEnableRequest(BaseModel):
+    code: str  # 6-digit TOTP code from the authenticator app
+
+    @field_validator("code")
+    @classmethod
+    def validate_code(cls, v: str) -> str:
+        v = v.strip()
+        if not v.isdigit() or len(v) != 6:
+            raise ValueError("TOTP code must be exactly 6 digits")
+        return v
+
+
+class TOTPEnableResponse(BaseModel):
+    message: str
+    backup_codes: list[str]  # plain-text codes shown once; user must save them
+
+
+class TOTPDisableRequest(BaseModel):
+    """Requires a valid TOTP code OR a backup code to disable TOTP."""
+
+    code: str = Field(..., max_length=128)
+
+
+class BackupCodesResponse(BaseModel):
+    backup_codes: list[str]
+    message: str
+
+
+class EmailOTPEnableRequest(BaseModel):
+    """No body required — email is taken from the authenticated user's profile."""
+
+    pass
+
+
+class TwoFAVerifyRequest(BaseModel):
+    pre_auth_token: str = Field(..., max_length=128)
+    # TOTP/email codes are 6 digits; backup codes are 8 uppercase alphanumeric chars.
+    # max_length=8 prevents excessively long inputs from reaching pbkdf2/pyotp.
+    code: str = Field(..., min_length=6, max_length=8)
+    method: Literal["totp", "email", "backup"] = "totp"
+
+    @field_validator("code")
+    @classmethod
+    def validate_code_format(cls, v: str) -> str:
+        v = v.strip()
+        if not re.match(r"^[A-Za-z0-9]{6,8}$", v):
+            raise ValueError("Code must be 6–8 alphanumeric characters")
+        return v.upper()  # normalise backup codes to uppercase
+
+
+class TwoFAVerifyResponse(BaseModel):
+    access_token: str
+    token_type: str = "bearer"
+    user: "UserResponse"
+
+
+class EmailOTPSendRequest(BaseModel):
+    pre_auth_token: str = Field(..., max_length=128)
+
+
+class EmailOTPEnableConfirmRequest(BaseModel):
+    """Body for the second step of email OTP enable: verify the proof-of-possession code."""
+
+    setup_token: str = Field(..., max_length=128)
+    # L-NEW-3: email OTP setup codes are always exactly 6 digits; reject anything else.
+    code: str = Field(..., min_length=6, max_length=6)
+
+    @field_validator("code")
+    @classmethod
+    def validate_code_digits(cls, v: str) -> str:
+        v = v.strip()
+        if not v.isdigit() or len(v) != 6:
+            raise ValueError("Email OTP setup code must be exactly 6 digits")
+        return v
+
+
+class EmailOTPDisableRequest(BaseModel):
+    """Requires the account password to disable email OTP."""
+
+    password: str = Field(..., max_length=256)
+
+
+class AdminDisable2FARequest(BaseModel):
+    """Admin must supply their own password as re-auth before disabling 2FA for another user.
+
+    OIDC/LDAP-only admins (no local password_hash) are exempt from this check.
+    """
+
+    admin_password: str | None = Field(default=None, max_length=256)
+
+
+# ---------------------------------------------------------------------------
+# OIDC schemas
+# ---------------------------------------------------------------------------
+
+
+def _validate_icon_url(v: str | None) -> str | None:
+    """Reject non-HTTPS icon URLs to prevent SSRF / mixed-content issues."""
+    if v is None:
+        return v
+    if not v.startswith("https://"):
+        raise ValueError("icon_url must start with https://")
+    return v
+
+
+def _validate_issuer_url(v: str | None) -> str | None:
+    """Nit4: Reject non-HTTPS issuer URLs and private/loopback/link-local hosts.
+
+    HTTP is no longer accepted — OIDC providers must be reachable over TLS.
+    Private-network and loopback addresses are rejected to prevent SSRF attacks
+    where an admin-supplied URL could reach internal services.
+    """
+    import ipaddress
+    from urllib.parse import urlparse
+
+    if v is None:
+        return v
+    if not v.startswith("https://"):
+        raise ValueError("issuer_url must start with https://")
+    host = urlparse(v).hostname or ""
+    try:
+        addr = ipaddress.ip_address(host)
+        if addr.is_private or addr.is_loopback or addr.is_link_local:
+            raise ValueError("issuer_url must not point to a private, loopback, or link-local address")
+    except ValueError as exc:
+        if "issuer_url" in str(exc):
+            raise
+        # hostname is a domain name, not a bare IP — that's fine
+    return v
+
+
+def _validate_scopes(v: str | None) -> str | None:
+    """Nit5: Require that the 'openid' scope is present.
+
+    The OpenID Connect spec mandates the 'openid' scope; without it the
+    response is plain OAuth2, not OIDC, and claims like sub/email are not
+    guaranteed.
+    """
+    if v is None:
+        return v
+    scope_list = v.split()
+    if "openid" not in scope_list:
+        raise ValueError("scopes must include 'openid'")
+    return v
+
+
+class OIDCProviderCreate(BaseModel):
+    name: str = Field(..., max_length=100)  # L-NEW-4
+    issuer_url: str
+    client_id: str = Field(..., max_length=256)  # L-NEW-4
+    client_secret: str = Field(..., max_length=512)  # L-NEW-4: Fernet input bounded
+    scopes: str = Field(default="openid email profile", max_length=256)  # L-NEW-4
+    is_enabled: bool = True
+    auto_create_users: bool = False
+    auto_link_existing_accounts: bool = False  # M-2: conservative default, opt-in only
+    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
+        return result
+
+    @field_validator("scopes")
+    @classmethod
+    def validate_scopes(cls, v: str) -> str:
+        result = _validate_scopes(v)
+        assert result is not None
+        return result
+
+    @field_validator("icon_url")
+    @classmethod
+    def validate_icon_url(cls, v: str | None) -> str | None:
+        return _validate_icon_url(v)
+
+
+class OIDCProviderUpdate(BaseModel):
+    name: str | None = Field(default=None, max_length=100)
+    issuer_url: str | None = None
+
+    @field_validator("issuer_url")
+    @classmethod
+    def validate_issuer_url(cls, v: str | None) -> str | None:
+        return _validate_issuer_url(v)
+
+    client_id: str | None = Field(default=None, max_length=256)
+    client_secret: str | None = Field(default=None, max_length=512)
+    scopes: str | None = Field(default=None, max_length=256)
+    is_enabled: bool | None = None
+    auto_create_users: bool | None = None
+    auto_link_existing_accounts: bool | None = None
+    icon_url: str | None = None
+
+    @field_validator("scopes")
+    @classmethod
+    def validate_scopes(cls, v: str | None) -> str | None:
+        return _validate_scopes(v)
+
+    @field_validator("icon_url")
+    @classmethod
+    def validate_icon_url(cls, v: str | None) -> str | None:
+        return _validate_icon_url(v)
+
+
+class OIDCProviderResponse(BaseModel):
+    id: int
+    name: str
+    issuer_url: str
+    client_id: str
+    scopes: str
+    is_enabled: bool
+    auto_create_users: bool
+    auto_link_existing_accounts: bool = False
+    icon_url: str | None = None
+
+    class Config:
+        from_attributes = True
+
+
+class OIDCAuthorizeResponse(BaseModel):
+    auth_url: str
+
+
+class OIDCExchangeRequest(BaseModel):
+    oidc_token: str = Field(..., max_length=128)
+
+
+class OIDCLinkResponse(BaseModel):
+    id: int
+    provider_id: int
+    provider_name: str
+    provider_email: str | None = None
+    created_at: str

+ 67 - 4
backend/app/services/email_service.py

@@ -32,8 +32,6 @@ def generate_secure_password(length: int = 16) -> str:
     Returns:
     Returns:
         A secure random password containing uppercase, lowercase, digits, and special characters
         A secure random password containing uppercase, lowercase, digits, and special characters
     """
     """
-    import random
-
     # Define character sets
     # Define character sets
     lowercase = string.ascii_lowercase
     lowercase = string.ascii_lowercase
     uppercase = string.ascii_uppercase
     uppercase = string.ascii_uppercase
@@ -52,8 +50,8 @@ def generate_secure_password(length: int = 16) -> str:
     all_chars = lowercase + uppercase + digits + special
     all_chars = lowercase + uppercase + digits + special
     password_chars.extend(secrets.choice(all_chars) for _ in range(length - 4))
     password_chars.extend(secrets.choice(all_chars) for _ in range(length - 4))
 
 
-    # Shuffle to avoid predictable patterns
-    random.shuffle(password_chars)
+    # Shuffle with CSPRNG — random.shuffle() is seeded from time and not cryptographically safe
+    secrets.SystemRandom().shuffle(password_chars)
 
 
     return "".join(password_chars)
     return "".join(password_chars)
 
 
@@ -381,6 +379,71 @@ BamBuddy Team
     return subject, text_body, html_body
     return subject, text_body, html_body
 
 
 
 
+def create_password_reset_link_email(username: str, reset_url: str) -> tuple[str, str, str]:
+    """Create a password-reset email that contains a secure link (not a plaintext password)."""
+    subject = "BamBuddy - Password Reset Request"
+
+    text_body = f"""A password reset was requested for your BamBuddy account.
+
+Username: {username}
+
+Click the link below to set a new password (valid for 1 hour):
+{reset_url}
+
+If you did not request this reset, you can safely ignore this email.
+
+Best regards,
+BamBuddy Team
+"""
+
+    html_body = f"""<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+</head>
+<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
+    <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 20px; border-radius: 8px 8px 0 0;">
+        <h1 style="color: #ffffff; margin: 0; font-size: 24px;">Password Reset Request</h1>
+    </div>
+    <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
+        <p style="font-size: 16px;">A password reset was requested for your BamBuddy account (<strong>{username}</strong>).</p>
+        <p>Click the button below to set a new password. This link is valid for <strong>1 hour</strong>.</p>
+        <div style="text-align: center; margin: 30px 0;">
+            <a href="{reset_url}" style="display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reset Password</a>
+        </div>
+        <div style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;">
+            <p style="margin: 0; font-size: 14px; color: #856404;">
+                <strong>Did not request this?</strong> You can safely ignore this email. Your password has not been changed.
+            </p>
+        </div>
+        <p style="font-size: 14px; color: #999; margin-top: 30px;">
+            Best regards,<br>BamBuddy Team
+        </p>
+    </div>
+</body>
+</html>
+"""
+    return subject, text_body, html_body
+
+
+async def create_password_reset_link_email_from_template(
+    db: AsyncSession, username: str, reset_url: str
+) -> tuple[str, str, str]:
+    """Create password-reset link email, using DB template if configured."""
+    template = await get_notification_template(db, "password_reset_link")
+    if template:
+        variables = {"username": username, "reset_url": reset_url}
+        subject = render_template(template.subject or "BamBuddy - Password Reset Request", variables)
+        text_body = render_template(template.body or "", variables)
+        html_body = render_template(template.html_body or "", variables) if template.html_body else None
+        if not html_body:
+            _, text_body, html_body = create_password_reset_link_email(username, reset_url)
+            return subject, text_body, html_body
+        return subject, text_body, html_body
+    return create_password_reset_link_email(username, reset_url)
+
+
 async def create_welcome_email_from_template(
 async def create_welcome_email_from_template(
     db: AsyncSession, username: str, password: str, login_url: str, app_name: str = "BamBuddy"
     db: AsyncSession, username: str, password: str, login_url: str, app_name: str = "BamBuddy"
 ) -> tuple[str, str, str]:
 ) -> tuple[str, str, str]:

+ 4 - 0
backend/tests/conftest.py

@@ -70,6 +70,7 @@ async def test_engine():
         ams_label,
         ams_label,
         api_key,
         api_key,
         archive,
         archive,
+        auth_ephemeral,
         color_catalog,
         color_catalog,
         external_link,
         external_link,
         filament,
         filament,
@@ -78,6 +79,7 @@ async def test_engine():
         maintenance,
         maintenance,
         notification,
         notification,
         notification_template,
         notification_template,
+        oidc_provider,
         print_queue,
         print_queue,
         printer,
         printer,
         project,
         project,
@@ -94,6 +96,8 @@ async def test_engine():
         spoolbuddy_device,
         spoolbuddy_device,
         user,
         user,
         user_email_pref,
         user_email_pref,
+        user_otp_code,
+        user_totp,
         virtual_printer,
         virtual_printer,
     )
     )
 
 

+ 111 - 27
backend/tests/integration/test_advanced_auth_api.py

@@ -22,7 +22,7 @@ SMTP_DATA = {
 }
 }
 
 
 
 
-async def _setup_admin(async_client: AsyncClient, username: str = "admin", password: str = "adminpass123"):
+async def _setup_admin(async_client: AsyncClient, username: str = "admin", password: str = "AdminPass1!"):
     """Enable auth and create admin user, return admin token."""
     """Enable auth and create admin user, return admin token."""
     await async_client.post(
     await async_client.post(
         "/api/v1/auth/setup",
         "/api/v1/auth/setup",
@@ -47,7 +47,7 @@ async def _setup_smtp_and_advanced_auth(async_client: AsyncClient, token: str):
 
 
 
 
 async def _create_regular_user(
 async def _create_regular_user(
-    async_client: AsyncClient, token: str, username: str = "regular", password: str = "regularpass123"
+    async_client: AsyncClient, token: str, username: str = "regular", password: str = "Regularpass1!"
 ):
 ):
     """Create a regular (non-admin) user and return their token."""
     """Create a regular (non-admin) user and return their token."""
     headers = {"Authorization": f"Bearer {token}"}
     headers = {"Authorization": f"Bearer {token}"}
@@ -68,7 +68,7 @@ class TestSMTPConfigAPI:
 
 
     @pytest.fixture
     @pytest.fixture
     async def admin_token(self, async_client: AsyncClient):
     async def admin_token(self, async_client: AsyncClient):
-        return await _setup_admin(async_client, "smtpadmin", "adminpass123")
+        return await _setup_admin(async_client, "smtpadmin", "AdminPass1!")
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -100,7 +100,7 @@ class TestSMTPConfigAPI:
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_smtp_settings_requires_admin(self, async_client: AsyncClient, admin_token: str):
     async def test_smtp_settings_requires_admin(self, async_client: AsyncClient, admin_token: str):
         """Non-admin user gets 403 on SMTP endpoints."""
         """Non-admin user gets 403 on SMTP endpoints."""
-        user_token = await _create_regular_user(async_client, admin_token, "smtpregular", "pass123456")
+        user_token = await _create_regular_user(async_client, admin_token, "smtpregular", "Pass12345!")
         headers = {"Authorization": f"Bearer {user_token}"}
         headers = {"Authorization": f"Bearer {user_token}"}
 
 
         response = await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
         response = await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
@@ -143,7 +143,7 @@ class TestAdvancedAuthToggleAPI:
 
 
     @pytest.fixture
     @pytest.fixture
     async def admin_token(self, async_client: AsyncClient):
     async def admin_token(self, async_client: AsyncClient):
-        return await _setup_admin(async_client, "toggleadmin", "adminpass123")
+        return await _setup_admin(async_client, "toggleadmin", "AdminPass1!")
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -193,7 +193,7 @@ class TestAdvancedAuthToggleAPI:
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_enable_requires_admin(self, async_client: AsyncClient, admin_token: str):
     async def test_enable_requires_admin(self, async_client: AsyncClient, admin_token: str):
         """Non-admin user gets 403 on enable/disable."""
         """Non-admin user gets 403 on enable/disable."""
-        user_token = await _create_regular_user(async_client, admin_token, "toggleregular", "pass123456")
+        user_token = await _create_regular_user(async_client, admin_token, "toggleregular", "Pass12345!")
         headers = {"Authorization": f"Bearer {user_token}"}
         headers = {"Authorization": f"Bearer {user_token}"}
 
 
         response = await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
         response = await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
@@ -208,7 +208,7 @@ class TestEmailLoginAPI:
 
 
     @pytest.fixture
     @pytest.fixture
     async def admin_token(self, async_client: AsyncClient):
     async def admin_token(self, async_client: AsyncClient):
-        return await _setup_admin(async_client, "emailadmin", "adminpass123")
+        return await _setup_admin(async_client, "emailadmin", "AdminPass1!")
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -233,13 +233,13 @@ class TestEmailLoginAPI:
             await async_client.patch(
             await async_client.patch(
                 f"/api/v1/users/{user_id}",
                 f"/api/v1/users/{user_id}",
                 headers=headers,
                 headers=headers,
-                json={"password": "knownpassword123"},
+                json={"password": "Knownpassword1!"},
             )
             )
 
 
         # Login with email
         # Login with email
         response = await async_client.post(
         response = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "emailuser@test.com", "password": "knownpassword123"},
+            json={"username": "emailuser@test.com", "password": "Knownpassword1!"},
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
         assert "access_token" in response.json()
         assert "access_token" in response.json()
@@ -262,12 +262,12 @@ class TestEmailLoginAPI:
             await async_client.patch(
             await async_client.patch(
                 f"/api/v1/users/{user_id}",
                 f"/api/v1/users/{user_id}",
                 headers=headers,
                 headers=headers,
-                json={"password": "casepassword123"},
+                json={"password": "Casepassword1!"},
             )
             )
 
 
         response = await async_client.post(
         response = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "CASEUSER@TEST.COM", "password": "casepassword123"},
+            json={"username": "CASEUSER@TEST.COM", "password": "Casepassword1!"},
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
         assert "access_token" in response.json()
         assert "access_token" in response.json()
@@ -282,13 +282,13 @@ class TestEmailLoginAPI:
         await async_client.post(
         await async_client.post(
             "/api/v1/users/",
             "/api/v1/users/",
             headers=headers,
             headers=headers,
-            json={"username": "noemail", "password": "noEmailPass1", "email": "noemail@test.com", "role": "user"},
+            json={"username": "noemail", "password": "NoEmailPass1!", "email": "noemail@test.com", "role": "user"},
         )
         )
 
 
         # Try to login with email — should fail since advanced auth is off
         # Try to login with email — should fail since advanced auth is off
         response = await async_client.post(
         response = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "noemail@test.com", "password": "noEmailPass1"},
+            json={"username": "noemail@test.com", "password": "NoEmailPass1!"},
         )
         )
         assert response.status_code == 401
         assert response.status_code == 401
 
 
@@ -310,13 +310,13 @@ class TestEmailLoginAPI:
             await async_client.patch(
             await async_client.patch(
                 f"/api/v1/users/{user_id}",
                 f"/api/v1/users/{user_id}",
                 headers=headers,
                 headers=headers,
-                json={"password": "usernamepass123"},
+                json={"password": "Usernamepass1!"},
             )
             )
 
 
         # Login with username (not email)
         # Login with username (not email)
         response = await async_client.post(
         response = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "usernameuser", "password": "usernamepass123"},
+            json={"username": "usernameuser", "password": "Usernamepass1!"},
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
         assert "access_token" in response.json()
         assert "access_token" in response.json()
@@ -327,7 +327,7 @@ class TestForgotPasswordAPI:
 
 
     @pytest.fixture
     @pytest.fixture
     async def admin_token(self, async_client: AsyncClient):
     async def admin_token(self, async_client: AsyncClient):
-        return await _setup_admin(async_client, "forgotadmin", "adminpass123")
+        return await _setup_admin(async_client, "forgotadmin", "AdminPass1!")
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -388,7 +388,13 @@ class TestForgotPasswordAPI:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_forgot_password_changes_password(self, async_client: AsyncClient, admin_token: str):
     async def test_forgot_password_changes_password(self, async_client: AsyncClient, admin_token: str):
-        """After forgot-password, old password stops working."""
+        """After forgot-password + confirm, old password stops working and new one works.
+
+        H-6: The flow is now token-based: /forgot-password issues a reset link and
+        /forgot-password/confirm consumes the token and sets the new password.
+        """
+        from unittest.mock import AsyncMock
+
         headers = {"Authorization": f"Bearer {admin_token}"}
         headers = {"Authorization": f"Bearer {admin_token}"}
 
 
         with patch("backend.app.api.routes.users.send_email"):
         with patch("backend.app.api.routes.users.send_email"):
@@ -403,37 +409,66 @@ class TestForgotPasswordAPI:
             await async_client.patch(
             await async_client.patch(
                 f"/api/v1/users/{user_id}",
                 f"/api/v1/users/{user_id}",
                 headers=headers,
                 headers=headers,
-                json={"password": "originalpass123"},
+                json={"password": "Originalpass1!"},
             )
             )
 
 
         # Verify login works with original password
         # Verify login works with original password
         login_resp = await async_client.post(
         login_resp = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "resetme", "password": "originalpass123"},
+            json={"username": "resetme", "password": "Originalpass1!"},
         )
         )
         assert login_resp.status_code == 200
         assert login_resp.status_code == 200
 
 
-        # Trigger forgot password
-        with patch("backend.app.api.routes.auth.send_email"):
-            await async_client.post(
+        # Trigger forgot-password and capture the reset URL (contains the token)
+        captured: dict[str, str] = {}
+
+        async def _capture_link_email(db, username, reset_url):
+            captured["reset_url"] = reset_url
+            return ("subject", "body", "<body/>")
+
+        with (
+            patch(
+                "backend.app.api.routes.auth.create_password_reset_link_email_from_template",
+                side_effect=_capture_link_email,
+            ),
+            patch("backend.app.api.routes.auth.send_email"),
+        ):
+            resp = await async_client.post(
                 "/api/v1/auth/forgot-password",
                 "/api/v1/auth/forgot-password",
                 json={"email": "resetme@test.com"},
                 json={"email": "resetme@test.com"},
             )
             )
+        assert resp.status_code == 200
+        assert "reset_url" in captured, "Reset URL not captured — email function was not called"
+
+        # Extract the token from the captured URL and confirm the reset
+        reset_token = captured["reset_url"].split("reset_token=")[1]
+        confirm_resp = await async_client.post(
+            "/api/v1/auth/forgot-password/confirm",
+            json={"token": reset_token, "new_password": "Newpass456!"},
+        )
+        assert confirm_resp.status_code == 200
 
 
         # Old password should no longer work
         # Old password should no longer work
         login_resp = await async_client.post(
         login_resp = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "resetme", "password": "originalpass123"},
+            json={"username": "resetme", "password": "Originalpass1!"},
         )
         )
         assert login_resp.status_code == 401
         assert login_resp.status_code == 401
 
 
+        # New password must work
+        login_resp = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "resetme", "password": "Newpass456!"},
+        )
+        assert login_resp.status_code == 200
+
 
 
 class TestAdminResetPasswordAPI:
 class TestAdminResetPasswordAPI:
     """Integration tests for admin password reset endpoint."""
     """Integration tests for admin password reset endpoint."""
 
 
     @pytest.fixture
     @pytest.fixture
     async def admin_token(self, async_client: AsyncClient):
     async def admin_token(self, async_client: AsyncClient):
-        return await _setup_admin(async_client, "resetadmin", "adminpass123")
+        return await _setup_admin(async_client, "resetadmin", "AdminPass1!")
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -467,7 +502,7 @@ class TestAdminResetPasswordAPI:
     async def test_reset_password_requires_admin(self, async_client: AsyncClient, admin_token: str):
     async def test_reset_password_requires_admin(self, async_client: AsyncClient, admin_token: str):
         """Non-admin user gets 403 on reset-password."""
         """Non-admin user gets 403 on reset-password."""
         # Create regular user before enabling advanced auth (no email required)
         # Create regular user before enabling advanced auth (no email required)
-        user_token = await _create_regular_user(async_client, admin_token, "resetregular", "pass123456")
+        user_token = await _create_regular_user(async_client, admin_token, "resetregular", "Pass12345!")
 
 
         with patch("backend.app.api.routes.users.send_email"):
         with patch("backend.app.api.routes.users.send_email"):
             await _setup_smtp_and_advanced_auth(async_client, admin_token)
             await _setup_smtp_and_advanced_auth(async_client, admin_token)
@@ -522,7 +557,7 @@ class TestAdminResetPasswordAPI:
         create_resp = await async_client.post(
         create_resp = await async_client.post(
             "/api/v1/users/",
             "/api/v1/users/",
             headers=headers,
             headers=headers,
-            json={"username": "noemailuser", "password": "noemail123456", "role": "user"},
+            json={"username": "noemailuser", "password": "Noemail12345!", "role": "user"},
         )
         )
         user_id = create_resp.json()["id"]
         user_id = create_resp.json()["id"]
 
 
@@ -543,7 +578,7 @@ class TestUserCreationAdvancedAuth:
 
 
     @pytest.fixture
     @pytest.fixture
     async def admin_token(self, async_client: AsyncClient):
     async def admin_token(self, async_client: AsyncClient):
-        return await _setup_admin(async_client, "createadmin", "adminpass123")
+        return await _setup_admin(async_client, "createadmin", "AdminPass1!")
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -628,3 +663,52 @@ class TestUserCreationAdvancedAuth:
         result = response.json()
         result = response.json()
         assert "email" in result
         assert "email" in result
         assert result["email"] == "emailresp@test.com"
         assert result["email"] == "emailresp@test.com"
+
+
+# ===========================================================================
+# M-1: OIDC/LDAP users must not be able to use the password reset flow
+# ===========================================================================
+
+
+class TestAuthSourcePasswordResetBlocking:
+    """Forgot-password must silently skip OIDC and LDAP users (M-1)."""
+
+    @pytest.fixture
+    async def admin_token(self, async_client: AsyncClient):
+        return await _setup_admin(async_client, "authsrcadmin", "AdminPass1!")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_forgot_password_silently_skips_oidc_user(
+        self, async_client: AsyncClient, admin_token: str, db_session
+    ):
+        """forgot-password for an OIDC user returns 200 but does NOT send email."""
+        from backend.app.core.auth import get_password_hash
+        from backend.app.models.user import User
+
+        headers = {"Authorization": f"Bearer {admin_token}"}
+        await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
+        await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
+
+        # Directly insert an OIDC-sourced user into the DB
+        oidc_user = User(
+            username="oidcpwreset",
+            email="oidcpwreset@test.com",
+            auth_source="oidc",
+            password_hash=get_password_hash("irrelevant"),
+            role="user",
+            is_active=True,
+        )
+        db_session.add(oidc_user)
+        await db_session.commit()
+
+        with patch("backend.app.api.routes.auth.send_email") as mock_send:
+            response = await async_client.post(
+                "/api/v1/auth/forgot-password",
+                json={"email": "oidcpwreset@test.com"},
+            )
+
+        # Anti-enumeration: still returns 200
+        assert response.status_code == 200
+        # But no email is sent for OIDC users
+        mock_send.assert_not_called()

+ 88 - 34
backend/tests/integration/test_auth_api.py

@@ -61,7 +61,7 @@ class TestAuthSetupAPI:
             json={
             json={
                 "auth_enabled": True,
                 "auth_enabled": True,
                 "admin_username": "testadmin",
                 "admin_username": "testadmin",
-                "admin_password": "testpassword123",
+                "admin_password": "TestPass1!",
             },
             },
         )
         )
 
 
@@ -96,14 +96,14 @@ class TestAuthLoginAPI:
             json={
             json={
                 "auth_enabled": True,
                 "auth_enabled": True,
                 "admin_username": "logintest",
                 "admin_username": "logintest",
-                "admin_password": "loginpassword123",
+                "admin_password": "LoginPass1!",
             },
             },
         )
         )
 
 
         # Now login
         # Now login
         response = await async_client.post(
         response = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "logintest", "password": "loginpassword123"},
+            json={"username": "logintest", "password": "LoginPass1!"},
         )
         )
 
 
         assert response.status_code == 200
         assert response.status_code == 200
@@ -123,7 +123,7 @@ class TestAuthLoginAPI:
             json={
             json={
                 "auth_enabled": True,
                 "auth_enabled": True,
                 "admin_username": "invalidtest",
                 "admin_username": "invalidtest",
-                "admin_password": "correctpassword",
+                "admin_password": "CorrectPass1!",
             },
             },
         )
         )
 
 
@@ -158,13 +158,13 @@ class TestAuthMeAPI:
             json={
             json={
                 "auth_enabled": True,
                 "auth_enabled": True,
                 "admin_username": "metest",
                 "admin_username": "metest",
-                "admin_password": "mepassword123",
+                "admin_password": "MePass1!",
             },
             },
         )
         )
 
 
         login_response = await async_client.post(
         login_response = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "metest", "password": "mepassword123"},
+            json={"username": "metest", "password": "MePass1!"},
         )
         )
         token = login_response.json()["access_token"]
         token = login_response.json()["access_token"]
 
 
@@ -254,13 +254,13 @@ class TestUsersAPI:
             json={
             json={
                 "auth_enabled": True,
                 "auth_enabled": True,
                 "admin_username": "usersadmin",
                 "admin_username": "usersadmin",
-                "admin_password": "adminpassword123",
+                "admin_password": "AdminPass1!",
             },
             },
         )
         )
 
 
         login_response = await async_client.post(
         login_response = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "usersadmin", "password": "adminpassword123"},
+            json={"username": "usersadmin", "password": "AdminPass1!"},
         )
         )
         return login_response.json()["access_token"]
         return login_response.json()["access_token"]
 
 
@@ -274,7 +274,7 @@ class TestUsersAPI:
             json={
             json={
                 "auth_enabled": True,
                 "auth_enabled": True,
                 "admin_username": "authreqadmin",
                 "admin_username": "authreqadmin",
-                "admin_password": "adminpassword123",
+                "admin_password": "AdminPass1!",
             },
             },
         )
         )
 
 
@@ -306,7 +306,7 @@ class TestUsersAPI:
             headers={"Authorization": f"Bearer {auth_token}"},
             headers={"Authorization": f"Bearer {auth_token}"},
             json={
             json={
                 "username": "newuser",
                 "username": "newuser",
-                "password": "newuserpassword",
+                "password": "Newuserpass1!",
                 "role": "user",
                 "role": "user",
             },
             },
         )
         )
@@ -327,7 +327,7 @@ class TestUsersAPI:
             headers={"Authorization": f"Bearer {auth_token}"},
             headers={"Authorization": f"Bearer {auth_token}"},
             json={
             json={
                 "username": "duplicateuser",
                 "username": "duplicateuser",
-                "password": "password123",
+                "password": "Password123!",
                 "role": "user",
                 "role": "user",
             },
             },
         )
         )
@@ -338,7 +338,7 @@ class TestUsersAPI:
             headers={"Authorization": f"Bearer {auth_token}"},
             headers={"Authorization": f"Bearer {auth_token}"},
             json={
             json={
                 "username": "duplicateuser",
                 "username": "duplicateuser",
-                "password": "password456",
+                "password": "Password456!",
                 "role": "user",
                 "role": "user",
             },
             },
         )
         )
@@ -356,7 +356,7 @@ class TestUsersAPI:
             headers={"Authorization": f"Bearer {auth_token}"},
             headers={"Authorization": f"Bearer {auth_token}"},
             json={
             json={
                 "username": "updateuser",
                 "username": "updateuser",
-                "password": "password123",
+                "password": "Password123!",
                 "role": "user",
                 "role": "user",
             },
             },
         )
         )
@@ -382,7 +382,7 @@ class TestUsersAPI:
             headers={"Authorization": f"Bearer {auth_token}"},
             headers={"Authorization": f"Bearer {auth_token}"},
             json={
             json={
                 "username": "deleteuser",
                 "username": "deleteuser",
-                "password": "password123",
+                "password": "Password123!",
                 "role": "user",
                 "role": "user",
             },
             },
         )
         )
@@ -410,14 +410,14 @@ class TestAuthDisableAPI:
             json={
             json={
                 "auth_enabled": True,
                 "auth_enabled": True,
                 "admin_username": "disableadmin",
                 "admin_username": "disableadmin",
-                "admin_password": "adminpassword123",
+                "admin_password": "AdminPass1!",
             },
             },
         )
         )
 
 
         # Login to get token
         # Login to get token
         login_response = await async_client.post(
         login_response = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "disableadmin", "password": "adminpassword123"},
+            json={"username": "disableadmin", "password": "AdminPass1!"},
         )
         )
         token = login_response.json()["access_token"]
         token = login_response.json()["access_token"]
 
 
@@ -446,13 +446,13 @@ class TestGroupsAPI:
             json={
             json={
                 "auth_enabled": True,
                 "auth_enabled": True,
                 "admin_username": "groupsadmin",
                 "admin_username": "groupsadmin",
-                "admin_password": "adminpassword123",
+                "admin_password": "AdminPass1!",
             },
             },
         )
         )
 
 
         login_response = await async_client.post(
         login_response = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "groupsadmin", "password": "adminpassword123"},
+            json={"username": "groupsadmin", "password": "AdminPass1!"},
         )
         )
         return login_response.json()["access_token"]
         return login_response.json()["access_token"]
 
 
@@ -592,13 +592,13 @@ class TestUserGroupsAPI:
             json={
             json={
                 "auth_enabled": True,
                 "auth_enabled": True,
                 "admin_username": "usergroupadmin",
                 "admin_username": "usergroupadmin",
-                "admin_password": "adminpassword123",
+                "admin_password": "AdminPass1!",
             },
             },
         )
         )
 
 
         login_response = await async_client.post(
         login_response = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "usergroupadmin", "password": "adminpassword123"},
+            json={"username": "usergroupadmin", "password": "AdminPass1!"},
         )
         )
         return login_response.json()["access_token"]
         return login_response.json()["access_token"]
 
 
@@ -619,7 +619,7 @@ class TestUserGroupsAPI:
             headers={"Authorization": f"Bearer {auth_token}"},
             headers={"Authorization": f"Bearer {auth_token}"},
             json={
             json={
                 "username": "groupuser",
                 "username": "groupuser",
-                "password": "password123",
+                "password": "Password123!",
                 "group_ids": [operators_group["id"]],
                 "group_ids": [operators_group["id"]],
             },
             },
         )
         )
@@ -636,7 +636,7 @@ class TestUserGroupsAPI:
         user_response = await async_client.post(
         user_response = await async_client.post(
             "/api/v1/users/",
             "/api/v1/users/",
             headers={"Authorization": f"Bearer {auth_token}"},
             headers={"Authorization": f"Bearer {auth_token}"},
-            json={"username": "addtogroup", "password": "password123"},
+            json={"username": "addtogroup", "password": "Password123!"},
         )
         )
         user_id = user_response.json()["id"]
         user_id = user_response.json()["id"]
 
 
@@ -675,13 +675,13 @@ class TestChangePasswordAPI:
             json={
             json={
                 "auth_enabled": True,
                 "auth_enabled": True,
                 "admin_username": "pwchangeadmin",
                 "admin_username": "pwchangeadmin",
-                "admin_password": "adminpassword123",
+                "admin_password": "AdminPass1!",
             },
             },
         )
         )
 
 
         admin_login = await async_client.post(
         admin_login = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "pwchangeadmin", "password": "adminpassword123"},
+            json={"username": "pwchangeadmin", "password": "AdminPass1!"},
         )
         )
         admin_token = admin_login.json()["access_token"]
         admin_token = admin_login.json()["access_token"]
 
 
@@ -689,13 +689,13 @@ class TestChangePasswordAPI:
         await async_client.post(
         await async_client.post(
             "/api/v1/users/",
             "/api/v1/users/",
             headers={"Authorization": f"Bearer {admin_token}"},
             headers={"Authorization": f"Bearer {admin_token}"},
-            json={"username": "pwchangeuser", "password": "oldpassword123"},
+            json={"username": "pwchangeuser", "password": "Oldpassword123!"},
         )
         )
 
 
         # Login as regular user
         # Login as regular user
         user_login = await async_client.post(
         user_login = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "pwchangeuser", "password": "oldpassword123"},
+            json={"username": "pwchangeuser", "password": "Oldpassword123!"},
         )
         )
         return user_login.json()["access_token"]
         return user_login.json()["access_token"]
 
 
@@ -707,8 +707,8 @@ class TestChangePasswordAPI:
             "/api/v1/users/me/change-password",
             "/api/v1/users/me/change-password",
             headers={"Authorization": f"Bearer {user_token}"},
             headers={"Authorization": f"Bearer {user_token}"},
             json={
             json={
-                "current_password": "oldpassword123",
-                "new_password": "newpassword456",
+                "current_password": "Oldpassword123!",
+                "new_password": "Newpassword456!",
             },
             },
         )
         )
 
 
@@ -718,7 +718,7 @@ class TestChangePasswordAPI:
         # Verify can login with new password
         # Verify can login with new password
         login_response = await async_client.post(
         login_response = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "pwchangeuser", "password": "newpassword456"},
+            json={"username": "pwchangeuser", "password": "Newpassword456!"},
         )
         )
         assert login_response.status_code == 200
         assert login_response.status_code == 200
 
 
@@ -731,7 +731,7 @@ class TestChangePasswordAPI:
             headers={"Authorization": f"Bearer {user_token}"},
             headers={"Authorization": f"Bearer {user_token}"},
             json={
             json={
                 "current_password": "wrongpassword",
                 "current_password": "wrongpassword",
-                "new_password": "newpassword456",
+                "new_password": "Newpassword456!",
             },
             },
         )
         )
 
 
@@ -746,7 +746,7 @@ class TestChangePasswordAPI:
             "/api/v1/users/me/change-password",
             "/api/v1/users/me/change-password",
             json={
             json={
                 "current_password": "oldpassword",
                 "current_password": "oldpassword",
-                "new_password": "newpassword",
+                "new_password": "Strongpass456!",
             },
             },
         )
         )
 
 
@@ -768,7 +768,7 @@ class TestAuthMiddlewarePublicRoutes:
             json={
             json={
                 "auth_enabled": True,
                 "auth_enabled": True,
                 "admin_username": "middlewareadmin",
                 "admin_username": "middlewareadmin",
-                "admin_password": "adminpassword123",
+                "admin_password": "AdminPass1!",
             },
             },
         )
         )
 
 
@@ -786,7 +786,7 @@ class TestAuthMiddlewarePublicRoutes:
         """Verify /api/v1/auth/login is accessible without auth."""
         """Verify /api/v1/auth/login is accessible without auth."""
         response = await async_client.post(
         response = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "middlewareadmin", "password": "adminpassword123"},
+            json={"username": "middlewareadmin", "password": "AdminPass1!"},
         )
         )
         # Should not return 401 (unauthorized) - it should either succeed or return
         # Should not return 401 (unauthorized) - it should either succeed or return
         # a different error (like 400 for wrong credentials)
         # a different error (like 400 for wrong credentials)
@@ -826,7 +826,7 @@ class TestAuthMiddlewarePublicRoutes:
         # Login to get token
         # Login to get token
         login_response = await async_client.post(
         login_response = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "middlewareadmin", "password": "adminpassword123"},
+            json={"username": "middlewareadmin", "password": "AdminPass1!"},
         )
         )
         token = login_response.json()["access_token"]
         token = login_response.json()["access_token"]
 
 
@@ -863,3 +863,57 @@ class TestAuthMiddlewarePublicRoutes:
         # Will likely be 400 (advanced auth not enabled) but that's okay -
         # Will likely be 400 (advanced auth not enabled) but that's okay -
         # the important thing is it's not blocked by auth middleware
         # the important thing is it's not blocked by auth middleware
         assert response.status_code in [200, 400]
         assert response.status_code in [200, 400]
+
+
+# ===========================================================================
+# H-1: Input length validation
+# ===========================================================================
+
+
+class TestInputLengthValidation:
+    """LoginRequest and SetupRequest must reject oversized inputs (H-1)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_password_too_long_rejected(self, async_client: AsyncClient):
+        """Password exceeding 256 characters must be rejected with 422."""
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "admin", "password": "x" * 257},
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_username_too_long_rejected(self, async_client: AsyncClient):
+        """Username exceeding 150 characters must be rejected with 422."""
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "u" * 151, "password": "password"},
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_setup_password_too_long_rejected(self, async_client: AsyncClient):
+        """SetupRequest admin_password exceeding 256 characters must be rejected with 422."""
+        response = await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "admin",
+                "admin_password": "x" * 257,
+            },
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_password_at_limit_accepted(self, async_client: AsyncClient):
+        """Password of exactly 256 characters must pass schema validation (may fail auth)."""
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "admin", "password": "x" * 256},
+        )
+        # Schema accepts it; auth may reject with 401 (auth disabled) or 400
+        assert response.status_code != 422

+ 130 - 0
backend/tests/integration/test_client_ip.py

@@ -0,0 +1,130 @@
+"""Unit tests for _get_client_ip (M-R9-A / M-R10-A).
+
+Covers:
+- Direct connection without TRUSTED_PROXY_IPS → returns client.host
+- Trusted proxy with XFF → walks right-to-left, returns first non-proxy IP
+- Spoofed XFF from an untrusted client → client.host is returned
+- Multiple trusted proxies in chain → returns leftmost non-proxy entry
+- All XFF entries are trusted proxies → falls back to leftmost
+- Empty XFF header with trusted proxy → returns direct_ip
+- No client (client=None) → returns unique per-request token
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+
+def _make_request(client_host: str | None, xff: str = "") -> MagicMock:
+    """Create a minimal mock Request with given client.host and X-Forwarded-For."""
+    req = MagicMock()
+    if client_host is None:
+        req.client = None
+    else:
+        req.client = MagicMock()
+        req.client.host = client_host
+    req.headers = MagicMock()
+    req.headers.get = lambda key, default="": xff if key == "X-Forwarded-For" else default
+    return req
+
+
+def _call(request, trusted: frozenset[str]) -> str:
+    from backend.app.api.routes.auth import _get_client_ip
+
+    with patch("backend.app.api.routes.auth._TRUSTED_PROXY_IPS", trusted):
+        return _get_client_ip(request)
+
+
+# ---------------------------------------------------------------------------
+# No proxy configured (TRUSTED_PROXY_IPS empty)
+# ---------------------------------------------------------------------------
+
+
+def test_no_proxy_returns_client_host():
+    req = _make_request("1.2.3.4")
+    assert _call(req, frozenset()) == "1.2.3.4"
+
+
+def test_no_proxy_xff_ignored():
+    """XFF must be ignored when TRUSTED_PROXY_IPS is not set."""
+    req = _make_request("1.2.3.4", xff="9.9.9.9")
+    assert _call(req, frozenset()) == "1.2.3.4"
+
+
+# ---------------------------------------------------------------------------
+# Trusted proxy present; direct peer is the proxy
+# ---------------------------------------------------------------------------
+
+
+def test_trusted_proxy_returns_rightmost_non_proxy():
+    """Single proxy: XFF = client_ip; direct_ip = proxy_ip → return client."""
+    proxy = "10.0.0.1"
+    client = "203.0.113.5"
+    req = _make_request(proxy, xff=client)
+    assert _call(req, frozenset({proxy})) == client
+
+
+def test_trusted_proxy_chain_skips_proxy_ips():
+    """Multi-hop: client → proxy1 → proxy2 (direct) → app.
+    XFF = 'client, proxy1'; direct = proxy2.  Should return client."""
+    proxy1 = "10.0.0.1"
+    proxy2 = "10.0.0.2"
+    client = "198.51.100.7"
+    req = _make_request(proxy2, xff=f"{client}, {proxy1}")
+    assert _call(req, frozenset({proxy1, proxy2})) == client
+
+
+def test_all_xff_entries_are_proxies_falls_back_to_leftmost():
+    """When every XFF entry is a trusted proxy, return the leftmost (original) entry."""
+    proxy1 = "10.0.0.1"
+    proxy2 = "10.0.0.2"
+    req = _make_request(proxy2, xff=f"{proxy1}, {proxy2}")
+    assert _call(req, frozenset({proxy1, proxy2})) == proxy1
+
+
+def test_empty_xff_with_trusted_proxy_returns_direct_ip():
+    """Trusted proxy but no XFF header → fall through to direct_ip."""
+    proxy = "10.0.0.1"
+    req = _make_request(proxy, xff="")
+    assert _call(req, frozenset({proxy})) == proxy
+
+
+# ---------------------------------------------------------------------------
+# Spoofed XFF from an untrusted client
+# ---------------------------------------------------------------------------
+
+
+def test_spoofed_xff_from_untrusted_client_ignored():
+    """Client not in TRUSTED_PROXY_IPS → XFF is ignored; client.host returned."""
+    untrusted_client = "203.0.113.99"
+    req = _make_request(untrusted_client, xff="1.1.1.1")
+    assert _call(req, frozenset({"10.0.0.1"})) == untrusted_client
+
+
+# ---------------------------------------------------------------------------
+# No client (transport layer provides no address)
+# ---------------------------------------------------------------------------
+
+
+def test_no_client_returns_unique_token():
+    """When request.client is None, each call returns a unique rate-limit sentinel."""
+    req1 = _make_request(None)
+    req2 = _make_request(None)
+    ip1 = _call(req1, frozenset())
+    ip2 = _call(req2, frozenset())
+    assert ip1.startswith("__no_ip_")
+    assert ip2.startswith("__no_ip_")
+    assert ip1 != ip2, "Each missing-client request must get a distinct sentinel"
+
+
+# ---------------------------------------------------------------------------
+# Whitespace in XFF values
+# ---------------------------------------------------------------------------
+
+
+def test_xff_with_extra_whitespace_trimmed():
+    """IPs in XFF with leading/trailing spaces are handled correctly."""
+    proxy = "10.0.0.1"
+    client = "192.0.2.33"
+    req = _make_request(proxy, xff=f"  {client}  ,  {proxy}  ")
+    assert _call(req, frozenset({proxy})) == client

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

@@ -0,0 +1,3016 @@
+"""Integration tests for 2FA and OIDC API endpoints.
+
+Tests the full request/response cycle for:
+- GET  /api/v1/auth/2fa/status
+- POST /api/v1/auth/2fa/totp/setup
+- POST /api/v1/auth/2fa/totp/enable
+- POST /api/v1/auth/2fa/totp/disable
+- POST /api/v1/auth/2fa/email/enable
+- POST /api/v1/auth/2fa/email/disable
+- POST /api/v1/auth/2fa/verify   (TOTP, email, backup paths)
+- DELETE /api/v1/auth/2fa/admin/{user_id}
+- GET  /api/v1/auth/oidc/providers
+- POST /api/v1/auth/oidc/providers
+- PATCH /api/v1/auth/oidc/providers/{id}
+- DELETE /api/v1/auth/oidc/providers/{id}
+"""
+
+from __future__ import annotations
+
+import secrets
+from datetime import datetime, timedelta, timezone
+
+import pyotp
+import pytest
+from httpx import AsyncClient
+from passlib.context import CryptContext
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.auth_ephemeral import AuthEphemeralToken
+from backend.app.models.user import User
+
+_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
+
+# ---------------------------------------------------------------------------
+# Fixtures / helpers
+# ---------------------------------------------------------------------------
+
+AUTH_SETUP_URL = "/api/v1/auth/setup"
+LOGIN_URL = "/api/v1/auth/login"
+
+
+def _norm_pw(password: str) -> str:
+    """Ensure password meets complexity requirements (I4: SetupRequest now validates)."""
+    if not any(c.isupper() for c in password):
+        password = password[0].upper() + password[1:]
+    if not any(c not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" for c in password):
+        password = password + "!"
+    return password
+
+
+async def _setup_and_login(client: AsyncClient, username: str, password: str) -> str:
+    """Enable auth, create an admin user, login, and return the bearer token."""
+    password = _norm_pw(password)
+    await client.post(
+        AUTH_SETUP_URL,
+        json={
+            "auth_enabled": True,
+            "admin_username": username,
+            "admin_password": password,
+        },
+    )
+    resp = await client.post(LOGIN_URL, json={"username": username, "password": password})
+    assert resp.status_code == 200
+    return resp.json()["access_token"]
+
+
+async def _login_get_pre_auth_token(client: AsyncClient, username: str, password: str) -> str:
+    """Login a user who has 2FA enabled; return the pre_auth_token from the response."""
+    password = _norm_pw(password)
+    resp = await client.post(LOGIN_URL, json={"username": username, "password": password})
+    assert resp.status_code == 200
+    data = resp.json()
+    assert data["requires_2fa"] is True, f"Expected requires_2fa=True, got {data}"
+    assert data["pre_auth_token"] is not None
+    return data["pre_auth_token"]
+
+
+def _auth_header(token: str) -> dict[str, str]:
+    return {"Authorization": f"Bearer {token}"}
+
+
+# ===========================================================================
+# 2FA Status
+# ===========================================================================
+
+
+class TestTwoFAStatus:
+    """Tests for GET /api/v1/auth/2fa/status."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_status_requires_auth(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/auth/2fa/status")
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_status_default_disabled(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "statususer", "statuspass123")
+        response = await async_client.get("/api/v1/auth/2fa/status", headers=_auth_header(token))
+        assert response.status_code == 200
+        data = response.json()
+        assert data["totp_enabled"] is False
+        assert data["email_otp_enabled"] is False
+        assert data["backup_codes_remaining"] == 0
+
+
+# ===========================================================================
+# TOTP Setup
+# ===========================================================================
+
+
+class TestTOTPSetup:
+    """Tests for POST /api/v1/auth/2fa/totp/setup."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_setup_requires_auth(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/auth/2fa/totp/setup")
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_setup_returns_secret_and_qr(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "totpsetup", "totpsetup123")
+        response = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        assert response.status_code == 200
+        data = response.json()
+        assert "secret" in data
+        assert len(data["secret"]) > 0
+        assert "qr_code_b64" in data
+        assert data["issuer"] == "Bambuddy"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_setup_secret_is_valid_base32(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "totpbase32", "totpbase32pw")
+        response = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        assert response.status_code == 200
+        secret = response.json()["secret"]
+        # pyotp will raise on invalid base32
+        totp = pyotp.TOTP(secret)
+        assert len(totp.now()) == 6
+
+
+# ===========================================================================
+# TOTP Enable
+# ===========================================================================
+
+
+class TestTOTPEnable:
+    """Tests for POST /api/v1/auth/2fa/totp/enable."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_enable_without_setup_returns_400(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "nosetupenable", "nosetupenable1")
+        response = await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": "123456"},
+            headers=_auth_header(token),
+        )
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_enable_with_invalid_code_returns_400(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "badcodeuser", "badcodeuser1")
+        await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        response = await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": "000000"},
+            headers=_auth_header(token),
+        )
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_enable_with_valid_code_returns_backup_codes(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "enableok", "enableok123")
+        setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        secret = setup_resp.json()["secret"]
+        valid_code = pyotp.TOTP(secret).now()
+
+        response = await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": valid_code},
+            headers=_auth_header(token),
+        )
+        assert response.status_code == 200
+        data = response.json()
+        assert "backup_codes" in data
+        assert len(data["backup_codes"]) == 10
+        for code in data["backup_codes"]:
+            assert len(code) == 8
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_status_reflects_enabled_totp(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "statustotp", "statustotp1")
+        setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        secret = setup_resp.json()["secret"]
+        valid_code = pyotp.TOTP(secret).now()
+        await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": valid_code},
+            headers=_auth_header(token),
+        )
+
+        status_resp = await async_client.get("/api/v1/auth/2fa/status", headers=_auth_header(token))
+        data = status_resp.json()
+        assert data["totp_enabled"] is True
+        assert data["backup_codes_remaining"] == 10
+
+
+# ===========================================================================
+# TOTP Disable
+# ===========================================================================
+
+
+class TestTOTPDisable:
+    """Tests for POST /api/v1/auth/2fa/totp/disable."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_disable_when_not_enabled_returns_400(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "disablenoenab", "disablenoenab1")
+        response = await async_client.post(
+            "/api/v1/auth/2fa/totp/disable",
+            json={"code": "123456"},
+            headers=_auth_header(token),
+        )
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_disable_with_valid_code(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "disableok", "disableok123")
+        setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        secret = setup_resp.json()["secret"]
+        valid_code = pyotp.TOTP(secret).now()
+        await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": valid_code},
+            headers=_auth_header(token),
+        )
+
+        # Disable with a fresh valid code
+        disable_code = pyotp.TOTP(secret).now()
+        response = await async_client.post(
+            "/api/v1/auth/2fa/totp/disable",
+            json={"code": disable_code},
+            headers=_auth_header(token),
+        )
+        assert response.status_code == 200
+        assert "disabled" in response.json()["message"].lower()
+
+        # Status should now show disabled
+        status_resp = await async_client.get("/api/v1/auth/2fa/status", headers=_auth_header(token))
+        assert status_resp.json()["totp_enabled"] is False
+
+
+# ===========================================================================
+# Email OTP Enable/Disable
+# ===========================================================================
+
+
+class TestEmailOTP:
+    """Tests for POST /api/v1/auth/2fa/email/enable, /enable/confirm and /disable."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_enable_email_otp_without_email_returns_400(self, async_client: AsyncClient):
+        """Users without an email address cannot enable email OTP."""
+        token = await _setup_and_login(async_client, "noemailuser", "noemailuser1")
+        response = await async_client.post("/api/v1/auth/2fa/email/enable", headers=_auth_header(token))
+        assert response.status_code == 400
+        assert "email" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_confirm_enable_email_otp_happy_path(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Confirm step activates email OTP when setup_token + code are valid (C5)."""
+        token = await _setup_and_login(async_client, "confirmenable", "confirmenable1")
+
+        # Give user an email address directly (SMTP not available in tests)
+        from sqlalchemy import select as sa_select
+
+        result = await db_session.execute(sa_select(User).where(User.username == "confirmenable"))
+        user = result.scalar_one()
+        user.email = "confirmenable@example.com"
+        await db_session.commit()
+
+        # Inject a known setup token directly into the DB (bypasses SMTP)
+        code = "123456"
+        code_hash = _pwd_context.hash(code)
+        setup_token = secrets.token_urlsafe(32)
+        db_session.add(
+            AuthEphemeralToken(
+                token=setup_token,
+                token_type="email_otp_setup",
+                username="confirmenable",
+                nonce=code_hash,
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
+            )
+        )
+        await db_session.commit()
+
+        resp = await async_client.post(
+            "/api/v1/auth/2fa/email/enable/confirm",
+            json={"setup_token": setup_token, "code": code},
+            headers=_auth_header(token),
+        )
+        assert resp.status_code == 200
+
+        status_resp = await async_client.get("/api/v1/auth/2fa/status", headers=_auth_header(token))
+        assert status_resp.json()["email_otp_enabled"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_confirm_enable_email_otp_wrong_code(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Wrong code on confirm step returns 400 and does not enable email OTP."""
+        token = await _setup_and_login(async_client, "confirmwrong", "confirmwrong1")
+
+        code_hash = _pwd_context.hash("654321")
+        setup_token = secrets.token_urlsafe(32)
+        db_session.add(
+            AuthEphemeralToken(
+                token=setup_token,
+                token_type="email_otp_setup",
+                username="confirmwrong",
+                nonce=code_hash,
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
+            )
+        )
+        await db_session.commit()
+
+        resp = await async_client.post(
+            "/api/v1/auth/2fa/email/enable/confirm",
+            json={"setup_token": setup_token, "code": "000000"},
+            headers=_auth_header(token),
+        )
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_confirm_enable_email_otp_setup_token_is_single_use(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """Setup token is consumed on first use; replay returns 400."""
+        token = await _setup_and_login(async_client, "confirmonce", "confirmonce1")
+
+        code = "111111"
+        code_hash = _pwd_context.hash(code)
+        setup_token = secrets.token_urlsafe(32)
+        db_session.add(
+            AuthEphemeralToken(
+                token=setup_token,
+                token_type="email_otp_setup",
+                username="confirmonce",
+                nonce=code_hash,
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
+            )
+        )
+        await db_session.commit()
+
+        first = await async_client.post(
+            "/api/v1/auth/2fa/email/enable/confirm",
+            json={"setup_token": setup_token, "code": code},
+            headers=_auth_header(token),
+        )
+        assert first.status_code == 200
+
+        second = await async_client.post(
+            "/api/v1/auth/2fa/email/enable/confirm",
+            json={"setup_token": setup_token, "code": code},
+            headers=_auth_header(token),
+        )
+        assert second.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_disable_email_otp_requires_password(self, async_client: AsyncClient):
+        """Disabling email OTP requires the account password (C6: re-auth)."""
+        token = await _setup_and_login(async_client, "disemailotp", "disemailotp1")
+        # Wrong password → 401
+        response = await async_client.post(
+            "/api/v1/auth/2fa/email/disable",
+            json={"password": "wrongpassword"},
+            headers=_auth_header(token),
+        )
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_disable_email_otp_when_enabled(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Disabling email OTP when enabled turns it off and status reflects that."""
+        token = await _setup_and_login(async_client, "disemailpw", "disemailpw1")
+
+        # Enable email OTP via direct DB injection (no SMTP)
+        code = "222222"
+        setup_token = secrets.token_urlsafe(32)
+        db_session.add(
+            AuthEphemeralToken(
+                token=setup_token,
+                token_type="email_otp_setup",
+                username="disemailpw",
+                nonce=_pwd_context.hash(code),
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
+            )
+        )
+        await db_session.commit()
+        await async_client.post(
+            "/api/v1/auth/2fa/email/enable/confirm",
+            json={"setup_token": setup_token, "code": code},
+            headers=_auth_header(token),
+        )
+
+        # Now disable
+        response = await async_client.post(
+            "/api/v1/auth/2fa/email/disable",
+            json={"password": _norm_pw("disemailpw1")},
+            headers=_auth_header(token),
+        )
+        assert response.status_code == 200
+
+        status_resp = await async_client.get("/api/v1/auth/2fa/status", headers=_auth_header(token))
+        assert status_resp.json()["email_otp_enabled"] is False
+
+
+# ===========================================================================
+# 2FA Verify — TOTP path
+# ===========================================================================
+
+
+class TestTwoFAVerifyTOTP:
+    """Tests for POST /api/v1/auth/2fa/verify using the TOTP method."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_verify_with_invalid_pre_auth_token(self, async_client: AsyncClient):
+        response = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": "bogus", "method": "totp", "code": "123456"},
+        )
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_verify_totp_issues_jwt(self, async_client: AsyncClient):
+        """Full flow: setup → enable TOTP → login → pre_auth_token → verify → JWT."""
+        token = await _setup_and_login(async_client, "verifytotpok", "verifytotpok1")
+        setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        secret = setup_resp.json()["secret"]
+        valid_code = pyotp.TOTP(secret).now()
+        await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": valid_code},
+            headers=_auth_header(token),
+        )
+
+        # Login now returns requires_2fa=True + pre_auth_token
+        pre_auth_token = await _login_get_pre_auth_token(async_client, "verifytotpok", "verifytotpok1")
+
+        verify_resp = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={
+                "pre_auth_token": pre_auth_token,
+                "method": "totp",
+                "code": pyotp.TOTP(secret).now(),
+            },
+        )
+        assert verify_resp.status_code == 200
+        data = verify_resp.json()
+        assert "access_token" in data
+        assert data["token_type"] == "bearer"
+        assert data["user"]["username"] == "verifytotpok"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_verify_totp_invalid_code(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "verifybadcode", "verifybadcode1")
+        setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        secret = setup_resp.json()["secret"]
+        valid_code = pyotp.TOTP(secret).now()
+        await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": valid_code},
+            headers=_auth_header(token),
+        )
+
+        pre_auth_token = await _login_get_pre_auth_token(async_client, "verifybadcode", "verifybadcode1")
+        verify_resp = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": pre_auth_token, "method": "totp", "code": "000000"},
+        )
+        assert verify_resp.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_verify_invalid_method(self, async_client: AsyncClient):
+        """An invalid 2FA method should return 400 even with a valid pre_auth_token."""
+        token = await _setup_and_login(async_client, "invalidmethod", "invalidmethod1")
+        setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        secret = setup_resp.json()["secret"]
+        valid_code = pyotp.TOTP(secret).now()
+        await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": valid_code},
+            headers=_auth_header(token),
+        )
+        pre_auth_token = await _login_get_pre_auth_token(async_client, "invalidmethod", "invalidmethod1")
+        response = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": pre_auth_token, "method": "sms", "code": "123456"},
+        )
+        assert response.status_code == 422  # Pydantic Literal validation
+
+
+# ===========================================================================
+# 2FA Verify — Backup code path
+# ===========================================================================
+
+
+class TestTwoFAVerifyBackup:
+    """Tests for POST /api/v1/auth/2fa/verify using the backup method."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_verify_with_backup_code(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "backupcodeok", "backupcodeok1")
+        setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        secret = setup_resp.json()["secret"]
+        valid_code = pyotp.TOTP(secret).now()
+        enable_resp = await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": valid_code},
+            headers=_auth_header(token),
+        )
+        backup_code = enable_resp.json()["backup_codes"][0]
+
+        pre_auth_token = await _login_get_pre_auth_token(async_client, "backupcodeok", "backupcodeok1")
+        verify_resp = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": pre_auth_token, "method": "backup", "code": backup_code},
+        )
+        assert verify_resp.status_code == 200
+        assert "access_token" in verify_resp.json()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_backup_code_is_single_use(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "backupsingle", "backupsingle1")
+        setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        secret = setup_resp.json()["secret"]
+        valid_code = pyotp.TOTP(secret).now()
+        enable_resp = await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": valid_code},
+            headers=_auth_header(token),
+        )
+        backup_code = enable_resp.json()["backup_codes"][0]
+
+        # First use — should succeed
+        pre_auth_token = await _login_get_pre_auth_token(async_client, "backupsingle", "backupsingle1")
+        first_resp = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": pre_auth_token, "method": "backup", "code": backup_code},
+        )
+        assert first_resp.status_code == 200
+
+        # Second use of the same code — must fail (need new pre_auth_token + same backup code)
+        pre_auth_token2 = await _login_get_pre_auth_token(async_client, "backupsingle", "backupsingle1")
+        second_resp = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": pre_auth_token2, "method": "backup", "code": backup_code},
+        )
+        assert second_resp.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_backup_code_count_decrements(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "backupcount", "backupcount1")
+        setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        secret = setup_resp.json()["secret"]
+        valid_code = pyotp.TOTP(secret).now()
+        enable_resp = await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": valid_code},
+            headers=_auth_header(token),
+        )
+        backup_code = enable_resp.json()["backup_codes"][0]
+
+        pre_auth_token = await _login_get_pre_auth_token(async_client, "backupcount", "backupcount1")
+        await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": pre_auth_token, "method": "backup", "code": backup_code},
+        )
+
+        # Status is readable with the original full token (still valid)
+        status_resp = await async_client.get("/api/v1/auth/2fa/status", headers=_auth_header(token))
+        assert status_resp.json()["backup_codes_remaining"] == 9
+
+
+# ===========================================================================
+# Rate Limiting
+# ===========================================================================
+
+
+class TestRateLimiting:
+    """Ensure 429 is returned after 5 failed 2FA attempts."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rate_limit_lockout(self, async_client: AsyncClient):
+        """After 5 failed TOTP attempts the 6th must return 429."""
+        token = await _setup_and_login(async_client, "ratelimituser", "ratelimituser1")
+        setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        secret = setup_resp.json()["secret"]
+        valid_code = pyotp.TOTP(secret).now()
+        await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": valid_code},
+            headers=_auth_header(token),
+        )
+
+        # 5 failed attempts via the login → pre_auth_token → verify flow
+        for _ in range(5):
+            pre_auth_token = await _login_get_pre_auth_token(async_client, "ratelimituser", "ratelimituser1")
+            await async_client.post(
+                "/api/v1/auth/2fa/verify",
+                json={"pre_auth_token": pre_auth_token, "method": "totp", "code": "000000"},
+            )
+
+        # 6th attempt should hit the rate limit
+        pre_auth_token = await _login_get_pre_auth_token(async_client, "ratelimituser", "ratelimituser1")
+        response = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": pre_auth_token, "method": "totp", "code": "000000"},
+        )
+        assert response.status_code == 429
+
+
+# ===========================================================================
+# Admin 2FA Disable
+# ===========================================================================
+
+
+class TestAdminDisable2FA:
+    """Tests for DELETE /api/v1/auth/2fa/admin/{user_id}."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_disable_requires_admin(self, async_client: AsyncClient):
+        """Only admins can use the admin disable endpoint."""
+        # The only user in a fresh setup IS admin, so just check the 404 path
+        token = await _setup_and_login(async_client, "admincheck", "admincheck123")
+        # Try to disable for a non-existent user_id — should get 200 (no-op) or 404
+        response = await async_client.request(
+            "DELETE",
+            "/api/v1/auth/2fa/admin/99999",
+            json={"admin_password": _norm_pw("admincheck123")},
+            headers=_auth_header(token),
+        )
+        # Admin users succeed regardless (returns 200 even if user doesn't exist)
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_disable_clears_totp(self, async_client: AsyncClient):
+        from sqlalchemy import select
+
+        from backend.app.models.user import User
+
+        token = await _setup_and_login(async_client, "admintotp", "admintotp123")
+        setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        secret = setup_resp.json()["secret"]
+        valid_code = pyotp.TOTP(secret).now()
+        await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": valid_code},
+            headers=_auth_header(token),
+        )
+
+        # Find the user's id by querying status (which works with the token)
+        me_resp = await async_client.get("/api/v1/auth/me", headers=_auth_header(token))
+        user_id = me_resp.json()["id"]
+
+        response = await async_client.request(
+            "DELETE",
+            f"/api/v1/auth/2fa/admin/{user_id}",
+            json={"admin_password": _norm_pw("admintotp123")},
+            headers=_auth_header(token),
+        )
+        assert response.status_code == 200
+
+        # I2: admin_disable_2fa bumps password_changed_at, invalidating the old token.
+        # Re-login to get a fresh token before checking status.
+        new_login = await async_client.post(
+            LOGIN_URL, json={"username": "admintotp", "password": _norm_pw("admintotp123")}
+        )
+        assert new_login.status_code == 200, f"re-login failed: {new_login.json()}"
+        assert new_login.json().get("requires_2fa") is False, f"still requires 2FA: {new_login.json()}"
+        new_token = new_login.json()["access_token"]
+        assert new_token is not None, f"no access_token in: {new_login.json()}"
+
+        # Status should now show TOTP disabled
+        status_resp = await async_client.get("/api/v1/auth/2fa/status", headers=_auth_header(new_token))
+        assert status_resp.status_code == 200, f"status check failed: {status_resp.json()}"
+        assert status_resp.json()["totp_enabled"] is False
+
+
+# ===========================================================================
+# OIDC Provider CRUD
+# ===========================================================================
+
+
+class TestOIDCProviders:
+    """Tests for OIDC provider management endpoints."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_public_providers_empty(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/auth/oidc/providers")
+        assert response.status_code == 200
+        assert isinstance(response.json(), list)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_provider_requires_admin(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "oidcadmincreate", "oidcadmincreate1")
+        response = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "PocketID",
+                "issuer_url": "https://auth.example.com",
+                "client_id": "bambuddy",
+                "client_secret": "supersecret",
+                "scopes": "openid email profile",
+                "is_enabled": True,
+                "auto_create_users": False,
+            },
+            headers=_auth_header(token),
+        )
+        assert response.status_code == 201
+        data = response.json()
+        assert data["name"] == "PocketID"
+        assert data["issuer_url"] == "https://auth.example.com"
+        assert "client_secret" not in data  # Secret must not be returned
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_created_provider_appears_in_all_list(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "oidclistall", "oidclistall123")
+        await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "TestProvider",
+                "issuer_url": "https://test.example.com",
+                "client_id": "testclient",
+                "client_secret": "testsecret",
+                "scopes": "openid",
+                "is_enabled": True,
+                "auto_create_users": False,
+            },
+            headers=_auth_header(token),
+        )
+        response = await async_client.get("/api/v1/auth/oidc/providers/all", headers=_auth_header(token))
+        assert response.status_code == 200
+        names = [p["name"] for p in response.json()]
+        assert "TestProvider" in names
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_disabled_provider_not_in_public_list(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "oidcdisabled", "oidcdisabled1")
+        await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "DisabledProvider",
+                "issuer_url": "https://disabled.example.com",
+                "client_id": "dc",
+                "client_secret": "ds",
+                "scopes": "openid",
+                "is_enabled": False,
+                "auto_create_users": False,
+            },
+            headers=_auth_header(token),
+        )
+        response = await async_client.get("/api/v1/auth/oidc/providers")
+        names = [p["name"] for p in response.json()]
+        assert "DisabledProvider" not in names
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_provider(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "oidcupdate", "oidcupdate123")
+        create_resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "OldName",
+                "issuer_url": "https://update.example.com",
+                "client_id": "uc",
+                "client_secret": "us",
+                "scopes": "openid",
+                "is_enabled": True,
+                "auto_create_users": False,
+            },
+            headers=_auth_header(token),
+        )
+        provider_id = create_resp.json()["id"]
+
+        put_resp = await async_client.put(
+            f"/api/v1/auth/oidc/providers/{provider_id}",
+            json={"name": "NewName"},
+            headers=_auth_header(token),
+        )
+        assert put_resp.status_code == 200
+        assert put_resp.json()["name"] == "NewName"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_provider(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "oidcdelete", "oidcdelete123")
+        create_resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "ToDelete",
+                "issuer_url": "https://delete.example.com",
+                "client_id": "dc",
+                "client_secret": "ds",
+                "scopes": "openid",
+                "is_enabled": True,
+                "auto_create_users": False,
+            },
+            headers=_auth_header(token),
+        )
+        provider_id = create_resp.json()["id"]
+
+        del_resp = await async_client.delete(
+            f"/api/v1/auth/oidc/providers/{provider_id}",
+            headers=_auth_header(token),
+        )
+        assert del_resp.status_code == 200
+
+        # No longer in list
+        all_resp = await async_client.get("/api/v1/auth/oidc/providers/all", headers=_auth_header(token))
+        ids = [p["id"] for p in all_resp.json()]
+        assert provider_id not in ids
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_nonexistent_provider_returns_404(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "oidc404", "oidc404pass1")
+        response = await async_client.put(
+            "/api/v1/auth/oidc/providers/99999",
+            json={"name": "ghost"},
+            headers=_auth_header(token),
+        )
+        assert response.status_code == 404
+
+
+# ===========================================================================
+# Security: pre-auth token single-use
+# ===========================================================================
+
+
+class TestPreAuthTokenSingleUse:
+    """pre_auth_token must be consumed on successful 2FA and cannot be reused."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pre_auth_token_is_single_use(self, async_client: AsyncClient):
+        """A pre_auth_token that was successfully used cannot be reused."""
+        token = await _setup_and_login(async_client, "singleusepat", "singleusepat1")
+        setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        secret = setup_resp.json()["secret"]
+        valid_code = pyotp.TOTP(secret).now()
+        await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": valid_code},
+            headers=_auth_header(token),
+        )
+
+        pre_auth_token = await _login_get_pre_auth_token(async_client, "singleusepat", "singleusepat1")
+
+        # First use — succeeds
+        first = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": pre_auth_token, "method": "totp", "code": pyotp.TOTP(secret).now()},
+        )
+        assert first.status_code == 200
+
+        # Second use of the same token — must fail (token already consumed on success)
+        second = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": pre_auth_token, "method": "totp", "code": pyotp.TOTP(secret).now()},
+        )
+        assert second.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pre_auth_token_survives_wrong_code(self, async_client: AsyncClient):
+        """A wrong 2FA code must NOT burn the pre_auth_token (user can retry)."""
+        token = await _setup_and_login(async_client, "survivepatuser", "survivepatuser1")
+        setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        secret = setup_resp.json()["secret"]
+        valid_code = pyotp.TOTP(secret).now()
+        await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": valid_code},
+            headers=_auth_header(token),
+        )
+
+        pre_auth_token = await _login_get_pre_auth_token(async_client, "survivepatuser", "survivepatuser1")
+
+        # Wrong code — should fail but not burn the token
+        bad = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": pre_auth_token, "method": "totp", "code": "000000"},
+        )
+        assert bad.status_code == 401
+
+        # Same token, correct code — should succeed (token still valid)
+        good = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": pre_auth_token, "method": "totp", "code": pyotp.TOTP(secret).now()},
+        )
+        assert good.status_code == 200
+
+
+# ===========================================================================
+# Security: cross-user token isolation
+# ===========================================================================
+
+
+class TestCrossUserTokenIsolation:
+    """A pre_auth_token issued for user A cannot authenticate as user B."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_token_cannot_be_used_for_different_user(self, async_client: AsyncClient):
+        """pre_auth_token is bound to the issuing user; using it to verify a different
+        user's TOTP code must fail."""
+        # Set up two users with TOTP
+        token_a = await _setup_and_login(async_client, "crossusera", "crossusera1")
+        setup_a = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token_a))
+        secret_a = setup_a.json()["secret"]
+        await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": pyotp.TOTP(secret_a).now()},
+            headers=_auth_header(token_a),
+        )
+
+        # Get pre_auth_token for user A
+        pre_auth_a = await _login_get_pre_auth_token(async_client, "crossusera", "crossusera1")
+
+        # Try to use user A's token but supply a clearly invalid code — must fail
+        resp = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": pre_auth_a, "method": "totp", "code": "000000"},
+        )
+        assert resp.status_code == 401
+
+
+# ===========================================================================
+# Security: admin disable non-admin rejection
+# ===========================================================================
+
+
+class TestAdminDisableNonAdminRejection:
+    """Non-admin users must be rejected from the admin disable endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_non_admin_cannot_disable_2fa(self, async_client: AsyncClient):
+        """A regular (non-admin) user must receive 403 from DELETE /auth/2fa/admin/{id}."""
+        # Set up admin, then create a regular user
+        admin_token = await _setup_and_login(async_client, "adminusr2fa", "adminusr2fa1")
+
+        # Create a regular user via user management
+        create_resp = await async_client.post(
+            "/api/v1/users",
+            json={"username": "regularusr2fa", "password": "Regularusr2fa1!"},
+            headers=_auth_header(admin_token),
+        )
+        assert create_resp.status_code == 201
+
+        # Login as regular user
+        login_resp = await async_client.post(
+            LOGIN_URL,
+            json={"username": "regularusr2fa", "password": "Regularusr2fa1!"},
+        )
+        regular_token = login_resp.json()["access_token"]
+
+        # Try to call admin endpoint with the regular user's token
+        resp = await async_client.delete(
+            f"/api/v1/auth/2fa/admin/{create_resp.json()['id']}",
+            headers=_auth_header(regular_token),
+        )
+        assert resp.status_code == 403
+
+
+# ===========================================================================
+# Regenerate backup codes
+# ===========================================================================
+
+
+class TestRegenerateBackupCodes:
+    """Tests for POST /api/v1/auth/2fa/totp/regenerate-backup-codes."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_regenerate_requires_totp_enabled(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "regennototp", "regennototp1")
+        resp = await async_client.post(
+            "/api/v1/auth/2fa/totp/regenerate-backup-codes",
+            json={"code": "123456"},
+            headers=_auth_header(token),
+        )
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_regenerate_invalidates_old_codes(self, async_client: AsyncClient):
+        """After regenerating, old backup codes must no longer work."""
+        token = await _setup_and_login(async_client, "regeninval", "regeninval1")
+        setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        secret = setup_resp.json()["secret"]
+        enable_resp = await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": pyotp.TOTP(secret).now()},
+            headers=_auth_header(token),
+        )
+        old_backup = enable_resp.json()["backup_codes"][0]
+
+        # Regenerate backup codes
+        regen_resp = await async_client.post(
+            "/api/v1/auth/2fa/totp/regenerate-backup-codes",
+            json={"code": pyotp.TOTP(secret).now()},
+            headers=_auth_header(token),
+        )
+        assert regen_resp.status_code == 200
+        new_codes = regen_resp.json()["backup_codes"]
+        assert len(new_codes) == 10
+        assert old_backup not in new_codes
+
+        # Old backup code must now fail
+        pre_auth_token = await _login_get_pre_auth_token(async_client, "regeninval", "regeninval1")
+        fail_resp = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": pre_auth_token, "method": "backup", "code": old_backup},
+        )
+        assert fail_resp.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_regenerate_with_invalid_code_fails(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "regeninvcode", "regeninvcode1")
+        setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        secret = setup_resp.json()["secret"]
+        await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": pyotp.TOTP(secret).now()},
+            headers=_auth_header(token),
+        )
+
+        resp = await async_client.post(
+            "/api/v1/auth/2fa/totp/regenerate-backup-codes",
+            json={"code": "000000"},
+            headers=_auth_header(token),
+        )
+        assert resp.status_code == 400
+
+
+# ===========================================================================
+# Security: method field validation
+# ===========================================================================
+
+
+class TestVerifyMethodValidation:
+    """The method field must be one of totp/email/backup (Pydantic Literal)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_invalid_method_rejected_by_schema(self, async_client: AsyncClient):
+        """Pydantic should reject unknown method values with 422."""
+        resp = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": "anytoken", "code": "123456", "method": "sms"},
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_oversized_pre_auth_token_rejected(self, async_client: AsyncClient):
+        """pre_auth_token exceeding max_length=128 should be rejected with 422."""
+        resp = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": "x" * 200, "code": "123456", "method": "totp"},
+        )
+        assert resp.status_code == 422
+
+
+# ===========================================================================
+# Login response shape for 2FA users
+# ===========================================================================
+
+
+class TestLoginResponseShape:
+    """Login for a 2FA-enabled user must return requires_2fa+pre_auth_token
+    and must NOT include access_token (which would bypass the 2FA gate)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_2fa_user_omits_access_token(self, async_client: AsyncClient):
+        """A user with TOTP enabled must not receive an access_token on /auth/login."""
+        token = await _setup_and_login(async_client, "loginshape", "loginshape1")
+        setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        secret = setup_resp.json()["secret"]
+        await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": pyotp.TOTP(secret).now()},
+            headers=_auth_header(token),
+        )
+
+        login_resp = await async_client.post(LOGIN_URL, json={"username": "loginshape", "password": "Loginshape1!"})
+        assert login_resp.status_code == 200
+        data = login_resp.json()
+        assert data.get("requires_2fa") is True
+        assert data.get("pre_auth_token") is not None
+        # access_token must NOT be present — it would bypass the 2FA gate
+        assert "access_token" not in data or data["access_token"] is None
+
+
+# ===========================================================================
+# TOTP replay protection
+# ===========================================================================
+
+
+async def _setup_totp_user(client: AsyncClient, username: str, password: str) -> tuple[str, str]:
+    """Create user, set up and enable TOTP; return (bearer_token, totp_secret)."""
+    token = await _setup_and_login(client, username, password)
+    setup_resp = await client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+    secret = setup_resp.json()["secret"]
+    await client.post(
+        "/api/v1/auth/2fa/totp/enable",
+        json={"code": pyotp.TOTP(secret).now()},
+        headers=_auth_header(token),
+    )
+    return token, secret
+
+
+class TestTOTPReplay:
+    """The same TOTP code must not be accepted twice within one 30-second window."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_totp_replay_rejected_on_verify(self, async_client: AsyncClient):
+        """Replaying the same code on /2fa/verify must return 400."""
+        _token, secret = await _setup_totp_user(async_client, "replayverify", "replayverify1")
+        code = pyotp.TOTP(secret).now()
+
+        pre_auth = await _login_get_pre_auth_token(async_client, "replayverify", "replayverify1")
+        first = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": pre_auth, "method": "totp", "code": code},
+        )
+        assert first.status_code == 200
+
+        # Second login to get a fresh pre_auth_token (first was consumed)
+        pre_auth2 = await _login_get_pre_auth_token(async_client, "replayverify", "replayverify1")
+        second = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": pre_auth2, "method": "totp", "code": code},
+        )
+        assert second.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_totp_replay_rejected_on_disable(self, async_client: AsyncClient):
+        """A code already used in verify_2fa must be rejected on /2fa/totp/disable."""
+        _setup_token, secret = await _setup_totp_user(async_client, "replaydisable", "replaydisable1")
+        code = pyotp.TOTP(secret).now()
+
+        # Use the code in verify_2fa — this sets last_totp_counter in DB
+        pre_auth = await _login_get_pre_auth_token(async_client, "replaydisable", "replaydisable1")
+        verify_resp = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": pre_auth, "method": "totp", "code": code},
+        )
+        assert verify_resp.status_code == 200
+        authed_token = verify_resp.json()["access_token"]
+
+        # Replay the same code on disable — must be rejected (same 30-second window)
+        disable_resp = await async_client.post(
+            "/api/v1/auth/2fa/totp/disable",
+            json={"code": code},
+            headers=_auth_header(authed_token),
+        )
+        assert disable_resp.status_code == 400
+
+
+# ===========================================================================
+# Rate limiting on disable_totp and regenerate_backup_codes (I10)
+# ===========================================================================
+
+
+class TestRateLimitingDisableRegenerate:
+    """disable_totp and regenerate_backup_codes must enforce rate limiting (I10)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_disable_totp_rate_limited_after_failures(self, async_client: AsyncClient):
+        """Repeated wrong codes on /2fa/totp/disable trigger 429."""
+        token, _secret = await _setup_totp_user(async_client, "rldisable", "rldisable1")
+        for _ in range(5):
+            await async_client.post(
+                "/api/v1/auth/2fa/totp/disable",
+                json={"code": "000000"},
+                headers=_auth_header(token),
+            )
+        resp = await async_client.post(
+            "/api/v1/auth/2fa/totp/disable",
+            json={"code": "000000"},
+            headers=_auth_header(token),
+        )
+        assert resp.status_code == 429
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_regenerate_backup_codes_rate_limited_after_failures(self, async_client: AsyncClient):
+        """Repeated wrong codes on /2fa/totp/regenerate-backup-codes trigger 429."""
+        token, _secret = await _setup_totp_user(async_client, "rlregen", "rlregen1")
+        for _ in range(5):
+            await async_client.post(
+                "/api/v1/auth/2fa/totp/regenerate-backup-codes",
+                json={"code": "000000"},
+                headers=_auth_header(token),
+            )
+        resp = await async_client.post(
+            "/api/v1/auth/2fa/totp/regenerate-backup-codes",
+            json={"code": "000000"},
+            headers=_auth_header(token),
+        )
+        assert resp.status_code == 429
+
+
+# ===========================================================================
+# Email OTP send → verify end-to-end (coverage gap C3)
+# ===========================================================================
+
+
+class TestEmailOTPSendVerify:
+    """Full email OTP login: send code → verify code → JWT."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_email_otp_send_and_verify(self, async_client: AsyncClient, db_session: AsyncSession):
+        """login → POST /2fa/email/send (patched SMTP) → POST /2fa/verify → JWT."""
+        import re
+        from unittest.mock import AsyncMock, MagicMock, patch
+
+        from sqlalchemy import select as sa_select
+
+        token = await _setup_and_login(async_client, "emailsendok", "emailsendok1")
+
+        # Give the user an email address
+        result = await db_session.execute(sa_select(User).where(User.username == "emailsendok"))
+        user = result.scalar_one()
+        user.email = "emailsendok@example.com"
+        await db_session.commit()
+
+        # Enable email OTP via DB injection
+        setup_code = "444444"
+        setup_token = secrets.token_urlsafe(32)
+        db_session.add(
+            AuthEphemeralToken(
+                token=setup_token,
+                token_type="email_otp_setup",
+                username="emailsendok",
+                nonce=_pwd_context.hash(setup_code),
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
+            )
+        )
+        await db_session.commit()
+        await async_client.post(
+            "/api/v1/auth/2fa/email/enable/confirm",
+            json={"setup_token": setup_token, "code": setup_code},
+            headers=_auth_header(token),
+        )
+
+        # Login now requires 2FA — get pre_auth_token (cookie set automatically)
+        pre_auth_token = await _login_get_pre_auth_token(async_client, "emailsendok", "emailsendok1")
+
+        # Mock SMTP and capture the sent OTP code
+        captured: dict[str, str] = {}
+        smtp_settings_mock = MagicMock()
+
+        def _capture_email(smtp_settings, to_email, subject, body_text, body_html):
+            m = re.search(r"login code is: (\d{6})", body_text)
+            if m:
+                captured["otp"] = m.group(1)
+
+        with (
+            patch("backend.app.api.routes.mfa.get_smtp_settings", new=AsyncMock(return_value=smtp_settings_mock)),
+            patch("backend.app.api.routes.mfa.send_email", side_effect=_capture_email),
+        ):
+            send_resp = await async_client.post(
+                "/api/v1/auth/2fa/email/send",
+                json={"pre_auth_token": pre_auth_token},
+            )
+
+        assert send_resp.status_code == 200, send_resp.text
+        fresh_token = send_resp.json()["pre_auth_token"]
+        assert "otp" in captured, "send_email was not called or code not found in body"
+
+        # Verify with the captured OTP code — cookie still in the async_client jar
+        verify_resp = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": fresh_token, "method": "email", "code": captured["otp"]},
+        )
+        assert verify_resp.status_code == 200
+        data = verify_resp.json()
+        assert "access_token" in data
+        assert data["user"]["username"] == "emailsendok"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_email_otp_wrong_code_rejected(self, async_client: AsyncClient, db_session: AsyncSession):
+        """A wrong email OTP code must return 401 without burning the pre_auth_token."""
+        from unittest.mock import AsyncMock, MagicMock, patch
+
+        from sqlalchemy import select as sa_select
+
+        token = await _setup_and_login(async_client, "emailwrongcode", "emailwrongcode1")
+
+        result = await db_session.execute(sa_select(User).where(User.username == "emailwrongcode"))
+        user = result.scalar_one()
+        user.email = "emailwrongcode@example.com"
+        await db_session.commit()
+
+        setup_code = "555555"
+        setup_token = secrets.token_urlsafe(32)
+        db_session.add(
+            AuthEphemeralToken(
+                token=setup_token,
+                token_type="email_otp_setup",
+                username="emailwrongcode",
+                nonce=_pwd_context.hash(setup_code),
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
+            )
+        )
+        await db_session.commit()
+        await async_client.post(
+            "/api/v1/auth/2fa/email/enable/confirm",
+            json={"setup_token": setup_token, "code": setup_code},
+            headers=_auth_header(token),
+        )
+
+        pre_auth_token = await _login_get_pre_auth_token(async_client, "emailwrongcode", "emailwrongcode1")
+
+        captured: dict[str, str] = {}
+        smtp_mock = MagicMock()
+
+        def _capture(smtp_settings, to_email, subject, body_text, body_html):
+            import re
+
+            m = re.search(r"login code is: (\d{6})", body_text)
+            if m:
+                captured["otp"] = m.group(1)
+
+        with (
+            patch("backend.app.api.routes.mfa.get_smtp_settings", new=AsyncMock(return_value=smtp_mock)),
+            patch("backend.app.api.routes.mfa.send_email", side_effect=_capture),
+        ):
+            send_resp = await async_client.post(
+                "/api/v1/auth/2fa/email/send",
+                json={"pre_auth_token": pre_auth_token},
+            )
+        assert send_resp.status_code == 200
+        fresh_token = send_resp.json()["pre_auth_token"]
+
+        # Wrong code → 401
+        bad = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": fresh_token, "method": "email", "code": "000000"},
+        )
+        assert bad.status_code == 401
+
+        # Correct code still works (token not burned by wrong attempt)
+        good = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": fresh_token, "method": "email", "code": captured["otp"]},
+        )
+        assert good.status_code == 200
+
+
+# ===========================================================================
+# OIDC end-to-end (coverage gap C4)
+# ===========================================================================
+
+
+def _make_test_rsa_key():
+    """Generate a throwaway RSA key pair and a matching JWK set for tests."""
+    import base64
+
+    from cryptography.hazmat.primitives import serialization
+    from cryptography.hazmat.primitives.asymmetric import rsa
+
+    private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+    private_pem = private_key.private_bytes(
+        serialization.Encoding.PEM,
+        serialization.PrivateFormat.TraditionalOpenSSL,
+        serialization.NoEncryption(),
+    )
+    pub_numbers = private_key.public_key().public_numbers()
+
+    def _b64url(n: int, length: int) -> str:
+        return base64.urlsafe_b64encode(n.to_bytes(length, "big")).rstrip(b"=").decode()
+
+    jwks = {
+        "keys": [
+            {
+                "kty": "RSA",
+                "use": "sig",
+                "alg": "RS256",
+                "kid": "test-kid-1",
+                "n": _b64url(pub_numbers.n, 256),
+                "e": _b64url(pub_numbers.e, 3),
+            }
+        ]
+    }
+    return private_pem, jwks
+
+
+class TestOIDCEndToEnd:
+    """Full OIDC auth-code flow: state → callback (mocked IdP) → exchange → JWT."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_oidc_callback_creates_user_and_issues_jwt(self, async_client: AsyncClient, db_session: AsyncSession):
+        """callback validates the mocked ID token, creates a user, and redirects
+        with an oidc_exchange token; exchanging that token returns a full JWT."""
+        import time
+        from unittest.mock import patch
+
+        import jwt as pyjwt
+
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://idp.test.example.com"
+        client_id = "oidc-test-client"
+        nonce = secrets.token_urlsafe(16)
+
+        now = int(time.time())
+        id_token = pyjwt.encode(
+            {
+                "sub": "oidc-sub-e2e",
+                "iss": issuer,
+                "aud": client_id,
+                "nonce": nonce,
+                "email": "oidce2e@example.com",
+                "email_verified": True,
+                "iat": now,
+                "exp": now + 300,
+            },
+            private_pem,
+            algorithm="RS256",
+            headers={"kid": "test-kid-1"},
+        )
+
+        # Create OIDC provider
+        admin_token = await _setup_and_login(async_client, "oidce2eadm", "oidce2eadm1")
+        create_resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "E2E-IdP",
+                "issuer_url": issuer,
+                "client_id": client_id,
+                "client_secret": "test-secret",
+                "scopes": "openid email profile",
+                "is_enabled": True,
+                "auto_create_users": True,
+            },
+            headers=_auth_header(admin_token),
+        )
+        assert create_resp.status_code == 201
+        provider_id = create_resp.json()["id"]
+
+        # Simulate the authorize step: insert an oidc_state token directly
+        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()
+
+        # Mock httpx calls made inside oidc_callback
+        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 _MockResp:
+            def __init__(self, data):
+                self._data = data
+                self.status_code = 200
+                self.is_success = True
+                self.text = str(data)
+
+            def json(self):
+                return self._data
+
+            def raise_for_status(self):
+                pass
+
+        class _MockHttpxClient:
+            def __init__(self, *args, **kwargs):
+                pass
+
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *args):
+                pass
+
+            async def get(self, url, **kwargs):
+                if "jwks" in url:
+                    return _MockResp(jwks_data)
+                return _MockResp(discovery_doc)
+
+            async def post(self, url, **kwargs):
+                return _MockResp(token_response)
+
+        with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _MockHttpxClient):
+            callback_resp = await async_client.get(
+                f"/api/v1/auth/oidc/callback?code=test-auth-code&state={state}",
+                follow_redirects=False,
+            )
+
+        assert callback_resp.status_code == 302, callback_resp.text
+        location = callback_resp.headers.get("location", "")
+        assert "oidc_token=" in location, f"Expected oidc_token in redirect, got: {location}"
+
+        # Extract and exchange the oidc_exchange token
+        oidc_exchange_token = location.split("oidc_token=")[1].split("&")[0]
+        exchange_resp = await async_client.post(
+            "/api/v1/auth/oidc/exchange",
+            json={"oidc_token": oidc_exchange_token},
+        )
+        assert exchange_resp.status_code == 200
+        data = exchange_resp.json()
+        assert "access_token" in data
+        assert data["user"]["username"] is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_oidc_callback_invalid_state_redirects_error(self, async_client: AsyncClient):
+        """An unknown state token must redirect to /?oidc_error=invalid_state."""
+        resp = await async_client.get(
+            "/api/v1/auth/oidc/callback?code=x&state=totally-bogus-state",
+            follow_redirects=False,
+        )
+        assert resp.status_code == 302
+        assert "invalid_state" in resp.headers.get("location", "")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_oidc_state_is_single_use(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Replaying the same state token must fail on the second callback."""
+        import time
+        from unittest.mock import patch
+
+        import jwt as pyjwt
+
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://idp2.test.example.com"
+        client_id = "oidc-client-2"
+        nonce = secrets.token_urlsafe(16)
+        now = int(time.time())
+        id_token = pyjwt.encode(
+            {
+                "sub": "sub-single-use",
+                "iss": issuer,
+                "aud": client_id,
+                "nonce": nonce,
+                "email": "su@example.com",
+                "email_verified": True,
+                "iat": now,
+                "exp": now + 300,
+            },
+            private_pem,
+            algorithm="RS256",
+            headers={"kid": "test-kid-1"},
+        )
+
+        admin_token = await _setup_and_login(async_client, "oidcsuadm", "oidcsuadm1")
+        cr = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "SU-IdP",
+                "issuer_url": issuer,
+                "client_id": client_id,
+                "client_secret": "s",
+                "scopes": "openid",
+                "is_enabled": True,
+                "auto_create_users": True,
+            },
+            headers=_auth_header(admin_token),
+        )
+        provider_id = cr.json()["id"]
+
+        state = secrets.token_urlsafe(32)
+        db_session.add(
+            AuthEphemeralToken(
+                token=state,
+                token_type="oidc_state",
+                provider_id=provider_id,
+                nonce=nonce,
+                code_verifier=secrets.token_urlsafe(48),
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
+            )
+        )
+        await db_session.commit()
+
+        discovery_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": "a", "token_type": "Bearer", "id_token": id_token}
+
+        class _MockResp:
+            def __init__(self, data):
+                self._data = data
+                self.status_code = 200
+                self.is_success = True
+                self.text = str(data)
+
+            def json(self):
+                return self._data
+
+            def raise_for_status(self):
+                pass
+
+        class _MockHttpxClient:
+            def __init__(self, *a, **kw):
+                pass
+
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *a):
+                pass
+
+            async def get(self, url, **kw):
+                return _MockResp(jwks_data if "jwks" in url else discovery_doc)
+
+            async def post(self, url, **kw):
+                return _MockResp(token_response)
+
+        with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _MockHttpxClient):
+            first = await async_client.get(
+                f"/api/v1/auth/oidc/callback?code=c&state={state}",
+                follow_redirects=False,
+            )
+            assert first.status_code == 302
+            assert "oidc_token=" in first.headers.get("location", "")
+
+            # Replay: second callback with the same state must fail
+            second = await async_client.get(
+                f"/api/v1/auth/oidc/callback?code=c&state={state}",
+                follow_redirects=False,
+            )
+            assert second.status_code == 302
+            assert "invalid_state" in second.headers.get("location", "")
+
+
+# ===========================================================================
+# H-2: Wrong code must NOT consume the email OTP setup token (peek-then-consume)
+# ===========================================================================
+
+
+class TestEmailOTPSetupTokenPreservedOnWrongCode:
+    """After H-2 fix: a wrong code leaves the setup token intact so the user can retry."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_wrong_code_does_not_consume_setup_token(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Wrong code returns 400 but the setup token survives; correct code then works."""
+        token = await _setup_and_login(async_client, "h2retryuser", "h2retrypass1")
+
+        code = "999999"
+        code_hash = _pwd_context.hash(code)
+        setup_token = secrets.token_urlsafe(32)
+        db_session.add(
+            AuthEphemeralToken(
+                token=setup_token,
+                token_type="email_otp_setup",
+                username="h2retryuser",
+                nonce=code_hash,
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
+            )
+        )
+        await db_session.commit()
+
+        # First attempt: wrong code → 400
+        wrong = await async_client.post(
+            "/api/v1/auth/2fa/email/enable/confirm",
+            json={"setup_token": setup_token, "code": "000000"},
+            headers=_auth_header(token),
+        )
+        assert wrong.status_code == 400
+
+        # Second attempt: correct code → must succeed (token was NOT consumed)
+        correct = await async_client.post(
+            "/api/v1/auth/2fa/email/enable/confirm",
+            json={"setup_token": setup_token, "code": code},
+            headers=_auth_header(token),
+        )
+        assert correct.status_code == 200
+
+
+# ===========================================================================
+# M-2: New OIDC provider must default to auto_link_existing_accounts=False
+# ===========================================================================
+
+
+class TestOIDCProviderAutoLinkDefault:
+    """auto_link_existing_accounts must default to False (M-2 fix)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_new_provider_auto_link_defaults_to_false(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "m2autolinkadmin", "m2autolinkadmin1")
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "AutoLinkTest",
+                "issuer_url": "https://autolink.example.com",
+                "client_id": "alc",
+                "client_secret": "als",
+                "scopes": "openid",
+                "is_enabled": True,
+                "auto_create_users": False,
+                # auto_link_existing_accounts intentionally omitted
+            },
+            headers=_auth_header(token),
+        )
+        assert resp.status_code == 201
+        assert resp.json()["auto_link_existing_accounts"] is False
+
+
+# ===========================================================================
+# L-5: 2FA verify code format validation
+# ===========================================================================
+
+
+class TestTwoFAVerifyCodeFormat:
+    """TwoFAVerifyRequest.code must be 6–8 alphanumeric characters (L-5)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_code_too_long_rejected(self, async_client: AsyncClient):
+        """code > 8 characters must be rejected with 422."""
+        resp = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": "anytoken", "code": "1" * 9, "method": "totp"},
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_code_non_alphanumeric_rejected(self, async_client: AsyncClient):
+        """code containing non-alphanumeric chars must be rejected with 422."""
+        resp = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": "anytoken", "code": "12-456", "method": "totp"},
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_code_too_short_rejected(self, async_client: AsyncClient):
+        """code < 6 characters must be rejected with 422."""
+        resp = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": "anytoken", "code": "12345", "method": "totp"},
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_code_exactly_6_passes_schema(self, async_client: AsyncClient):
+        """6-character alphanumeric code passes schema (may fail 2FA logic with 400)."""
+        resp = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": "x" * 32, "code": "123456", "method": "totp"},
+        )
+        assert resp.status_code != 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_code_exactly_8_passes_schema(self, async_client: AsyncClient):
+        """8-character alphanumeric backup code passes schema."""
+        resp = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": "x" * 32, "code": "ABCD1234", "method": "backup"},
+        )
+        assert resp.status_code != 422
+
+
+# ===========================================================================
+# M-NEW-1: verify_slicer_download_token must NOT consume token on wrong resource
+# ===========================================================================
+
+
+class TestSlicerTokenResourceBinding:
+    """Token for resource A must survive a wrong-resource check and still work for A."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_wrong_resource_does_not_consume_token(self, async_client: AsyncClient, db_session: AsyncSession):
+        """A slicer token bound to archive:5 must NOT be consumed when checked against archive:6."""
+        from datetime import datetime, timedelta, timezone
+
+        from backend.app.core.auth import verify_slicer_download_token
+        from backend.app.models.auth_ephemeral import AuthEphemeralToken
+
+        now = datetime.now(timezone.utc)
+        token_val = secrets.token_urlsafe(24)
+        db_session.add(
+            AuthEphemeralToken(
+                token=token_val,
+                token_type="slicer_download",
+                nonce="archive:5",
+                expires_at=now + timedelta(minutes=5),
+            )
+        )
+        await db_session.commit()
+
+        # Wrong resource → must return False and NOT consume the token
+        wrong = await verify_slicer_download_token(token_val, "archive", 6)
+        assert wrong is False
+
+        # Correct resource → must return True (token survived the wrong-resource check)
+        correct = await verify_slicer_download_token(token_val, "archive", 5)
+        assert correct is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_correct_resource_consumes_token(self, async_client: AsyncClient, db_session: AsyncSession):
+        """A slicer token is single-use: second correct-resource check must return False."""
+        from datetime import datetime, timedelta, timezone
+
+        from backend.app.core.auth import verify_slicer_download_token
+        from backend.app.models.auth_ephemeral import AuthEphemeralToken
+
+        now = datetime.now(timezone.utc)
+        token_val = secrets.token_urlsafe(24)
+        db_session.add(
+            AuthEphemeralToken(
+                token=token_val,
+                token_type="slicer_download",
+                nonce="library:99",
+                expires_at=now + timedelta(minutes=5),
+            )
+        )
+        await db_session.commit()
+
+        first = await verify_slicer_download_token(token_val, "library", 99)
+        assert first is True
+
+        second = await verify_slicer_download_token(token_val, "library", 99)
+        assert second is False
+
+
+# ===========================================================================
+# M-NEW-3 / L-NEW-1: Schema length validation for change-password & forgot-password
+# ===========================================================================
+
+
+class TestSchemaLengthValidationR2:
+    """Input length limits added in review round 2."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_change_password_current_too_long_rejected(self, async_client: AsyncClient):
+        """current_password > 256 chars must be rejected with 422 (prevents pbkdf2 DoS)."""
+        resp = await async_client.post(
+            "/api/v1/users/me/change-password",
+            json={"current_password": "x" * 257, "new_password": "ValidPass1!"},
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_forgot_password_email_too_long_rejected(self, async_client: AsyncClient):
+        """email > 254 chars must be rejected with 422."""
+        resp = await async_client.post(
+            "/api/v1/auth/forgot-password",
+            json={"email": "a" * 243 + "@example.com"},
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_forgot_password_email_at_limit_passes_schema(self, async_client: AsyncClient):
+        """Short email passes schema (may return 400/200 from business logic)."""
+        resp = await async_client.post(
+            "/api/v1/auth/forgot-password",
+            json={"email": "user@example.com"},
+        )
+        assert resp.status_code != 422
+
+
+# ===========================================================================
+# L-NEW-2: TOTPSetupRequest.code max_length
+# ===========================================================================
+
+
+class TestTOTPSetupCodeMaxLength:
+    """TOTPSetupRequest.code must be bounded (L-NEW-2)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_setup_code_too_long_rejected(self, async_client: AsyncClient):
+        """code > 8 chars must be rejected with 422."""
+        import pyotp as _pyotp
+
+        token = await _setup_and_login(async_client, "totp_setup_maxlen", "totp_setup_maxlen1")
+        # Enable TOTP so the setup-code guard path is active
+        setup_resp = await async_client.post("/api/v1/auth/2fa/totp/setup", headers=_auth_header(token))
+        secret = setup_resp.json()["secret"]
+        await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": _pyotp.TOTP(secret).now()},
+            headers=_auth_header(token),
+        )
+
+        resp = await async_client.post(
+            "/api/v1/auth/2fa/totp/setup",
+            json={"code": "1" * 9},
+            headers=_auth_header(token),
+        )
+        assert resp.status_code == 422
+
+
+# ===========================================================================
+# L-NEW-3: EmailOTPEnableConfirmRequest.code must be exactly 6 digits
+# ===========================================================================
+
+
+class TestEmailOTPConfirmCodeFormat:
+    """EmailOTPEnableConfirmRequest.code must be 6 digits (L-NEW-3)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_non_digit_code_rejected(self, async_client: AsyncClient):
+        """Alpha characters in the email OTP confirm code must be rejected with 422."""
+        token = await _setup_and_login(async_client, "emailotpfmt", "emailotpfmt1")
+
+        resp = await async_client.post(
+            "/api/v1/auth/2fa/email/enable/confirm",
+            json={"setup_token": "x" * 32, "code": "ABCDEF"},
+            headers=_auth_header(token),
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_seven_digit_code_rejected(self, async_client: AsyncClient):
+        """7-digit code must be rejected with 422 (min_length=max_length=6)."""
+        token = await _setup_and_login(async_client, "emailotplen7", "emailotplen7x")
+
+        resp = await async_client.post(
+            "/api/v1/auth/2fa/email/enable/confirm",
+            json={"setup_token": "x" * 32, "code": "1234567"},
+            headers=_auth_header(token),
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_valid_six_digit_code_passes_schema(self, async_client: AsyncClient):
+        """6-digit numeric code passes schema (may return 400 on bad token — that's fine)."""
+        token = await _setup_and_login(async_client, "emailotpfmt6", "emailotpfmt6x")
+
+        resp = await async_client.post(
+            "/api/v1/auth/2fa/email/enable/confirm",
+            json={"setup_token": "x" * 32, "code": "123456"},
+            headers=_auth_header(token),
+        )
+        assert resp.status_code != 422
+
+
+# ===========================================================================
+# L-NEW-4: OIDCProviderCreate field max_length constraints
+# ===========================================================================
+
+
+class TestOIDCProviderFieldLengths:
+    """OIDCProviderCreate fields must reject inputs exceeding max_length (L-NEW-4)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_name_too_long_rejected(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "oidcfldadmin", "oidcfldadmin1")
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "n" * 101,
+                "issuer_url": "https://test.example.com",
+                "client_id": "cid",
+                "client_secret": "csec",
+                "scopes": "openid",
+            },
+            headers=_auth_header(token),
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_client_secret_too_long_rejected(self, async_client: AsyncClient):
+        token = await _setup_and_login(async_client, "oidcseclen", "oidcseclen123")
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "ValidName",
+                "issuer_url": "https://test.example.com",
+                "client_id": "cid",
+                "client_secret": "s" * 513,
+                "scopes": "openid",
+            },
+            headers=_auth_header(token),
+        )
+        assert resp.status_code == 422
+
+
+# ---------------------------------------------------------------------------
+# M-NEW-4 / M-NEW-5 / L-NEW-5: UserCreate & UserUpdate field length limits
+# ---------------------------------------------------------------------------
+
+
+class TestUserCreateUpdateFieldLengths:
+    """UserCreate and UserUpdate must enforce max_length on username, password, email."""
+
+    @pytest.fixture
+    async def admin_token(self, async_client: AsyncClient) -> str:
+        return await _setup_and_login(async_client, "ucfldadmin", "ucfldadmin1!")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_username_too_long_rejected(self, async_client: AsyncClient, admin_token: str):
+        resp = await async_client.post(
+            "/api/v1/users/",
+            json={
+                "username": "u" * 151,
+                "password": "ValidPass1!",
+                "role": "user",
+            },
+            headers=_auth_header(admin_token),
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_password_too_long_rejected(self, async_client: AsyncClient, admin_token: str):
+        resp = await async_client.post(
+            "/api/v1/users/",
+            json={
+                "username": "newuserX",
+                "password": "A1!" + "x" * 254,
+                "role": "user",
+            },
+            headers=_auth_header(admin_token),
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_email_too_long_rejected(self, async_client: AsyncClient, admin_token: str):
+        resp = await async_client.post(
+            "/api/v1/users/",
+            json={
+                "username": "newuserY",
+                "password": "ValidPass1!",
+                "email": "a" * 246 + "@x.com",  # total 253 chars -> fine; 248+@x.com=255 -> too long
+                "role": "user",
+            },
+            headers=_auth_header(admin_token),
+        )
+        # 248 'a' + '@x.com' (6) = 254 chars — just at limit, should pass
+        # Use 249 + '@x.com' = 255 chars to trigger the 422
+        assert resp.status_code in (201, 422)  # boundary sanity check
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_email_exceeds_limit_rejected(self, async_client: AsyncClient, admin_token: str):
+        resp = await async_client.post(
+            "/api/v1/users/",
+            json={
+                "username": "newuserZ",
+                "password": "ValidPass1!",
+                "email": "a" * 249 + "@x.com",  # 255 chars — exceeds RFC 5321 max of 254
+                "role": "user",
+            },
+            headers=_auth_header(admin_token),
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_username_too_long_rejected(self, async_client: AsyncClient, admin_token: str):
+        # Create a user first
+        create_resp = await async_client.post(
+            "/api/v1/users/",
+            json={"username": "updusr1", "password": "ValidPass1!", "role": "user"},
+            headers=_auth_header(admin_token),
+        )
+        assert create_resp.status_code == 201
+        user_id = create_resp.json()["id"]
+
+        resp = await async_client.patch(
+            f"/api/v1/users/{user_id}",
+            json={"username": "u" * 151},
+            headers=_auth_header(admin_token),
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_password_too_long_rejected(self, async_client: AsyncClient, admin_token: str):
+        create_resp = await async_client.post(
+            "/api/v1/users/",
+            json={"username": "updusr2", "password": "ValidPass1!", "role": "user"},
+            headers=_auth_header(admin_token),
+        )
+        assert create_resp.status_code == 201
+        user_id = create_resp.json()["id"]
+
+        resp = await async_client.patch(
+            f"/api/v1/users/{user_id}",
+            json={"password": "A1!" + "x" * 254},
+            headers=_auth_header(admin_token),
+        )
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_email_too_long_rejected(self, async_client: AsyncClient, admin_token: str):
+        create_resp = await async_client.post(
+            "/api/v1/users/",
+            json={"username": "updusr3", "password": "ValidPass1!", "role": "user"},
+            headers=_auth_header(admin_token),
+        )
+        assert create_resp.status_code == 201
+        user_id = create_resp.json()["id"]
+
+        resp = await async_client.patch(
+            f"/api/v1/users/{user_id}",
+            json={"email": "a" * 249 + "@x.com"},  # 255 chars
+            headers=_auth_header(admin_token),
+        )
+        assert resp.status_code == 422
+
+
+# ---------------------------------------------------------------------------
+# L-NEW-6: per-IP rate limiting on /forgot-password
+# ---------------------------------------------------------------------------
+
+_SMTP_DATA_FOR_IPLIMIT = {
+    "smtp_host": "smtp.test.com",
+    "smtp_port": 587,
+    "smtp_username": "test@test.com",
+    "smtp_password": "testpass",
+    "smtp_security": "starttls",
+    "smtp_auth_enabled": True,
+    "smtp_from_email": "noreply@test.com",
+}
+
+
+class TestForgotPasswordPerIpRateLimit:
+    """POST /forgot-password must enforce a per-IP cap (L-NEW-6).
+
+    The test sends 11 requests from the simulated test-client IP using 11
+    different email addresses (so the per-email bucket is never exhausted).
+    The 11th request must be rejected with 429.
+    """
+
+    @pytest.fixture
+    async def advanced_auth_token(self, async_client: AsyncClient) -> str:
+        """Set up auth, SMTP, and enable advanced auth; return admin token."""
+        token = await _setup_and_login(async_client, "iprladmin", "iprladmin1!")
+        headers = _auth_header(token)
+        await async_client.post("/api/v1/auth/smtp", headers=headers, json=_SMTP_DATA_FOR_IPLIMIT)
+        await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
+        return token
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_per_ip_limit_triggers_429(self, async_client: AsyncClient, advanced_auth_token: str):
+        # Send 11 requests from the same test-client IP using unique email
+        # addresses so the per-email bucket (limit=3) is never exhausted.
+        responses = []
+        for i in range(11):
+            resp = await async_client.post(
+                "/api/v1/auth/forgot-password",
+                json={"email": f"unique{i}@example.com"},
+            )
+            responses.append(resp.status_code)
+
+        # First 10 must not be rate-limited by the IP bucket
+        for code in responses[:10]:
+            assert code != 429, f"Unexpected 429 before limit reached: {responses}"
+
+        # The 11th must be rate-limited
+        assert responses[10] == 429, f"Expected 429 on 11th request, got {responses[10]}"
+
+
+# ---------------------------------------------------------------------------
+# M-NEW-6: OIDC auto-link must be rejected if target user already has an
+#          OIDC link to a different provider
+# ---------------------------------------------------------------------------
+
+
+class TestOIDCAutoLinkExistingLinkRejection:
+    """OIDC callback must reject auto-linking when the email-matched user
+    already has an OIDC link to a different provider (M-NEW-6)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auto_link_rejected_when_user_already_linked(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """Auto-link via email-match is rejected when the target user is
+        already linked to another OIDC provider."""
+        import base64
+        import hashlib
+        from unittest.mock import AsyncMock, MagicMock, patch
+
+        from backend.app.core.auth import get_password_hash
+        from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
+        from backend.app.models.user import User
+
+        # ── 1. Target user with a known email ────────────────────────────
+        target = User(
+            username="oidcALTarget",
+            email="alinktest@example.com",
+            auth_source="oidc",
+            password_hash=get_password_hash(secrets.token_urlsafe(16)),
+            role="user",
+            is_active=True,
+        )
+        db_session.add(target)
+        await db_session.flush()
+
+        # ── 2. Provider B — legitimate, already linked to target ──────────
+        prov_b = OIDCProvider(
+            name="ProvB_m6test",
+            issuer_url="https://providerb-m6.example.com",
+            client_id="client_b",
+            _client_secret_enc="secret_b",
+            scopes="openid email profile",
+            is_enabled=True,
+            auto_link_existing_accounts=False,
+            auto_create_users=False,
+        )
+        db_session.add(prov_b)
+        await db_session.flush()
+
+        db_session.add(
+            UserOIDCLink(
+                user_id=target.id,
+                provider_id=prov_b.id,
+                provider_user_id="legitimate_sub",
+                provider_email="alinktest@example.com",
+            )
+        )
+
+        # ── 3. Provider A — attacker-controlled, auto_link=True ───────────
+        prov_a = OIDCProvider(
+            name="ProvA_m6test",
+            issuer_url="https://providera-m6.example.com",
+            client_id="client_a",
+            _client_secret_enc="secret_a",
+            scopes="openid email profile",
+            is_enabled=True,
+            auto_link_existing_accounts=True,
+            auto_create_users=False,
+        )
+        db_session.add(prov_a)
+        await db_session.flush()
+
+        # ── 4. OIDC state for Provider A ──────────────────────────────────
+        state = secrets.token_urlsafe(32)
+        nonce = secrets.token_urlsafe(32)
+        code_verifier = secrets.token_urlsafe(48)
+
+        db_session.add(
+            AuthEphemeralToken(
+                token=state,
+                token_type="oidc_state",
+                provider_id=prov_a.id,
+                nonce=nonce,
+                code_verifier=code_verifier,
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
+            )
+        )
+        await db_session.commit()
+
+        # ── 5. Mock HTTP + JWT so the callback can reach the auto-link check ─
+        fake_discovery = {
+            "issuer": "https://providera-m6.example.com",
+            "token_endpoint": "https://providera-m6.example.com/token",
+            "jwks_uri": "https://providera-m6.example.com/jwks",
+        }
+        fake_token = {"access_token": "acc_tok", "id_token": "fake.id.token"}
+        fake_claims = {
+            "sub": "attacker_sub_unique",
+            "email": "alinktest@example.com",
+            "email_verified": True,
+            "nonce": nonce,
+            "iss": "https://providera-m6.example.com",
+            "aud": "client_a",
+            "exp": 9_999_999_999,
+        }
+
+        disc_resp = AsyncMock()
+        disc_resp.raise_for_status = MagicMock()
+        disc_resp.json = MagicMock(return_value=fake_discovery)
+
+        token_resp = AsyncMock()
+        token_resp.ok = True
+        token_resp.json = MagicMock(return_value=fake_token)
+
+        jwks_resp = AsyncMock()
+        jwks_resp.raise_for_status = MagicMock()
+        jwks_resp.json = MagicMock(return_value={})
+
+        mock_http = AsyncMock()
+        mock_http.get = AsyncMock(side_effect=[disc_resp, jwks_resp])
+        mock_http.post = AsyncMock(return_value=token_resp)
+
+        mock_signing_key = MagicMock()
+        mock_signing_key.key = "fake_key"
+
+        with (
+            patch("backend.app.api.routes.mfa.httpx.AsyncClient") as mock_httpx_cls,
+            patch("backend.app.api.routes.mfa.jwt.decode", return_value=fake_claims),
+            patch("backend.app.api.routes.mfa.PyJWKClient") as mock_jwks_cls,
+        ):
+            mock_httpx_cls.return_value.__aenter__ = AsyncMock(return_value=mock_http)
+            mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=False)
+            mock_jwks_cls.return_value.get_signing_key_from_jwt.return_value = mock_signing_key
+
+            resp = await async_client.get(
+                f"/api/v1/auth/oidc/callback?code=fake_code&state={state}",
+                follow_redirects=False,
+            )
+
+        # M-NEW-6: must redirect with no_linked_account — NOT create a second link
+        assert resp.status_code == 302
+        location = resp.headers.get("location", "")
+        assert "no_linked_account" in location, f"Expected no_linked_account in redirect, got: {location}"
+
+        # Verify no second OIDC link was created for Provider A
+        from sqlalchemy import select as sa_select
+
+        from backend.app.models.oidc_provider import UserOIDCLink as _UOL
+
+        async with db_session as s:
+            links_result = await s.execute(
+                sa_select(_UOL).where(_UOL.user_id == target.id, _UOL.provider_id == prov_a.id)
+            )
+            assert links_result.scalar_one_or_none() is None, "No link to Provider A must exist"
+
+
+# ===========================================================================
+# Test Gap 1: OIDC state token is single-use — replay must be rejected
+# ===========================================================================
+
+
+class TestOIDCStateReplay:
+    """OIDC state token must be consumed on first use; a second callback with
+    the same state must redirect to ``?oidc_error=invalid_state``."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_state_replay_rejected(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Replaying a consumed OIDC state token must return invalid_state."""
+        from backend.app.models.oidc_provider import OIDCProvider
+
+        # ── 1. Seed a minimal provider ────────────────────────────────────
+        provider = OIDCProvider(
+            name="StateReplayIdP",
+            issuer_url="https://statereplay-idp.example.com",
+            client_id="client_replay",
+            _client_secret_enc="secret_replay",
+            scopes="openid",
+            is_enabled=True,
+            auto_link_existing_accounts=False,
+            auto_create_users=False,
+        )
+        db_session.add(provider)
+        await db_session.flush()
+
+        # ── 2. Seed an OIDC state token ───────────────────────────────────
+        state = secrets.token_urlsafe(32)
+        db_session.add(
+            AuthEphemeralToken(
+                token=state,
+                token_type="oidc_state",
+                provider_id=provider.id,
+                nonce=secrets.token_urlsafe(32),
+                code_verifier=secrets.token_urlsafe(48),
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
+            )
+        )
+        await db_session.commit()
+
+        # ── 3. First callback — discovery will fail (no real IdP), but the
+        #       state token is atomically consumed (DELETE…RETURNING + commit)
+        #       before the HTTP call is attempted.
+        first = await async_client.get(
+            f"/api/v1/auth/oidc/callback?code=any_code&state={state}",
+            follow_redirects=False,
+        )
+        assert first.status_code == 302
+        # The first call may fail for any reason except invalid_state
+        assert "invalid_state" not in first.headers.get("location", ""), (
+            f"First call should NOT get invalid_state: {first.headers.get('location')}"
+        )
+
+        # ── 4. Second callback with the same state → must be invalid_state ─
+        second = await async_client.get(
+            f"/api/v1/auth/oidc/callback?code=any_code&state={state}",
+            follow_redirects=False,
+        )
+        assert second.status_code == 302
+        assert "invalid_state" in second.headers.get("location", ""), (
+            f"Replayed state must redirect to invalid_state, got: {second.headers.get('location')}"
+        )
+
+
+# ===========================================================================
+# Test Gap 2: OIDC iss claim mismatch must redirect to token_validation_failed
+# ===========================================================================
+
+
+class TestOIDCIssMismatch:
+    """JWT whose iss claim does not match the discovery issuer must be rejected."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_iss_mismatch_redirects_token_validation_failed(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        import time
+        from unittest.mock import patch
+
+        import jwt as pyjwt
+
+        private_pem, jwks_data = _make_test_rsa_key()
+        correct_issuer = "https://correct-iss.example.com"
+        wrong_issuer = "https://wrong-iss.example.com"
+        client_id = "iss-mismatch-client"
+        nonce = secrets.token_urlsafe(16)
+        now = int(time.time())
+
+        # Sign the token with the WRONG issuer (iss != discovery_issuer)
+        id_token = pyjwt.encode(
+            {
+                "sub": "sub-iss-test",
+                "iss": wrong_issuer,
+                "aud": client_id,
+                "nonce": nonce,
+                "email": "iss@example.com",
+                "email_verified": True,
+                "iat": now,
+                "exp": now + 300,
+            },
+            private_pem,
+            algorithm="RS256",
+            headers={"kid": "test-kid-1"},
+        )
+
+        admin_token = await _setup_and_login(async_client, "issadmin1", "issadmin1!")
+        cr = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "IssTest-IdP",
+                "issuer_url": correct_issuer,
+                "client_id": client_id,
+                "client_secret": "s",
+                "scopes": "openid",
+                "is_enabled": True,
+                "auto_create_users": True,
+            },
+            headers=_auth_header(admin_token),
+        )
+        assert cr.status_code in (200, 201), cr.text
+        provider_id = cr.json()["id"]
+
+        state = secrets.token_urlsafe(32)
+        db_session.add(
+            AuthEphemeralToken(
+                token=state,
+                token_type="oidc_state",
+                provider_id=provider_id,
+                nonce=nonce,
+                code_verifier=secrets.token_urlsafe(48),
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
+            )
+        )
+        await db_session.commit()
+
+        # Discovery returns the CORRECT issuer; JWT carries the WRONG one.
+        discovery_doc = {
+            "issuer": correct_issuer,
+            "token_endpoint": f"{correct_issuer}/token",
+            "jwks_uri": f"{correct_issuer}/.well-known/jwks.json",
+        }
+        token_response = {"access_token": "a", "id_token": id_token}
+
+        class _MockResp:
+            def __init__(self, data):
+                self._data = data
+                self.status_code = 200
+                self.is_success = True
+                self.text = ""
+
+            def json(self):
+                return self._data
+
+            def raise_for_status(self):
+                pass
+
+        class _MockHttpxClient:
+            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 _MockResp(jwks_data if "jwks" in url else discovery_doc)
+
+            async def post(self, url, **kw):
+                return _MockResp(token_response)
+
+        with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _MockHttpxClient):
+            resp = await async_client.get(
+                f"/api/v1/auth/oidc/callback?code=c&state={state}",
+                follow_redirects=False,
+            )
+
+        assert resp.status_code == 302
+        location = resp.headers.get("location", "")
+        assert "token_validation_failed" in location, f"Expected token_validation_failed, got: {location}"
+
+
+# ===========================================================================
+# Test Gap 3: /forgot-password/confirm token is single-use
+# ===========================================================================
+
+
+class TestForgotPasswordTokenSingleUse:
+    """POST /forgot-password/confirm must reject a token after its first use."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_token_reuse_rejected(self, async_client: AsyncClient, db_session: AsyncSession):
+        from backend.app.core.auth import get_password_hash
+        from backend.app.models.user import User as _User
+
+        user = _User(
+            username="fpcuser1",
+            email="fpc@example.com",
+            password_hash=get_password_hash("OldPass1!"),
+            role="user",
+            is_active=True,
+        )
+        db_session.add(user)
+        await db_session.flush()
+
+        reset_token = secrets.token_urlsafe(32)
+        db_session.add(
+            AuthEphemeralToken(
+                token=reset_token,
+                token_type="password_reset",
+                username="fpcuser1",
+                expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
+            )
+        )
+        await db_session.commit()
+
+        # First use → success
+        resp1 = await async_client.post(
+            "/api/v1/auth/forgot-password/confirm",
+            json={"token": reset_token, "new_password": "NewPass1!"},
+        )
+        assert resp1.status_code == 200, resp1.text
+
+        # Second use → token already consumed, must fail
+        resp2 = await async_client.post(
+            "/api/v1/auth/forgot-password/confirm",
+            json={"token": reset_token, "new_password": "AnotherNew1!"},
+        )
+        assert resp2.status_code == 400
+
+
+# ===========================================================================
+# C1 regression: setup_totp must reject a replayed TOTP code
+# ===========================================================================
+
+
+class TestSetupTOTPReplayRejected:
+    """setup_totp must reject a TOTP code that was already accepted in its window."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_replayed_setup_code_rejected(self, async_client: AsyncClient, db_session: AsyncSession):
+        from sqlalchemy import select as sa_select
+
+        from backend.app.models.user_totp import UserTOTP
+
+        token = await _setup_and_login(async_client, "setupreplay1", "setupreplay1!")
+
+        # Step 1: Initial TOTP setup (no active TOTP yet → no code required)
+        setup_resp = await async_client.post(
+            "/api/v1/auth/2fa/totp/setup",
+            headers=_auth_header(token),
+        )
+        assert setup_resp.status_code == 200
+        secret = setup_resp.json()["secret"]
+
+        # Step 2: Enable TOTP with a valid code
+        totp_obj = pyotp.TOTP(secret)
+        enable_resp = await async_client.post(
+            "/api/v1/auth/2fa/totp/enable",
+            json={"code": totp_obj.now()},
+            headers=_auth_header(token),
+        )
+        assert enable_resp.status_code == 200  # TOTP is now active (is_enabled=True)
+
+        # Step 3: Determine current valid code and its counter
+        me_resp = await async_client.get("/api/v1/auth/me", headers=_auth_header(token))
+        user_id = me_resp.json()["id"]
+
+        totp_result = await db_session.execute(sa_select(UserTOTP).where(UserTOTP.user_id == user_id))
+        totp_record = totp_result.scalar_one()
+        secret_now = totp_record.secret  # decrypted via property
+
+        totp_now = pyotp.TOTP(secret_now)
+        valid_code = totp_now.now()
+        accepted_counter = totp_now.timecode(datetime.now(timezone.utc))
+
+        # Step 4: Pre-set last_totp_counter so this code looks already used
+        totp_record.last_totp_counter = accepted_counter
+        await db_session.commit()
+
+        # Step 5: Attempt setup_totp with the "already used" code → must be rejected
+        replay_resp = await async_client.post(
+            "/api/v1/auth/2fa/totp/setup",
+            json={"code": valid_code},
+            headers=_auth_header(token),
+        )
+        assert replay_resp.status_code == 400
+        assert "already used" in replay_resp.json()["detail"]
+
+
+# ===========================================================================
+# Nit8: OIDC aud mismatch and nonce mismatch tests
+# ===========================================================================
+
+
+class TestOIDCAudAndNonceMismatch:
+    """Nit8: aud != client_id and nonce != stored value must each fail the callback."""
+
+    def _make_oidc_provider_setup(self):
+        """Return a helper for building OIDC test fixtures inline."""
+        private_pem, jwks_data = _make_test_rsa_key()
+        return private_pem, jwks_data
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_aud_mismatch_redirects_token_validation_failed(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """ID token with aud != client_id must be rejected (PyJWT InvalidAudienceError)."""
+        import time
+        from unittest.mock import patch
+
+        import jwt as pyjwt
+
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://aud-mismatch.example.com"
+        client_id = "aud-test-client"
+        wrong_aud = "some-other-client"
+        nonce = secrets.token_urlsafe(16)
+        now = int(time.time())
+
+        id_token = pyjwt.encode(
+            {
+                "sub": "sub-aud-test",
+                "iss": issuer,
+                "aud": wrong_aud,  # <-- wrong audience
+                "nonce": nonce,
+                "email": "aud@example.com",
+                "email_verified": True,
+                "iat": now,
+                "exp": now + 300,
+            },
+            private_pem,
+            algorithm="RS256",
+            headers={"kid": "test-kid-1"},
+        )
+
+        admin_token = await _setup_and_login(async_client, "audmismatch_admin", "AudMismatch_admin1")
+        cr = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "AudMismatch-IdP",
+                "issuer_url": issuer,
+                "client_id": client_id,
+                "client_secret": "s",
+                "scopes": "openid",
+                "is_enabled": True,
+                "auto_create_users": True,
+            },
+            headers=_auth_header(admin_token),
+        )
+        assert cr.status_code in (200, 201), cr.text
+        provider_id = cr.json()["id"]
+
+        state = secrets.token_urlsafe(32)
+        db_session.add(
+            AuthEphemeralToken(
+                token=state,
+                token_type="oidc_state",
+                provider_id=provider_id,
+                nonce=nonce,
+                code_verifier=secrets.token_urlsafe(48),
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
+            )
+        )
+        await db_session.commit()
+
+        discovery_doc = {
+            "issuer": issuer,
+            "token_endpoint": f"{issuer}/token",
+            "jwks_uri": f"{issuer}/.well-known/jwks.json",
+        }
+
+        class _MockResp:
+            def __init__(self, data):
+                self._data = data
+                self.status_code = 200
+                self.is_success = True
+                self.text = ""
+
+            def json(self):
+                return self._data
+
+            def raise_for_status(self):
+                pass
+
+        class _MockHttpxClient:
+            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 _MockResp(jwks_data if "jwks" in url else discovery_doc)
+
+            async def post(self, url, **kw):
+                return _MockResp({"access_token": "a", "id_token": id_token})
+
+        with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _MockHttpxClient):
+            resp = await async_client.get(
+                f"/api/v1/auth/oidc/callback?code=c&state={state}",
+                follow_redirects=False,
+            )
+
+        assert resp.status_code == 302
+        location = resp.headers.get("location", "")
+        assert "token_validation_failed" in location, (
+            f"Expected token_validation_failed redirect for aud mismatch, got: {location}"
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_nonce_mismatch_redirects_token_validation_failed(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """ID token with nonce != stored state nonce must be rejected."""
+        import time
+        from unittest.mock import patch
+
+        import jwt as pyjwt
+
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://nonce-mismatch.example.com"
+        client_id = "nonce-test-client"
+        stored_nonce = secrets.token_urlsafe(16)
+        wrong_nonce = secrets.token_urlsafe(16)  # different from stored_nonce
+        now = int(time.time())
+
+        id_token = pyjwt.encode(
+            {
+                "sub": "sub-nonce-test",
+                "iss": issuer,
+                "aud": client_id,
+                "nonce": wrong_nonce,  # <-- does not match stored_nonce
+                "email": "nonce@example.com",
+                "email_verified": True,
+                "iat": now,
+                "exp": now + 300,
+            },
+            private_pem,
+            algorithm="RS256",
+            headers={"kid": "test-kid-1"},
+        )
+
+        admin_token = await _setup_and_login(async_client, "noncemismatch_admin", "NonceMismatch_admin1")
+        cr = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "NonceMismatch-IdP",
+                "issuer_url": issuer,
+                "client_id": client_id,
+                "client_secret": "s",
+                "scopes": "openid",
+                "is_enabled": True,
+                "auto_create_users": True,
+            },
+            headers=_auth_header(admin_token),
+        )
+        assert cr.status_code in (200, 201), cr.text
+        provider_id = cr.json()["id"]
+
+        state = secrets.token_urlsafe(32)
+        db_session.add(
+            AuthEphemeralToken(
+                token=state,
+                token_type="oidc_state",
+                provider_id=provider_id,
+                nonce=stored_nonce,  # state has correct nonce; JWT carries wrong_nonce
+                code_verifier=secrets.token_urlsafe(48),
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
+            )
+        )
+        await db_session.commit()
+
+        discovery_doc = {
+            "issuer": issuer,
+            "token_endpoint": f"{issuer}/token",
+            "jwks_uri": f"{issuer}/.well-known/jwks.json",
+        }
+
+        class _MockResp:
+            def __init__(self, data):
+                self._data = data
+                self.status_code = 200
+                self.is_success = True
+                self.text = ""
+
+            def json(self):
+                return self._data
+
+            def raise_for_status(self):
+                pass
+
+        class _MockHttpxClient:
+            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 _MockResp(jwks_data if "jwks" in url else discovery_doc)
+
+            async def post(self, url, **kw):
+                return _MockResp({"access_token": "a", "id_token": id_token})
+
+        with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _MockHttpxClient):
+            resp = await async_client.get(
+                f"/api/v1/auth/oidc/callback?code=c&state={state}",
+                follow_redirects=False,
+            )
+
+        assert resp.status_code == 302
+        location = resp.headers.get("location", "")
+        # The callback redirects to ?oidc_error=nonce_mismatch when nonces differ.
+        assert "nonce_mismatch" in location, f"Expected nonce_mismatch redirect for nonce mismatch, got: {location}"
+
+
+# ===========================================================================
+# Expired OIDC token rejection — state and exchange tokens
+# ===========================================================================
+
+
+class TestOIDCExpiredTokenRejection:
+    """Expired OIDC state and exchange tokens must be rejected atomically.
+
+    The DELETE … WHERE expires_at > now must ensure that an already-expired
+    token is never consumed (committed) before the expiry is checked, so the
+    token row stays in the DB and is not silently discarded.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_expired_state_token_rejected_as_invalid_state(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """An expired OIDC state token must redirect to invalid_state without
+        being consumed — it must still exist in the DB after the rejected call."""
+        from backend.app.models.oidc_provider import OIDCProvider
+
+        provider = OIDCProvider(
+            name="ExpiredStateIdP",
+            issuer_url="https://expired-state.example.com",
+            client_id="client_expired_state",
+            _client_secret_enc="secret_exp_state",
+            scopes="openid",
+            is_enabled=True,
+            auto_link_existing_accounts=False,
+            auto_create_users=False,
+        )
+        db_session.add(provider)
+        await db_session.flush()
+
+        state = secrets.token_urlsafe(32)
+        db_session.add(
+            AuthEphemeralToken(
+                token=state,
+                token_type="oidc_state",
+                provider_id=provider.id,
+                nonce=secrets.token_urlsafe(16),
+                code_verifier=secrets.token_urlsafe(48),
+                # already expired
+                expires_at=datetime.now(timezone.utc) - timedelta(minutes=5),
+            )
+        )
+        await db_session.commit()
+
+        resp = await async_client.get(
+            f"/api/v1/auth/oidc/callback?code=any_code&state={state}",
+            follow_redirects=False,
+        )
+
+        assert resp.status_code == 302
+        location = resp.headers.get("location", "")
+        assert "invalid_state" in location, f"Expected invalid_state redirect for expired state, got: {location}"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_expired_exchange_token_rejected(self, async_client: AsyncClient, db_session: AsyncSession):
+        """An expired OIDC exchange token must return 401 without being consumed."""
+        from sqlalchemy import select as sa_select
+
+        expired_token = secrets.token_urlsafe(32)
+        db_session.add(
+            AuthEphemeralToken(
+                token=expired_token,
+                token_type="oidc_exchange",
+                username="some_user",
+                # already expired
+                expires_at=datetime.now(timezone.utc) - timedelta(minutes=5),
+            )
+        )
+        await db_session.commit()
+
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/exchange",
+            json={"oidc_token": expired_token},
+        )
+
+        assert resp.status_code == 401
+        assert "expired" in resp.json().get("detail", "").lower() or "invalid" in resp.json().get("detail", "").lower()
+
+        # Token must NOT have been consumed — it should still be in the DB
+        # (the atomic DELETE WHERE expires_at > now left it untouched)
+        result = await db_session.execute(
+            sa_select(AuthEphemeralToken).where(AuthEphemeralToken.token == expired_token)
+        )
+        remaining = result.scalar_one_or_none()
+        assert remaining is not None, "Expired exchange token must not be consumed by a rejected request"

+ 9 - 9
backend/tests/integration/test_ownership_permissions.py

@@ -22,14 +22,14 @@ class TestOwnershipPermissionsSetup:
             json={
             json={
                 "auth_enabled": True,
                 "auth_enabled": True,
                 "admin_username": "ownershipadmin",
                 "admin_username": "ownershipadmin",
-                "admin_password": "adminpassword123",
+                "admin_password": "AdminPass1!",
             },
             },
         )
         )
 
 
         # Login as admin
         # Login as admin
         admin_login = await async_client.post(
         admin_login = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "ownershipadmin", "password": "adminpassword123"},
+            json={"username": "ownershipadmin", "password": "AdminPass1!"},
         )
         )
         admin_token = admin_login.json()["access_token"]
         admin_token = admin_login.json()["access_token"]
         admin_user = admin_login.json()["user"]
         admin_user = admin_login.json()["user"]
@@ -49,7 +49,7 @@ class TestOwnershipPermissionsSetup:
             headers={"Authorization": f"Bearer {admin_token}"},
             headers={"Authorization": f"Bearer {admin_token}"},
             json={
             json={
                 "username": "operator1",
                 "username": "operator1",
-                "password": "operatorpass123",
+                "password": "Operatorpass1!",
                 "group_ids": [operators_group["id"]],
                 "group_ids": [operators_group["id"]],
             },
             },
         )
         )
@@ -58,7 +58,7 @@ class TestOwnershipPermissionsSetup:
         # Login as operator
         # Login as operator
         operator_login = await async_client.post(
         operator_login = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "operator1", "password": "operatorpass123"},
+            json={"username": "operator1", "password": "Operatorpass1!"},
         )
         )
         operator_token = operator_login.json()["access_token"]
         operator_token = operator_login.json()["access_token"]
 
 
@@ -68,7 +68,7 @@ class TestOwnershipPermissionsSetup:
             headers={"Authorization": f"Bearer {admin_token}"},
             headers={"Authorization": f"Bearer {admin_token}"},
             json={
             json={
                 "username": "operator2",
                 "username": "operator2",
-                "password": "operatorpass123",
+                "password": "Operatorpass1!",
                 "group_ids": [operators_group["id"]],
                 "group_ids": [operators_group["id"]],
             },
             },
         )
         )
@@ -76,7 +76,7 @@ class TestOwnershipPermissionsSetup:
 
 
         operator2_login = await async_client.post(
         operator2_login = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "operator2", "password": "operatorpass123"},
+            json={"username": "operator2", "password": "Operatorpass1!"},
         )
         )
         operator2_token = operator2_login.json()["access_token"]
         operator2_token = operator2_login.json()["access_token"]
 
 
@@ -86,14 +86,14 @@ class TestOwnershipPermissionsSetup:
             headers={"Authorization": f"Bearer {admin_token}"},
             headers={"Authorization": f"Bearer {admin_token}"},
             json={
             json={
                 "username": "viewer1",
                 "username": "viewer1",
-                "password": "viewerpass123",
+                "password": "Viewerpass1!",
                 "group_ids": [viewers_group["id"]],
                 "group_ids": [viewers_group["id"]],
             },
             },
         )
         )
 
 
         viewer_login = await async_client.post(
         viewer_login = await async_client.post(
             "/api/v1/auth/login",
             "/api/v1/auth/login",
-            json={"username": "viewer1", "password": "viewerpass123"},
+            json={"username": "viewer1", "password": "Viewerpass1!"},
         )
         )
         viewer_token = viewer_login.json()["access_token"]
         viewer_token = viewer_login.json()["access_token"]
 
 
@@ -721,7 +721,7 @@ class TestUserItemsCountAndDeletion(TestOwnershipPermissionsSetup):
             headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
             headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
             json={
             json={
                 "username": "deletewithitems",
                 "username": "deletewithitems",
-                "password": "password123",
+                "password": "Password123!",
             },
             },
         )
         )
         user_id = create_response.json()["id"]
         user_id = create_response.json()["id"]

+ 796 - 0
backend/tests/integration/test_security.py

@@ -0,0 +1,796 @@
+"""Security tests for the 8 coverage gaps identified in the maintainer review.
+
+Gap 1: encryption.py has zero tests
+Gap 2: JWT revocation (revoke_jti, is_jti_revoked, _is_token_fresh) untested
+Gap 3: OIDC exchange token replay untested
+Gap 4: OIDC email_verified claim handling untested
+Gap 5: Email OTP max-attempts invalidation untested
+Gap 6: OIDC callback error redirects (SSRF protection) undertested
+Gap 7: Login rate limiting untested
+Gap 8: challenge_id cookie binding untested
+"""
+
+from __future__ import annotations
+
+import base64
+import secrets
+import time
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import jwt as pyjwt
+import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.auth_ephemeral import AuthEphemeralToken
+from backend.app.models.user import User
+
+AUTH_SETUP_URL = "/api/v1/auth/setup"
+LOGIN_URL = "/api/v1/auth/login"
+LOGOUT_URL = "/api/v1/auth/logout"
+ME_URL = "/api/v1/auth/me"
+
+
+def _auth_header(token: str) -> dict[str, str]:
+    return {"Authorization": f"Bearer {token}"}
+
+
+def _norm_pw(password: str) -> str:
+    """Ensure password meets complexity requirements (I4: SetupRequest now validates)."""
+    if not any(c.isupper() for c in password):
+        password = password[0].upper() + password[1:]
+    if not any(c not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" for c in password):
+        password = password + "!"
+    return password
+
+
+async def _setup_and_login(client: AsyncClient, username: str, password: str) -> str:
+    password = _norm_pw(password)
+    await client.post(
+        AUTH_SETUP_URL,
+        json={"auth_enabled": True, "admin_username": username, "admin_password": password},
+    )
+    resp = await client.post(LOGIN_URL, json={"username": username, "password": password})
+    assert resp.status_code == 200
+    return resp.json()["access_token"]
+
+
+def _make_test_rsa_key():
+    def _b64url(n: int, length: int) -> str:
+        return base64.urlsafe_b64encode(n.to_bytes(length, "big")).rstrip(b"=").decode()
+
+    private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+    private_pem = private_key.private_bytes(
+        serialization.Encoding.PEM,
+        serialization.PrivateFormat.TraditionalOpenSSL,
+        serialization.NoEncryption(),
+    )
+    pub_numbers = private_key.public_key().public_numbers()
+    jwks = {
+        "keys": [
+            {
+                "kty": "RSA",
+                "use": "sig",
+                "alg": "RS256",
+                "kid": "test-kid-1",
+                "n": _b64url(pub_numbers.n, 256),
+                "e": _b64url(pub_numbers.e, 3),
+            }
+        ]
+    }
+    return private_pem, jwks
+
+
+# ===========================================================================
+# Gap 1: encryption.py unit tests
+# ===========================================================================
+
+
+class TestEncryption:
+    """encrypt/decrypt round-trips, plaintext passthrough, RuntimeError on missing key."""
+
+    def test_encrypt_decrypt_roundtrip_with_key(self):
+        from cryptography.fernet import Fernet
+
+        test_key = Fernet.generate_key().decode()
+
+        import backend.app.core.encryption as enc_mod
+
+        original = enc_mod._fernet_instance
+        original_warn = enc_mod._warn_shown
+        try:
+            enc_mod._fernet_instance = None
+            enc_mod._warn_shown = False
+            with patch.dict("os.environ", {"MFA_ENCRYPTION_KEY": test_key}):
+                ciphertext = enc_mod.mfa_encrypt("my-totp-secret")
+                assert ciphertext.startswith("fernet:")
+                assert enc_mod.mfa_decrypt(ciphertext) == "my-totp-secret"
+        finally:
+            enc_mod._fernet_instance = original
+            enc_mod._warn_shown = original_warn
+
+    def test_plaintext_passthrough_without_key(self):
+        import backend.app.core.encryption as enc_mod
+
+        original = enc_mod._fernet_instance
+        original_warn = enc_mod._warn_shown
+        try:
+            enc_mod._fernet_instance = None
+            enc_mod._warn_shown = False
+            with patch.dict("os.environ", {}, clear=True):
+                env = {k: v for k, v in __import__("os").environ.items() if k != "MFA_ENCRYPTION_KEY"}
+                with patch.dict("os.environ", env, clear=True):
+                    result = enc_mod.mfa_encrypt("plaintext-secret")
+                    assert result == "plaintext-secret"
+                    assert enc_mod.mfa_decrypt("plaintext-secret") == "plaintext-secret"
+        finally:
+            enc_mod._fernet_instance = original
+            enc_mod._warn_shown = original_warn
+
+    def test_decrypt_raises_runtime_error_without_key_for_encrypted_value(self):
+        import backend.app.core.encryption as enc_mod
+
+        original = enc_mod._fernet_instance
+        original_warn = enc_mod._warn_shown
+        try:
+            enc_mod._fernet_instance = None
+            enc_mod._warn_shown = False
+            # A value with the fernet: prefix but no key configured
+            env = {k: v for k, v in __import__("os").environ.items() if k != "MFA_ENCRYPTION_KEY"}
+            with (
+                patch.dict("os.environ", env, clear=True),
+                pytest.raises(RuntimeError, match="MFA_ENCRYPTION_KEY must be set"),
+            ):
+                enc_mod.mfa_decrypt("fernet:gAAAAA-fake-ciphertext")
+        finally:
+            enc_mod._fernet_instance = original
+            enc_mod._warn_shown = original_warn
+
+
+# ===========================================================================
+# Gap 2: JWT revocation — revoke_jti, is_jti_revoked, _is_token_fresh, /me
+# ===========================================================================
+
+
+class TestJWTRevocation:
+    """JWT revocation and token freshness checks."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_revoke_jti_and_is_jti_revoked(self, async_client: AsyncClient, db_session: AsyncSession):
+        """revoke_jti stores the JTI; is_jti_revoked returns True afterwards."""
+        from backend.app.core.auth import is_jti_revoked, revoke_jti
+
+        test_jti = secrets.token_urlsafe(16)
+        expires = datetime.now(timezone.utc) + timedelta(hours=1)
+
+        assert not await is_jti_revoked(test_jti)
+        await revoke_jti(test_jti, expires, username="testuser")
+        assert await is_jti_revoked(test_jti)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_revoke_jti_idempotent(self, async_client: AsyncClient):
+        """Double-revocation of the same JTI should not raise."""
+        from backend.app.core.auth import is_jti_revoked, revoke_jti
+
+        jti = secrets.token_urlsafe(16)
+        expires = datetime.now(timezone.utc) + timedelta(hours=1)
+        await revoke_jti(jti, expires)
+        await revoke_jti(jti, expires)  # must not raise
+        assert await is_jti_revoked(jti)
+
+    def test_is_token_fresh_rejects_none_iat(self):
+        """_is_token_fresh returns False when iat is None (I1 hard cutoff)."""
+        from backend.app.core.auth import _is_token_fresh
+
+        user = MagicMock()
+        user.password_changed_at = None
+        assert _is_token_fresh(None, user) is False
+
+    def test_is_token_fresh_rejects_token_before_password_change(self):
+        """_is_token_fresh returns False when iat predates password_changed_at."""
+        from backend.app.core.auth import _is_token_fresh
+
+        now = datetime.now(timezone.utc)
+        user = MagicMock()
+        user.password_changed_at = now
+        old_iat = (now - timedelta(hours=1)).timestamp()
+        assert _is_token_fresh(old_iat, user) is False
+
+    def test_is_token_fresh_accepts_token_after_password_change(self):
+        """_is_token_fresh returns True when iat is after password_changed_at."""
+        from backend.app.core.auth import _is_token_fresh
+
+        now = datetime.now(timezone.utc)
+        user = MagicMock()
+        user.password_changed_at = now - timedelta(hours=1)
+        recent_iat = now.timestamp()
+        assert _is_token_fresh(recent_iat, user) is True
+
+    def test_is_token_fresh_returns_true_when_no_password_change(self):
+        """_is_token_fresh returns True when password_changed_at is None (I2 migration not yet run)."""
+        from backend.app.core.auth import _is_token_fresh
+
+        user = MagicMock()
+        user.password_changed_at = None
+        assert _is_token_fresh(time.time(), user) is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_me_endpoint_rejects_token_after_logout(self, async_client: AsyncClient):
+        """After logout, the bearer token must be rejected by /me (B1 + revocation)."""
+        token = await _setup_and_login(async_client, "sec_logout_me", "sec_logout_me1")
+
+        # Token works before logout
+        me_resp = await async_client.get(ME_URL, headers=_auth_header(token))
+        assert me_resp.status_code == 200
+
+        # Logout
+        logout_resp = await async_client.post(LOGOUT_URL, headers=_auth_header(token))
+        assert logout_resp.status_code == 200
+
+        # Token must now be rejected
+        me_after = await async_client.get(ME_URL, headers=_auth_header(token))
+        assert me_after.status_code == 401
+
+
+# ===========================================================================
+# Gap 3: OIDC exchange token replay
+# ===========================================================================
+
+
+class TestOIDCExchangeReplay:
+    """A single-use OIDC exchange token cannot be redeemed twice."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_exchange_token_is_single_use(self, async_client: AsyncClient, db_session: AsyncSession):
+        """The second call to /oidc/exchange with the same token returns 401."""
+        exchange_token = secrets.token_urlsafe(32)
+        db_session.add(
+            AuthEphemeralToken(
+                token=exchange_token,
+                token_type="oidc_exchange",
+                username="oidc_replay_user",
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
+            )
+        )
+        await db_session.commit()
+
+        # Seed the user so the exchange can resolve it
+        from backend.app.core.auth import get_password_hash
+        from backend.app.core.database import async_session, seed_default_groups
+
+        async with async_session() as db:
+            result = await db.execute(__import__("sqlalchemy").select(User).where(User.username == "oidc_replay_user"))
+            if result.scalar_one_or_none() is None:
+                db.add(
+                    User(
+                        username="oidc_replay_user",
+                        password_hash=get_password_hash("pw"),
+                        is_active=True,
+                    )
+                )
+                await db.commit()
+
+        first = await async_client.post("/api/v1/auth/oidc/exchange", json={"oidc_token": exchange_token})
+        assert first.status_code == 200
+
+        second = await async_client.post("/api/v1/auth/oidc/exchange", json={"oidc_token": exchange_token})
+        assert second.status_code == 401
+
+
+# ===========================================================================
+# Gap 4: OIDC email_verified claim handling
+# ===========================================================================
+
+
+class TestOIDCEmailVerified:
+    """email_verified: False/absent must not link OIDC identity to an existing email."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unverified_email_does_not_link_to_existing_user(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """If email_verified is False, the OIDC callback must not auto-link by email."""
+        private_pem, jwks_data = _make_test_rsa_key()
+        issuer = "https://idp.evtest.example.com"
+        client_id = "ev-client"
+        nonce = secrets.token_urlsafe(16)
+        now = int(time.time())
+
+        id_token = pyjwt.encode(
+            {
+                "sub": "ev-sub-new",
+                "iss": issuer,
+                "aud": client_id,
+                "nonce": nonce,
+                "email": "existing@example.com",
+                "email_verified": False,  # <-- must be ignored
+                "iat": now,
+                "exp": now + 300,
+            },
+            private_pem,
+            algorithm="RS256",
+            headers={"kid": "test-kid-1"},
+        )
+
+        admin_token = await _setup_and_login(async_client, "ev_admin", "ev_admin1")
+
+        # Create existing user with the same email (use strong password for validator)
+        create_user_resp = await async_client.post(
+            "/api/v1/users",
+            json={"username": "existing_email_user", "password": "Str0ng!Pass", "email": "existing@example.com"},
+            headers=_auth_header(admin_token),
+        )
+        assert create_user_resp.status_code in (200, 201), create_user_resp.json()
+
+        # Create OIDC provider
+        create_resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "EV-IdP",
+                "issuer_url": issuer,
+                "client_id": client_id,
+                "client_secret": "secret",
+                "scopes": "openid email",
+                "is_enabled": True,
+                "auto_create_users": True,
+            },
+            headers=_auth_header(admin_token),
+        )
+        assert create_resp.status_code == 201
+        provider_id = create_resp.json()["id"]
+
+        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",
+        }
+
+        class _MockResp:
+            def __init__(self, data):
+                self._data = data
+                self.status_code = 200
+                self.is_success = True
+                self.text = str(data)
+
+            def json(self):
+                return self._data
+
+            def raise_for_status(self):
+                pass
+
+        class _MockHttpxClientEV:
+            def __init__(self, *args, **kwargs):
+                pass
+
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *_):
+                pass
+
+            async def get(self, url, **kwargs):
+                if "jwks" in url:
+                    return _MockResp(jwks_data)
+                return _MockResp(discovery_doc)
+
+            async def post(self, url, **kwargs):
+                return _MockResp({"access_token": "mock", "token_type": "Bearer", "id_token": id_token})
+
+        with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _MockHttpxClientEV):
+            await async_client.get(
+                f"/api/v1/auth/oidc/callback?code=test-code&state={state}",
+                follow_redirects=False,
+            )
+
+        # Callback must NOT link to the existing_email_user — a new user is created
+        # instead (because the email claim was ignored due to email_verified=False).
+        # Either a new user is provisioned (redirect with oidc_token) or the callback
+        # fails.  In either case, the existing user must not have an OIDC link.
+        from sqlalchemy import select as sa_select
+
+        from backend.app.models.oidc_provider import UserOIDCLink
+
+        link_result = await db_session.execute(
+            sa_select(UserOIDCLink)
+            .join(User, UserOIDCLink.user_id == User.id)
+            .where(User.email == "existing@example.com")
+        )
+        link = link_result.scalar_one_or_none()
+        assert link is None, "Existing user must not be auto-linked when email_verified is False"
+
+
+# ===========================================================================
+# Gap 5: Email OTP max-attempts invalidation
+# ===========================================================================
+
+
+class TestEmailOTPMaxAttempts:
+    """After MAX_ATTEMPTS wrong codes, the OTP is permanently invalidated."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_email_otp_invalidated_after_max_attempts(self, async_client: AsyncClient, db_session: AsyncSession):
+        from passlib.context import CryptContext
+        from sqlalchemy import select as sa_select
+
+        from backend.app.models.user_otp_code import UserOTPCode
+
+        _pwd_ctx = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
+
+        admin_token = await _setup_and_login(async_client, "otp_max_admin", "otp_max_admin1")
+
+        # Enable email OTP for admin user
+        result = await db_session.execute(sa_select(User).where(User.username == "otp_max_admin"))
+        user = result.scalar_one()
+        user.email = "otpmax@example.com"
+        await db_session.commit()
+
+        setup_code = "123456"
+        from backend.app.models.auth_ephemeral import AuthEphemeralToken as AET
+
+        setup_token = secrets.token_urlsafe(32)
+        db_session.add(
+            AET(
+                token=setup_token,
+                token_type="email_otp_setup",
+                username="otp_max_admin",
+                nonce=_pwd_ctx.hash(setup_code),
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
+            )
+        )
+        await db_session.commit()
+        await async_client.post(
+            "/api/v1/auth/2fa/email/enable/confirm",
+            json={"setup_token": setup_token, "code": setup_code},
+            headers=_auth_header(admin_token),
+        )
+
+        # Login to get pre_auth_token
+        login_resp = await async_client.post(
+            LOGIN_URL, json={"username": "otp_max_admin", "password": "Otp_max_admin1"}
+        )
+        pre_auth_token = login_resp.json()["pre_auth_token"]
+
+        # Insert an OTP record directly (bypassing SMTP)
+        real_code = "654321"
+        otp = UserOTPCode(
+            user_id=user.id,
+            code_hash=_pwd_ctx.hash(real_code),
+            attempts=0,
+            used=False,
+            expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
+        )
+        db_session.add(otp)
+        await db_session.commit()
+
+        # Submit MAX_ATTEMPTS wrong codes
+        from backend.app.api.routes.mfa import MAX_2FA_ATTEMPTS
+
+        for _ in range(MAX_2FA_ATTEMPTS):
+            r = await async_client.post(
+                "/api/v1/auth/2fa/verify",
+                json={"pre_auth_token": pre_auth_token, "code": "000000", "method": "email"},
+            )
+            # Each attempt must fail with 401
+            assert r.status_code == 401
+
+        # After max attempts, the correct code is also rejected (either OTP
+        # invalidated → 401, or rate limit hit → 429). Either means locked out.
+        final = await async_client.post(
+            "/api/v1/auth/2fa/verify",
+            json={"pre_auth_token": pre_auth_token, "code": real_code, "method": "email"},
+        )
+        assert final.status_code in (401, 429), f"Expected lockout, got {final.status_code}: {final.json()}"
+
+
+# ===========================================================================
+# Gap 6: OIDC callback SSRF protection — invalid authorization_endpoint scheme
+# ===========================================================================
+
+
+class TestOIDCSSRFProtection:
+    """authorization_endpoint with non-http(s) scheme must be rejected."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_invalid_authorization_endpoint_scheme_rejected(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        issuer = "https://idp.ssrf.example.com"
+        client_id = "ssrf-client"
+
+        admin_token = await _setup_and_login(async_client, "ssrf_admin", "ssrf_admin1")
+        create_resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "SSRF-IdP",
+                "issuer_url": issuer,
+                "client_id": client_id,
+                "client_secret": "secret",
+                "scopes": "openid",
+                "is_enabled": True,
+                "auto_create_users": False,
+            },
+            headers=_auth_header(admin_token),
+        )
+        assert create_resp.status_code == 201
+        provider_id = create_resp.json()["id"]
+
+        # Discovery doc returns a javascript: authorization_endpoint
+        malicious_discovery = {
+            "issuer": issuer,
+            "authorization_endpoint": "javascript:alert(1)",  # <-- malicious
+            "token_endpoint": f"{issuer}/token",
+            "jwks_uri": f"{issuer}/.well-known/jwks.json",
+        }
+
+        class _MockResp:
+            def __init__(self, data):
+                self._data = data
+                self.status_code = 200
+                self.is_success = True
+                self.text = str(data)
+
+            def json(self):
+                return self._data
+
+            def raise_for_status(self):
+                pass
+
+        class _MockHttpxClientSSRF:
+            def __init__(self, *args, **kwargs):
+                pass
+
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *_):
+                pass
+
+            async def get(self, url, **kwargs):
+                return _MockResp(malicious_discovery)
+
+            async def post(self, url, **kwargs):
+                return _MockResp({})
+
+        with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _MockHttpxClientSSRF):
+            # oidc_authorize uses a path parameter, not query param
+            authorize_resp = await async_client.get(
+                f"/api/v1/auth/oidc/authorize/{provider_id}",
+                follow_redirects=False,
+            )
+
+        # Must be rejected with 502 — B2 guard rejects invalid authorization_endpoint scheme
+        assert authorize_resp.status_code == 502, authorize_resp.json()
+        detail = authorize_resp.json().get("detail", "").lower()
+        assert "authorization_endpoint" in detail or "invalid" in detail
+
+
+# ===========================================================================
+# Gap 7: Login rate limiting
+# ===========================================================================
+
+
+class TestLoginRateLimiting:
+    """10+ failed logins for the same username must return 429."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_excessive_failed_logins_return_429(self, async_client: AsyncClient):
+        from backend.app.api.routes.mfa import MAX_LOGIN_ATTEMPTS
+
+        # Setup auth but do NOT log in
+        await async_client.post(
+            AUTH_SETUP_URL,
+            json={"auth_enabled": True, "admin_username": "ratelimit_user", "admin_password": "Ratelimit_pw1"},
+        )
+
+        status_codes = []
+        for _ in range(MAX_LOGIN_ATTEMPTS + 2):
+            resp = await async_client.post(
+                LOGIN_URL,
+                json={"username": "ratelimit_user", "password": "wrong_password"},
+            )
+            status_codes.append(resp.status_code)
+
+        # The last attempts must be 429 (Too Many Requests)
+        assert status_codes[-1] == 429, f"Expected 429 after {MAX_LOGIN_ATTEMPTS} failures, got: {status_codes}"
+
+
+# ===========================================================================
+# Gap 8: challenge_id cookie binding
+# ===========================================================================
+
+
+class TestChallengeIdCookieBinding:
+    """A pre-auth token stolen from session A cannot be used from session B."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pre_auth_token_rejected_without_matching_cookie(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        import pyotp
+        from passlib.context import CryptContext
+
+        _pwd_ctx = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
+
+        # Set up user with TOTP
+        await _setup_and_login(async_client, "cookie_bind_user", "cookie_bind_pw1")
+
+        secret = pyotp.random_base32()
+        totp_obj = pyotp.TOTP(secret)
+        from sqlalchemy import select as sa_select
+
+        from backend.app.models.user_totp import UserTOTP
+
+        result = await db_session.execute(sa_select(User).where(User.username == "cookie_bind_user"))
+        user = result.scalar_one()
+        db_session.add(UserTOTP(user_id=user.id, secret=secret, is_enabled=True))
+        await db_session.commit()
+
+        # Login from "session A" — gets a pre_auth_token and a 2fa_challenge cookie
+        login_resp = await async_client.post(
+            LOGIN_URL, json={"username": "cookie_bind_user", "password": "Cookie_bind_pw1"}
+        )
+        assert login_resp.status_code == 200
+        assert login_resp.json()["requires_2fa"] is True
+        pre_auth_token = login_resp.json()["pre_auth_token"]
+        # The async_client jar now holds the 2fa_challenge cookie for session A
+
+        # Simulate session B by creating a new client WITHOUT the cookie
+        from httpx import ASGITransport, AsyncClient as FreshClient
+
+        from backend.app.main import app
+
+        async with FreshClient(transport=ASGITransport(app=app), base_url="http://test") as session_b:
+            # Attempt to use session A's pre_auth_token from session B (no cookie)
+            verify_resp = await session_b.post(
+                "/api/v1/auth/2fa/verify",
+                json={
+                    "pre_auth_token": pre_auth_token,
+                    "code": totp_obj.now(),
+                    "method": "totp",
+                },
+            )
+            # Must be rejected — pre_auth_token is bound to session A's cookie
+            assert verify_resp.status_code == 401, (
+                f"Expected 401 for token replay from cookieless session, got {verify_resp.status_code}: "
+                f"{verify_resp.json()}"
+            )
+
+
+# ===========================================================================
+# C2: Security-header middleware
+# ===========================================================================
+
+
+class TestSecurityHeaders:
+    """Every HTTP response must include standard security headers (C2)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_security_headers_present(self, async_client: AsyncClient):
+        """GET /api/v1/auth/me (unauthenticated → 401) still carries security headers."""
+        resp = await async_client.get(ME_URL)
+        assert resp.status_code == 401  # sanity — no auth token
+
+        assert resp.headers.get("x-content-type-options") == "nosniff"
+        assert resp.headers.get("x-frame-options") == "SAMEORIGIN"
+        assert resp.headers.get("referrer-policy") == "strict-origin-when-cross-origin"
+
+        csp = resp.headers.get("content-security-policy", "")
+        assert "default-src 'self'" in csp
+        assert "script-src 'self'" in csp
+        assert "frame-ancestors 'none'" in csp
+        assert "object-src 'none'" in csp
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_hsts_absent_for_http(self, async_client: AsyncClient):
+        """HSTS must NOT be set over plain HTTP (test transport uses http)."""
+        resp = await async_client.get(ME_URL)
+        assert "strict-transport-security" not in resp.headers
+
+
+# ===========================================================================
+# I3: Rate-limit bucket interaction — IP spray vs. username spray
+# ===========================================================================
+
+
+class TestRateLimitBuckets:
+    """IP-spray and username-spray must each trip the correct independent bucket."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ip_spray_trips_ip_bucket(self, async_client: AsyncClient):
+        """20 failed logins from one IP across 20 different usernames trips the IP bucket.
+
+        Each per-username bucket only has 1 failure (well below MAX_LOGIN_ATTEMPTS=10),
+        so the username bucket is never the reason for the 429.
+        """
+        from unittest.mock import patch as _patch
+
+        unique_ip = "10.99.1.1"
+
+        # Ensure auth is enabled
+        await async_client.post(
+            AUTH_SETUP_URL,
+            json={"auth_enabled": True, "admin_username": "spray_ip_admin", "admin_password": "SprayIp_admin1"},
+        )
+
+        status_codes: list[int] = []
+        with _patch("backend.app.api.routes.auth._get_client_ip", return_value=unique_ip):
+            for i in range(22):
+                resp = await async_client.post(
+                    LOGIN_URL,
+                    json={"username": f"spray_ip_victim_{i}", "password": "wrong"},
+                )
+                status_codes.append(resp.status_code)
+
+        # The first 20 attempts fail with 401; the 21st+ must be 429 (IP bucket full)
+        assert status_codes[-1] == 429, f"Expected 429 after 20 IP-spray failures, got: {status_codes}"
+        # No single username saw more than one attempt → username buckets not tripped
+        non_429 = [c for c in status_codes[:-2] if c == 429]
+        assert not non_429, f"Username bucket triggered early: {status_codes}"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_username_spray_trips_username_bucket(self, async_client: AsyncClient):
+        """One username targeted from 10+ different IPs trips the username bucket.
+
+        Each per-IP bucket only sees 1 failure, so no IP bucket is tripped.
+        The username bucket (max 10) is what fires the 429.
+        """
+        from unittest.mock import patch as _patch
+
+        from backend.app.api.routes.mfa import MAX_LOGIN_ATTEMPTS
+
+        # Ensure auth is enabled
+        await async_client.post(
+            AUTH_SETUP_URL,
+            json={
+                "auth_enabled": True,
+                "admin_username": "spray_uname_admin",
+                "admin_password": "SprayUname_admin1",
+            },
+        )
+
+        target_username = "spray_uname_victim"
+        status_codes: list[int] = []
+        for i in range(MAX_LOGIN_ATTEMPTS + 2):
+            rotating_ip = f"10.99.2.{i + 1}"
+            with _patch("backend.app.api.routes.auth._get_client_ip", return_value=rotating_ip):
+                resp = await async_client.post(
+                    LOGIN_URL,
+                    json={"username": target_username, "password": "wrong"},
+                )
+                status_codes.append(resp.status_code)
+
+        # After MAX_LOGIN_ATTEMPTS failures for same username the bucket fires
+        assert status_codes[-1] == 429, (
+            f"Expected 429 after {MAX_LOGIN_ATTEMPTS} username-spray failures, got: {status_codes}"
+        )

+ 49 - 0
backend/tests/unit/test_mfa_helpers.py

@@ -0,0 +1,49 @@
+"""Unit tests for 2FA helper functions in mfa.py."""
+
+import base64
+import string
+
+import pytest
+from passlib.context import CryptContext
+
+from backend.app.api.routes.mfa import _generate_backup_codes, _generate_totp_qr_b64
+
+
+class TestBackupCodeGeneration:
+    """Tests for backup code helpers."""
+
+    def test_generates_ten_codes(self):
+        plain, hashed = _generate_backup_codes()
+        assert len(plain) == 10
+        assert len(hashed) == 10
+
+    def test_codes_are_eight_chars(self):
+        plain, _ = _generate_backup_codes()
+        for code in plain:
+            assert len(code) == 8
+
+    def test_codes_are_alphanumeric(self):
+        allowed = set(string.ascii_uppercase + string.digits)
+        plain, _ = _generate_backup_codes()
+        for code in plain:
+            assert all(c in allowed for c in code)
+
+    def test_hashes_verify_against_plain(self):
+        ctx = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
+        plain, hashed = _generate_backup_codes()
+        for p, h in zip(plain, hashed, strict=True):
+            assert ctx.verify(p, h)
+
+    def test_codes_are_unique(self):
+        plain, _ = _generate_backup_codes()
+        assert len(set(plain)) == 10
+
+
+class TestTOTPQRCode:
+    """Tests for QR code generation helper."""
+
+    def test_generates_base64_png(self):
+        uri = "otpauth://totp/Bambuddy:testuser?secret=BASE32SECRET&issuer=Bambuddy"
+        result = _generate_totp_qr_b64(uri)
+        decoded = base64.b64decode(result)
+        assert decoded[:4] == b"\x89PNG"

+ 3 - 0
frontend/index.html

@@ -3,6 +3,9 @@
   <head>
   <head>
     <meta charset="UTF-8" />
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+    <!-- L-4: Restrict Referer header to origin-only on cross-origin navigation so
+         sensitive tokens in query parameters are not leaked to third-party servers. -->
+    <meta name="referrer" content="strict-origin-when-cross-origin" />
     <title>Bambuddy</title>
     <title>Bambuddy</title>
 
 
     <!-- PWA Meta Tags -->
     <!-- PWA Meta Tags -->

+ 14 - 14
frontend/src/__tests__/api/client.test.ts

@@ -7,23 +7,23 @@ import { http, HttpResponse } from 'msw';
 import { setupServer } from 'msw/node';
 import { setupServer } from 'msw/node';
 import { setAuthToken, getAuthToken, api } from '../../api/client';
 import { setAuthToken, getAuthToken, api } from '../../api/client';
 
 
-// Mock localStorage
-const localStorageMock = {
+// Mock sessionStorage (H-5: tokens are stored in sessionStorage, not localStorage)
+const sessionStorageMock = {
   store: {} as Record<string, string>,
   store: {} as Record<string, string>,
-  getItem: vi.fn((key: string) => localStorageMock.store[key] || null),
+  getItem: vi.fn((key: string) => sessionStorageMock.store[key] || null),
   setItem: vi.fn((key: string, value: string) => {
   setItem: vi.fn((key: string, value: string) => {
-    localStorageMock.store[key] = value;
+    sessionStorageMock.store[key] = value;
   }),
   }),
   removeItem: vi.fn((key: string) => {
   removeItem: vi.fn((key: string) => {
-    delete localStorageMock.store[key];
+    delete sessionStorageMock.store[key];
   }),
   }),
   clear: vi.fn(() => {
   clear: vi.fn(() => {
-    localStorageMock.store = {};
+    sessionStorageMock.store = {};
   }),
   }),
 };
 };
 
 
-Object.defineProperty(window, 'localStorage', {
-  value: localStorageMock,
+Object.defineProperty(window, 'sessionStorage', {
+  value: sessionStorageMock,
 });
 });
 
 
 // Create MSW server
 // Create MSW server
@@ -32,22 +32,22 @@ const server = setupServer();
 beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
 beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
 afterEach(() => {
 afterEach(() => {
   server.resetHandlers();
   server.resetHandlers();
-  localStorageMock.clear();
+  sessionStorageMock.clear();
   setAuthToken(null);
   setAuthToken(null);
 });
 });
 afterAll(() => server.close());
 afterAll(() => server.close());
 
 
 describe('Auth Token Management', () => {
 describe('Auth Token Management', () => {
-  it('setAuthToken stores token in localStorage', () => {
+  it('setAuthToken stores token in sessionStorage', () => {
     setAuthToken('test-token-123');
     setAuthToken('test-token-123');
-    expect(localStorageMock.setItem).toHaveBeenCalledWith('auth_token', 'test-token-123');
+    expect(sessionStorageMock.setItem).toHaveBeenCalledWith('auth_token', 'test-token-123');
     expect(getAuthToken()).toBe('test-token-123');
     expect(getAuthToken()).toBe('test-token-123');
   });
   });
 
 
-  it('setAuthToken removes token from localStorage when null', () => {
+  it('setAuthToken removes token from sessionStorage when null', () => {
     setAuthToken('test-token-123');
     setAuthToken('test-token-123');
     setAuthToken(null);
     setAuthToken(null);
-    expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
+    expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
     expect(getAuthToken()).toBeNull();
     expect(getAuthToken()).toBeNull();
   });
   });
 });
 });
@@ -115,7 +115,7 @@ describe('API Client Auth Header', () => {
     }
     }
 
 
     expect(getAuthToken()).toBeNull();
     expect(getAuthToken()).toBeNull();
-    expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
+    expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
   });
   });
 
 
   it('does not clear token on 401 with generic auth error', async () => {
   it('does not clear token on 401 with generic auth error', async () => {

+ 141 - 0
frontend/src/__tests__/pages/LoginPage.test.tsx

@@ -159,4 +159,145 @@ describe('LoginPage', () => {
       resolveLogin!();
       resolveLogin!();
     });
     });
   });
   });
+
+  describe('2FA flow', () => {
+    // Helper: login as a 2FA user and get to the 2FA step
+    async function loginWith2FA(twoFAMethods = ['totp', 'backup']) {
+      const user = userEvent.setup();
+
+      server.use(
+        http.post('/api/v1/auth/login', () =>
+          HttpResponse.json({
+            requires_2fa: true,
+            pre_auth_token: 'test-pre-auth-token',
+            two_fa_methods: twoFAMethods,
+          })
+        )
+      );
+
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
+      });
+
+      await user.type(screen.getByLabelText(/Username/i), 'mfa-user');
+      await user.type(screen.getByLabelText(/Password/i), 'mfa-password');
+      await user.click(screen.getByRole('button', { name: /Sign in/i }));
+
+      return user;
+    }
+
+    it('shows 2FA step when login returns requires_2fa', async () => {
+      await loginWith2FA();
+
+      await waitFor(() => {
+        expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
+      });
+    });
+
+    it('shows code input on the 2FA step', async () => {
+      await loginWith2FA();
+
+      await waitFor(() => {
+        // The code input field is rendered
+        expect(screen.getByRole('textbox', { name: /Verification Code/i })).toBeInTheDocument();
+      });
+    });
+
+    it('submits 2FA verify request with code and pre_auth_token', async () => {
+      let verifyCalled = false;
+      let verifyBody: unknown;
+
+      server.use(
+        http.post('/api/v1/auth/2fa/verify', async ({ request }) => {
+          verifyCalled = true;
+          verifyBody = await request.json();
+          return HttpResponse.json({
+            access_token: 'final-jwt',
+            token_type: 'bearer',
+            user: {
+              id: 1,
+              username: 'mfa-user',
+              role: 'admin',
+              is_active: true,
+              created_at: new Date().toISOString(),
+            },
+          });
+        })
+      );
+
+      const user = await loginWith2FA();
+
+      await waitFor(() => {
+        expect(screen.getByRole('textbox', { name: /Verification Code/i })).toBeInTheDocument();
+      });
+
+      await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456');
+      await user.click(screen.getByRole('button', { name: /Verify/i }));
+
+      await waitFor(() => {
+        expect(verifyCalled).toBe(true);
+      });
+
+      expect(verifyBody).toMatchObject({
+        pre_auth_token: 'test-pre-auth-token',
+        code: '123456',
+        method: 'totp',
+      });
+    });
+
+    it('returns to credentials step when back button is clicked', async () => {
+      await loginWith2FA();
+
+      await waitFor(() => {
+        expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
+      });
+
+      const user = userEvent.setup();
+      const backButton = screen.getByRole('button', { name: /Back to login/i });
+      await user.click(backButton);
+
+      await waitFor(() => {
+        expect(screen.getByRole('heading', { name: /Bambuddy Login/i })).toBeInTheDocument();
+      });
+    });
+
+    it('shows method selector when multiple 2FA methods are available', async () => {
+      await loginWith2FA(['totp', 'email', 'backup']);
+
+      await waitFor(() => {
+        expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
+      });
+
+      // Multiple method buttons should be visible
+      expect(screen.getByRole('button', { name: /Authenticator/i })).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: /Email/i })).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: /Backup/i })).toBeInTheDocument();
+    });
+
+    it('does not show method selector with only one 2FA method', async () => {
+      await loginWith2FA(['totp']);
+
+      await waitFor(() => {
+        expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
+      });
+
+      // Single-method: no method selector buttons
+      expect(screen.queryByRole('button', { name: /Authenticator/i })).not.toBeInTheDocument();
+    });
+
+    it('shows send code button when email method is selected', async () => {
+      const _user = await loginWith2FA(['email']);
+
+      await waitFor(() => {
+        expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
+      });
+
+      // For email method the "Send code" button should be shown
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: /Send Code/i })).toBeInTheDocument();
+      });
+    });
+  });
 });
 });

+ 173 - 6
frontend/src/api/client.ts

@@ -3,13 +3,21 @@ import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/
 const API_BASE = '/api/v1';
 const API_BASE = '/api/v1';
 
 
 // Auth token storage
 // Auth token storage
-let authToken: string | null = localStorage.getItem('auth_token');
+// 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.
+let authToken: string | null =
+  sessionStorage.getItem('auth_token') ?? localStorage.getItem('auth_token');
 
 
-export function setAuthToken(token: string | null) {
+export function setAuthToken(token: string | null, persist = false) {
   authToken = token;
   authToken = token;
   if (token) {
   if (token) {
-    localStorage.setItem('auth_token', token);
+    sessionStorage.setItem('auth_token', token);
+    if (persist) {
+      localStorage.setItem('auth_token', token);
+    }
   } else {
   } else {
+    sessionStorage.removeItem('auth_token');
     localStorage.removeItem('auth_token');
     localStorage.removeItem('auth_token');
   }
   }
 }
 }
@@ -66,6 +74,7 @@ async function request<T>(
   const response = await fetch(`${API_BASE}${endpoint}`, {
   const response = await fetch(`${API_BASE}${endpoint}`, {
     ...options,
     ...options,
     cache: 'no-store', // Prevent browser caching of API responses
     cache: 'no-store', // Prevent browser caching of API responses
+    credentials: 'include', // Required for HttpOnly cookies (e.g. 2fa_challenge)
     headers,
     headers,
   });
   });
 
 
@@ -2346,9 +2355,13 @@ export interface LoginRequest {
 }
 }
 
 
 export interface LoginResponse {
 export interface LoginResponse {
-  access_token: string;
-  token_type: string;
-  user: UserResponse;
+  access_token?: string;
+  token_type?: string;
+  user?: UserResponse;
+  /** Set when 2FA verification is required before a full token is issued. */
+  requires_2fa?: boolean;
+  pre_auth_token?: string;
+  two_fa_methods?: string[];
 }
 }
 
 
 export interface UserResponse {
 export interface UserResponse {
@@ -2414,6 +2427,66 @@ export interface SMTPSettings {
   smtp_from_name: string;
   smtp_from_name: string;
 }
 }
 
 
+// 2FA / MFA interfaces
+export interface TwoFAStatus {
+  totp_enabled: boolean;
+  email_otp_enabled: boolean;
+  backup_codes_remaining: number;
+}
+
+export interface TOTPSetupResponse {
+  secret: string;
+  qr_code_b64: string;
+  issuer: string;
+}
+
+export interface TOTPEnableResponse {
+  message: string;
+  backup_codes: string[];
+}
+
+export interface BackupCodesResponse {
+  backup_codes: string[];
+  message: string;
+}
+
+export interface TwoFAVerifyRequest {
+  pre_auth_token: string;
+  code: string;
+  method: 'totp' | 'email' | 'backup';
+}
+
+// OIDC interfaces
+export interface OIDCProvider {
+  id: number;
+  name: string;
+  issuer_url: string;
+  client_id: string;
+  scopes: string;
+  is_enabled: boolean;
+  auto_create_users: boolean;
+  icon_url?: string | null;
+}
+
+export interface OIDCProviderCreate {
+  name: string;
+  issuer_url: string;
+  client_id: string;
+  client_secret: string;
+  scopes?: string;
+  is_enabled?: boolean;
+  auto_create_users?: boolean;
+  icon_url?: string | null;
+}
+
+export interface OIDCLink {
+  id: number;
+  provider_id: number;
+  provider_name: string;
+  provider_email?: string | null;
+  created_at: string;
+}
+
 export interface TestSMTPRequest {
 export interface TestSMTPRequest {
   test_recipient: string;
   test_recipient: string;
 }
 }
@@ -2504,12 +2577,106 @@ export const api = {
       method: 'POST',
       method: 'POST',
       body: JSON.stringify(data),
       body: JSON.stringify(data),
     }),
     }),
+  // H-6: Confirm password reset using the token from the emailed link
+  forgotPasswordConfirm: (token: string, newPassword: string) =>
+    request<ForgotPasswordResponse>('/auth/forgot-password/confirm', {
+      method: 'POST',
+      body: JSON.stringify({ token, new_password: newPassword }),
+    }),
   resetUserPassword: (data: ResetPasswordRequest) =>
   resetUserPassword: (data: ResetPasswordRequest) =>
     request<ResetPasswordResponse>('/auth/reset-password', {
     request<ResetPasswordResponse>('/auth/reset-password', {
       method: 'POST',
       method: 'POST',
       body: JSON.stringify(data),
       body: JSON.stringify(data),
     }),
     }),
 
 
+  // 2FA - status
+  get2FAStatus: () => request<TwoFAStatus>('/auth/2fa/status'),
+
+  // 2FA - TOTP
+  setupTOTP: () => request<TOTPSetupResponse>('/auth/2fa/totp/setup', { method: 'POST' }),
+  enableTOTP: (code: string) =>
+    request<TOTPEnableResponse>('/auth/2fa/totp/enable', {
+      method: 'POST',
+      body: JSON.stringify({ code }),
+    }),
+  disableTOTP: (code: string) =>
+    request<{ message: string }>('/auth/2fa/totp/disable', {
+      method: 'POST',
+      body: JSON.stringify({ code }),
+    }),
+  regenerateBackupCodes: (code: string) =>
+    request<BackupCodesResponse>('/auth/2fa/totp/regenerate-backup-codes', {
+      method: 'POST',
+      body: JSON.stringify({ code }),
+    }),
+
+  // 2FA - Email OTP
+  // Step 1: send a verification code to the user's email (proof of possession)
+  enableEmailOTP: () =>
+    request<{ message: string; setup_token: string }>('/auth/2fa/email/enable', { method: 'POST' }),
+  // Step 2: confirm with the code received by email
+  confirmEnableEmailOTP: (setup_token: string, code: string) =>
+    request<{ message: string }>('/auth/2fa/email/enable/confirm', {
+      method: 'POST',
+      body: JSON.stringify({ setup_token, code }),
+    }),
+  // Disable requires account password for re-auth
+  disableEmailOTP: (password: string) =>
+    request<{ message: string }>('/auth/2fa/email/disable', {
+      method: 'POST',
+      body: JSON.stringify({ password }),
+    }),
+  sendEmailOTP: (preAuthToken: string) =>
+    request<{ message: string; pre_auth_token?: string }>('/auth/2fa/email/send', {
+      method: 'POST',
+      body: JSON.stringify({ pre_auth_token: preAuthToken }),
+    }),
+
+  // 2FA - verify (completes login)
+  verify2FA: (data: TwoFAVerifyRequest) =>
+    request<LoginResponse>('/auth/2fa/verify', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+
+  // 2FA - admin
+  admin2FADisable: (userId: number) =>
+    request<{ message: string }>(`/auth/2fa/admin/${userId}`, { method: 'DELETE' }),
+
+  // OIDC providers (public list)
+  getOIDCProviders: () => request<OIDCProvider[]>('/auth/oidc/providers'),
+
+  // OIDC providers (admin)
+  getOIDCProvidersAll: () => request<OIDCProvider[]>('/auth/oidc/providers/all'),
+  createOIDCProvider: (data: OIDCProviderCreate) =>
+    request<OIDCProvider>('/auth/oidc/providers', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateOIDCProvider: (id: number, data: Partial<OIDCProviderCreate>) =>
+    request<OIDCProvider>(`/auth/oidc/providers/${id}`, {
+      method: 'PUT',
+      body: JSON.stringify(data),
+    }),
+  deleteOIDCProvider: (id: number) =>
+    request<{ message: string }>(`/auth/oidc/providers/${id}`, { method: 'DELETE' }),
+
+  // OIDC authorize URL
+  getOIDCAuthorizeUrl: (providerId: number) =>
+    request<{ auth_url: string }>(`/auth/oidc/authorize/${providerId}`),
+
+  // OIDC exchange token for JWT
+  exchangeOIDCToken: (oidcToken: string) =>
+    request<LoginResponse>('/auth/oidc/exchange', {
+      method: 'POST',
+      body: JSON.stringify({ oidc_token: oidcToken }),
+    }),
+
+  // OIDC links for current user
+  getOIDCLinks: () => request<OIDCLink[]>('/auth/oidc/links'),
+  deleteOIDCLink: (providerId: number) =>
+    request<{ message: string }>(`/auth/oidc/links/${providerId}`, { method: 'DELETE' }),
+
   // Users
   // Users
   getUsers: () => request<UserResponse[]>('/users/'),
   getUsers: () => request<UserResponse[]>('/users/'),
   getUser: (id: number) => request<UserResponse>(`/users/${id}`),
   getUser: (id: number) => request<UserResponse>(`/users/${id}`),

+ 344 - 0
frontend/src/components/OIDCProviderSettings.tsx

@@ -0,0 +1,344 @@
+import { useState } 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';
+import { api } from '../api/client';
+import type { OIDCProvider, OIDCProviderCreate } from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { Button } from './Button';
+import { Toggle } from './Toggle';
+import { ConfirmModal } from './ConfirmModal';
+import { useToast } from '../contexts/ToastContext';
+
+const EMPTY_FORM: OIDCProviderCreate = {
+  name: '',
+  issuer_url: '',
+  client_id: '',
+  client_secret: '',
+  scopes: 'openid email profile',
+  is_enabled: true,
+  auto_create_users: false,
+  icon_url: undefined,
+};
+
+// ─── Provider form (create / edit) ───────────────────────────────────────────
+function ProviderForm({
+  initial,
+  isEdit = false,
+  onSave,
+  onCancel,
+  isPending,
+}: {
+  initial: OIDCProviderCreate;
+  isEdit?: boolean;
+  onSave: (data: OIDCProviderCreate) => void;
+  onCancel: () => void;
+  isPending: boolean;
+}) {
+  const { t } = useTranslation();
+  const [form, setForm] = useState<OIDCProviderCreate>(initial);
+  const [secretChanged, setSecretChanged] = useState(false);
+  const set = (key: keyof OIDCProviderCreate, value: unknown) =>
+    setForm((prev) => ({ ...prev, [key]: value }));
+
+  const inputCls =
+    'w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors text-sm';
+  const labelCls = 'block text-sm font-medium text-white mb-1';
+
+  const handleSave = () => {
+    const payload = { ...form };
+    if (isEdit && !secretChanged) {
+      delete (payload as Partial<OIDCProviderCreate>).client_secret;
+    }
+    onSave(payload);
+  };
+
+  return (
+    <div className="space-y-4">
+      <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
+        <div>
+          <label className={labelCls}>{t('settings.oidc.form.name')} <span className="text-red-400">*</span></label>
+          <input className={inputCls} value={form.name} onChange={(e) => set('name', e.target.value)} placeholder="Google" />
+        </div>
+        <div>
+          <label className={labelCls}>{t('settings.oidc.form.issuerUrl')} <span className="text-red-400">*</span></label>
+          <input className={inputCls} value={form.issuer_url} onChange={(e) => set('issuer_url', e.target.value)} placeholder="https://accounts.google.com" />
+        </div>
+        <div>
+          <label className={labelCls}>{t('settings.oidc.form.clientId')} <span className="text-red-400">*</span></label>
+          <input className={inputCls} value={form.client_id} onChange={(e) => set('client_id', e.target.value)} placeholder="your-client-id" />
+        </div>
+        <div>
+          <label className={labelCls}>
+            {t('settings.oidc.form.clientSecret')}
+            {!isEdit && <span className="text-red-400"> *</span>}
+            {isEdit && <span className="text-bambu-gray text-xs ml-1">({t('settings.oidc.form.secretHint')})</span>}
+          </label>
+          <input
+            className={inputCls}
+            type="password"
+            value={secretChanged ? form.client_secret : ''}
+            placeholder={isEdit && !secretChanged ? '••••••••' : t('settings.oidc.form.secretPlaceholder')}
+            onChange={(e) => {
+              setSecretChanged(true);
+              set('client_secret', e.target.value);
+            }}
+          />
+        </div>
+        <div>
+          <label className={labelCls}>{t('settings.oidc.form.scopes')}</label>
+          <input className={inputCls} value={form.scopes} onChange={(e) => set('scopes', e.target.value)} placeholder="openid email profile" />
+        </div>
+        <div>
+          <label className={labelCls}>{t('settings.oidc.form.iconUrl')}</label>
+          <input className={inputCls} value={form.icon_url ?? ''} onChange={(e) => set('icon_url', e.target.value || undefined)} placeholder="https://..." />
+        </div>
+      </div>
+
+      <div className="flex flex-wrap gap-6 pt-2">
+        <label className="flex items-center gap-3 cursor-pointer">
+          <Toggle checked={form.is_enabled ?? true} onChange={(v) => set('is_enabled', v)} />
+          <span className="text-white text-sm">{t('settings.oidc.form.enabled')}</span>
+        </label>
+        <label className="flex items-center gap-3 cursor-pointer">
+          <Toggle checked={form.auto_create_users ?? false} onChange={(v) => set('auto_create_users', v)} />
+          <div>
+            <p className="text-white text-sm">{t('settings.oidc.form.autoCreate')}</p>
+            <p className="text-bambu-gray text-xs">{t('settings.oidc.form.autoCreateDesc')}</p>
+          </div>
+        </label>
+      </div>
+
+      <div className="flex gap-3 pt-2">
+        <Button variant="secondary" onClick={onCancel} className="flex-1">
+          {t('common.cancel')}
+        </Button>
+        <Button
+          variant="primary"
+          className="flex-1"
+          disabled={!form.name || !form.issuer_url || !form.client_id || (!isEdit && !form.client_secret) || (isEdit && secretChanged && !form.client_secret) || isPending}
+          onClick={handleSave}
+        >
+          {isPending ? t('common.saving') : t('common.save')}
+        </Button>
+      </div>
+    </div>
+  );
+}
+
+// ─── Main component ───────────────────────────────────────────────────────────
+export function OIDCProviderSettings() {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [showCreate, setShowCreate] = useState(false);
+  const [editingId, setEditingId] = useState<number | null>(null);
+  const [deleteTarget, setDeleteTarget] = useState<OIDCProvider | null>(null);
+
+  const { data: providers, isLoading } = useQuery({
+    queryKey: ['oidc-providers-all'],
+    queryFn: () => api.getOIDCProvidersAll(),
+  });
+
+  const createMutation = useMutation({
+    mutationFn: (data: OIDCProviderCreate) => api.createOIDCProvider(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
+      setShowCreate(false);
+      showToast(t('settings.oidc.created'), 'success');
+    },
+    onError: (e: Error) => showToast(e.message, 'error'),
+  });
+
+  const updateMutation = useMutation({
+    mutationFn: ({ id, data }: { id: number; data: Partial<OIDCProviderCreate> }) =>
+      api.updateOIDCProvider(id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
+      setEditingId(null);
+      showToast(t('settings.oidc.updated'), 'success');
+    },
+    onError: (e: Error) => showToast(e.message, 'error'),
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (id: number) => api.deleteOIDCProvider(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
+      setDeleteTarget(null);
+      showToast(t('settings.oidc.deleted'), 'success');
+    },
+    onError: (e: Error) => showToast(e.message, 'error'),
+  });
+
+  const toggleEnabled = (provider: OIDCProvider) =>
+    updateMutation.mutate({ id: provider.id, data: { is_enabled: !provider.is_enabled } });
+
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center py-12">
+        <RefreshCw className="w-6 h-6 animate-spin text-bambu-green" />
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-6">
+      {/* Header */}
+      <Card>
+        <CardHeader>
+          <div className="flex items-center justify-between">
+            <div>
+              <h3 className="text-white font-semibold">{t('settings.oidc.title')}</h3>
+              <p className="text-bambu-gray text-sm">{t('settings.oidc.desc')}</p>
+            </div>
+            {!showCreate && (
+              <Button variant="primary" size="sm" onClick={() => setShowCreate(true)} className="flex items-center gap-2">
+                <Plus className="w-4 h-4" />
+                {t('settings.oidc.addProvider')}
+              </Button>
+            )}
+          </div>
+        </CardHeader>
+
+        {showCreate && (
+          <CardContent>
+            <div className="border-t border-bambu-dark-tertiary pt-4">
+              <h4 className="text-white font-medium mb-4">{t('settings.oidc.newProvider')}</h4>
+              <ProviderForm
+                initial={EMPTY_FORM}
+                onSave={(data) => createMutation.mutate(data)}
+                onCancel={() => setShowCreate(false)}
+                isPending={createMutation.isPending}
+              />
+            </div>
+          </CardContent>
+        )}
+      </Card>
+
+      {/* Provider list */}
+      {providers && providers.length === 0 && !showCreate && (
+        <Card>
+          <CardContent>
+            <div className="text-center py-8 space-y-3">
+              <Globe className="w-12 h-12 text-bambu-gray mx-auto" />
+              <p className="text-bambu-gray">{t('settings.oidc.empty')}</p>
+              <Button variant="primary" size="sm" onClick={() => setShowCreate(true)} className="inline-flex items-center gap-2">
+                <Plus className="w-4 h-4" />
+                {t('settings.oidc.addProvider')}
+              </Button>
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {providers?.map((provider) => (
+        <Card key={provider.id}>
+          <CardHeader>
+            <div className="flex items-center gap-3">
+              {provider.icon_url ? (
+                <img src={provider.icon_url} alt={provider.name} className="w-8 h-8 rounded object-contain" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
+              ) : (
+                <div className="w-8 h-8 rounded-full bg-bambu-dark-tertiary flex items-center justify-center">
+                  <Globe className="w-4 h-4 text-bambu-gray" />
+                </div>
+              )}
+              <div className="flex-1">
+                <div className="flex items-center gap-2">
+                  <h4 className="text-white font-medium">{provider.name}</h4>
+                  {provider.is_enabled ? (
+                    <span className="flex items-center gap-1 text-xs text-green-400 bg-green-400/10 px-2 py-0.5 rounded-full">
+                      <Check className="w-3 h-3" /> {t('common.enabled')}
+                    </span>
+                  ) : (
+                    <span className="flex items-center gap-1 text-xs text-bambu-gray bg-bambu-dark-tertiary px-2 py-0.5 rounded-full">
+                      <X className="w-3 h-3" /> {t('common.disabled')}
+                    </span>
+                  )}
+                </div>
+                <div className="flex items-center gap-1 text-bambu-gray text-xs mt-0.5">
+                  <ExternalLink className="w-3 h-3" />
+                  <span>{provider.issuer_url}</span>
+                </div>
+              </div>
+              <div className="flex items-center gap-2">
+                <Toggle
+                  checked={provider.is_enabled}
+                  onChange={() => toggleEnabled(provider)}
+                  disabled={updateMutation.isPending}
+                />
+                <Button
+                  variant="secondary"
+                  size="sm"
+                  onClick={() => setEditingId(editingId === provider.id ? null : provider.id)}
+                >
+                  <Edit2 className="w-4 h-4" />
+                </Button>
+                <Button variant="danger" size="sm" onClick={() => setDeleteTarget(provider)}>
+                  <Trash2 className="w-4 h-4" />
+                </Button>
+              </div>
+            </div>
+          </CardHeader>
+
+          {editingId === provider.id && (
+            <CardContent>
+              <div className="border-t border-bambu-dark-tertiary pt-4">
+                <ProviderForm
+                  isEdit={true}
+                  initial={{
+                    name: provider.name,
+                    issuer_url: provider.issuer_url,
+                    client_id: provider.client_id,
+                    client_secret: '',
+                    scopes: provider.scopes,
+                    is_enabled: provider.is_enabled,
+                    auto_create_users: provider.auto_create_users,
+                    icon_url: provider.icon_url ?? undefined,
+                  }}
+                  onSave={(data) => updateMutation.mutate({ id: provider.id, data })}
+                  onCancel={() => setEditingId(null)}
+                  isPending={updateMutation.isPending}
+                />
+              </div>
+            </CardContent>
+          )}
+
+          {editingId !== provider.id && (
+            <CardContent>
+              <dl className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
+                <div>
+                  <dt className="text-bambu-gray">{t('settings.oidc.form.clientId')}</dt>
+                  <dd className="text-white font-mono truncate">{provider.client_id}</dd>
+                </div>
+                <div>
+                  <dt className="text-bambu-gray">{t('settings.oidc.form.scopes')}</dt>
+                  <dd className="text-white">{provider.scopes}</dd>
+                </div>
+                <div>
+                  <dt className="text-bambu-gray">{t('settings.oidc.form.autoCreate')}</dt>
+                  <dd className={provider.auto_create_users ? 'text-green-400' : 'text-bambu-gray'}>
+                    {provider.auto_create_users ? t('common.yes') : t('common.no')}
+                  </dd>
+                </div>
+              </dl>
+            </CardContent>
+          )}
+        </Card>
+      ))}
+
+      {/* Delete confirm */}
+      {deleteTarget && (
+        <ConfirmModal
+          title={t('settings.oidc.deleteTitle')}
+          message={t('settings.oidc.deleteMessage', { name: deleteTarget.name })}
+          confirmText={t('common.delete')}
+          variant="danger"
+          onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
+          onCancel={() => setDeleteTarget(null)}
+        />
+      )}
+    </div>
+  );
+}

+ 547 - 0
frontend/src/components/TwoFactorSettings.tsx

@@ -0,0 +1,547 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { ShieldCheck, ShieldOff, Mail, Smartphone, Key, RefreshCw, Trash2, X, Eye, EyeOff, Copy } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { api } from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
+
+// ─── Small reusable code input ────────────────────────────────────────────────
+function CodeInput({
+  value,
+  onChange,
+  placeholder,
+  maxLength = 6,
+}: {
+  value: string;
+  onChange: (v: string) => void;
+  placeholder?: string;
+  maxLength?: number;
+}) {
+  return (
+    <input
+      type="text"
+      value={value}
+      onChange={(e) => onChange(e.target.value.toUpperCase().replace(/\s/g, ''))}
+      maxLength={maxLength}
+      className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors font-mono tracking-widest text-center"
+      placeholder={placeholder}
+      autoComplete="one-time-code"
+    />
+  );
+}
+
+// ─── Backup codes display ─────────────────────────────────────────────────────
+function BackupCodesDisplay({ codes, onDone }: { codes: string[]; onDone: () => void }) {
+  const { t } = useTranslation();
+  const [copied, setCopied] = useState(false);
+
+  const handleCopy = () => {
+    navigator.clipboard.writeText(codes.join('\n'));
+    setCopied(true);
+    setTimeout(() => setCopied(false), 2000);
+  };
+
+  return (
+    <div className="space-y-4">
+      <div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-4">
+        <p className="text-amber-400 text-sm font-medium">{t('settings.twoFa.backupCodesWarning')}</p>
+      </div>
+      <div className="grid grid-cols-2 gap-2">
+        {codes.map((code, index) => (
+          <code key={index} className="bg-bambu-dark-secondary rounded px-3 py-2 text-center font-mono text-sm text-white tracking-widest">
+            {code}
+          </code>
+        ))}
+      </div>
+      <div className="flex gap-3">
+        <Button variant="secondary" size="sm" onClick={handleCopy} className="flex items-center gap-2">
+          <Copy className="w-4 h-4" />
+          {copied ? t('common.copied') : t('common.copy')}
+        </Button>
+        <Button variant="primary" size="sm" onClick={onDone} className="flex-1">
+          {t('settings.twoFa.savedCodes')}
+        </Button>
+      </div>
+    </div>
+  );
+}
+
+// ─── TOTP setup wizard ────────────────────────────────────────────────────────
+function TOTPSetupWizard({ onDone }: { onDone: () => void }) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [step, setStep] = useState<'qr' | 'confirm' | 'backup'>('qr');
+  const [code, setCode] = useState('');
+  const [backupCodes, setBackupCodes] = useState<string[]>([]);
+  const [showSecret, setShowSecret] = useState(false);
+
+  const { data: setupData, isLoading } = useQuery({
+    queryKey: ['totp-setup'],
+    queryFn: () => api.setupTOTP(),
+    staleTime: Infinity,
+  });
+
+  const enableMutation = useMutation({
+    mutationFn: (c: string) => api.enableTOTP(c),
+    onSuccess: (data) => {
+      setBackupCodes(data.backup_codes);
+      setStep('backup');
+      queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
+    },
+    onError: () => showToast(t('settings.twoFa.invalidCode'), 'error'),
+  });
+
+  if (isLoading || !setupData) {
+    return (
+      <div className="flex items-center justify-center py-8">
+        <RefreshCw className="w-6 h-6 animate-spin text-bambu-green" />
+      </div>
+    );
+  }
+
+  if (step === 'qr') {
+    return (
+      <div className="space-y-4">
+        <p className="text-bambu-gray-light text-sm">{t('settings.twoFa.setupInstructions')}</p>
+        <div className="flex justify-center">
+          <img
+            src={`data:image/png;base64,${setupData.qr_code_b64}`}
+            alt="TOTP QR Code"
+            className="w-48 h-48 rounded-lg"
+          />
+        </div>
+        <div>
+          <p className="text-xs text-bambu-gray mb-1">{t('settings.twoFa.manualEntry')}</p>
+          <div className="flex items-center gap-2 bg-bambu-dark-secondary rounded-lg px-3 py-2">
+            <code className="text-white text-xs font-mono flex-1 break-all">
+              {showSecret ? setupData.secret : '••••••••••••••••'}
+            </code>
+            <button onClick={() => setShowSecret(!showSecret)} className="text-bambu-gray hover:text-white">
+              {showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
+            </button>
+            <button
+              onClick={() => { navigator.clipboard.writeText(setupData.secret); }}
+              className="text-bambu-gray hover:text-white"
+            >
+              <Copy className="w-4 h-4" />
+            </button>
+          </div>
+        </div>
+        <Button variant="primary" className="w-full" onClick={() => setStep('confirm')}>
+          {t('settings.twoFa.scannedContinue')}
+        </Button>
+      </div>
+    );
+  }
+
+  if (step === 'confirm') {
+    return (
+      <div className="space-y-4">
+        <p className="text-bambu-gray-light text-sm">{t('settings.twoFa.enterCodeToConfirm')}</p>
+        <CodeInput value={code} onChange={setCode} placeholder="000000" />
+        <div className="flex gap-3">
+          <Button variant="secondary" onClick={() => setStep('qr')} className="flex-1">
+            {t('common.back')}
+          </Button>
+          <Button
+            variant="primary"
+            className="flex-1"
+            disabled={code.length !== 6 || enableMutation.isPending}
+            onClick={() => enableMutation.mutate(code)}
+          >
+            {enableMutation.isPending ? t('common.saving') : t('settings.twoFa.activate')}
+          </Button>
+        </div>
+      </div>
+    );
+  }
+
+  // step === 'backup'
+  return (
+    <div className="space-y-4">
+      <h3 className="text-white font-medium">{t('settings.twoFa.backupCodesTitle')}</h3>
+      <BackupCodesDisplay codes={backupCodes} onDone={onDone} />
+    </div>
+  );
+}
+
+// ─── Main component ───────────────────────────────────────────────────────────
+export function TwoFactorSettings() {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const { user } = useAuth();
+
+  const [showTOTPSetup, setShowTOTPSetup] = useState(false);
+  const [showDisableTOTP, setShowDisableTOTP] = useState(false);
+  const [showRegenBackup, setShowRegenBackup] = useState(false);
+  const [disableCode, setDisableCode] = useState('');
+  const [regenCode, setRegenCode] = useState('');
+  const [newBackupCodes, setNewBackupCodes] = useState<string[] | null>(null);
+
+  // Email OTP enable: two-step proof-of-possession flow
+  const [emailSetupToken, setEmailSetupToken] = useState<string | null>(null);
+  const [emailSetupCode, setEmailSetupCode] = useState('');
+
+  // Email OTP disable: requires account password
+  const [showDisableEmail, setShowDisableEmail] = useState(false);
+  const [emailDisablePassword, setEmailDisablePassword] = useState('');
+  const [showEmailDisablePassword, setShowEmailDisablePassword] = useState(false);
+
+  const { data: status, isLoading } = useQuery({
+    queryKey: ['2fa-status'],
+    queryFn: () => api.get2FAStatus(),
+  });
+
+  const { data: oidcLinks } = useQuery({
+    queryKey: ['oidc-links'],
+    queryFn: () => api.getOIDCLinks(),
+  });
+
+  // Step 1: request verification code (proof of possession)
+  const enableEmailRequestMutation = useMutation({
+    mutationFn: () => api.enableEmailOTP(),
+    onSuccess: (data: { message: string; setup_token: string }) => {
+      setEmailSetupToken(data.setup_token);
+      showToast(data.message, 'success');
+    },
+    onError: (e: Error) => {
+      const msg = e.message ?? '';
+      if (msg.toLowerCase().includes('smtp')) {
+        showToast(t('settings.twoFa.smtpRequired'), 'error');
+      } else {
+        showToast(msg, 'error');
+      }
+    },
+  });
+
+  // Step 2: confirm with the code received by email
+  const enableEmailConfirmMutation = useMutation({
+    mutationFn: () => api.confirmEnableEmailOTP(emailSetupToken!, emailSetupCode),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
+      setEmailSetupToken(null);
+      setEmailSetupCode('');
+      showToast(t('settings.twoFa.emailOtpEnabled'), 'success');
+    },
+    onError: (e: Error) => showToast(e.message, 'error'),
+  });
+
+  const disableEmailMutation = useMutation({
+    mutationFn: (password: string) => api.disableEmailOTP(password),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
+      setShowDisableEmail(false);
+      setEmailDisablePassword('');
+      showToast(t('settings.twoFa.emailOtpDisabled'), 'success');
+    },
+    onError: (e: Error) => showToast(e.message, 'error'),
+  });
+
+  const disableTOTPMutation = useMutation({
+    mutationFn: (code: string) => api.disableTOTP(code),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
+      setShowDisableTOTP(false);
+      setDisableCode('');
+      showToast(t('settings.twoFa.totpDisabled'), 'success');
+    },
+    onError: () => showToast(t('settings.twoFa.invalidCode'), 'error'),
+  });
+
+  const regenMutation = useMutation({
+    mutationFn: (code: string) => api.regenerateBackupCodes(code),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
+      setShowRegenBackup(false);
+      setRegenCode('');
+      setNewBackupCodes(data.backup_codes);
+    },
+    onError: () => showToast(t('settings.twoFa.invalidCode'), 'error'),
+  });
+
+  const unlinkOIDCMutation = useMutation({
+    mutationFn: (providerId: number) => api.deleteOIDCLink(providerId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['oidc-links'] });
+      showToast(t('settings.twoFa.oidcUnlinked'), 'success');
+    },
+    onError: (e: Error) => showToast(e.message, 'error'),
+  });
+
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center py-12">
+        <RefreshCw className="w-6 h-6 animate-spin text-bambu-green" />
+      </div>
+    );
+  }
+
+  const hasEmail = !!user?.email;
+
+  return (
+    <div className="space-y-6">
+      {/* ── TOTP ─────────────────────────────────────────────────────────── */}
+      <Card>
+        <CardHeader>
+          <div className="flex items-center gap-3">
+            <div className={`w-10 h-10 rounded-full flex items-center justify-center ${status?.totp_enabled ? 'bg-green-500/20' : 'bg-gray-500/20'}`}>
+              <Smartphone className={`w-5 h-5 ${status?.totp_enabled ? 'text-green-400' : 'text-gray-400'}`} />
+            </div>
+            <div>
+              <h3 className="text-white font-semibold">{t('settings.twoFa.totpTitle')}</h3>
+              <p className="text-bambu-gray text-sm">{t('settings.twoFa.totpDesc')}</p>
+            </div>
+            <div className="ml-auto">
+              {status?.totp_enabled ? (
+                <span className="flex items-center gap-1 text-green-400 text-sm font-medium">
+                  <ShieldCheck className="w-4 h-4" /> {t('common.enabled')}
+                </span>
+              ) : (
+                <span className="flex items-center gap-1 text-bambu-gray text-sm">
+                  <ShieldOff className="w-4 h-4" /> {t('common.disabled')}
+                </span>
+              )}
+            </div>
+          </div>
+        </CardHeader>
+        <CardContent>
+          {/* TOTP Setup wizard */}
+          {showTOTPSetup ? (
+            <div className="space-y-4">
+              <div className="flex items-center justify-between mb-2">
+                <h4 className="text-white font-medium">{t('settings.twoFa.setupAuthApp')}</h4>
+                <button onClick={() => { setShowTOTPSetup(false); queryClient.removeQueries({ queryKey: ['totp-setup'] }); }} className="text-bambu-gray hover:text-white">
+                  <X className="w-5 h-5" />
+                </button>
+              </div>
+              <TOTPSetupWizard onDone={() => { setShowTOTPSetup(false); queryClient.removeQueries({ queryKey: ['totp-setup'] }); }} />
+            </div>
+          ) : showDisableTOTP ? (
+            <div className="space-y-4">
+              <p className="text-bambu-gray-light text-sm">{t('settings.twoFa.disableConfirmHint')}</p>
+              <CodeInput value={disableCode} onChange={setDisableCode} placeholder="000000 or XXXXXXXX" maxLength={8} />
+              <div className="flex gap-3">
+                <Button variant="secondary" onClick={() => { setShowDisableTOTP(false); setDisableCode(''); }} className="flex-1">
+                  {t('common.cancel')}
+                </Button>
+                <Button
+                  variant="danger"
+                  className="flex-1"
+                  disabled={disableCode.length < 6 || disableTOTPMutation.isPending}
+                  onClick={() => disableTOTPMutation.mutate(disableCode)}
+                >
+                  {disableTOTPMutation.isPending ? t('common.saving') : t('settings.twoFa.disableTotp')}
+                </Button>
+              </div>
+            </div>
+          ) : showRegenBackup ? (
+            <div className="space-y-4">
+              <p className="text-bambu-gray-light text-sm">{t('settings.twoFa.regenBackupHint')}</p>
+              <CodeInput value={regenCode} onChange={setRegenCode} placeholder="000000 or XXXXXXXX" maxLength={8} />
+              <div className="flex gap-3">
+                <Button variant="secondary" onClick={() => { setShowRegenBackup(false); setRegenCode(''); }} className="flex-1">
+                  {t('common.cancel')}
+                </Button>
+                <Button
+                  variant="primary"
+                  className="flex-1"
+                  disabled={regenCode.length < 6 || regenMutation.isPending}
+                  onClick={() => regenMutation.mutate(regenCode)}
+                >
+                  {regenMutation.isPending ? t('common.saving') : t('settings.twoFa.regenBackup')}
+                </Button>
+              </div>
+            </div>
+          ) : newBackupCodes ? (
+            <div className="space-y-4">
+              <h4 className="text-white font-medium">{t('settings.twoFa.newBackupCodes')}</h4>
+              <BackupCodesDisplay codes={newBackupCodes} onDone={() => setNewBackupCodes(null)} />
+            </div>
+          ) : (
+            <div className="space-y-3">
+              {!status?.totp_enabled ? (
+                <Button variant="primary" onClick={() => setShowTOTPSetup(true)} className="flex items-center gap-2">
+                  <Smartphone className="w-4 h-4" />
+                  {t('settings.twoFa.setupTotp')}
+                </Button>
+              ) : (
+                <div className="flex flex-wrap gap-3">
+                  <div className="flex items-center gap-2 text-sm text-bambu-gray-light">
+                    <Key className="w-4 h-4" />
+                    {t('settings.twoFa.backupCodesRemaining', { count: status.backup_codes_remaining })}
+                  </div>
+                  <Button variant="secondary" size="sm" onClick={() => setShowRegenBackup(true)} className="flex items-center gap-2">
+                    <RefreshCw className="w-4 h-4" />
+                    {t('settings.twoFa.regenBackup')}
+                  </Button>
+                  <Button variant="danger" size="sm" onClick={() => setShowDisableTOTP(true)} className="flex items-center gap-2">
+                    <Trash2 className="w-4 h-4" />
+                    {t('settings.twoFa.disableTotp')}
+                  </Button>
+                </div>
+              )}
+            </div>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* ── Email OTP ─────────────────────────────────────────────────────── */}
+      <Card>
+        <CardHeader>
+          <div className="flex items-center gap-3">
+            <div className={`w-10 h-10 rounded-full flex items-center justify-center ${status?.email_otp_enabled ? 'bg-green-500/20' : 'bg-gray-500/20'}`}>
+              <Mail className={`w-5 h-5 ${status?.email_otp_enabled ? 'text-green-400' : 'text-gray-400'}`} />
+            </div>
+            <div className="flex-1">
+              <h3 className="text-white font-semibold">{t('settings.twoFa.emailOtpTitle')}</h3>
+              <p className="text-bambu-gray text-sm">
+                {hasEmail
+                  ? t('settings.twoFa.emailOtpDesc', { email: user?.email })
+                  : t('settings.twoFa.emailOtpNoEmail')}
+              </p>
+            </div>
+            {/* Show status badge; enable/disable handled in CardContent */}
+            <div className="ml-auto">
+              {status?.email_otp_enabled ? (
+                <span className="flex items-center gap-1 text-green-400 text-sm font-medium">
+                  <ShieldCheck className="w-4 h-4" /> {t('common.enabled')}
+                </span>
+              ) : (
+                <span className="flex items-center gap-1 text-bambu-gray text-sm">
+                  <ShieldOff className="w-4 h-4" /> {t('common.disabled')}
+                </span>
+              )}
+            </div>
+          </div>
+        </CardHeader>
+        <CardContent>
+          {!hasEmail ? (
+            <p className="text-amber-400 text-sm">{t('settings.twoFa.addEmailFirst')}</p>
+          ) : emailSetupToken ? (
+            /* Step 2: enter the code that was sent to the email */
+            <div className="space-y-4">
+              <p className="text-bambu-gray-light text-sm">{t('settings.twoFa.emailSetupEnterCode')}</p>
+              <CodeInput value={emailSetupCode} onChange={setEmailSetupCode} placeholder="000000" />
+              <div className="flex gap-3">
+                <Button
+                  variant="secondary"
+                  onClick={() => { setEmailSetupToken(null); setEmailSetupCode(''); }}
+                  className="flex-1"
+                >
+                  {t('common.cancel')}
+                </Button>
+                <Button
+                  variant="primary"
+                  className="flex-1"
+                  disabled={emailSetupCode.length !== 6 || enableEmailConfirmMutation.isPending}
+                  onClick={() => enableEmailConfirmMutation.mutate()}
+                >
+                  {enableEmailConfirmMutation.isPending ? t('common.saving') : t('settings.twoFa.verifyAndEnable')}
+                </Button>
+              </div>
+            </div>
+          ) : showDisableEmail ? (
+            /* Disable: require account password for re-auth */
+            <div className="space-y-4">
+              <p className="text-bambu-gray-light text-sm">{t('settings.twoFa.emailDisablePasswordHint')}</p>
+              <div className="relative">
+                <input
+                  type={showEmailDisablePassword ? 'text' : 'password'}
+                  value={emailDisablePassword}
+                  onChange={(e) => setEmailDisablePassword(e.target.value)}
+                  placeholder={t('settings.twoFa.passwordPlaceholder')}
+                  className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+                />
+                <button
+                  type="button"
+                  onClick={() => setShowEmailDisablePassword(!showEmailDisablePassword)}
+                  className="absolute right-3 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
+                >
+                  {showEmailDisablePassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
+                </button>
+              </div>
+              <div className="flex gap-3">
+                <Button
+                  variant="secondary"
+                  onClick={() => { setShowDisableEmail(false); setEmailDisablePassword(''); }}
+                  className="flex-1"
+                >
+                  {t('common.cancel')}
+                </Button>
+                <Button
+                  variant="danger"
+                  className="flex-1"
+                  disabled={!emailDisablePassword || disableEmailMutation.isPending}
+                  onClick={() => disableEmailMutation.mutate(emailDisablePassword)}
+                >
+                  {disableEmailMutation.isPending ? t('common.saving') : t('settings.twoFa.disableEmailOtp')}
+                </Button>
+              </div>
+            </div>
+          ) : (
+            <div className="flex gap-3">
+              {!status?.email_otp_enabled ? (
+                <Button
+                  variant="primary"
+                  disabled={!hasEmail || enableEmailRequestMutation.isPending}
+                  onClick={() => enableEmailRequestMutation.mutate()}
+                  className="flex items-center gap-2"
+                >
+                  <Mail className="w-4 h-4" />
+                  {enableEmailRequestMutation.isPending ? t('common.saving') : t('settings.twoFa.enableEmailOtp')}
+                </Button>
+              ) : (
+                <Button
+                  variant="danger"
+                  size="sm"
+                  onClick={() => setShowDisableEmail(true)}
+                  className="flex items-center gap-2"
+                >
+                  <Trash2 className="w-4 h-4" />
+                  {t('settings.twoFa.disableEmailOtp')}
+                </Button>
+              )}
+            </div>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* ── Linked SSO accounts ───────────────────────────────────────────── */}
+      {oidcLinks && oidcLinks.length > 0 && (
+        <Card>
+          <CardHeader>
+            <h3 className="text-white font-semibold">{t('settings.twoFa.linkedAccounts')}</h3>
+            <p className="text-bambu-gray text-sm">{t('settings.twoFa.linkedAccountsDesc')}</p>
+          </CardHeader>
+          <CardContent>
+            <div className="space-y-3">
+              {oidcLinks.map((link) => (
+                <div key={link.id} className="flex items-center justify-between py-2 border-b border-bambu-dark-tertiary last:border-0">
+                  <div>
+                    <p className="text-white text-sm font-medium">{link.provider_name}</p>
+                    {link.provider_email && (
+                      <p className="text-bambu-gray text-xs">{link.provider_email}</p>
+                    )}
+                  </div>
+                  <Button
+                    variant="danger"
+                    size="sm"
+                    onClick={() => unlinkOIDCMutation.mutate(link.provider_id)}
+                    disabled={unlinkOIDCMutation.isPending}
+                  >
+                    <Trash2 className="w-4 h-4" />
+                  </Button>
+                </div>
+              ))}
+            </div>
+          </CardContent>
+        </Card>
+      )}
+    </div>
+  );
+}

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

@@ -1,6 +1,6 @@
 import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
 import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
 import { api, getAuthToken, setAuthToken } from '../api/client';
 import { api, getAuthToken, setAuthToken } from '../api/client';
-import type { Permission, UserResponse } from '../api/client';
+import type { LoginResponse, Permission, UserResponse } from '../api/client';
 
 
 interface AuthContextType {
 interface AuthContextType {
   user: UserResponse | null;
   user: UserResponse | null;
@@ -8,7 +8,10 @@ interface AuthContextType {
   requiresSetup: boolean;
   requiresSetup: boolean;
   loading: boolean;
   loading: boolean;
   isAdmin: boolean;
   isAdmin: boolean;
-  login: (username: string, password: string) => Promise<void>;
+  /** Login with username/password. Returns LoginResponse (may include requires_2fa). */
+  login: (username: string, password: string) => Promise<LoginResponse>;
+  /** Finalise login after 2FA or OIDC — store token and set user directly. */
+  loginWithToken: (token: string, user: UserResponse) => void;
   logout: () => void;
   logout: () => void;
   refreshUser: () => Promise<void>;
   refreshUser: () => Promise<void>;
   refreshAuth: () => Promise<void>;
   refreshAuth: () => Promise<void>;
@@ -30,12 +33,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
 
 
   const checkAuthStatus = async () => {
   const checkAuthStatus = async () => {
     try {
     try {
-      // Bootstrap: if URL has ?token= param, store it and strip from URL.
-      // Allows SpoolBuddy kiosk to pass API key via URL on first load.
+      // Bootstrap: if URL has ?token= param, store it session-only first and
+      // strip it from the URL. Allows SpoolBuddy kiosk to pass an API key via
+      // URL on first load. Persistence to localStorage is deferred until the
+      // token has been verified by the server (L-4: prevents session fixation
+      // where an attacker-crafted URL immediately persists a forged/stolen token).
       const urlParams = new URLSearchParams(window.location.search);
       const urlParams = new URLSearchParams(window.location.search);
       const urlToken = urlParams.get('token');
       const urlToken = urlParams.get('token');
       if (urlToken) {
       if (urlToken) {
-        setAuthToken(urlToken);
+        setAuthToken(urlToken, false); // session-only until server confirms it's valid
         urlParams.delete('token');
         urlParams.delete('token');
         const cleanSearch = urlParams.toString();
         const cleanSearch = urlParams.toString();
         const cleanUrl = window.location.pathname
         const cleanUrl = window.location.pathname
@@ -56,8 +62,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
             const currentUser = await api.getCurrentUser();
             const currentUser = await api.getCurrentUser();
             if (!mountedRef.current) return;
             if (!mountedRef.current) return;
             setUser(currentUser);
             setUser(currentUser);
+            // Persist kiosk token only after the server confirms it is valid.
+            if (urlToken && token === urlToken) {
+              setAuthToken(urlToken, true);
+            }
           } catch {
           } catch {
-            // Token invalid, clear it
+            // Token invalid, clear it (removes from both sessionStorage and localStorage)
             setAuthToken(null);
             setAuthToken(null);
             if (!mountedRef.current) return;
             if (!mountedRef.current) return;
             setUser(null);
             setUser(null);
@@ -106,10 +116,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     }
     }
   }, [loading, requiresSetup, authEnabled]);
   }, [loading, requiresSetup, authEnabled]);
 
 
-  const login = async (username: string, password: string) => {
+  const login = async (username: string, password: string): Promise<LoginResponse> => {
     const response = await api.login({ username, password });
     const response = await api.login({ username, password });
-    setAuthToken(response.access_token);
-    await checkAuthStatus();
+    if (!response.requires_2fa && response.access_token) {
+      setAuthToken(response.access_token);
+      await checkAuthStatus();
+    }
+    return response;
+  };
+
+  const loginWithToken = (token: string, userObj: UserResponse) => {
+    setAuthToken(token);
+    setUser(userObj);
+    setAuthEnabled(true);
   };
   };
 
 
   const logout = () => {
   const logout = () => {
@@ -205,6 +224,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
         loading,
         loading,
         isAdmin,
         isAdmin,
         login,
         login,
+        loginWithToken,
         logout,
         logout,
         refreshUser,
         refreshUser,
         refreshAuth,
         refreshAuth,

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

@@ -102,6 +102,9 @@ export default {
     more: '+{{count}} weitere',
     more: '+{{count}} weitere',
     ascending: 'Aufsteigend',
     ascending: 'Aufsteigend',
     descending: 'Absteigend',
     descending: 'Absteigend',
+    back: 'Zurück',
+    copy: 'Kopieren',
+    copied: 'Kopiert!',
     printer: 'Drucker',
     printer: 'Drucker',
     remove: 'Entfernen',
     remove: 'Entfernen',
     type: 'Typ',
     type: 'Typ',
@@ -1325,6 +1328,8 @@ export default {
       backup: 'Sicherung',
       backup: 'Sicherung',
       emailAuth: 'E-Mail-Authentifizierung',
       emailAuth: 'E-Mail-Authentifizierung',
       ldap: 'LDAP',
       ldap: 'LDAP',
+      twoFa: 'Zwei-Faktor-Auth',
+      oidc: 'SSO / OIDC',
     },
     },
     spoolbuddy: {
     spoolbuddy: {
       infoTitle: 'SpoolBuddy-Geräte',
       infoTitle: 'SpoolBuddy-Geräte',
@@ -2078,6 +2083,74 @@ export default {
     deleteUserAndItems: 'Benutzer UND dessen Elemente löschen',
     deleteUserAndItems: 'Benutzer UND dessen Elemente löschen',
     deleteUserKeepItems: 'Benutzer löschen, Elemente behalten (werden herrenlos)',
     deleteUserKeepItems: 'Benutzer löschen, Elemente behalten (werden herrenlos)',
     ok: 'OK',
     ok: 'OK',
+
+    // 2FA settings
+    twoFa: {
+      totpTitle: 'Authenticator-App (TOTP)',
+      totpDesc: 'Verwende eine Authenticator-App wie Google Authenticator, Aegis oder Authy.',
+      emailOtpTitle: 'E-Mail OTP',
+      emailOtpDesc: 'Sende einen Einmalcode an {{email}} beim Einloggen.',
+      emailOtpNoEmail: 'Füge eine E-Mail-Adresse zu deinem Konto hinzu, um diese Methode zu aktivieren.',
+      addEmailFirst: 'Dein Konto hat keine E-Mail-Adresse. Bitte einen Administrator, eine hinzuzufügen.',
+      setupTotp: 'Authenticator-App einrichten',
+      setupAuthApp: 'Authenticator-App einrichten',
+      setupInstructions: 'Scanne den QR-Code mit deiner Authenticator-App und bestätige mit einem Code.',
+      manualEntry: 'Kein Scanner? Gib dieses Secret manuell ein:',
+      scannedContinue: 'Code gescannt — weiter',
+      enterCodeToConfirm: 'Gib den 6-stelligen Code aus deiner Authenticator-App ein, um die Einrichtung zu bestätigen.',
+      activate: 'Aktivieren',
+      disableTotp: 'Authenticator deaktivieren',
+      disableConfirmHint: 'Gib einen gültigen TOTP-Code oder einen Backup-Code ein, um den Authenticator zu deaktivieren.',
+      totpDisabled: 'Authenticator-App deaktiviert.',
+      emailOtpEnabled: 'E-Mail OTP aktiviert.',
+      emailOtpDisabled: 'E-Mail OTP deaktiviert.',
+      smtpRequired: 'Bitte konfigurieren und testen Sie zuerst die SMTP-Einstellungen.',
+      invalidCode: 'Ungültiger Code. Bitte erneut versuchen.',
+      enableEmailOtp: 'E-Mail OTP aktivieren',
+      disableEmailOtp: 'E-Mail OTP deaktivieren',
+      emailSetupEnterCode: 'Ein Bestätigungscode wurde an Ihre E-Mail-Adresse gesendet. Geben Sie ihn unten ein, um zu bestätigen, dass Ihnen dieses Postfach gehört.',
+      verifyAndEnable: 'Verifizieren & Aktivieren',
+      emailDisablePasswordHint: 'Geben Sie Ihr Kontopasswort ein, um die Deaktivierung des E-Mail OTP zu bestätigen.',
+      passwordPlaceholder: 'Passwort eingeben',
+      backupCodesTitle: 'Backup-Codes sichern',
+      backupCodesWarning: 'Speichere diese Codes sicher. Jeder Code kann nur einmal verwendet werden und wird nicht erneut angezeigt.',
+      backupCodesRemaining: '{{count}} Backup-Codes verbleibend',
+      savedCodes: 'Codes gespeichert',
+      regenBackup: 'Backup-Codes neu generieren',
+      regenBackupHint: 'Gib deinen aktuellen TOTP-Code ein, um 10 neue Backup-Codes zu generieren. Alle bestehenden Codes werden ungültig.',
+      newBackupCodes: 'Neue Backup-Codes',
+      linkedAccounts: 'Verknüpfte SSO-Konten',
+      linkedAccountsDesc: 'Diese externen Identitätsanbieter sind mit deinem Konto verknüpft.',
+      oidcUnlinked: 'Konto getrennt.',
+    },
+
+    // OIDC provider settings
+    oidc: {
+      title: 'SSO / OIDC-Anbieter',
+      desc: 'Konfiguriere OpenID Connect-Anbieter für Single Sign-On.',
+      addProvider: 'Anbieter hinzufügen',
+      newProvider: 'Neuer Anbieter',
+      empty: 'Noch keine OIDC-Anbieter konfiguriert.',
+      created: 'Anbieter erstellt.',
+      updated: 'Anbieter aktualisiert.',
+      deleted: 'Anbieter gelöscht.',
+      deleteTitle: 'Anbieter löschen',
+      deleteMessage: '"{{name}}" löschen? Alle verknüpften Benutzerkonten werden getrennt.',
+      form: {
+        name: 'Anzeigename',
+        issuerUrl: 'Aussteller-URL',
+        clientId: 'Client-ID',
+        clientSecret: 'Client-Secret',
+        scopes: 'Scopes',
+        iconUrl: 'Symbol-URL (optional)',
+        enabled: 'Aktiviert',
+        autoCreate: 'Benutzer automatisch anlegen',
+        autoCreateDesc: 'Erstellt beim ersten Login automatisch ein lokales Konto.',
+        secretHint: 'leer lassen zum Beibehalten',
+        secretPlaceholder: 'neues Secret',
+      },
+    },
+
   },
   },
 
 
   // Notifications (for push notifications)
   // Notifications (for push notifications)
@@ -2208,6 +2281,28 @@ export default {
     loginSuccess: 'Erfolgreich angemeldet',
     loginSuccess: 'Erfolgreich angemeldet',
     loginFailed: 'Anmeldung fehlgeschlagen',
     loginFailed: 'Anmeldung fehlgeschlagen',
     enterCredentials: 'Bitte Benutzername und Passwort eingeben',
     enterCredentials: 'Bitte Benutzername und Passwort eingeben',
+    enterEmail: 'Bitte geben Sie Ihre E-Mail-Adresse ein',
+    oidcLoginFailed: 'OIDC-Anmeldung fehlgeschlagen',
+    oidcErrors: {
+      providerError: 'Der Identity-Provider hat einen Fehler zurückgegeben',
+      missingParameters: 'Dem OIDC-Callback fehlen erforderliche Parameter',
+      invalidState: 'OIDC-State ist ungültig oder wurde bereits verwendet',
+      stateExpired: 'OIDC-Sitzung abgelaufen — bitte erneut versuchen',
+      providerNotFound: 'OIDC-Provider nicht gefunden',
+      discoveryFailed: 'OIDC-Discovery-Dokument konnte nicht abgerufen werden',
+      invalidDiscovery: 'OIDC-Discovery-Dokument ist ungültig',
+      networkError: 'Netzwerkfehler beim OIDC-Token-Austausch',
+      badResponse: 'Unerwartete Antwort beim OIDC-Token-Austausch',
+      noIdToken: 'OIDC-Provider hat kein ID-Token zurückgegeben',
+      validationFailed: 'OIDC-Token-Validierung fehlgeschlagen',
+      nonceMismatch: 'OIDC-Nonce stimmt nicht überein — möglicher Replay-Angriff',
+      missingSubClaim: 'OIDC-Token enthält keinen Sub-Claim',
+      noLinkedAccount: 'Kein lokales Konto mit dieser OIDC-Identität verknüpft',
+      accountInactive: 'Ihr Konto ist inaktiv',
+      userResolutionFailed: 'Ihr Konto konnte nicht aufgelöst werden',
+      internalError: 'Interner Fehler beim OIDC-Login',
+      tokenExchangeFailed: 'OIDC-Token-Austausch fehlgeschlagen',
+    },
     forgotPasswordTitle: 'Passwort vergessen',
     forgotPasswordTitle: 'Passwort vergessen',
     forgotPasswordMessage: 'Wenn Sie Ihr Passwort vergessen haben, wenden Sie sich bitte an Ihren Systemadministrator.',
     forgotPasswordMessage: 'Wenn Sie Ihr Passwort vergessen haben, wenden Sie sich bitte an Ihren Systemadministrator.',
     forgotPasswordEmailMessage: 'Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen ein neues Passwort.',
     forgotPasswordEmailMessage: 'Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen ein neues Passwort.',
@@ -2222,6 +2317,47 @@ export default {
     resetStep3: 'Er kann ein neues temporäres Passwort für Sie festlegen',
     resetStep3: 'Er kann ein neues temporäres Passwort für Sie festlegen',
     resetStep4: 'Melden Sie sich mit dem neuen Passwort an und ändern Sie es in den Einstellungen',
     resetStep4: 'Melden Sie sich mit dem neuen Passwort an und ändern Sie es in den Einstellungen',
     gotIt: 'Verstanden',
     gotIt: 'Verstanden',
+    resetPassword: {
+      title: 'Neues Passwort festlegen',
+      subtitle: 'Geben Sie unten Ihr neues Passwort ein und bestätigen Sie es.',
+      newPassword: 'Neues Passwort',
+      newPasswordPlaceholder: 'Mindestens 8 Zeichen',
+      confirmPassword: 'Passwort bestätigen',
+      confirmPasswordPlaceholder: 'Neues Passwort wiederholen',
+      saving: 'Wird gespeichert\u2026',
+      submit: 'Neues Passwort festlegen',
+      backToLogin: 'Zurück zur Anmeldung',
+      passwordsDoNotMatch: 'Passwörter stimmen nicht überein',
+      passwordTooShort: 'Passwort muss mindestens 8 Zeichen lang sein',
+      resetFailed: 'Passwort zurücksetzen fehlgeschlagen. Der Link ist möglicherweise abgelaufen.',
+    },
+    twoFA: {
+      title: 'Zwei-Faktor-Authentifizierung',
+      subtitle: 'Ihr Konto ist mit 2FA geschützt. Geben Sie unten den Bestätigungscode ein.',
+      methodAuthenticator: 'Authenticator-App',
+      methodEmail: 'E-Mail-Code',
+      methodBackup: 'Wiederherstellungscode',
+      instructionsTotp: 'Öffnen Sie Ihre Authenticator-App und geben Sie den 6-stelligen Code für Bambuddy ein.',
+      instructionsEmail: 'Ein 6-stelliger Code wurde an Ihre E-Mail-Adresse gesendet. Er ist 10 Minuten gültig.',
+      instructionsEmailNotSent: 'Klicken Sie unten, um einen Bestätigungscode per E-Mail zu erhalten.',
+      instructionsBackup: 'Geben Sie einen Ihrer 8-stelligen Wiederherstellungscodes ein. Jeder Code kann nur einmal verwendet werden.',
+      sendCodeButton: 'Code per E-Mail senden',
+      sendingCode: 'Wird gesendet...',
+      resendCode: 'Code erneut senden',
+      codeLabel: 'Bestätigungscode',
+      backupCodeLabel: 'Wiederherstellungscode',
+      codePlaceholder: '000000',
+      backupCodePlaceholder: 'XXXXXXXX',
+      verifyButton: 'Bestätigen',
+      verifyingButton: 'Wird überprüft...',
+      backToLogin: '← Zurück zur Anmeldung',
+      orContinueWith: 'oder anmelden mit',
+      signInWith: 'Anmelden mit {{provider}}',
+      enterCode: 'Bitte geben Sie den Bestätigungscode ein',
+      sendCodeFailed: 'Bestätigungscode konnte nicht gesendet werden',
+      invalidCode: 'Ungültiger Code. Bitte erneut versuchen.',
+    },
+
   },
   },
 
 
   // Setup page
   // Setup page

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

@@ -102,6 +102,9 @@ export default {
     more: '+{{count}} more',
     more: '+{{count}} more',
     ascending: 'Ascending',
     ascending: 'Ascending',
     descending: 'Descending',
     descending: 'Descending',
+    back: 'Back',
+    copy: 'Copy',
+    copied: 'Copied!',
     printer: 'Printer',
     printer: 'Printer',
     remove: 'Remove',
     remove: 'Remove',
     type: 'Type',
     type: 'Type',
@@ -1326,6 +1329,8 @@ export default {
       backup: 'Backup',
       backup: 'Backup',
       emailAuth: 'Email Authentication',
       emailAuth: 'Email Authentication',
       ldap: 'LDAP',
       ldap: 'LDAP',
+      twoFa: 'Two-Factor Auth',
+      oidc: 'SSO / OIDC',
     },
     },
     spoolbuddy: {
     spoolbuddy: {
       infoTitle: 'SpoolBuddy devices',
       infoTitle: 'SpoolBuddy devices',
@@ -2080,6 +2085,74 @@ export default {
     deleteUserAndItems: 'Delete user AND their items',
     deleteUserAndItems: 'Delete user AND their items',
     deleteUserKeepItems: 'Delete user, keep items (become ownerless)',
     deleteUserKeepItems: 'Delete user, keep items (become ownerless)',
     ok: 'OK',
     ok: 'OK',
+
+    // 2FA settings
+    twoFa: {
+      totpTitle: 'Authenticator App (TOTP)',
+      totpDesc: 'Use an authenticator app like Google Authenticator, Aegis or Authy.',
+      emailOtpTitle: 'Email OTP',
+      emailOtpDesc: 'Send a one-time code to {{email}} when you log in.',
+      emailOtpNoEmail: 'Add an email address to your account to enable this method.',
+      addEmailFirst: 'Your account has no email address. Ask an admin to add one before enabling Email OTP.',
+      setupTotp: 'Set up Authenticator App',
+      setupAuthApp: 'Set up Authenticator App',
+      setupInstructions: 'Scan the QR code below with your authenticator app, then confirm with a code.',
+      manualEntry: 'Can\'t scan? Enter this secret manually:',
+      scannedContinue: 'I\'ve scanned the code — continue',
+      enterCodeToConfirm: 'Enter the 6-digit code from your authenticator app to confirm setup.',
+      activate: 'Activate',
+      disableTotp: 'Disable Authenticator',
+      disableConfirmHint: 'Enter a valid TOTP code or a backup code to disable the authenticator.',
+      totpDisabled: 'Authenticator app disabled.',
+      emailOtpEnabled: 'Email OTP enabled.',
+      emailOtpDisabled: 'Email OTP disabled.',
+      smtpRequired: 'Please configure and test SMTP settings first.',
+      invalidCode: 'Invalid code. Please try again.',
+      enableEmailOtp: 'Enable Email OTP',
+      disableEmailOtp: 'Disable Email OTP',
+      emailSetupEnterCode: 'A verification code has been sent to your email address. Enter it below to confirm you own this inbox.',
+      verifyAndEnable: 'Verify & Enable',
+      emailDisablePasswordHint: 'Enter your account password to confirm disabling email OTP.',
+      passwordPlaceholder: 'Enter your password',
+      backupCodesTitle: 'Save your backup codes',
+      backupCodesWarning: 'Save these codes somewhere safe. Each code can only be used once and they will not be shown again.',
+      backupCodesRemaining: '{{count}} backup codes remaining',
+      savedCodes: 'I\'ve saved my codes',
+      regenBackup: 'Regenerate Backup Codes',
+      regenBackupHint: 'Enter your current TOTP code to generate 10 new backup codes. All existing backup codes will be invalidated.',
+      newBackupCodes: 'New backup codes',
+      linkedAccounts: 'Linked SSO Accounts',
+      linkedAccountsDesc: 'These external identity providers are linked to your account.',
+      oidcUnlinked: 'Account unlinked.',
+    },
+
+    // OIDC provider settings
+    oidc: {
+      title: 'SSO / OIDC Providers',
+      desc: 'Configure OpenID Connect providers to allow single sign-on via external identity providers.',
+      addProvider: 'Add Provider',
+      newProvider: 'New Provider',
+      empty: 'No OIDC providers configured yet.',
+      created: 'Provider created.',
+      updated: 'Provider updated.',
+      deleted: 'Provider deleted.',
+      deleteTitle: 'Delete Provider',
+      deleteMessage: 'Delete "{{name}}"? All linked user accounts will be disconnected.',
+      form: {
+        name: 'Display Name',
+        issuerUrl: 'Issuer URL',
+        clientId: 'Client ID',
+        clientSecret: 'Client Secret',
+        scopes: 'Scopes',
+        iconUrl: 'Icon URL (optional)',
+        enabled: 'Enabled',
+        autoCreate: 'Auto-create users',
+        autoCreateDesc: 'Automatically create a local account on first login.',
+        secretHint: 'leave blank to keep current',
+        secretPlaceholder: 'new secret',
+      },
+    },
+
   },
   },
 
 
   // Notifications (for push notifications)
   // Notifications (for push notifications)
@@ -2210,6 +2283,28 @@ export default {
     loginSuccess: 'Logged in successfully',
     loginSuccess: 'Logged in successfully',
     loginFailed: 'Login failed',
     loginFailed: 'Login failed',
     enterCredentials: 'Please enter username and password',
     enterCredentials: 'Please enter username and password',
+    enterEmail: 'Please enter your email address',
+    oidcLoginFailed: 'OIDC login failed',
+    oidcErrors: {
+      providerError: 'The identity provider returned an error',
+      missingParameters: 'OIDC callback is missing required parameters',
+      invalidState: 'OIDC state is invalid or has already been used',
+      stateExpired: 'OIDC login session expired — please try again',
+      providerNotFound: 'OIDC provider not found',
+      discoveryFailed: 'Failed to fetch OIDC discovery document',
+      invalidDiscovery: 'OIDC discovery document is invalid',
+      networkError: 'Network error during OIDC token exchange',
+      badResponse: 'Unexpected response during OIDC token exchange',
+      noIdToken: 'OIDC provider did not return an ID token',
+      validationFailed: 'OIDC token validation failed',
+      nonceMismatch: 'OIDC nonce mismatch — possible replay attack',
+      missingSubClaim: 'OIDC token is missing the sub claim',
+      noLinkedAccount: 'No local account is linked to this OIDC identity',
+      accountInactive: 'Your account is inactive',
+      userResolutionFailed: 'Failed to resolve your account',
+      internalError: 'An internal error occurred during OIDC login',
+      tokenExchangeFailed: 'OIDC token exchange failed',
+    },
     forgotPasswordTitle: 'Forgot Password',
     forgotPasswordTitle: 'Forgot Password',
     forgotPasswordMessage: "If you've forgotten your password, please contact your system administrator to reset it.",
     forgotPasswordMessage: "If you've forgotten your password, please contact your system administrator to reset it.",
     forgotPasswordEmailMessage: "Enter your email address and we'll send you a new password.",
     forgotPasswordEmailMessage: "Enter your email address and we'll send you a new password.",
@@ -2224,6 +2319,47 @@ export default {
     resetStep3: 'They can set a new temporary password for you',
     resetStep3: 'They can set a new temporary password for you',
     resetStep4: 'Log in with the new password and change it in Settings',
     resetStep4: 'Log in with the new password and change it in Settings',
     gotIt: 'Got it',
     gotIt: 'Got it',
+    resetPassword: {
+      title: 'Set New Password',
+      subtitle: 'Enter and confirm your new password below.',
+      newPassword: 'New Password',
+      newPasswordPlaceholder: 'At least 8 characters',
+      confirmPassword: 'Confirm Password',
+      confirmPasswordPlaceholder: 'Repeat new password',
+      saving: 'Saving\u2026',
+      submit: 'Set New Password',
+      backToLogin: 'Back to login',
+      passwordsDoNotMatch: 'Passwords do not match',
+      passwordTooShort: 'Password must be at least 8 characters',
+      resetFailed: 'Password reset failed. The link may have expired.',
+    },
+    twoFA: {
+      title: 'Two-Factor Authentication',
+      subtitle: 'Your account is protected with 2FA. Enter the verification code below.',
+      methodAuthenticator: 'Authenticator App',
+      methodEmail: 'Email Code',
+      methodBackup: 'Backup Code',
+      instructionsTotp: 'Open your authenticator app and enter the 6-digit code for Bambuddy.',
+      instructionsEmail: 'A 6-digit code has been sent to your email address. It expires in 10 minutes.',
+      instructionsEmailNotSent: 'Click the button below to receive a verification code via email.',
+      instructionsBackup: 'Enter one of your 8-character backup recovery codes. Each code can only be used once.',
+      sendCodeButton: 'Send Code via Email',
+      sendingCode: 'Sending...',
+      resendCode: 'Resend code',
+      codeLabel: 'Verification Code',
+      backupCodeLabel: 'Backup Code',
+      codePlaceholder: '000000',
+      backupCodePlaceholder: 'XXXXXXXX',
+      verifyButton: 'Verify',
+      verifyingButton: 'Verifying...',
+      backToLogin: '← Back to login',
+      orContinueWith: 'or continue with',
+      signInWith: 'Sign in with {{provider}}',
+      enterCode: 'Please enter the verification code',
+      sendCodeFailed: 'Failed to send verification code',
+      invalidCode: 'Invalid code. Please try again.',
+    },
+
   },
   },
 
 
   // Setup page
   // Setup page

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

@@ -102,6 +102,9 @@ export default {
     more: '+{{count}} de plus',
     more: '+{{count}} de plus',
     ascending: 'Croissant',
     ascending: 'Croissant',
     descending: 'Décroissant',
     descending: 'Décroissant',
+    back: 'Retour',
+    copy: 'Copier',
+    copied: 'Copié !',
     printer: 'Imprimante',
     printer: 'Imprimante',
     remove: 'Retirer',
     remove: 'Retirer',
     type: 'Type',
     type: 'Type',
@@ -1324,6 +1327,8 @@ export default {
       backup: 'Sauvegarde',
       backup: 'Sauvegarde',
       emailAuth: 'Authentification Email',
       emailAuth: 'Authentification Email',
       ldap: 'LDAP',
       ldap: 'LDAP',
+      twoFa: 'Authentification 2FA',
+      oidc: 'SSO / OIDC',
     },
     },
     ldap: {
     ldap: {
       title: 'Authentification LDAP',
       title: 'Authentification LDAP',
@@ -2039,6 +2044,74 @@ export default {
     deleteUserAndItems: 'Supprimer l\'utilisateur ET ses éléments',
     deleteUserAndItems: 'Supprimer l\'utilisateur ET ses éléments',
     deleteUserKeepItems: 'Supprimer l\'utilisateur, garder les éléments (deviennent sans propriétaire)',
     deleteUserKeepItems: 'Supprimer l\'utilisateur, garder les éléments (deviennent sans propriétaire)',
     ok: 'OK',
     ok: 'OK',
+
+    // 2FA settings
+    twoFa: {
+      totpTitle: 'Application Authenticator (TOTP)',
+      totpDesc: 'Utilisez une application comme Google Authenticator, Aegis ou Authy.',
+      emailOtpTitle: 'OTP par e-mail',
+      emailOtpDesc: 'Envoyez un code à usage unique à {{email}} lors de la connexion.',
+      emailOtpNoEmail: 'Ajoutez une adresse e-mail à votre compte pour activer cette méthode.',
+      addEmailFirst: 'Votre compte n\'a pas d\'adresse e-mail. Demandez à un administrateur d\'en ajouter une.',
+      setupTotp: 'Configurer l\'application Authenticator',
+      setupAuthApp: 'Configurer l\'application Authenticator',
+      setupInstructions: 'Scannez le QR code avec votre application authenticator, puis confirmez avec un code.',
+      manualEntry: 'Impossible de scanner ? Entrez ce secret manuellement :',
+      scannedContinue: 'Code scanné — continuer',
+      enterCodeToConfirm: 'Entrez le code à 6 chiffres de votre application authenticator pour confirmer.',
+      activate: 'Activer',
+      disableTotp: 'Désactiver l\'Authenticator',
+      disableConfirmHint: 'Entrez un code TOTP valide ou un code de secours pour désactiver l\'authenticator.',
+      totpDisabled: 'Application Authenticator désactivée.',
+      emailOtpEnabled: 'OTP par e-mail activé.',
+      emailOtpDisabled: 'OTP par e-mail désactivé.',
+      smtpRequired: 'Veuillez d\'abord configurer et tester les paramètres SMTP.',
+      invalidCode: 'Code invalide. Veuillez réessayer.',
+      enableEmailOtp: 'Activer OTP par e-mail',
+      disableEmailOtp: 'Désactiver OTP par e-mail',
+      emailSetupEnterCode: 'Un code de vérification a été envoyé à votre adresse e-mail. Entrez-le ci-dessous pour confirmer que vous possédez cette boîte de réception.',
+      verifyAndEnable: 'Vérifier et activer',
+      emailDisablePasswordHint: 'Entrez le mot de passe de votre compte pour confirmer la désactivation de l\'OTP par e-mail.',
+      passwordPlaceholder: 'Entrez votre mot de passe',
+      backupCodesTitle: 'Sauvegardez vos codes de secours',
+      backupCodesWarning: 'Conservez ces codes en lieu sûr. Chaque code ne peut être utilisé qu\'une seule fois et ne sera plus affiché.',
+      backupCodesRemaining: '{{count}} codes de secours restants',
+      savedCodes: 'Codes sauvegardés',
+      regenBackup: 'Régénérer les codes de secours',
+      regenBackupHint: 'Entrez votre code TOTP actuel pour générer 10 nouveaux codes de secours. Tous les codes existants seront invalidés.',
+      newBackupCodes: 'Nouveaux codes de secours',
+      linkedAccounts: 'Comptes SSO liés',
+      linkedAccountsDesc: 'Ces fournisseurs d\'identité externes sont liés à votre compte.',
+      oidcUnlinked: 'Compte dissocié.',
+    },
+
+    // OIDC provider settings
+    oidc: {
+      title: 'Fournisseurs SSO / OIDC',
+      desc: 'Configurez des fournisseurs OpenID Connect pour l\'authentification unique.',
+      addProvider: 'Ajouter un fournisseur',
+      newProvider: 'Nouveau fournisseur',
+      empty: 'Aucun fournisseur OIDC configuré.',
+      created: 'Fournisseur créé.',
+      updated: 'Fournisseur mis à jour.',
+      deleted: 'Fournisseur supprimé.',
+      deleteTitle: 'Supprimer le fournisseur',
+      deleteMessage: 'Supprimer "{{name}}" ? Tous les comptes liés seront déconnectés.',
+      form: {
+        name: 'Nom d\'affichage',
+        issuerUrl: 'URL de l\'émetteur',
+        clientId: 'ID client',
+        clientSecret: 'Secret client',
+        scopes: 'Scopes',
+        iconUrl: 'URL de l\'icône (optionnel)',
+        enabled: 'Activé',
+        autoCreate: 'Créer les utilisateurs automatiquement',
+        autoCreateDesc: 'Crée automatiquement un compte local lors de la première connexion.',
+        secretHint: 'laisser vide pour conserver',
+        secretPlaceholder: 'nouveau secret',
+      },
+    },
+
   },
   },
 
 
   // Notifications (for push notifications)
   // Notifications (for push notifications)
@@ -2169,6 +2242,28 @@ export default {
     loginSuccess: 'Connecté avec succès',
     loginSuccess: 'Connecté avec succès',
     loginFailed: 'Échec de connexion',
     loginFailed: 'Échec de connexion',
     enterCredentials: 'Entrez vos identifiants',
     enterCredentials: 'Entrez vos identifiants',
+    enterEmail: 'Veuillez entrer votre adresse e-mail',
+    oidcLoginFailed: 'Échec de la connexion OIDC',
+    oidcErrors: {
+      providerError: "Le fournisseur d'identité a renvoyé une erreur",
+      missingParameters: 'Il manque des paramètres requis dans le callback OIDC',
+      invalidState: "L'état OIDC est invalide ou a déjà été utilisé",
+      stateExpired: 'La session OIDC a expiré — veuillez réessayer',
+      providerNotFound: 'Fournisseur OIDC introuvable',
+      discoveryFailed: 'Impossible de récupérer le document de découverte OIDC',
+      invalidDiscovery: 'Le document de découverte OIDC est invalide',
+      networkError: "Erreur réseau lors de l'échange de jeton OIDC",
+      badResponse: "Réponse inattendue lors de l'échange de jeton OIDC",
+      noIdToken: "Le fournisseur OIDC n'a pas renvoyé de jeton d'identité",
+      validationFailed: 'La validation du jeton OIDC a échoué',
+      nonceMismatch: 'Le nonce OIDC ne correspond pas — possible attaque par rejeu',
+      missingSubClaim: 'Le jeton OIDC est dépourvu de la revendication sub',
+      noLinkedAccount: 'Aucun compte local est lié à cette identité OIDC',
+      accountInactive: 'Votre compte est inactif',
+      userResolutionFailed: 'Impossible de résoudre votre compte',
+      internalError: 'Une erreur interne est survenue lors de la connexion OIDC',
+      tokenExchangeFailed: "L'échange de jeton OIDC a échoué",
+    },
     forgotPasswordTitle: 'Mot de passe oublié',
     forgotPasswordTitle: 'Mot de passe oublié',
     forgotPasswordMessage: 'Contactez votre administrateur pour réinitialiser votre accès.',
     forgotPasswordMessage: 'Contactez votre administrateur pour réinitialiser votre accès.',
     forgotPasswordEmailMessage: 'Entrez votre email pour recevoir un nouveau mot de passe.',
     forgotPasswordEmailMessage: 'Entrez votre email pour recevoir un nouveau mot de passe.',
@@ -2183,6 +2278,33 @@ export default {
     resetStep3: 'Il vous donnera un mot de passe temporaire',
     resetStep3: 'Il vous donnera un mot de passe temporaire',
     resetStep4: 'Connectez-vous et changez-le dans les Paramètres',
     resetStep4: 'Connectez-vous et changez-le dans les Paramètres',
     gotIt: 'Compris',
     gotIt: 'Compris',
+    twoFA: {
+      title: 'Authentification à deux facteurs',
+      subtitle: 'Votre compte est protégé par la 2FA. Saisissez le code de vérification ci-dessous.',
+      methodAuthenticator: "Application d'authentification",
+      methodEmail: 'Code par e-mail',
+      methodBackup: 'Code de récupération',
+      instructionsTotp: "Ouvrez votre application d'authentification et saisissez le code à 6 chiffres pour Bambuddy.",
+      instructionsEmail: 'Un code à 6 chiffres a été envoyé à votre adresse e-mail. Il est valable 10 minutes.',
+      instructionsEmailNotSent: 'Cliquez ci-dessous pour recevoir un code de vérification par e-mail.',
+      instructionsBackup: "Saisissez l'un de vos codes de récupération à 8 caractères. Chaque code ne peut être utilisé qu'une seule fois.",
+      sendCodeButton: 'Envoyer le code par e-mail',
+      sendingCode: 'Envoi en cours...',
+      resendCode: 'Renvoyer le code',
+      codeLabel: 'Code de vérification',
+      backupCodeLabel: 'Code de récupération',
+      codePlaceholder: '000000',
+      backupCodePlaceholder: 'XXXXXXXX',
+      verifyButton: 'Vérifier',
+      verifyingButton: 'Vérification en cours...',
+      backToLogin: '← Retour à la connexion',
+      orContinueWith: 'ou continuer avec',
+      signInWith: 'Se connecter avec {{provider}}',
+      enterCode: 'Veuillez entrer le code de vérification',
+      sendCodeFailed: 'Échec de l\'envoi du code de vérification',
+      invalidCode: 'Code invalide. Veuillez réessayer.',
+    },
+
   },
   },
 
 
   // Setup page
   // Setup page

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

@@ -102,6 +102,9 @@ export default {
     more: '+{{count}} altre',
     more: '+{{count}} altre',
     ascending: 'Crescente',
     ascending: 'Crescente',
     descending: 'Decrescente',
     descending: 'Decrescente',
+    back: 'Indietro',
+    copy: 'Copia',
+    copied: 'Copiato!',
     printer: 'Stampante',
     printer: 'Stampante',
     remove: 'Rimuovi',
     remove: 'Rimuovi',
     type: 'Tipo',
     type: 'Tipo',
@@ -1324,6 +1327,8 @@ export default {
       backup: 'Backup',
       backup: 'Backup',
       emailAuth: 'Autenticazione Email',
       emailAuth: 'Autenticazione Email',
       ldap: 'LDAP',
       ldap: 'LDAP',
+      twoFa: 'Autenticazione 2FA',
+      oidc: 'SSO / OIDC',
     },
     },
     ldap: {
     ldap: {
       title: 'Autenticazione LDAP',
       title: 'Autenticazione LDAP',
@@ -2038,6 +2043,74 @@ export default {
     deleteUserAndItems: 'Elimina utente E i suoi elementi',
     deleteUserAndItems: 'Elimina utente E i suoi elementi',
     deleteUserKeepItems: 'Elimina utente, mantieni elementi (diventeranno senza proprietario)',
     deleteUserKeepItems: 'Elimina utente, mantieni elementi (diventeranno senza proprietario)',
     ok: 'OK',
     ok: 'OK',
+
+    // 2FA settings
+    twoFa: {
+      totpTitle: 'App Authenticator (TOTP)',
+      totpDesc: 'Usa un\'app come Google Authenticator, Aegis o Authy.',
+      emailOtpTitle: 'OTP via e-mail',
+      emailOtpDesc: 'Invia un codice monouso a {{email}} al momento del login.',
+      emailOtpNoEmail: 'Aggiungi un indirizzo e-mail al tuo account per abilitare questo metodo.',
+      addEmailFirst: 'Il tuo account non ha un indirizzo e-mail. Chiedi a un amministratore di aggiungerne uno.',
+      setupTotp: 'Configura app Authenticator',
+      setupAuthApp: 'Configura app Authenticator',
+      setupInstructions: 'Scansiona il codice QR con la tua app authenticator, poi conferma con un codice.',
+      manualEntry: 'Impossibile scansionare? Inserisci questo segreto manualmente:',
+      scannedContinue: 'Codice scansionato — continua',
+      enterCodeToConfirm: 'Inserisci il codice a 6 cifre dalla tua app authenticator per confermare.',
+      activate: 'Attiva',
+      disableTotp: 'Disabilita Authenticator',
+      disableConfirmHint: 'Inserisci un codice TOTP valido o un codice di backup per disabilitare l\'authenticator.',
+      totpDisabled: 'App Authenticator disabilitata.',
+      emailOtpEnabled: 'OTP via e-mail abilitato.',
+      emailOtpDisabled: 'OTP via e-mail disabilitato.',
+      smtpRequired: 'Configura e testa prima le impostazioni SMTP.',
+      invalidCode: 'Codice non valido. Riprova.',
+      enableEmailOtp: 'Abilita OTP via e-mail',
+      disableEmailOtp: 'Disabilita OTP via e-mail',
+      emailSetupEnterCode: 'È stato inviato un codice di verifica al tuo indirizzo e-mail. Inseriscilo qui sotto per confermare che possiedi questa casella di posta.',
+      verifyAndEnable: 'Verifica e abilita',
+      emailDisablePasswordHint: 'Inserisci la password del tuo account per confermare la disabilitazione dell\'OTP via e-mail.',
+      passwordPlaceholder: 'Inserisci la tua password',
+      backupCodesTitle: 'Salva i tuoi codici di backup',
+      backupCodesWarning: 'Conserva questi codici in un posto sicuro. Ogni codice può essere usato una sola volta.',
+      backupCodesRemaining: '{{count}} codici di backup rimanenti',
+      savedCodes: 'Codici salvati',
+      regenBackup: 'Rigenera codici di backup',
+      regenBackupHint: 'Inserisci il tuo codice TOTP corrente per generare 10 nuovi codici di backup.',
+      newBackupCodes: 'Nuovi codici di backup',
+      linkedAccounts: 'Account SSO collegati',
+      linkedAccountsDesc: 'Questi provider di identità esterni sono collegati al tuo account.',
+      oidcUnlinked: 'Account scollegato.',
+    },
+
+    // OIDC provider settings
+    oidc: {
+      title: 'Provider SSO / OIDC',
+      desc: 'Configura provider OpenID Connect per il single sign-on.',
+      addProvider: 'Aggiungi provider',
+      newProvider: 'Nuovo provider',
+      empty: 'Nessun provider OIDC configurato.',
+      created: 'Provider creato.',
+      updated: 'Provider aggiornato.',
+      deleted: 'Provider eliminato.',
+      deleteTitle: 'Elimina provider',
+      deleteMessage: 'Eliminare "{{name}}"? Tutti gli account collegati verranno disconnessi.',
+      form: {
+        name: 'Nome visualizzato',
+        issuerUrl: 'URL emittente',
+        clientId: 'Client ID',
+        clientSecret: 'Client secret',
+        scopes: 'Scope',
+        iconUrl: 'URL icona (opzionale)',
+        enabled: 'Abilitato',
+        autoCreate: 'Crea utenti automaticamente',
+        autoCreateDesc: 'Crea automaticamente un account locale al primo accesso.',
+        secretHint: 'lascia vuoto per mantenere',
+        secretPlaceholder: 'nuovo segreto',
+      },
+    },
+
   },
   },
 
 
   // Notifications (for push notifications)
   // Notifications (for push notifications)
@@ -2168,6 +2241,28 @@ export default {
     loginSuccess: 'Accesso riuscito',
     loginSuccess: 'Accesso riuscito',
     loginFailed: 'Accesso fallito',
     loginFailed: 'Accesso fallito',
     enterCredentials: 'Inserisci nome utente e password',
     enterCredentials: 'Inserisci nome utente e password',
+    enterEmail: 'Inserisci il tuo indirizzo e-mail',
+    oidcLoginFailed: 'Accesso OIDC fallito',
+    oidcErrors: {
+      providerError: "Il provider di identità ha restituito un errore",
+      missingParameters: 'Parametri obbligatori mancanti nel callback OIDC',
+      invalidState: 'Lo stato OIDC non è valido o è già stato utilizzato',
+      stateExpired: 'La sessione OIDC è scaduta — riprovare',
+      providerNotFound: 'Provider OIDC non trovato',
+      discoveryFailed: 'Impossibile recuperare il documento di discovery OIDC',
+      invalidDiscovery: 'Il documento di discovery OIDC non è valido',
+      networkError: "Errore di rete durante lo scambio di token OIDC",
+      badResponse: "Risposta inattesa durante lo scambio di token OIDC",
+      noIdToken: 'Il provider OIDC non ha restituito un ID token',
+      validationFailed: 'La validazione del token OIDC non è riuscita',
+      nonceMismatch: 'Il nonce OIDC non corrisponde — possibile attacco di replay',
+      missingSubClaim: 'Il token OIDC è privo del claim sub',
+      noLinkedAccount: 'Nessun account locale è collegato a questa identità OIDC',
+      accountInactive: 'Il tuo account è inattivo',
+      userResolutionFailed: 'Impossibile risolvere il tuo account',
+      internalError: "Si è verificato un errore interno durante il login OIDC",
+      tokenExchangeFailed: 'Lo scambio di token OIDC non è riuscito',
+    },
     forgotPasswordTitle: 'Password dimenticata',
     forgotPasswordTitle: 'Password dimenticata',
     forgotPasswordMessage: 'Se hai dimenticato la password, contatta il tuo amministratore di sistema per reimpostarla.',
     forgotPasswordMessage: 'Se hai dimenticato la password, contatta il tuo amministratore di sistema per reimpostarla.',
     forgotPasswordEmailMessage: 'Inserisci il tuo indirizzo email e ti invieremo una nuova password.',
     forgotPasswordEmailMessage: 'Inserisci il tuo indirizzo email e ti invieremo una nuova password.',
@@ -2182,6 +2277,33 @@ export default {
     resetStep3: 'Possono impostare una nuova password temporanea',
     resetStep3: 'Possono impostare una nuova password temporanea',
     resetStep4: 'Accedi con la nuova password e cambiala in Impostazioni',
     resetStep4: 'Accedi con la nuova password e cambiala in Impostazioni',
     gotIt: 'Capito',
     gotIt: 'Capito',
+    twoFA: {
+      title: 'Autenticazione a due fattori',
+      subtitle: 'Il tuo account è protetto da 2FA. Inserisci il codice di verifica qui sotto.',
+      methodAuthenticator: 'App di autenticazione',
+      methodEmail: 'Codice via e-mail',
+      methodBackup: 'Codice di recupero',
+      instructionsTotp: "Apri la tua app di autenticazione e inserisci il codice a 6 cifre per Bambuddy.",
+      instructionsEmail: "Un codice a 6 cifre è stato inviato al tuo indirizzo e-mail. È valido per 10 minuti.",
+      instructionsEmailNotSent: 'Clicca il pulsante qui sotto per ricevere un codice di verifica via e-mail.',
+      instructionsBackup: 'Inserisci uno dei tuoi codici di recupero a 8 caratteri. Ogni codice può essere utilizzato una sola volta.',
+      sendCodeButton: 'Invia codice via e-mail',
+      sendingCode: 'Invio in corso...',
+      resendCode: 'Invia nuovamente il codice',
+      codeLabel: 'Codice di verifica',
+      backupCodeLabel: 'Codice di recupero',
+      codePlaceholder: '000000',
+      backupCodePlaceholder: 'XXXXXXXX',
+      verifyButton: 'Verifica',
+      verifyingButton: 'Verifica in corso...',
+      backToLogin: '← Torna alla pagina di accesso',
+      orContinueWith: 'oppure accedi con',
+      signInWith: 'Accedi con {{provider}}',
+      enterCode: 'Inserisci il codice di verifica',
+      sendCodeFailed: 'Invio del codice di verifica non riuscito',
+      invalidCode: 'Codice non valido. Riprova.',
+    },
+
   },
   },
 
 
   // Setup page
   // Setup page

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

@@ -102,6 +102,9 @@ export default {
     more: 'もっと見る',
     more: 'もっと見る',
     ascending: '昇順',
     ascending: '昇順',
     descending: '降順',
     descending: '降順',
+    back: '戻る',
+    copy: 'コピー',
+    copied: 'コピーしました!',
     printer: 'プリンター',
     printer: 'プリンター',
     remove: '削除',
     remove: '削除',
     type: '種類',
     type: '種類',
@@ -1324,6 +1327,8 @@ export default {
       backup: 'バックアップ',
       backup: 'バックアップ',
       emailAuth: 'メール認証',
       emailAuth: 'メール認証',
       ldap: 'LDAP',
       ldap: 'LDAP',
+      twoFa: '二段階認証',
+      oidc: 'SSO / OIDC',
     },
     },
     spoolbuddy: {
     spoolbuddy: {
       infoTitle: 'SpoolBuddy デバイス',
       infoTitle: 'SpoolBuddy デバイス',
@@ -2077,6 +2082,74 @@ export default {
     deleteUserAndItems: 'ユーザーとそのアイテムを削除',
     deleteUserAndItems: 'ユーザーとそのアイテムを削除',
     deleteUserKeepItems: 'ユーザーを削除、アイテムは保持(オーナーなしになります)',
     deleteUserKeepItems: 'ユーザーを削除、アイテムは保持(オーナーなしになります)',
     ok: 'OK',
     ok: 'OK',
+
+    // 2FA settings
+    twoFa: {
+      totpTitle: '認証アプリ (TOTP)',
+      totpDesc: 'Google Authenticator、Aegis、Authyなどのアプリを使用します。',
+      emailOtpTitle: 'メールOTP',
+      emailOtpDesc: 'ログイン時に{{email}}にワンタイムコードを送信します。',
+      emailOtpNoEmail: 'この方法を有効にするには、アカウントにメールアドレスを追加してください。',
+      addEmailFirst: 'アカウントにメールアドレスがありません。管理者に追加を依頼してください。',
+      setupTotp: '認証アプリを設定',
+      setupAuthApp: '認証アプリを設定',
+      setupInstructions: '認証アプリでQRコードをスキャンし、コードで確認してください。',
+      manualEntry: 'スキャンできない場合は、このシークレットを手動で入力してください:',
+      scannedContinue: 'コードをスキャンしました — 続ける',
+      enterCodeToConfirm: '認証アプリの6桁のコードを入力して設定を確認してください。',
+      activate: '有効化',
+      disableTotp: '認証アプリを無効化',
+      disableConfirmHint: '認証アプリを無効にするには、有効なTOTPコードまたはバックアップコードを入力してください。',
+      totpDisabled: '認証アプリが無効化されました。',
+      emailOtpEnabled: 'メールOTPが有効化されました。',
+      emailOtpDisabled: 'メールOTPが無効化されました。',
+      smtpRequired: '先にSMTP設定を構成してテストしてください。',
+      invalidCode: '無効なコードです。もう一度お試しください。',
+      enableEmailOtp: 'メールOTPを有効化',
+      disableEmailOtp: 'メールOTPを無効化',
+      emailSetupEnterCode: '確認コードがメールアドレスに送信されました。このメールボックスを所有していることを確認するために、以下に入力してください。',
+      verifyAndEnable: '確認して有効化',
+      emailDisablePasswordHint: 'メールOTPの無効化を確認するには、アカウントのパスワードを入力してください。',
+      passwordPlaceholder: 'パスワードを入力してください',
+      backupCodesTitle: 'バックアップコードを保存',
+      backupCodesWarning: 'これらのコードを安全な場所に保存してください。各コードは一度しか使用できません。',
+      backupCodesRemaining: 'バックアップコード残り{{count}}個',
+      savedCodes: 'コードを保存しました',
+      regenBackup: 'バックアップコードを再生成',
+      regenBackupHint: '現在のTOTPコードを入力して10個の新しいバックアップコードを生成します。',
+      newBackupCodes: '新しいバックアップコード',
+      linkedAccounts: 'リンクされたSSOアカウント',
+      linkedAccountsDesc: 'これらの外部IDプロバイダーがあなたのアカウントにリンクされています。',
+      oidcUnlinked: 'アカウントのリンクを解除しました。',
+    },
+
+    // OIDC provider settings
+    oidc: {
+      title: 'SSO / OIDCプロバイダー',
+      desc: 'シングルサインオン用のOpenID Connectプロバイダーを設定します。',
+      addProvider: 'プロバイダーを追加',
+      newProvider: '新しいプロバイダー',
+      empty: 'OIDCプロバイダーがまだ設定されていません。',
+      created: 'プロバイダーが作成されました。',
+      updated: 'プロバイダーが更新されました。',
+      deleted: 'プロバイダーが削除されました。',
+      deleteTitle: 'プロバイダーを削除',
+      deleteMessage: '"{{name}}"を削除しますか?リンクされたすべてのユーザーアカウントが切断されます。',
+      form: {
+        name: '表示名',
+        issuerUrl: '発行者URL',
+        clientId: 'クライアントID',
+        clientSecret: 'クライアントシークレット',
+        scopes: 'スコープ',
+        iconUrl: 'アイコンURL (任意)',
+        enabled: '有効',
+        autoCreate: 'ユーザーを自動作成',
+        autoCreateDesc: '初回ログイン時にローカルアカウントを自動的に作成します。',
+        secretHint: '空白のままで現在のものを維持',
+        secretPlaceholder: '新しいシークレット',
+      },
+    },
+
   },
   },
 
 
   // Notifications (for push notifications)
   // Notifications (for push notifications)
@@ -2207,6 +2280,28 @@ export default {
     loginSuccess: 'ログインしました',
     loginSuccess: 'ログインしました',
     loginFailed: 'ログインに失敗しました',
     loginFailed: 'ログインに失敗しました',
     enterCredentials: 'ユーザー名とパスワードを入力してください',
     enterCredentials: 'ユーザー名とパスワードを入力してください',
+    enterEmail: 'メールアドレスを入力してください',
+    oidcLoginFailed: 'OIDCログインに失敗しました',
+    oidcErrors: {
+      providerError: 'IDプロバイダーがエラーを返しました',
+      missingParameters: 'OIDCコールバックに必須パラメーターがありません',
+      invalidState: 'OIDCの状態が無効か、すでに使用されています',
+      stateExpired: 'OIDCログインセッションが期限切れです。もう一度お試しください',
+      providerNotFound: 'OIDCプロバイダーが見つかりません',
+      discoveryFailed: 'OIDCディスカバリードキュメントの取得に失敗しました',
+      invalidDiscovery: 'OIDCディスカバリードキュメントが無効です',
+      networkError: 'OIDCトークン交換中にネットワークエラーが発生しました',
+      badResponse: 'OIDCトークン交換中に予期しない応答を受信しました',
+      noIdToken: 'OIDCプロバイダーがIDトークンを返しませんでした',
+      validationFailed: 'OIDCトークンの検証に失敗しました',
+      nonceMismatch: 'OIDCノンスが一致しません。リプレイ攻撃の可能性があります',
+      missingSubClaim: 'OIDCトークンにsubクレームがありません',
+      noLinkedAccount: 'このOIDCアイデンティティに関連付けられたローカルアカウントがありません',
+      accountInactive: 'あなたのアカウントは無効です',
+      userResolutionFailed: 'アカウントを解決できませんでした',
+      internalError: 'OIDCログイン中に内部エラーが発生しました',
+      tokenExchangeFailed: 'OIDCトークン交換に失敗しました',
+    },
     forgotPasswordTitle: 'パスワードを忘れた場合',
     forgotPasswordTitle: 'パスワードを忘れた場合',
     forgotPasswordMessage: 'パスワードを忘れた場合は、システム管理者に連絡してリセットしてもらってください。',
     forgotPasswordMessage: 'パスワードを忘れた場合は、システム管理者に連絡してリセットしてもらってください。',
     forgotPasswordEmailMessage: 'メールアドレスを入力すると、新しいパスワードを送信します。',
     forgotPasswordEmailMessage: 'メールアドレスを入力すると、新しいパスワードを送信します。',
@@ -2221,6 +2316,33 @@ export default {
     resetStep3: '管理者が新しい仮パスワードを設定',
     resetStep3: '管理者が新しい仮パスワードを設定',
     resetStep4: '新しいパスワードでログインし、設定で変更',
     resetStep4: '新しいパスワードでログインし、設定で変更',
     gotIt: '了解',
     gotIt: '了解',
+    twoFA: {
+      title: '二段階認証',
+      subtitle: 'アカウントは二段階認証で保護されています。確認コードを入力してください。',
+      methodAuthenticator: '認証アプリ',
+      methodEmail: 'メール認証',
+      methodBackup: 'バックアップコード',
+      instructionsTotp: '認証アプリを開いて、Bambuddy用の6桁のコードを入力してください。',
+      instructionsEmail: '6桁の確認コードをメールアドレスに送信しました。有効期限は10分です。',
+      instructionsEmailNotSent: '下のボタンをクリックして、メールで確認コードを受け取ってください。',
+      instructionsBackup: '8文字のバックアップコードをいずれか1つ入力してください。各コードは1回のみ使用可能です。',
+      sendCodeButton: 'メールでコードを送信する',
+      sendingCode: '送信中...',
+      resendCode: 'コードを再送する',
+      codeLabel: '確認コード',
+      backupCodeLabel: 'バックアップコード',
+      codePlaceholder: '000000',
+      backupCodePlaceholder: 'XXXXXXXX',
+      verifyButton: '確認する',
+      verifyingButton: '確認中...',
+      backToLogin: '← ログイン画面に戻る',
+      orContinueWith: 'または以下でログイン',
+      signInWith: '{{provider}}でログイン',
+      enterCode: '確認コードを入力してください',
+      sendCodeFailed: '確認コードの送信に失敗しました',
+      invalidCode: '無効なコードです。もう一度お試しください。',
+    },
+
   },
   },
 
 
   // Setup page
   // Setup page

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

@@ -102,6 +102,9 @@ export default {
     more: '+{{count}} mais',
     more: '+{{count}} mais',
     ascending: 'Crescente',
     ascending: 'Crescente',
     descending: 'Decrescente',
     descending: 'Decrescente',
+    back: 'Voltar',
+    copy: 'Copiar',
+    copied: 'Copiado!',
     printer: 'Impressora',
     printer: 'Impressora',
     remove: 'Remover',
     remove: 'Remover',
     type: 'Tipo',
     type: 'Tipo',
@@ -1324,6 +1327,8 @@ export default {
       backup: 'Backup',
       backup: 'Backup',
       emailAuth: 'Autenticação por Email',
       emailAuth: 'Autenticação por Email',
       ldap: 'LDAP',
       ldap: 'LDAP',
+      twoFa: 'Autenticação 2FA',
+      oidc: 'SSO / OIDC',
     },
     },
     ldap: {
     ldap: {
       title: 'Autenticação LDAP',
       title: 'Autenticação LDAP',
@@ -2038,6 +2043,74 @@ export default {
     deleteUserAndItems: 'Excluir usuário E seus itens',
     deleteUserAndItems: 'Excluir usuário E seus itens',
     deleteUserKeepItems: 'Excluir usuário, manter itens (ficarão sem dono)',
     deleteUserKeepItems: 'Excluir usuário, manter itens (ficarão sem dono)',
     ok: 'OK',
     ok: 'OK',
+
+    // 2FA settings
+    twoFa: {
+      totpTitle: 'App Autenticador (TOTP)',
+      totpDesc: 'Use um app como Google Authenticator, Aegis ou Authy.',
+      emailOtpTitle: 'OTP por e-mail',
+      emailOtpDesc: 'Envie um código único para {{email}} ao fazer login.',
+      emailOtpNoEmail: 'Adicione um endereço de e-mail à sua conta para ativar este método.',
+      addEmailFirst: 'Sua conta não tem endereço de e-mail. Peça a um administrador para adicionar um.',
+      setupTotp: 'Configurar App Autenticador',
+      setupAuthApp: 'Configurar App Autenticador',
+      setupInstructions: 'Escaneie o código QR com seu app autenticador e confirme com um código.',
+      manualEntry: 'Não consegue escanear? Digite este segredo manualmente:',
+      scannedContinue: 'Código escaneado — continuar',
+      enterCodeToConfirm: 'Digite o código de 6 dígitos do seu app autenticador para confirmar.',
+      activate: 'Ativar',
+      disableTotp: 'Desativar Autenticador',
+      disableConfirmHint: 'Digite um código TOTP válido ou um código de backup para desativar o autenticador.',
+      totpDisabled: 'App autenticador desativado.',
+      emailOtpEnabled: 'OTP por e-mail ativado.',
+      emailOtpDisabled: 'OTP por e-mail desativado.',
+      smtpRequired: 'Por favor, configure e teste as configurações SMTP primeiro.',
+      invalidCode: 'Código inválido. Por favor, tente novamente.',
+      enableEmailOtp: 'Ativar OTP por e-mail',
+      disableEmailOtp: 'Desativar OTP por e-mail',
+      emailSetupEnterCode: 'Um código de verificação foi enviado para o seu endereço de e-mail. Digite-o abaixo para confirmar que você possui esta caixa de entrada.',
+      verifyAndEnable: 'Verificar e Ativar',
+      emailDisablePasswordHint: 'Digite a senha da sua conta para confirmar a desativação do OTP por e-mail.',
+      passwordPlaceholder: 'Digite sua senha',
+      backupCodesTitle: 'Salve seus códigos de backup',
+      backupCodesWarning: 'Guarde estes códigos em lugar seguro. Cada código só pode ser usado uma vez.',
+      backupCodesRemaining: '{{count}} códigos de backup restantes',
+      savedCodes: 'Códigos salvos',
+      regenBackup: 'Regenerar códigos de backup',
+      regenBackupHint: 'Digite seu código TOTP atual para gerar 10 novos códigos de backup.',
+      newBackupCodes: 'Novos códigos de backup',
+      linkedAccounts: 'Contas SSO vinculadas',
+      linkedAccountsDesc: 'Estes provedores de identidade externos estão vinculados à sua conta.',
+      oidcUnlinked: 'Conta desvinculada.',
+    },
+
+    // OIDC provider settings
+    oidc: {
+      title: 'Provedores SSO / OIDC',
+      desc: 'Configure provedores OpenID Connect para login único.',
+      addProvider: 'Adicionar provedor',
+      newProvider: 'Novo provedor',
+      empty: 'Nenhum provedor OIDC configurado ainda.',
+      created: 'Provedor criado.',
+      updated: 'Provedor atualizado.',
+      deleted: 'Provedor excluído.',
+      deleteTitle: 'Excluir provedor',
+      deleteMessage: 'Excluir "{{name}}"? Todas as contas vinculadas serão desconectadas.',
+      form: {
+        name: 'Nome de exibição',
+        issuerUrl: 'URL do emissor',
+        clientId: 'Client ID',
+        clientSecret: 'Client secret',
+        scopes: 'Escopos',
+        iconUrl: 'URL do ícone (opcional)',
+        enabled: 'Ativado',
+        autoCreate: 'Criar usuários automaticamente',
+        autoCreateDesc: 'Cria automaticamente uma conta local no primeiro login.',
+        secretHint: 'deixe em branco para manter',
+        secretPlaceholder: 'novo segredo',
+      },
+    },
+
   },
   },
 
 
   // Notifications (for push notifications)
   // Notifications (for push notifications)
@@ -2168,6 +2241,28 @@ export default {
     loginSuccess: 'Login realizado com sucesso',
     loginSuccess: 'Login realizado com sucesso',
     loginFailed: 'Falha no login',
     loginFailed: 'Falha no login',
     enterCredentials: 'Por favor, insira nome de usuário e senha',
     enterCredentials: 'Por favor, insira nome de usuário e senha',
+    enterEmail: 'Por favor, insira seu endereço de e-mail',
+    oidcLoginFailed: 'Falha no login OIDC',
+    oidcErrors: {
+      providerError: 'O provedor de identidade retornou um erro',
+      missingParameters: 'Parâmetros obrigatórios ausentes no callback OIDC',
+      invalidState: 'Estado OIDC inválido ou já utilizado',
+      stateExpired: 'Sessão OIDC expirada — tente novamente',
+      providerNotFound: 'Provedor OIDC não encontrado',
+      discoveryFailed: 'Falha ao obter o documento de descoberta OIDC',
+      invalidDiscovery: 'Documento de descoberta OIDC inválido',
+      networkError: 'Erro de rede durante a troca de token OIDC',
+      badResponse: 'Resposta inesperada durante a troca de token OIDC',
+      noIdToken: 'O provedor OIDC não retornou um token de ID',
+      validationFailed: 'Falha na validação do token OIDC',
+      nonceMismatch: 'Nonce OIDC não corresponde — possível ataque de replay',
+      missingSubClaim: 'Token OIDC sem claim sub',
+      noLinkedAccount: 'Nenhuma conta local vinculada a esta identidade OIDC',
+      accountInactive: 'Sua conta está inativa',
+      userResolutionFailed: 'Falha ao resolver sua conta',
+      internalError: 'Erro interno durante o login OIDC',
+      tokenExchangeFailed: 'Falha na troca de token OIDC',
+    },
     forgotPasswordTitle: 'Esqueceu a Senha',
     forgotPasswordTitle: 'Esqueceu a Senha',
     forgotPasswordMessage: 'Se você esqueceu sua senha, entre em contato com o administrador do sistema para redefini-la.',
     forgotPasswordMessage: 'Se você esqueceu sua senha, entre em contato com o administrador do sistema para redefini-la.',
     forgotPasswordEmailMessage: 'Digite seu endereço de email e enviaremos uma nova senha.',
     forgotPasswordEmailMessage: 'Digite seu endereço de email e enviaremos uma nova senha.',
@@ -2182,6 +2277,47 @@ export default {
     resetStep3: 'Eles podem definir uma nova senha temporária para você',
     resetStep3: 'Eles podem definir uma nova senha temporária para você',
     resetStep4: 'Faça login com a nova senha e altere-a nas Configurações',
     resetStep4: 'Faça login com a nova senha e altere-a nas Configurações',
     gotIt: 'Entendi',
     gotIt: 'Entendi',
+    resetPassword: {
+      title: 'Definir nova senha',
+      subtitle: 'Digite e confirme sua nova senha abaixo.',
+      newPassword: 'Nova senha',
+      newPasswordPlaceholder: 'Pelo menos 8 caracteres',
+      confirmPassword: 'Confirmar senha',
+      confirmPasswordPlaceholder: 'Repetir nova senha',
+      saving: 'Salvando\u2026',
+      submit: 'Definir nova senha',
+      backToLogin: 'Voltar para o login',
+      passwordsDoNotMatch: 'As senhas não coincidem',
+      passwordTooShort: 'A senha deve ter pelo menos 8 caracteres',
+      resetFailed: 'Falha ao redefinir senha. O link pode ter expirado.',
+    },
+    twoFA: {
+      title: 'Autenticação em dois fatores',
+      subtitle: 'Sua conta está protegida com 2FA. Insira o código de verificação abaixo.',
+      methodAuthenticator: 'Aplicativo autenticador',
+      methodEmail: 'Código por e-mail',
+      methodBackup: 'Código de recuperação',
+      instructionsTotp: 'Abra seu aplicativo autenticador e insira o código de 6 dígitos gerado para o Bambuddy.',
+      instructionsEmail: 'Um código de 6 dígitos foi enviado para o seu e-mail. Ele é válido por 10 minutos.',
+      instructionsEmailNotSent: 'Clique no botão abaixo para receber um código de verificação por e-mail.',
+      instructionsBackup: 'Insira um dos seus códigos de recuperação de 8 caracteres. Cada código só pode ser utilizado uma vez.',
+      sendCodeButton: 'Enviar código por e-mail',
+      sendingCode: 'Enviando...',
+      resendCode: 'Reenviar código',
+      codeLabel: 'Código de verificação',
+      backupCodeLabel: 'Código de recuperação',
+      codePlaceholder: '000000',
+      backupCodePlaceholder: 'XXXXXXXX',
+      verifyButton: 'Verificar',
+      verifyingButton: 'Verificando...',
+      backToLogin: '← Voltar para o login',
+      orContinueWith: 'ou entrar com',
+      signInWith: 'Entrar com {{provider}}',
+      enterCode: 'Por favor, insira o código de verificação',
+      sendCodeFailed: 'Falha ao enviar o código de verificação',
+      invalidCode: 'Código inválido. Por favor, tente novamente.',
+    },
+
   },
   },
 
 
   // Setup page
   // Setup page

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

@@ -102,6 +102,9 @@ export default {
     more: '还有 {{count}} 个',
     more: '还有 {{count}} 个',
     ascending: '升序',
     ascending: '升序',
     descending: '降序',
     descending: '降序',
+    back: '返回',
+    copy: '复制',
+    copied: '已复制!',
     printer: '打印机',
     printer: '打印机',
     remove: '移除',
     remove: '移除',
     type: '类型',
     type: '类型',
@@ -1324,6 +1327,8 @@ export default {
       backup: '备份',
       backup: '备份',
       emailAuth: '邮箱认证',
       emailAuth: '邮箱认证',
       ldap: 'LDAP',
       ldap: 'LDAP',
+      twoFa: '双因素认证',
+      oidc: 'SSO / OIDC',
     },
     },
     ldap: {
     ldap: {
       title: 'LDAP 认证',
       title: 'LDAP 认证',
@@ -2038,6 +2043,74 @@ export default {
     deleteUserAndItems: '删除用户及其所有项目',
     deleteUserAndItems: '删除用户及其所有项目',
     deleteUserKeepItems: '删除用户,保留项目(将变为无主项目)',
     deleteUserKeepItems: '删除用户,保留项目(将变为无主项目)',
     ok: '确定',
     ok: '确定',
+
+    // 2FA settings
+    twoFa: {
+      totpTitle: '身份验证器应用 (TOTP)',
+      totpDesc: '使用 Google Authenticator、Aegis 或 Authy 等应用。',
+      emailOtpTitle: '邮件 OTP',
+      emailOtpDesc: '登录时向 {{email}} 发送一次性验证码。',
+      emailOtpNoEmail: '请先为账户添加邮箱地址以启用此方式。',
+      addEmailFirst: '您的账户没有邮箱地址,请联系管理员添加。',
+      setupTotp: '设置身份验证器应用',
+      setupAuthApp: '设置身份验证器应用',
+      setupInstructions: '使用身份验证器应用扫描二维码,然后输入验证码确认。',
+      manualEntry: '无法扫描?请手动输入此密钥:',
+      scannedContinue: '已扫描 — 继续',
+      enterCodeToConfirm: '请输入身份验证器应用中的6位验证码以确认设置。',
+      activate: '激活',
+      disableTotp: '停用身份验证器',
+      disableConfirmHint: '请输入有效的 TOTP 码或备用码来停用身份验证器。',
+      totpDisabled: '身份验证器应用已停用。',
+      emailOtpEnabled: '邮件 OTP 已启用。',
+      emailOtpDisabled: '邮件 OTP 已停用。',
+      smtpRequired: '请先配置并测试SMTP设置。',
+      invalidCode: '无效验证码,请重试。',
+      enableEmailOtp: '启用邮件 OTP',
+      disableEmailOtp: '停用邮件 OTP',
+      emailSetupEnterCode: '验证码已发送至您的邮箱地址。请在下方输入以确认您拥有此邮箱。',
+      verifyAndEnable: '验证并启用',
+      emailDisablePasswordHint: '请输入您的账户密码以确认停用邮件 OTP。',
+      passwordPlaceholder: '输入您的密码',
+      backupCodesTitle: '保存备用码',
+      backupCodesWarning: '请将这些码保存在安全的地方。每个码只能使用一次,且不会再次显示。',
+      backupCodesRemaining: '剩余 {{count}} 个备用码',
+      savedCodes: '已保存',
+      regenBackup: '重新生成备用码',
+      regenBackupHint: '输入当前 TOTP 码以生成 10 个新备用码,所有现有备用码将失效。',
+      newBackupCodes: '新备用码',
+      linkedAccounts: '已关联的 SSO 账户',
+      linkedAccountsDesc: '以下外部身份提供商已与您的账户关联。',
+      oidcUnlinked: '账户已解除关联。',
+    },
+
+    // OIDC provider settings
+    oidc: {
+      title: 'SSO / OIDC 提供商',
+      desc: '配置 OpenID Connect 提供商以实现单点登录。',
+      addProvider: '添加提供商',
+      newProvider: '新提供商',
+      empty: '尚未配置 OIDC 提供商。',
+      created: '提供商已创建。',
+      updated: '提供商已更新。',
+      deleted: '提供商已删除。',
+      deleteTitle: '删除提供商',
+      deleteMessage: '删除"{{name}}"?所有关联账户将断开连接。',
+      form: {
+        name: '显示名称',
+        issuerUrl: '颁发者 URL',
+        clientId: '客户端 ID',
+        clientSecret: '客户端密钥',
+        scopes: '作用域',
+        iconUrl: '图标 URL(可选)',
+        enabled: '已启用',
+        autoCreate: '自动创建用户',
+        autoCreateDesc: '首次登录时自动创建本地账户。',
+        secretHint: '留空以保留当前',
+        secretPlaceholder: '新密钥',
+      },
+    },
+
   },
   },
 
 
   // Notifications (for push notifications)
   // Notifications (for push notifications)
@@ -2168,6 +2241,28 @@ export default {
     loginSuccess: '登录成功',
     loginSuccess: '登录成功',
     loginFailed: '登录失败',
     loginFailed: '登录失败',
     enterCredentials: '请输入用户名和密码',
     enterCredentials: '请输入用户名和密码',
+    enterEmail: '请输入您的电子邮件地址',
+    oidcLoginFailed: 'OIDC 登录失败',
+    oidcErrors: {
+      providerError: '身份提供商返回了一个错误',
+      missingParameters: 'OIDC 回调缺少必要参数',
+      invalidState: 'OIDC 状态无效或已被使用',
+      stateExpired: 'OIDC 登录会话已过期,请重试',
+      providerNotFound: '未找到 OIDC 提供商',
+      discoveryFailed: '无法获取 OIDC 发现文档',
+      invalidDiscovery: 'OIDC 发现文档无效',
+      networkError: 'OIDC 令牌交换时出现网络错误',
+      badResponse: 'OIDC 令牌交换时收到意外响应',
+      noIdToken: 'OIDC 提供商未返回 ID 令牌',
+      validationFailed: 'OIDC 令牌验证失败',
+      nonceMismatch: 'OIDC nonce 不匹配,可能存在重放攻击',
+      missingSubClaim: 'OIDC 令牌缺少 sub 声明',
+      noLinkedAccount: '没有与此 OIDC 身份关联的本地帐户',
+      accountInactive: '您的帐户已被停用',
+      userResolutionFailed: '无法解析您的帐户',
+      internalError: 'OIDC 登录过程中发生内部错误',
+      tokenExchangeFailed: 'OIDC 令牌交换失败',
+    },
     forgotPasswordTitle: '忘记密码',
     forgotPasswordTitle: '忘记密码',
     forgotPasswordMessage: '如果您忘记了密码,请联系系统管理员进行重置。',
     forgotPasswordMessage: '如果您忘记了密码,请联系系统管理员进行重置。',
     forgotPasswordEmailMessage: '输入您的邮箱地址,我们将向您发送新密码。',
     forgotPasswordEmailMessage: '输入您的邮箱地址,我们将向您发送新密码。',
@@ -2182,6 +2277,33 @@ export default {
     resetStep3: '他们可以为您设置一个临时密码',
     resetStep3: '他们可以为您设置一个临时密码',
     resetStep4: '使用新密码登录并在设置中修改密码',
     resetStep4: '使用新密码登录并在设置中修改密码',
     gotIt: '知道了',
     gotIt: '知道了',
+    twoFA: {
+      title: '两步验证',
+      subtitle: '您的账户已启用两步验证。请在下方输入验证码。',
+      methodAuthenticator: '身份验证器应用',
+      methodEmail: '邮箱验证码',
+      methodBackup: '备用恢复码',
+      instructionsTotp: '请打开您的身份验证器应用,输入 Bambuddy 的 6 位验证码。',
+      instructionsEmail: '6 位验证码已发送至您的邮箱,有效期为 10 分钟。',
+      instructionsEmailNotSent: '点击下方按钮,通过邮件获取验证码。',
+      instructionsBackup: '请输入您的一个 8 位备用恢复码。每个恢复码只能使用一次。',
+      sendCodeButton: '发送邮箱验证码',
+      sendingCode: '发送中...',
+      resendCode: '重新发送验证码',
+      codeLabel: '验证码',
+      backupCodeLabel: '备用恢复码',
+      codePlaceholder: '000000',
+      backupCodePlaceholder: 'XXXXXXXX',
+      verifyButton: '验证',
+      verifyingButton: '验证中...',
+      backToLogin: '← 返回登录页面',
+      orContinueWith: '或通过以下方式登录',
+      signInWith: '使用 {{provider}} 登录',
+      enterCode: '请输入验证码',
+      sendCodeFailed: '验证码发送失败',
+      invalidCode: '无效验证码,请重试。',
+    },
+
   },
   },
 
 
   // Setup page
   // Setup page

+ 502 - 29
frontend/src/pages/LoginPage.tsx

@@ -1,37 +1,153 @@
-import { useState } from 'react';
-import { useNavigate } from 'react-router-dom';
+import { useEffect, useRef, useState } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
 import { useMutation, useQuery } from '@tanstack/react-query';
 import { useMutation, useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useTheme } from '../contexts/ThemeContext';
 import { useTheme } from '../contexts/ThemeContext';
-import { X, Mail } from 'lucide-react';
-import { api } from '../api/client';
+import { X, Mail, Shield, Smartphone, Key } from 'lucide-react';
+import { api, type LoginResponse } from '../api/client';
 import { Card, CardHeader, CardContent } from '../components/Card';
 import { Card, CardHeader, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 
 
+type LoginStep = 'credentials' | '2fa' | 'reset-password';
+
 export function LoginPage() {
 export function LoginPage() {
   const navigate = useNavigate();
   const navigate = useNavigate();
+  const [searchParams] = useSearchParams();
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { login } = useAuth();
+  const { login, loginWithToken } = useAuth();
   const { showToast } = useToast();
   const { showToast } = useToast();
   const { mode } = useTheme();
   const { mode } = useTheme();
+
+  // Credentials step state
   const [username, setUsername] = useState('');
   const [username, setUsername] = useState('');
   const [password, setPassword] = useState('');
   const [password, setPassword] = useState('');
   const [showForgotPassword, setShowForgotPassword] = useState(false);
   const [showForgotPassword, setShowForgotPassword] = useState(false);
   const [forgotEmail, setForgotEmail] = useState('');
   const [forgotEmail, setForgotEmail] = useState('');
 
 
+  // 2FA step state
+  const [step, setStep] = useState<LoginStep>('credentials');
+  const [preAuthToken, setPreAuthToken] = useState('');
+  const [twoFAMethods, setTwoFAMethods] = useState<string[]>([]);
+  const [twoFAMethod, setTwoFAMethod] = useState<'totp' | 'email' | 'backup'>('totp');
+  const [twoFACode, setTwoFACode] = useState('');
+  const [emailOTPSent, setEmailOTPSent] = useState(false);
+  const twoFAInputRef = useRef<HTMLInputElement>(null);
+
+  // H-6: Password reset step state
+  const [resetToken, setResetToken] = useState('');
+  const [newPassword, setNewPassword] = useState('');
+  const [confirmPassword, setConfirmPassword] = useState('');
+
   // Check if advanced auth is enabled
   // Check if advanced auth is enabled
   const { data: advancedAuthStatus } = useQuery({
   const { data: advancedAuthStatus } = useQuery({
     queryKey: ['advancedAuthStatus'],
     queryKey: ['advancedAuthStatus'],
     queryFn: () => api.getAdvancedAuthStatus(),
     queryFn: () => api.getAdvancedAuthStatus(),
   });
   });
 
 
+  // Fetch enabled OIDC providers for login buttons
+  const { data: oidcProviders } = useQuery({
+    queryKey: ['oidcProviders'],
+    queryFn: () => api.getOIDCProviders(),
+  });
+
+  // M-B: Detect #reset_token=... in the URL fragment and switch to the reset step.
+  // Fragments are never sent to the server so the token never appears in access-logs
+  // or Referer headers — mirrors the H-4 treatment of the OIDC token.
+  useEffect(() => {
+    const hash = window.location.hash;
+    const token = hash.startsWith('#reset_token=') ? hash.slice('#reset_token='.length) : null;
+    if (token) {
+      setResetToken(token);
+      setStep('reset-password');
+      // Clear the fragment from the URL so it can't be bookmarked or re-triggered.
+      navigate('/login', { replace: true });
+    }
+  }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+  // Handle OIDC callback: if #oidc_token=... is present in the fragment, exchange it.
+  // H-4: Read from the URL fragment (#) — fragments are never sent to the server
+  // so the exchange token stays out of access logs and Referer headers.
+  useEffect(() => {
+    const hash = window.location.hash;
+    const oidcToken = hash.startsWith('#oidc_token=') ? hash.slice('#oidc_token='.length) : null;
+    const oidcError = searchParams.get('oidc_error');
+
+    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.
+      const KNOWN_OIDC_ERRORS: Record<string, string> = {
+        oidc_provider_error: t('login.oidcErrors.providerError'),
+        missing_parameters: t('login.oidcErrors.missingParameters'),
+        invalid_state: t('login.oidcErrors.invalidState'),
+        state_expired: t('login.oidcErrors.stateExpired'),
+        provider_not_found: t('login.oidcErrors.providerNotFound'),
+        discovery_failed: t('login.oidcErrors.discoveryFailed'),
+        invalid_discovery_document: t('login.oidcErrors.invalidDiscovery'),
+        token_exchange_network_error: t('login.oidcErrors.networkError'),
+        token_exchange_bad_response: t('login.oidcErrors.badResponse'),
+        no_id_token: t('login.oidcErrors.noIdToken'),
+        token_validation_failed: t('login.oidcErrors.validationFailed'),
+        nonce_mismatch: t('login.oidcErrors.nonceMismatch'),
+        missing_sub_claim: t('login.oidcErrors.missingSubClaim'),
+        no_linked_account: t('login.oidcErrors.noLinkedAccount'),
+        account_inactive: t('login.oidcErrors.accountInactive'),
+        user_resolution_failed: t('login.oidcErrors.userResolutionFailed'),
+        internal_error: t('login.oidcErrors.internalError'),
+      };
+      // Dynamic codes like "token_exchange_<provider_code>" → generic message
+      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;
+    }
+
+    if (oidcToken) {
+      api.exchangeOIDCToken(oidcToken).then((resp: LoginResponse) => {
+        if (resp.requires_2fa && resp.pre_auth_token) {
+          // OIDC user has 2FA enabled — redirect to 2FA step
+          setPreAuthToken(resp.pre_auth_token);
+          const methods = resp.two_fa_methods ?? [];
+          setTwoFAMethods(methods);
+          if (methods.includes('totp')) setTwoFAMethod('totp');
+          else if (methods.includes('email')) setTwoFAMethod('email');
+          else setTwoFAMethod('backup');
+          setStep('2fa');
+          // 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);
+          showToast(t('login.loginSuccess'));
+          navigate('/', { replace: true });
+        }
+      }).catch((err: Error) => {
+        showToast(err.message || t('login.oidcLoginFailed'), 'error');
+        navigate('/login', { replace: true });
+      });
+    }
+  }, [searchParams]); // eslint-disable-line react-hooks/exhaustive-deps
+
+  // --- Step 1: Credentials login ---
   const loginMutation = useMutation({
   const loginMutation = useMutation({
     mutationFn: () => login(username, password),
     mutationFn: () => login(username, password),
-    onSuccess: () => {
-      showToast(t('login.loginSuccess'));
-      navigate('/');
+    onSuccess: (resp: LoginResponse) => {
+      if (resp.requires_2fa && resp.pre_auth_token) {
+        // 2FA required — switch to verification step
+        setPreAuthToken(resp.pre_auth_token);
+        const methods = resp.two_fa_methods ?? [];
+        setTwoFAMethods(methods);
+        // Pick a sensible default method
+        if (methods.includes('totp')) setTwoFAMethod('totp');
+        else if (methods.includes('email')) setTwoFAMethod('email');
+        else setTwoFAMethod('backup');
+        setStep('2fa');
+      } else if (resp.access_token && resp.user) {
+        showToast(t('login.loginSuccess'));
+        navigate('/');
+      }
     },
     },
     onError: (error: Error) => {
     onError: (error: Error) => {
       showToast(error.message || t('login.loginFailed'), 'error');
       showToast(error.message || t('login.loginFailed'), 'error');
@@ -50,6 +166,62 @@ export function LoginPage() {
     },
     },
   });
   });
 
 
+  // H-6: Mutation to set a new password using the reset token from the email link
+  const resetPasswordMutation = useMutation({
+    mutationFn: () => api.forgotPasswordConfirm(resetToken, newPassword),
+    onSuccess: (data) => {
+      showToast(data.message, 'success');
+      setStep('credentials');
+      setResetToken('');
+      setNewPassword('');
+      setConfirmPassword('');
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('login.resetPassword.resetFailed'), 'error');
+    },
+  });
+
+  // --- Step 2: 2FA verification ---
+  const sendEmailOTPMutation = useMutation({
+    mutationFn: () => api.sendEmailOTP(preAuthToken),
+    onSuccess: (data: { message: string; pre_auth_token?: string }) => {
+      setEmailOTPSent(true);
+      // Backend issues a fresh pre-auth token after consuming the original one
+      if (data.pre_auth_token) setPreAuthToken(data.pre_auth_token);
+      showToast(data.message, 'success');
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('login.twoFA.sendCodeFailed'), 'error');
+    },
+  });
+
+  const verify2FAMutation = useMutation({
+    mutationFn: () =>
+      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);
+        showToast(t('login.loginSuccess'));
+        navigate('/');
+      }
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('login.twoFA.invalidCode'), 'error');
+      setTwoFACode('');
+    },
+  });
+
+  // OIDC login
+  const oidcLoginMutation = useMutation({
+    mutationFn: (providerId: number) => api.getOIDCAuthorizeUrl(providerId),
+    onSuccess: (data) => {
+      window.location.href = data.auth_url;
+    },
+    onError: (error: Error) => {
+      showToast(error.message || t('login.oidcLoginFailed'), 'error');
+    },
+  });
+
   const handleSubmit = (e: React.FormEvent) => {
   const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault();
     e.preventDefault();
     if (!username || !password) {
     if (!username || !password) {
@@ -59,15 +231,285 @@ export function LoginPage() {
     loginMutation.mutate();
     loginMutation.mutate();
   };
   };
 
 
+  const handle2FASubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!twoFACode.trim()) {
+      showToast(t('login.twoFA.enterCode'), 'error');
+      return;
+    }
+    verify2FAMutation.mutate();
+  };
+
   const handleForgotPassword = (e: React.FormEvent) => {
   const handleForgotPassword = (e: React.FormEvent) => {
     e.preventDefault();
     e.preventDefault();
     if (!forgotEmail) {
     if (!forgotEmail) {
-      showToast('Please enter your email address', 'error');
+      showToast(t('login.enterEmail'), 'error');
       return;
       return;
     }
     }
     forgotPasswordMutation.mutate(forgotEmail);
     forgotPasswordMutation.mutate(forgotEmail);
   };
   };
 
 
+  const handleMethodChange = (method: 'totp' | 'email' | 'backup') => {
+    setTwoFAMethod(method);
+    setTwoFACode('');
+    setEmailOTPSent(false);
+    // Re-focus the code input after method switch (autoFocus only fires on mount)
+    setTimeout(() => twoFAInputRef.current?.focus(), 0);
+  };
+
+  // ---- Render: password-reset step (H-6) ----
+  if (step === 'reset-password') {
+    const handleResetSubmit = (e: React.FormEvent) => {
+      e.preventDefault();
+      if (newPassword !== confirmPassword) {
+        showToast(t('login.resetPassword.passwordsDoNotMatch'), 'error');
+        return;
+      }
+      if (newPassword.length < 8) {
+        showToast(t('login.resetPassword.passwordTooShort'), 'error');
+        return;
+      }
+      resetPasswordMutation.mutate();
+    };
+
+    return (
+      <div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
+        <div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
+          <div className="text-center">
+            <div className="flex items-center justify-center mb-4">
+              <div className="w-14 h-14 rounded-full bg-bambu-green/20 flex items-center justify-center">
+                <Key className="w-7 h-7 text-bambu-green" />
+              </div>
+            </div>
+            <h2 className="text-2xl font-bold text-white">{t('login.resetPassword.title')}</h2>
+            <p className="mt-2 text-sm text-bambu-gray">{t('login.resetPassword.subtitle')}</p>
+          </div>
+
+          <form onSubmit={handleResetSubmit} className="space-y-4">
+            <div>
+              <label htmlFor="new-password" className="block text-sm font-medium text-white mb-2">
+                {t('login.resetPassword.newPassword')}
+              </label>
+              <input
+                id="new-password"
+                type="password"
+                required
+                value={newPassword}
+                onChange={(e) => setNewPassword(e.target.value)}
+                className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+                placeholder={t('login.resetPassword.newPasswordPlaceholder')}
+                autoFocus
+                autoComplete="new-password"
+                minLength={8}
+              />
+            </div>
+
+            <div>
+              <label htmlFor="confirm-password" className="block text-sm font-medium text-white mb-2">
+                {t('login.resetPassword.confirmPassword')}
+              </label>
+              <input
+                id="confirm-password"
+                type="password"
+                required
+                value={confirmPassword}
+                onChange={(e) => setConfirmPassword(e.target.value)}
+                className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+                placeholder={t('login.resetPassword.confirmPasswordPlaceholder')}
+                autoComplete="new-password"
+              />
+            </div>
+
+            <button
+              type="submit"
+              disabled={resetPasswordMutation.isPending || !newPassword || !confirmPassword}
+              className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed"
+            >
+              {resetPasswordMutation.isPending ? t('login.resetPassword.saving') : t('login.resetPassword.submit')}
+            </button>
+          </form>
+
+          <div className="text-center">
+            <button
+              type="button"
+              onClick={() => {
+                setStep('credentials');
+                setResetToken('');
+                setNewPassword('');
+                setConfirmPassword('');
+              }}
+              className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
+            >
+              {t('login.resetPassword.backToLogin')}
+            </button>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  // ---- Render: 2FA step ----
+  if (step === '2fa') {
+    return (
+      <div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
+        <div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
+          <div className="text-center">
+            <div className="flex items-center justify-center mb-4">
+              <div className="w-14 h-14 rounded-full bg-bambu-green/20 flex items-center justify-center">
+                <Shield className="w-7 h-7 text-bambu-green" />
+              </div>
+            </div>
+            <h2 className="text-2xl font-bold text-white">{t('login.twoFA.title')}</h2>
+            <p className="mt-2 text-sm text-bambu-gray">{t('login.twoFA.subtitle')}</p>
+          </div>
+
+          {/* Method selector — only show if multiple methods available */}
+          {twoFAMethods.length > 1 && (
+            <div className="flex gap-2">
+              {twoFAMethods.includes('totp') && (
+                <button
+                  type="button"
+                  onClick={() => handleMethodChange('totp')}
+                  className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${
+                    twoFAMethod === 'totp'
+                      ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
+                      : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'
+                  }`}
+                >
+                  <Smartphone className="w-4 h-4" />
+                  {t('login.twoFA.methodAuthenticator')}
+                </button>
+              )}
+              {twoFAMethods.includes('email') && (
+                <button
+                  type="button"
+                  onClick={() => handleMethodChange('email')}
+                  className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${
+                    twoFAMethod === 'email'
+                      ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
+                      : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'
+                  }`}
+                >
+                  <Mail className="w-4 h-4" />
+                  {t('login.twoFA.methodEmail')}
+                </button>
+              )}
+              {twoFAMethods.includes('backup') && (
+                <button
+                  type="button"
+                  onClick={() => handleMethodChange('backup')}
+                  className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${
+                    twoFAMethod === 'backup'
+                      ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
+                      : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'
+                  }`}
+                >
+                  <Key className="w-4 h-4" />
+                  {t('login.twoFA.methodBackup')}
+                </button>
+              )}
+            </div>
+          )}
+
+          <form onSubmit={handle2FASubmit} className="space-y-4">
+            {/* Method-specific instructions */}
+            {twoFAMethod === 'totp' && (
+              <p className="text-sm text-bambu-gray">{t('login.twoFA.instructionsTotp')}</p>
+            )}
+            {twoFAMethod === 'email' && (
+              <div className="space-y-3">
+                <p className="text-sm text-bambu-gray">
+                  {emailOTPSent
+                    ? t('login.twoFA.instructionsEmail')
+                    : t('login.twoFA.instructionsEmailNotSent')}
+                </p>
+                {!emailOTPSent && (
+                  <Button
+                    type="button"
+                    variant="secondary"
+                    className="w-full"
+                    onClick={() => sendEmailOTPMutation.mutate()}
+                    disabled={sendEmailOTPMutation.isPending}
+                  >
+                    {sendEmailOTPMutation.isPending
+                      ? t('login.twoFA.sendingCode')
+                      : t('login.twoFA.sendCodeButton')}
+                  </Button>
+                )}
+                {emailOTPSent && (
+                  <button
+                    type="button"
+                    onClick={() => { setEmailOTPSent(false); sendEmailOTPMutation.mutate(); }}
+                    className="text-xs text-bambu-gray hover:text-bambu-green transition-colors"
+                  >
+                    {t('login.twoFA.resendCode')}
+                  </button>
+                )}
+              </div>
+            )}
+            {twoFAMethod === 'backup' && (
+              <p className="text-sm text-bambu-gray">{t('login.twoFA.instructionsBackup')}</p>
+            )}
+
+            <div>
+              <label htmlFor="twofa-code" className="block text-sm font-medium text-white mb-2">
+                {twoFAMethod === 'backup'
+                  ? t('login.twoFA.backupCodeLabel')
+                  : t('login.twoFA.codeLabel')}
+              </label>
+              <input
+                ref={twoFAInputRef}
+                id="twofa-code"
+                type="text"
+                inputMode={twoFAMethod === 'backup' ? 'text' : 'numeric'}
+                autoComplete="one-time-code"
+                value={twoFACode}
+                onChange={(e) => setTwoFACode(e.target.value.trim())}
+                disabled={twoFAMethod === 'email' && !emailOTPSent}
+                className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray text-center tracking-widest text-xl font-mono focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors disabled:opacity-40"
+                placeholder={twoFAMethod === 'backup'
+                  ? t('login.twoFA.backupCodePlaceholder')
+                  : t('login.twoFA.codePlaceholder')}
+                maxLength={twoFAMethod === 'backup' ? 8 : 6}
+                autoFocus
+              />
+            </div>
+
+            <button
+              type="submit"
+              disabled={
+                verify2FAMutation.isPending ||
+                !twoFACode.trim() ||
+                (twoFAMethod === 'email' && !emailOTPSent)
+              }
+              className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed"
+            >
+              {verify2FAMutation.isPending
+                ? t('login.twoFA.verifyingButton')
+                : t('login.twoFA.verifyButton')}
+            </button>
+          </form>
+
+          <div className="text-center">
+            <button
+              type="button"
+              onClick={() => {
+                setStep('credentials');
+                setPreAuthToken('');
+                setTwoFACode('');
+                setEmailOTPSent(false);
+              }}
+              className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
+            >
+              {t('login.twoFA.backToLogin')}
+            </button>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  // ---- Render: credentials step ----
   return (
   return (
     <div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
     <div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
       <div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
       <div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
@@ -92,7 +534,7 @@ export function LoginPage() {
             <div>
             <div>
               <label htmlFor="username" className="block text-sm font-medium text-white mb-2">
               <label htmlFor="username" className="block text-sm font-medium text-white mb-2">
                 {advancedAuthStatus?.advanced_auth_enabled
                 {advancedAuthStatus?.advanced_auth_enabled
-                  ? t('login.usernameOrEmail') || 'Username or Email'
+                  ? t('login.usernameOrEmail')
                   : t('login.username')}
                   : t('login.username')}
               </label>
               </label>
               <input
               <input
@@ -103,7 +545,7 @@ export function LoginPage() {
                 onChange={(e) => setUsername(e.target.value)}
                 onChange={(e) => setUsername(e.target.value)}
                 className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
                 className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
                 placeholder={advancedAuthStatus?.advanced_auth_enabled
                 placeholder={advancedAuthStatus?.advanced_auth_enabled
-                  ? t('login.usernameOrEmailPlaceholder') || 'Enter your username or email'
+                  ? t('login.usernameOrEmailPlaceholder')
                   : t('login.usernamePlaceholder')}
                   : t('login.usernamePlaceholder')}
                 autoComplete="username"
                 autoComplete="username"
               />
               />
@@ -111,7 +553,7 @@ export function LoginPage() {
 
 
             <div>
             <div>
               <label htmlFor="password" className="block text-sm font-medium text-white mb-2">
               <label htmlFor="password" className="block text-sm font-medium text-white mb-2">
-                {t('login.password')}
+                {t('login.password') || 'Password'}
               </label>
               </label>
               <input
               <input
                 id="password"
                 id="password"
@@ -136,18 +578,49 @@ export function LoginPage() {
             </button>
             </button>
           </div>
           </div>
 
 
-          {advancedAuthStatus?.advanced_auth_enabled && (
-            <div className="text-center">
-              <button
-                type="button"
-                onClick={() => setShowForgotPassword(true)}
-                className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
-              >
-                {t('login.forgotPassword')}
-              </button>
-            </div>
-          )}
+          <div className="text-center">
+            <button
+              type="button"
+              onClick={() => setShowForgotPassword(true)}
+              className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
+            >
+              {t('login.forgotPassword')}
+            </button>
+          </div>
         </form>
         </form>
+
+        {/* OIDC provider buttons */}
+        {oidcProviders && oidcProviders.length > 0 && (
+          <div className="space-y-3">
+            <div className="relative">
+              <div className="absolute inset-0 flex items-center">
+                <div className="w-full border-t border-bambu-dark-tertiary" />
+              </div>
+              <div className="relative flex justify-center text-sm">
+                <span className="px-2 bg-bambu-dark-secondary text-bambu-gray">{t('login.twoFA.orContinueWith')}</span>
+              </div>
+            </div>
+
+            <div className="space-y-2">
+              {oidcProviders.map((provider) => (
+                <button
+                  key={provider.id}
+                  type="button"
+                  onClick={() => oidcLoginMutation.mutate(provider.id)}
+                  disabled={oidcLoginMutation.isPending}
+                  className="w-full flex items-center justify-center gap-3 py-3 px-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary hover:border-bambu-green/50 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
+                >
+                  {provider.icon_url ? (
+                    <img src={provider.icon_url} alt="" className="w-5 h-5 object-contain" />
+                  ) : (
+                    <Shield className="w-5 h-5 text-bambu-green" />
+                  )}
+                  {t('login.twoFA.signInWith', { provider: provider.name })}
+                </button>
+              ))}
+            </div>
+          </div>
+        )}
       </div>
       </div>
 
 
       {/* Forgot Password Modal */}
       {/* Forgot Password Modal */}
@@ -182,12 +655,12 @@ export function LoginPage() {
               {advancedAuthStatus?.advanced_auth_enabled ? (
               {advancedAuthStatus?.advanced_auth_enabled ? (
                 <form onSubmit={handleForgotPassword} className="space-y-4">
                 <form onSubmit={handleForgotPassword} className="space-y-4">
                   <p className="text-bambu-gray text-sm">
                   <p className="text-bambu-gray text-sm">
-                    {t('login.forgotPasswordEmailMessage') || 'Enter your email address and we\'ll send you a new password.'}
+                    {t('login.forgotPasswordEmailMessage')}
                   </p>
                   </p>
 
 
                   <div>
                   <div>
                     <label htmlFor="forgot-email" className="block text-sm font-medium text-white mb-2">
                     <label htmlFor="forgot-email" className="block text-sm font-medium text-white mb-2">
-                      {t('login.emailAddress') || 'Email Address'}
+                      {t('login.emailAddress')}
                     </label>
                     </label>
                     <input
                     <input
                       id="forgot-email"
                       id="forgot-email"
@@ -196,7 +669,7 @@ export function LoginPage() {
                       value={forgotEmail}
                       value={forgotEmail}
                       onChange={(e) => setForgotEmail(e.target.value)}
                       onChange={(e) => setForgotEmail(e.target.value)}
                       className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
                       className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                      placeholder={t('login.emailPlaceholder') || 'your.email@example.com'}
+                      placeholder={t('login.emailPlaceholder')}
                     />
                     />
                   </div>
                   </div>
 
 
@@ -210,7 +683,7 @@ export function LoginPage() {
                         setForgotEmail('');
                         setForgotEmail('');
                       }}
                       }}
                     >
                     >
-                      {t('login.cancel') || 'Cancel'}
+                      {t('login.cancel')}
                     </Button>
                     </Button>
                     <Button
                     <Button
                       type="submit"
                       type="submit"
@@ -218,8 +691,8 @@ export function LoginPage() {
                       disabled={forgotPasswordMutation.isPending}
                       disabled={forgotPasswordMutation.isPending}
                     >
                     >
                       {forgotPasswordMutation.isPending
                       {forgotPasswordMutation.isPending
-                        ? (t('login.sending') || 'Sending...')
-                        : (t('login.sendResetEmail') || 'Send Reset Email')}
+                        ? t('login.sending')
+                        : t('login.sendResetEmail')}
                     </Button>
                     </Button>
                   </div>
                   </div>
                 </form>
                 </form>

+ 40 - 2
frontend/src/pages/SettingsPage.tsx

@@ -28,6 +28,8 @@ import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
 import { FailureDetectionSettings } from '../components/FailureDetectionSettings';
 import { FailureDetectionSettings } from '../components/FailureDetectionSettings';
 import { EmailSettings } from '../components/EmailSettings';
 import { EmailSettings } from '../components/EmailSettings';
 import { LDAPSettings } from '../components/LDAPSettings';
 import { LDAPSettings } from '../components/LDAPSettings';
+import { TwoFactorSettings } from '../components/TwoFactorSettings';
+import { OIDCProviderSettings } from '../components/OIDCProviderSettings';
 import { APIBrowser } from '../components/APIBrowser';
 import { APIBrowser } from '../components/APIBrowser';
 import { Toggle } from '../components/Toggle';
 import { Toggle } from '../components/Toggle';
 import { virtualPrinterApi, spoolbuddyApi } from '../api/client';
 import { virtualPrinterApi, spoolbuddyApi } from '../api/client';
@@ -40,7 +42,7 @@ import { Palette } from 'lucide-react';
 
 
 const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
 const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
 type TabType = typeof validTabs[number];
 type TabType = typeof validTabs[number];
-type UsersSubTab = 'users' | 'email' | 'ldap';
+type UsersSubTab = 'users' | 'email' | 'ldap' | 'twofa' | 'oidc';
 
 
 const STORAGE_CATEGORY_COLORS: Record<string, string> = {
 const STORAGE_CATEGORY_COLORS: Record<string, string> = {
   database: 'bg-blue-600',
   database: 'bg-blue-600',
@@ -80,7 +82,7 @@ export function SettingsPage() {
   const [searchParams, setSearchParams] = useSearchParams();
   const [searchParams, setSearchParams] = useSearchParams();
   const { t, i18n } = useTranslation();
   const { t, i18n } = useTranslation();
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const { authEnabled, user, refreshAuth, hasPermission } = useAuth();
+  const { authEnabled, user, isAdmin, refreshAuth, hasPermission } = useAuth();
   const {
   const {
     mode,
     mode,
     darkStyle, darkBackground, darkAccent,
     darkStyle, darkBackground, darkAccent,
@@ -4403,6 +4405,30 @@ export function SettingsPage() {
                 <span className="w-2 h-2 rounded-full bg-green-400" />
                 <span className="w-2 h-2 rounded-full bg-green-400" />
               )}
               )}
             </button>
             </button>
+            <button
+              onClick={() => setUsersSubTab('twofa')}
+              className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
+                usersSubTab === 'twofa'
+                  ? 'text-bambu-green border-bambu-green'
+                  : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+              }`}
+            >
+              <Shield className="w-4 h-4" />
+              {t('settings.tabs.twoFa')}
+            </button>
+            {isAdmin && (
+              <button
+                onClick={() => setUsersSubTab('oidc')}
+                className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
+                  usersSubTab === 'oidc'
+                    ? 'text-bambu-green border-bambu-green'
+                    : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+                }`}
+              >
+                <Globe className="w-4 h-4" />
+                {t('settings.tabs.oidc')}
+              </button>
+            )}
           </div>
           </div>
 
 
           {/* Users Sub-tab */}
           {/* Users Sub-tab */}
@@ -4729,6 +4755,18 @@ export function SettingsPage() {
               <LDAPSettings />
               <LDAPSettings />
             </div>
             </div>
           )}
           )}
+
+          {usersSubTab === 'twofa' && (
+            <div className="max-w-2xl">
+              <TwoFactorSettings />
+            </div>
+          )}
+
+          {usersSubTab === 'oidc' && isAdmin && (
+            <div className="max-w-3xl">
+              <OIDCProviderSettings />
+            </div>
+          )}
         </div>
         </div>
       )}
       )}
 
 

+ 6 - 0
pyproject.toml

@@ -79,3 +79,9 @@ filterwarnings = [
 markers = [
 markers = [
     "docker: marks tests that run in Docker integration environment",
     "docker: marks tests that run in Docker integration environment",
 ]
 ]
+
+[dependency-groups]
+dev = [
+    "cryptography>=46.0.7",
+    "pyjwt>=2.12.1",
+]

+ 4 - 0
requirements.txt

@@ -53,6 +53,10 @@ psutil>=6.0.0
 PyJWT>=2.12.0
 PyJWT>=2.12.0
 passlib[bcrypt]>=1.7.4
 passlib[bcrypt]>=1.7.4
 ldap3>=2.9.0
 ldap3>=2.9.0
+pyotp>=2.9.0
+
+# HTTP client (used for OIDC token exchange)
+httpx>=0.26.0
 
 
 # Plate Detection (optional - enables build plate empty detection)
 # Plate Detection (optional - enables build plate empty detection)
 opencv-python-headless>=4.8.0
 opencv-python-headless>=4.8.0