Parcourir la 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 il y a 1 mois
Parent
commit
ba1c97c808
44 fichiers modifiés avec 10473 ajouts et 290 suppressions
  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:
         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}
 
 
@@ -1533,7 +1533,7 @@ async def download_archive_for_slicer(
     """
     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")
 
     service = ArchiveService(db)
@@ -3512,7 +3512,7 @@ async def create_source_slicer_token(
     if not archive.source_3mf_path:
         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}
 
 
@@ -3530,7 +3530,7 @@ async def download_source_3mf_for_slicer_with_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")
 
     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 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 sqlalchemy import select
+from jwt.exceptions import PyJWTError
+from sqlalchemy import delete, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -14,6 +19,7 @@ from backend.app.core.auth import (
     SECRET_KEY,
     Permission,
     RequirePermissionIfAuthEnabled,
+    _is_token_fresh,
     _validate_api_key,
     authenticate_user,
     authenticate_user_by_email,
@@ -22,14 +28,18 @@ from backend.app.core.auth import (
     get_password_hash,
     get_user_by_email,
     get_user_by_username,
+    is_jti_revoked,
+    revoke_jti,
     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.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
 from backend.app.models.group import Group
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.schemas.auth import (
+    ForgotPasswordConfirmRequest,
     ForgotPasswordRequest,
     ForgotPasswordResponse,
     GroupBrief,
@@ -45,13 +55,14 @@ from backend.app.schemas.auth import (
     UserResponse,
 )
 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,
     save_smtp_settings,
     send_email,
 )
 
+_logger = logging.getLogger(__name__)
+
 
 def _user_to_response(user: User) -> UserResponse:
     """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"])
 
 
@@ -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)
                     raise HTTPException(
                         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
@@ -227,7 +282,7 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
         await db.rollback()
         raise HTTPException(
             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)
         raise HTTPException(
             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)
-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.
 
     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
     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",
         )
 
+    # 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
     ldap_user = None
     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)
 
     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(
             status_code=status.HTTP_401_UNAUTHORIZED,
             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)))
     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 = 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",
                     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:
             raise HTTPException(
                 status_code=status.HTTP_401_UNAUTHORIZED,
@@ -420,6 +558,13 @@ async def get_current_user_info(
         # Reload with groups for proper permission calculation
         result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
         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)
 
     # No credentials provided
@@ -431,8 +576,44 @@ async def get_current_user_info(
 
 
 @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"}
 
 
@@ -467,8 +648,8 @@ async def test_smtp_connection(
         logger.info(f"Test email sent successfully to {test_request.test_recipient}")
         return TestSMTPResponse(success=True, message="Test email sent successfully")
     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)
@@ -502,10 +683,10 @@ async def save_smtp_config(
         return {"message": "SMTP settings saved successfully"}
     except Exception as e:
         await db.rollback()
-        logger.error(f"Failed to save SMTP settings: {e}")
+        logger.error("Failed to save SMTP settings: %s", e)
         raise HTTPException(
             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}
     except Exception as e:
         await db.rollback()
-        logger.error(f"Failed to enable advanced authentication: {e}")
+        logger.error("Failed to enable advanced authentication: %s", e)
         raise HTTPException(
             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}
     except Exception as e:
         await db.rollback()
-        logger.error(f"Failed to disable advanced authentication: {e}")
+        logger.error("Failed to disable advanced authentication: %s", e)
         raise HTTPException(
             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
     advanced_auth = await is_advanced_auth_enabled(db)
     if not advanced_auth:
@@ -614,6 +850,47 @@ async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Dep
             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
     smtp_settings = await get_smtp_settings(db)
     if not smtp_settings:
@@ -622,47 +899,116 @@ async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Dep
             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)
 
-    # 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:
-            # 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()
 
             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:
-            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(
         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)
 async def reset_user_password(
     request: ResetPasswordRequest,
+    background_tasks: BackgroundTasks,
     current_user: User = Depends(get_current_active_user),
     db: AsyncSession = Depends(get_db),
 ):
     """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
     result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
     admin_user = result.scalar_one()
@@ -698,10 +1044,11 @@ async def reset_user_password(
             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(
             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:
@@ -711,27 +1058,51 @@ async def reset_user_password(
         )
 
     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()
 
         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:
         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(
             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
     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")

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

@@ -2499,7 +2499,7 @@ async def create_library_slicer_token(
     if not file:
         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}
 
 
@@ -2518,7 +2518,7 @@ async def download_library_file_for_slicer(
     """
     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")
 
     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.security import HTTPAuthorizationCredentials
 from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes.settings import get_external_login_url
 from backend.app.core.auth import (
+    ALGORITHM,
+    SECRET_KEY,
     RequirePermissionIfAuthEnabled,
     get_current_user_optional,
     get_password_hash,
+    revoke_jti,
+    security,
     verify_password,
 )
 from backend.app.core.database import get_db
@@ -398,6 +407,7 @@ async def delete_user(
 @router.post("/me/change-password", response_model=dict)
 async def change_own_password(
     password_data: ChangePasswordRequest,
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
     current_user: User | None = Depends(get_current_user_optional),
     db: AsyncSession = Depends(get_db),
 ):
@@ -421,19 +431,19 @@ async def change_own_password(
             status_code=status.HTTP_400_BAD_REQUEST,
             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):
+        await record_failed_attempt(db, current_user.username, event_type="password_change")
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
             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
     result = await db.execute(select(User).where(User.id == current_user.id))
     user = result.scalar_one_or_none()
@@ -445,6 +455,32 @@ async def change_own_password(
 
     # Update 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()
 
+    # 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"}

+ 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 jwt.exceptions import PyJWTError as JWTError
 from passlib.context import CryptContext
-from sqlalchemy import func, select
+from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
 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.user import User
 
@@ -93,79 +94,118 @@ def _get_jwt_secret() -> str:
 # JWT settings
 SECRET_KEY = _get_jwt_secret()
 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
 security = HTTPBearer(auto_error=False)
 
 # --- 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
 
 
-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)
-    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)
     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
 
 
-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}"
-    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 ---
-# 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
 
 
-def create_camera_stream_token() -> str:
+async def create_camera_stream_token() -> str:
     """Create a reusable token for camera stream/snapshot access."""
     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)
-    _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
 
 
-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:
@@ -185,17 +225,73 @@ def get_password_hash(password: str) -> 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()
+    now = datetime.now(timezone.utc)
     if expires_delta:
-        expire = datetime.now(timezone.utc) + expires_delta
+        expire = now + expires_delta
     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)
     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:
     """Get a user by username (case-insensitive) with groups loaded for permission checks."""
     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.
 
     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)
     if not user:
         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):
         return None
     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.
 
     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)
     if not user:
         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):
         return None
     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:
     """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:
-        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()
 
         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(
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = 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:
         return None
 
+    _unauthorized = HTTPException(
+        status_code=status.HTTP_401_UNAUTHORIZED,
+        detail="Could not validate credentials",
+        headers={"WWW-Authenticate": "Bearer"},
+    )
+
     try:
         token = credentials.credentials
         payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
         username: str = payload.get("sub")
         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:
-        return None
+        raise _unauthorized
 
     async with async_session() as db:
         user = await get_user_by_username(db, username)
         if user is None or not user.is_active:
-            return None
+            raise _unauthorized
+        if not _is_token_fresh(iat, user):
+            raise _unauthorized
         return user
 
 
@@ -326,6 +454,10 @@ async def get_current_user(
         username: str = payload.get("sub")
         if username is None:
             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:
         raise credentials_exception
 
@@ -338,6 +470,8 @@ async def get_current_user(
                 status_code=status.HTTP_403_FORBIDDEN,
                 detail="User account is disabled",
             )
+        if not _is_token_fresh(iat, user):
+            raise credentials_exception
         return user
 
 
@@ -390,6 +524,14 @@ async def require_auth_if_enabled(
                         detail="Could not validate credentials",
                         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:
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED,
@@ -404,6 +546,12 @@ async def require_auth_if_enabled(
                     detail="Could not validate credentials",
                     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
 
         # 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>'",
         )
 
-    # 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()
 
     for api_key in api_keys:
@@ -627,12 +785,18 @@ def require_permission(*permissions: str | Permission):
                 username: str = payload.get("sub")
                 if username is None:
                     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:
                 raise credentials_exception
 
             user = await get_user_by_username(db, username)
             if user is None or not user.is_active:
                 raise credentials_exception
+            if not _is_token_fresh(iat, user):
+                raise credentials_exception
 
             if not user.has_all_permissions(*perm_strings):
                 raise HTTPException(
@@ -699,6 +863,14 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
                             detail="Could not validate credentials",
                             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:
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
@@ -713,6 +885,12 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
                         detail="Could not validate credentials",
                         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):
                     raise HTTPException(
@@ -753,7 +931,7 @@ def require_camera_stream_token_if_auth_enabled():
         async with async_session() as db:
             if not await is_auth_enabled(db):
                 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(
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 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",
                             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:
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
@@ -842,6 +1028,12 @@ def require_ownership_permission(
                         detail="Could not validate credentials",
                         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):
                     return user, True

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

@@ -156,6 +156,7 @@ async def init_db():
         ams_label,
         api_key,
         archive,
+        auth_ephemeral,
         bug_report,
         color_catalog,
         external_link,
@@ -168,6 +169,7 @@ async def init_db():
         maintenance,
         notification,
         notification_template,
+        oidc_provider,
         orca_base_cache,
         pending_upload,
         print_batch,
@@ -188,6 +190,8 @@ async def init_db():
         spoolbuddy_device,
         user,
         user_email_pref,
+        user_otp_code,
+        user_totp,
         virtual_printer,
     )
 
@@ -306,6 +310,19 @@ async def run_migrations(conn):
     except (OperationalError, ProgrammingError):
         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)
     # PostgreSQL uses tsvector + GIN index instead (set up in archives.py search route)
     if is_sqlite():
@@ -1439,6 +1456,33 @@ async def run_migrations(conn):
         "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
     default_settings = [
         ("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,
     maintenance,
     metrics,
+    mfa,
     notification_templates,
     notifications,
     obico,
@@ -3741,6 +3742,101 @@ def stop_expected_prints_cleanup() -> None:
         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
 async def lifespan(app: FastAPI):
     # Startup
@@ -3942,6 +4038,9 @@ async def lifespan(app: FastAPI):
     # registered but on_print_start never fires)
     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
     from backend.app.services.virtual_printer import virtual_printer_manager
 
@@ -3967,6 +4066,7 @@ async def lifespan(app: FastAPI):
     stop_spoolbuddy_watchdog()
     stop_camera_cleanup()
     stop_expected_prints_cleanup()
+    stop_auth_cleanup()
     printer_manager.disconnect_all()
     await close_spoolman_client()
 
@@ -4010,6 +4110,14 @@ PUBLIC_API_ROUTES = {
     # Advanced auth status needed for login page
     "/api/v1/auth/advanced-auth/status",
     "/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)
     "/api/v1/updates/version",
     # Metrics endpoint handles its own prometheus_token authentication
@@ -4020,6 +4128,8 @@ PUBLIC_API_ROUTES = {
 PUBLIC_API_PREFIXES = [
     # WebSocket connections handle their own auth
     "/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)
@@ -4053,6 +4163,27 @@ async def security_headers_middleware(request, call_next):
     response.headers["X-Content-Type-Options"] = "nosniff"
     response.headers["X-Frame-Options"] = "SAMEORIGIN"
     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
 
 
@@ -4121,18 +4252,34 @@ async def auth_middleware(request, call_next):
     import jwt
 
     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 ", "")
         payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
         username = payload.get("sub")
         if not username:
             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:
-            from backend.app.core.auth import get_user_by_username
-
             user = await get_user_by_username(db, username)
             if not user or not user.is_active:
                 return JSONResponse(
@@ -4140,6 +4287,12 @@ async def auth_middleware(request, call_next):
                     content={"detail": "User not found or inactive"},
                     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:
         return JSONResponse(
             status_code=401,
@@ -4158,6 +4311,7 @@ async def auth_middleware(request, call_next):
 
 # API routes
 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(users.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.api_key import APIKey
 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.filament import Filament
 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.notification import NotificationLog
 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.pending_upload import PendingUpload
 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.user import User
 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__ = [
     "Printer",
@@ -56,6 +60,8 @@ __all__ = [
     "GitHubBackupConfig",
     "GitHubBackupLog",
     "LocalPreset",
+    "OIDCProvider",
+    "UserOIDCLink",
     "OrcaBaseProfile",
     "Spool",
     "SpoolKProfile",
@@ -65,4 +71,8 @@ __all__ = [
     "ColorCatalogEntry",
     "SpoolBuddyDevice",
     "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(
         String(20), default="user"
     )  # "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)
     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())
 
+    # 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)
     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)

+ 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):
@@ -12,32 +32,50 @@ class GroupBrief(BaseModel):
 
 
 class LoginRequest(BaseModel):
-    username: str
-    password: str
+    username: str = Field(..., max_length=150)
+    password: str = Field(..., max_length=256)
 
 
 class LoginResponse(BaseModel):
-    access_token: str
+    access_token: str | None = None
     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):
-    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"
     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):
-    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
     is_active: bool | 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):
     id: int
@@ -56,14 +94,26 @@ class UserResponse(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):
     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):
@@ -72,7 +122,17 @@ class SetupResponse(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):
@@ -107,3 +167,271 @@ class TestSMTPRequest(BaseModel):
 class TestSMTPResponse(BaseModel):
     success: bool
     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:
         A secure random password containing uppercase, lowercase, digits, and special characters
     """
-    import random
-
     # Define character sets
     lowercase = string.ascii_lowercase
     uppercase = string.ascii_uppercase
@@ -52,8 +50,8 @@ def generate_secure_password(length: int = 16) -> str:
     all_chars = lowercase + uppercase + digits + special
     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)
 
@@ -381,6 +379,71 @@ BamBuddy Team
     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(
     db: AsyncSession, username: str, password: str, login_url: str, app_name: str = "BamBuddy"
 ) -> tuple[str, str, str]:

+ 4 - 0
backend/tests/conftest.py

@@ -70,6 +70,7 @@ async def test_engine():
         ams_label,
         api_key,
         archive,
+        auth_ephemeral,
         color_catalog,
         external_link,
         filament,
@@ -78,6 +79,7 @@ async def test_engine():
         maintenance,
         notification,
         notification_template,
+        oidc_provider,
         print_queue,
         printer,
         project,
@@ -94,6 +96,8 @@ async def test_engine():
         spoolbuddy_device,
         user,
         user_email_pref,
+        user_otp_code,
+        user_totp,
         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."""
     await async_client.post(
         "/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_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."""
     headers = {"Authorization": f"Bearer {token}"}
@@ -68,7 +68,7 @@ class TestSMTPConfigAPI:
 
     @pytest.fixture
     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.integration
@@ -100,7 +100,7 @@ class TestSMTPConfigAPI:
     @pytest.mark.integration
     async def test_smtp_settings_requires_admin(self, async_client: AsyncClient, admin_token: str):
         """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}"}
 
         response = await async_client.post("/api/v1/auth/smtp", headers=headers, json=SMTP_DATA)
@@ -143,7 +143,7 @@ class TestAdvancedAuthToggleAPI:
 
     @pytest.fixture
     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.integration
@@ -193,7 +193,7 @@ class TestAdvancedAuthToggleAPI:
     @pytest.mark.integration
     async def test_enable_requires_admin(self, async_client: AsyncClient, admin_token: str):
         """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}"}
 
         response = await async_client.post("/api/v1/auth/advanced-auth/enable", headers=headers)
@@ -208,7 +208,7 @@ class TestEmailLoginAPI:
 
     @pytest.fixture
     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.integration
@@ -233,13 +233,13 @@ class TestEmailLoginAPI:
             await async_client.patch(
                 f"/api/v1/users/{user_id}",
                 headers=headers,
-                json={"password": "knownpassword123"},
+                json={"password": "Knownpassword1!"},
             )
 
         # Login with email
         response = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "emailuser@test.com", "password": "knownpassword123"},
+            json={"username": "emailuser@test.com", "password": "Knownpassword1!"},
         )
         assert response.status_code == 200
         assert "access_token" in response.json()
@@ -262,12 +262,12 @@ class TestEmailLoginAPI:
             await async_client.patch(
                 f"/api/v1/users/{user_id}",
                 headers=headers,
-                json={"password": "casepassword123"},
+                json={"password": "Casepassword1!"},
             )
 
         response = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "CASEUSER@TEST.COM", "password": "casepassword123"},
+            json={"username": "CASEUSER@TEST.COM", "password": "Casepassword1!"},
         )
         assert response.status_code == 200
         assert "access_token" in response.json()
@@ -282,13 +282,13 @@ class TestEmailLoginAPI:
         await async_client.post(
             "/api/v1/users/",
             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
         response = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "noemail@test.com", "password": "noEmailPass1"},
+            json={"username": "noemail@test.com", "password": "NoEmailPass1!"},
         )
         assert response.status_code == 401
 
@@ -310,13 +310,13 @@ class TestEmailLoginAPI:
             await async_client.patch(
                 f"/api/v1/users/{user_id}",
                 headers=headers,
-                json={"password": "usernamepass123"},
+                json={"password": "Usernamepass1!"},
             )
 
         # Login with username (not email)
         response = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "usernameuser", "password": "usernamepass123"},
+            json={"username": "usernameuser", "password": "Usernamepass1!"},
         )
         assert response.status_code == 200
         assert "access_token" in response.json()
@@ -327,7 +327,7 @@ class TestForgotPasswordAPI:
 
     @pytest.fixture
     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.integration
@@ -388,7 +388,13 @@ class TestForgotPasswordAPI:
     @pytest.mark.asyncio
     @pytest.mark.integration
     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}"}
 
         with patch("backend.app.api.routes.users.send_email"):
@@ -403,37 +409,66 @@ class TestForgotPasswordAPI:
             await async_client.patch(
                 f"/api/v1/users/{user_id}",
                 headers=headers,
-                json={"password": "originalpass123"},
+                json={"password": "Originalpass1!"},
             )
 
         # Verify login works with original password
         login_resp = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "resetme", "password": "originalpass123"},
+            json={"username": "resetme", "password": "Originalpass1!"},
         )
         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",
                 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
         login_resp = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "resetme", "password": "originalpass123"},
+            json={"username": "resetme", "password": "Originalpass1!"},
         )
         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:
     """Integration tests for admin password reset endpoint."""
 
     @pytest.fixture
     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.integration
@@ -467,7 +502,7 @@ class TestAdminResetPasswordAPI:
     async def test_reset_password_requires_admin(self, async_client: AsyncClient, admin_token: str):
         """Non-admin user gets 403 on reset-password."""
         # 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"):
             await _setup_smtp_and_advanced_auth(async_client, admin_token)
@@ -522,7 +557,7 @@ class TestAdminResetPasswordAPI:
         create_resp = await async_client.post(
             "/api/v1/users/",
             headers=headers,
-            json={"username": "noemailuser", "password": "noemail123456", "role": "user"},
+            json={"username": "noemailuser", "password": "Noemail12345!", "role": "user"},
         )
         user_id = create_resp.json()["id"]
 
@@ -543,7 +578,7 @@ class TestUserCreationAdvancedAuth:
 
     @pytest.fixture
     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.integration
@@ -628,3 +663,52 @@ class TestUserCreationAdvancedAuth:
         result = response.json()
         assert "email" in result
         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={
                 "auth_enabled": True,
                 "admin_username": "testadmin",
-                "admin_password": "testpassword123",
+                "admin_password": "TestPass1!",
             },
         )
 
@@ -96,14 +96,14 @@ class TestAuthLoginAPI:
             json={
                 "auth_enabled": True,
                 "admin_username": "logintest",
-                "admin_password": "loginpassword123",
+                "admin_password": "LoginPass1!",
             },
         )
 
         # Now login
         response = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "logintest", "password": "loginpassword123"},
+            json={"username": "logintest", "password": "LoginPass1!"},
         )
 
         assert response.status_code == 200
@@ -123,7 +123,7 @@ class TestAuthLoginAPI:
             json={
                 "auth_enabled": True,
                 "admin_username": "invalidtest",
-                "admin_password": "correctpassword",
+                "admin_password": "CorrectPass1!",
             },
         )
 
@@ -158,13 +158,13 @@ class TestAuthMeAPI:
             json={
                 "auth_enabled": True,
                 "admin_username": "metest",
-                "admin_password": "mepassword123",
+                "admin_password": "MePass1!",
             },
         )
 
         login_response = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "metest", "password": "mepassword123"},
+            json={"username": "metest", "password": "MePass1!"},
         )
         token = login_response.json()["access_token"]
 
@@ -254,13 +254,13 @@ class TestUsersAPI:
             json={
                 "auth_enabled": True,
                 "admin_username": "usersadmin",
-                "admin_password": "adminpassword123",
+                "admin_password": "AdminPass1!",
             },
         )
 
         login_response = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "usersadmin", "password": "adminpassword123"},
+            json={"username": "usersadmin", "password": "AdminPass1!"},
         )
         return login_response.json()["access_token"]
 
@@ -274,7 +274,7 @@ class TestUsersAPI:
             json={
                 "auth_enabled": True,
                 "admin_username": "authreqadmin",
-                "admin_password": "adminpassword123",
+                "admin_password": "AdminPass1!",
             },
         )
 
@@ -306,7 +306,7 @@ class TestUsersAPI:
             headers={"Authorization": f"Bearer {auth_token}"},
             json={
                 "username": "newuser",
-                "password": "newuserpassword",
+                "password": "Newuserpass1!",
                 "role": "user",
             },
         )
@@ -327,7 +327,7 @@ class TestUsersAPI:
             headers={"Authorization": f"Bearer {auth_token}"},
             json={
                 "username": "duplicateuser",
-                "password": "password123",
+                "password": "Password123!",
                 "role": "user",
             },
         )
@@ -338,7 +338,7 @@ class TestUsersAPI:
             headers={"Authorization": f"Bearer {auth_token}"},
             json={
                 "username": "duplicateuser",
-                "password": "password456",
+                "password": "Password456!",
                 "role": "user",
             },
         )
@@ -356,7 +356,7 @@ class TestUsersAPI:
             headers={"Authorization": f"Bearer {auth_token}"},
             json={
                 "username": "updateuser",
-                "password": "password123",
+                "password": "Password123!",
                 "role": "user",
             },
         )
@@ -382,7 +382,7 @@ class TestUsersAPI:
             headers={"Authorization": f"Bearer {auth_token}"},
             json={
                 "username": "deleteuser",
-                "password": "password123",
+                "password": "Password123!",
                 "role": "user",
             },
         )
@@ -410,14 +410,14 @@ class TestAuthDisableAPI:
             json={
                 "auth_enabled": True,
                 "admin_username": "disableadmin",
-                "admin_password": "adminpassword123",
+                "admin_password": "AdminPass1!",
             },
         )
 
         # Login to get token
         login_response = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "disableadmin", "password": "adminpassword123"},
+            json={"username": "disableadmin", "password": "AdminPass1!"},
         )
         token = login_response.json()["access_token"]
 
@@ -446,13 +446,13 @@ class TestGroupsAPI:
             json={
                 "auth_enabled": True,
                 "admin_username": "groupsadmin",
-                "admin_password": "adminpassword123",
+                "admin_password": "AdminPass1!",
             },
         )
 
         login_response = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "groupsadmin", "password": "adminpassword123"},
+            json={"username": "groupsadmin", "password": "AdminPass1!"},
         )
         return login_response.json()["access_token"]
 
@@ -592,13 +592,13 @@ class TestUserGroupsAPI:
             json={
                 "auth_enabled": True,
                 "admin_username": "usergroupadmin",
-                "admin_password": "adminpassword123",
+                "admin_password": "AdminPass1!",
             },
         )
 
         login_response = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "usergroupadmin", "password": "adminpassword123"},
+            json={"username": "usergroupadmin", "password": "AdminPass1!"},
         )
         return login_response.json()["access_token"]
 
@@ -619,7 +619,7 @@ class TestUserGroupsAPI:
             headers={"Authorization": f"Bearer {auth_token}"},
             json={
                 "username": "groupuser",
-                "password": "password123",
+                "password": "Password123!",
                 "group_ids": [operators_group["id"]],
             },
         )
@@ -636,7 +636,7 @@ class TestUserGroupsAPI:
         user_response = await async_client.post(
             "/api/v1/users/",
             headers={"Authorization": f"Bearer {auth_token}"},
-            json={"username": "addtogroup", "password": "password123"},
+            json={"username": "addtogroup", "password": "Password123!"},
         )
         user_id = user_response.json()["id"]
 
@@ -675,13 +675,13 @@ class TestChangePasswordAPI:
             json={
                 "auth_enabled": True,
                 "admin_username": "pwchangeadmin",
-                "admin_password": "adminpassword123",
+                "admin_password": "AdminPass1!",
             },
         )
 
         admin_login = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "pwchangeadmin", "password": "adminpassword123"},
+            json={"username": "pwchangeadmin", "password": "AdminPass1!"},
         )
         admin_token = admin_login.json()["access_token"]
 
@@ -689,13 +689,13 @@ class TestChangePasswordAPI:
         await async_client.post(
             "/api/v1/users/",
             headers={"Authorization": f"Bearer {admin_token}"},
-            json={"username": "pwchangeuser", "password": "oldpassword123"},
+            json={"username": "pwchangeuser", "password": "Oldpassword123!"},
         )
 
         # Login as regular user
         user_login = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "pwchangeuser", "password": "oldpassword123"},
+            json={"username": "pwchangeuser", "password": "Oldpassword123!"},
         )
         return user_login.json()["access_token"]
 
@@ -707,8 +707,8 @@ class TestChangePasswordAPI:
             "/api/v1/users/me/change-password",
             headers={"Authorization": f"Bearer {user_token}"},
             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
         login_response = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "pwchangeuser", "password": "newpassword456"},
+            json={"username": "pwchangeuser", "password": "Newpassword456!"},
         )
         assert login_response.status_code == 200
 
@@ -731,7 +731,7 @@ class TestChangePasswordAPI:
             headers={"Authorization": f"Bearer {user_token}"},
             json={
                 "current_password": "wrongpassword",
-                "new_password": "newpassword456",
+                "new_password": "Newpassword456!",
             },
         )
 
@@ -746,7 +746,7 @@ class TestChangePasswordAPI:
             "/api/v1/users/me/change-password",
             json={
                 "current_password": "oldpassword",
-                "new_password": "newpassword",
+                "new_password": "Strongpass456!",
             },
         )
 
@@ -768,7 +768,7 @@ class TestAuthMiddlewarePublicRoutes:
             json={
                 "auth_enabled": True,
                 "admin_username": "middlewareadmin",
-                "admin_password": "adminpassword123",
+                "admin_password": "AdminPass1!",
             },
         )
 
@@ -786,7 +786,7 @@ class TestAuthMiddlewarePublicRoutes:
         """Verify /api/v1/auth/login is accessible without auth."""
         response = await async_client.post(
             "/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
         # a different error (like 400 for wrong credentials)
@@ -826,7 +826,7 @@ class TestAuthMiddlewarePublicRoutes:
         # Login to get token
         login_response = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "middlewareadmin", "password": "adminpassword123"},
+            json={"username": "middlewareadmin", "password": "AdminPass1!"},
         )
         token = login_response.json()["access_token"]
 
@@ -863,3 +863,57 @@ class TestAuthMiddlewarePublicRoutes:
         # Will likely be 400 (advanced auth not enabled) but that's okay -
         # the important thing is it's not blocked by auth middleware
         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={
                 "auth_enabled": True,
                 "admin_username": "ownershipadmin",
-                "admin_password": "adminpassword123",
+                "admin_password": "AdminPass1!",
             },
         )
 
         # Login as admin
         admin_login = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "ownershipadmin", "password": "adminpassword123"},
+            json={"username": "ownershipadmin", "password": "AdminPass1!"},
         )
         admin_token = admin_login.json()["access_token"]
         admin_user = admin_login.json()["user"]
@@ -49,7 +49,7 @@ class TestOwnershipPermissionsSetup:
             headers={"Authorization": f"Bearer {admin_token}"},
             json={
                 "username": "operator1",
-                "password": "operatorpass123",
+                "password": "Operatorpass1!",
                 "group_ids": [operators_group["id"]],
             },
         )
@@ -58,7 +58,7 @@ class TestOwnershipPermissionsSetup:
         # Login as operator
         operator_login = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "operator1", "password": "operatorpass123"},
+            json={"username": "operator1", "password": "Operatorpass1!"},
         )
         operator_token = operator_login.json()["access_token"]
 
@@ -68,7 +68,7 @@ class TestOwnershipPermissionsSetup:
             headers={"Authorization": f"Bearer {admin_token}"},
             json={
                 "username": "operator2",
-                "password": "operatorpass123",
+                "password": "Operatorpass1!",
                 "group_ids": [operators_group["id"]],
             },
         )
@@ -76,7 +76,7 @@ class TestOwnershipPermissionsSetup:
 
         operator2_login = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "operator2", "password": "operatorpass123"},
+            json={"username": "operator2", "password": "Operatorpass1!"},
         )
         operator2_token = operator2_login.json()["access_token"]
 
@@ -86,14 +86,14 @@ class TestOwnershipPermissionsSetup:
             headers={"Authorization": f"Bearer {admin_token}"},
             json={
                 "username": "viewer1",
-                "password": "viewerpass123",
+                "password": "Viewerpass1!",
                 "group_ids": [viewers_group["id"]],
             },
         )
 
         viewer_login = await async_client.post(
             "/api/v1/auth/login",
-            json={"username": "viewer1", "password": "viewerpass123"},
+            json={"username": "viewer1", "password": "Viewerpass1!"},
         )
         viewer_token = viewer_login.json()["access_token"]
 
@@ -721,7 +721,7 @@ class TestUserItemsCountAndDeletion(TestOwnershipPermissionsSetup):
             headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
             json={
                 "username": "deletewithitems",
-                "password": "password123",
+                "password": "Password123!",
             },
         )
         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>
     <meta charset="UTF-8" />
     <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>
 
     <!-- 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 { 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>,
-  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) => {
-    localStorageMock.store[key] = value;
+    sessionStorageMock.store[key] = value;
   }),
   removeItem: vi.fn((key: string) => {
-    delete localStorageMock.store[key];
+    delete sessionStorageMock.store[key];
   }),
   clear: vi.fn(() => {
-    localStorageMock.store = {};
+    sessionStorageMock.store = {};
   }),
 };
 
-Object.defineProperty(window, 'localStorage', {
-  value: localStorageMock,
+Object.defineProperty(window, 'sessionStorage', {
+  value: sessionStorageMock,
 });
 
 // Create MSW server
@@ -32,22 +32,22 @@ const server = setupServer();
 beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
 afterEach(() => {
   server.resetHandlers();
-  localStorageMock.clear();
+  sessionStorageMock.clear();
   setAuthToken(null);
 });
 afterAll(() => server.close());
 
 describe('Auth Token Management', () => {
-  it('setAuthToken stores token in localStorage', () => {
+  it('setAuthToken stores token in sessionStorage', () => {
     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');
   });
 
-  it('setAuthToken removes token from localStorage when null', () => {
+  it('setAuthToken removes token from sessionStorage when null', () => {
     setAuthToken('test-token-123');
     setAuthToken(null);
-    expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
+    expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
     expect(getAuthToken()).toBeNull();
   });
 });
@@ -115,7 +115,7 @@ describe('API Client Auth Header', () => {
     }
 
     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 () => {

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

@@ -159,4 +159,145 @@ describe('LoginPage', () => {
       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';
 
 // 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;
   if (token) {
-    localStorage.setItem('auth_token', token);
+    sessionStorage.setItem('auth_token', token);
+    if (persist) {
+      localStorage.setItem('auth_token', token);
+    }
   } else {
+    sessionStorage.removeItem('auth_token');
     localStorage.removeItem('auth_token');
   }
 }
@@ -66,6 +74,7 @@ async function request<T>(
   const response = await fetch(`${API_BASE}${endpoint}`, {
     ...options,
     cache: 'no-store', // Prevent browser caching of API responses
+    credentials: 'include', // Required for HttpOnly cookies (e.g. 2fa_challenge)
     headers,
   });
 
@@ -2346,9 +2355,13 @@ export interface LoginRequest {
 }
 
 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 {
@@ -2414,6 +2427,66 @@ export interface SMTPSettings {
   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 {
   test_recipient: string;
 }
@@ -2504,12 +2577,106 @@ export const api = {
       method: 'POST',
       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) =>
     request<ResetPasswordResponse>('/auth/reset-password', {
       method: 'POST',
       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
   getUsers: () => request<UserResponse[]>('/users/'),
   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 { api, getAuthToken, setAuthToken } from '../api/client';
-import type { Permission, UserResponse } from '../api/client';
+import type { LoginResponse, Permission, UserResponse } from '../api/client';
 
 interface AuthContextType {
   user: UserResponse | null;
@@ -8,7 +8,10 @@ interface AuthContextType {
   requiresSetup: boolean;
   loading: 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;
   refreshUser: () => Promise<void>;
   refreshAuth: () => Promise<void>;
@@ -30,12 +33,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
 
   const checkAuthStatus = async () => {
     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 urlToken = urlParams.get('token');
       if (urlToken) {
-        setAuthToken(urlToken);
+        setAuthToken(urlToken, false); // session-only until server confirms it's valid
         urlParams.delete('token');
         const cleanSearch = urlParams.toString();
         const cleanUrl = window.location.pathname
@@ -56,8 +62,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
             const currentUser = await api.getCurrentUser();
             if (!mountedRef.current) return;
             setUser(currentUser);
+            // Persist kiosk token only after the server confirms it is valid.
+            if (urlToken && token === urlToken) {
+              setAuthToken(urlToken, true);
+            }
           } catch {
-            // Token invalid, clear it
+            // Token invalid, clear it (removes from both sessionStorage and localStorage)
             setAuthToken(null);
             if (!mountedRef.current) return;
             setUser(null);
@@ -106,10 +116,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     }
   }, [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 });
-    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 = () => {
@@ -205,6 +224,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
         loading,
         isAdmin,
         login,
+        loginWithToken,
         logout,
         refreshUser,
         refreshAuth,

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

@@ -102,6 +102,9 @@ export default {
     more: '+{{count}} weitere',
     ascending: 'Aufsteigend',
     descending: 'Absteigend',
+    back: 'Zurück',
+    copy: 'Kopieren',
+    copied: 'Kopiert!',
     printer: 'Drucker',
     remove: 'Entfernen',
     type: 'Typ',
@@ -1325,6 +1328,8 @@ export default {
       backup: 'Sicherung',
       emailAuth: 'E-Mail-Authentifizierung',
       ldap: 'LDAP',
+      twoFa: 'Zwei-Faktor-Auth',
+      oidc: 'SSO / OIDC',
     },
     spoolbuddy: {
       infoTitle: 'SpoolBuddy-Geräte',
@@ -2078,6 +2083,74 @@ export default {
     deleteUserAndItems: 'Benutzer UND dessen Elemente löschen',
     deleteUserKeepItems: 'Benutzer löschen, Elemente behalten (werden herrenlos)',
     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)
@@ -2208,6 +2281,28 @@ export default {
     loginSuccess: 'Erfolgreich angemeldet',
     loginFailed: 'Anmeldung fehlgeschlagen',
     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',
     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.',
@@ -2222,6 +2317,47 @@ export default {
     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',
     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

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

@@ -102,6 +102,9 @@ export default {
     more: '+{{count}} more',
     ascending: 'Ascending',
     descending: 'Descending',
+    back: 'Back',
+    copy: 'Copy',
+    copied: 'Copied!',
     printer: 'Printer',
     remove: 'Remove',
     type: 'Type',
@@ -1326,6 +1329,8 @@ export default {
       backup: 'Backup',
       emailAuth: 'Email Authentication',
       ldap: 'LDAP',
+      twoFa: 'Two-Factor Auth',
+      oidc: 'SSO / OIDC',
     },
     spoolbuddy: {
       infoTitle: 'SpoolBuddy devices',
@@ -2080,6 +2085,74 @@ export default {
     deleteUserAndItems: 'Delete user AND their items',
     deleteUserKeepItems: 'Delete user, keep items (become ownerless)',
     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)
@@ -2210,6 +2283,28 @@ export default {
     loginSuccess: 'Logged in successfully',
     loginFailed: 'Login failed',
     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',
     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.",
@@ -2224,6 +2319,47 @@ export default {
     resetStep3: 'They can set a new temporary password for you',
     resetStep4: 'Log in with the new password and change it in Settings',
     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

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

@@ -102,6 +102,9 @@ export default {
     more: '+{{count}} de plus',
     ascending: 'Croissant',
     descending: 'Décroissant',
+    back: 'Retour',
+    copy: 'Copier',
+    copied: 'Copié !',
     printer: 'Imprimante',
     remove: 'Retirer',
     type: 'Type',
@@ -1324,6 +1327,8 @@ export default {
       backup: 'Sauvegarde',
       emailAuth: 'Authentification Email',
       ldap: 'LDAP',
+      twoFa: 'Authentification 2FA',
+      oidc: 'SSO / OIDC',
     },
     ldap: {
       title: 'Authentification LDAP',
@@ -2039,6 +2044,74 @@ export default {
     deleteUserAndItems: 'Supprimer l\'utilisateur ET ses éléments',
     deleteUserKeepItems: 'Supprimer l\'utilisateur, garder les éléments (deviennent sans propriétaire)',
     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)
@@ -2169,6 +2242,28 @@ export default {
     loginSuccess: 'Connecté avec succès',
     loginFailed: 'Échec de connexion',
     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é',
     forgotPasswordMessage: 'Contactez votre administrateur pour réinitialiser votre accès.',
     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',
     resetStep4: 'Connectez-vous et changez-le dans les Paramètres',
     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

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

@@ -102,6 +102,9 @@ export default {
     more: '+{{count}} altre',
     ascending: 'Crescente',
     descending: 'Decrescente',
+    back: 'Indietro',
+    copy: 'Copia',
+    copied: 'Copiato!',
     printer: 'Stampante',
     remove: 'Rimuovi',
     type: 'Tipo',
@@ -1324,6 +1327,8 @@ export default {
       backup: 'Backup',
       emailAuth: 'Autenticazione Email',
       ldap: 'LDAP',
+      twoFa: 'Autenticazione 2FA',
+      oidc: 'SSO / OIDC',
     },
     ldap: {
       title: 'Autenticazione LDAP',
@@ -2038,6 +2043,74 @@ export default {
     deleteUserAndItems: 'Elimina utente E i suoi elementi',
     deleteUserKeepItems: 'Elimina utente, mantieni elementi (diventeranno senza proprietario)',
     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)
@@ -2168,6 +2241,28 @@ export default {
     loginSuccess: 'Accesso riuscito',
     loginFailed: 'Accesso fallito',
     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',
     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.',
@@ -2182,6 +2277,33 @@ export default {
     resetStep3: 'Possono impostare una nuova password temporanea',
     resetStep4: 'Accedi con la nuova password e cambiala in Impostazioni',
     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

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

@@ -102,6 +102,9 @@ export default {
     more: 'もっと見る',
     ascending: '昇順',
     descending: '降順',
+    back: '戻る',
+    copy: 'コピー',
+    copied: 'コピーしました!',
     printer: 'プリンター',
     remove: '削除',
     type: '種類',
@@ -1324,6 +1327,8 @@ export default {
       backup: 'バックアップ',
       emailAuth: 'メール認証',
       ldap: 'LDAP',
+      twoFa: '二段階認証',
+      oidc: 'SSO / OIDC',
     },
     spoolbuddy: {
       infoTitle: 'SpoolBuddy デバイス',
@@ -2077,6 +2082,74 @@ export default {
     deleteUserAndItems: 'ユーザーとそのアイテムを削除',
     deleteUserKeepItems: 'ユーザーを削除、アイテムは保持(オーナーなしになります)',
     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)
@@ -2207,6 +2280,28 @@ export default {
     loginSuccess: 'ログインしました',
     loginFailed: 'ログインに失敗しました',
     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: 'パスワードを忘れた場合',
     forgotPasswordMessage: 'パスワードを忘れた場合は、システム管理者に連絡してリセットしてもらってください。',
     forgotPasswordEmailMessage: 'メールアドレスを入力すると、新しいパスワードを送信します。',
@@ -2221,6 +2316,33 @@ export default {
     resetStep3: '管理者が新しい仮パスワードを設定',
     resetStep4: '新しいパスワードでログインし、設定で変更',
     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

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

@@ -102,6 +102,9 @@ export default {
     more: '+{{count}} mais',
     ascending: 'Crescente',
     descending: 'Decrescente',
+    back: 'Voltar',
+    copy: 'Copiar',
+    copied: 'Copiado!',
     printer: 'Impressora',
     remove: 'Remover',
     type: 'Tipo',
@@ -1324,6 +1327,8 @@ export default {
       backup: 'Backup',
       emailAuth: 'Autenticação por Email',
       ldap: 'LDAP',
+      twoFa: 'Autenticação 2FA',
+      oidc: 'SSO / OIDC',
     },
     ldap: {
       title: 'Autenticação LDAP',
@@ -2038,6 +2043,74 @@ export default {
     deleteUserAndItems: 'Excluir usuário E seus itens',
     deleteUserKeepItems: 'Excluir usuário, manter itens (ficarão sem dono)',
     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)
@@ -2168,6 +2241,28 @@ export default {
     loginSuccess: 'Login realizado com sucesso',
     loginFailed: 'Falha no login',
     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',
     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.',
@@ -2182,6 +2277,47 @@ export default {
     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',
     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

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

@@ -102,6 +102,9 @@ export default {
     more: '还有 {{count}} 个',
     ascending: '升序',
     descending: '降序',
+    back: '返回',
+    copy: '复制',
+    copied: '已复制!',
     printer: '打印机',
     remove: '移除',
     type: '类型',
@@ -1324,6 +1327,8 @@ export default {
       backup: '备份',
       emailAuth: '邮箱认证',
       ldap: 'LDAP',
+      twoFa: '双因素认证',
+      oidc: 'SSO / OIDC',
     },
     ldap: {
       title: 'LDAP 认证',
@@ -2038,6 +2043,74 @@ export default {
     deleteUserAndItems: '删除用户及其所有项目',
     deleteUserKeepItems: '删除用户,保留项目(将变为无主项目)',
     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)
@@ -2168,6 +2241,28 @@ export default {
     loginSuccess: '登录成功',
     loginFailed: '登录失败',
     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: '忘记密码',
     forgotPasswordMessage: '如果您忘记了密码,请联系系统管理员进行重置。',
     forgotPasswordEmailMessage: '输入您的邮箱地址,我们将向您发送新密码。',
@@ -2182,6 +2277,33 @@ export default {
     resetStep3: '他们可以为您设置一个临时密码',
     resetStep4: '使用新密码登录并在设置中修改密码',
     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

+ 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 { useTranslation } from 'react-i18next';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 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 { Button } from '../components/Button';
 
+type LoginStep = 'credentials' | '2fa' | 'reset-password';
+
 export function LoginPage() {
   const navigate = useNavigate();
+  const [searchParams] = useSearchParams();
   const { t } = useTranslation();
-  const { login } = useAuth();
+  const { login, loginWithToken } = useAuth();
   const { showToast } = useToast();
   const { mode } = useTheme();
+
+  // Credentials step state
   const [username, setUsername] = useState('');
   const [password, setPassword] = useState('');
   const [showForgotPassword, setShowForgotPassword] = useState(false);
   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
   const { data: advancedAuthStatus } = useQuery({
     queryKey: ['advancedAuthStatus'],
     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({
     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) => {
       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) => {
     e.preventDefault();
     if (!username || !password) {
@@ -59,15 +231,285 @@ export function LoginPage() {
     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) => {
     e.preventDefault();
     if (!forgotEmail) {
-      showToast('Please enter your email address', 'error');
+      showToast(t('login.enterEmail'), 'error');
       return;
     }
     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 (
     <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">
@@ -92,7 +534,7 @@ export function LoginPage() {
             <div>
               <label htmlFor="username" className="block text-sm font-medium text-white mb-2">
                 {advancedAuthStatus?.advanced_auth_enabled
-                  ? t('login.usernameOrEmail') || 'Username or Email'
+                  ? t('login.usernameOrEmail')
                   : t('login.username')}
               </label>
               <input
@@ -103,7 +545,7 @@ export function LoginPage() {
                 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"
                 placeholder={advancedAuthStatus?.advanced_auth_enabled
-                  ? t('login.usernameOrEmailPlaceholder') || 'Enter your username or email'
+                  ? t('login.usernameOrEmailPlaceholder')
                   : t('login.usernamePlaceholder')}
                 autoComplete="username"
               />
@@ -111,7 +553,7 @@ export function LoginPage() {
 
             <div>
               <label htmlFor="password" className="block text-sm font-medium text-white mb-2">
-                {t('login.password')}
+                {t('login.password') || 'Password'}
               </label>
               <input
                 id="password"
@@ -136,18 +578,49 @@ export function LoginPage() {
             </button>
           </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>
+
+        {/* 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>
 
       {/* Forgot Password Modal */}
@@ -182,12 +655,12 @@ export function LoginPage() {
               {advancedAuthStatus?.advanced_auth_enabled ? (
                 <form onSubmit={handleForgotPassword} className="space-y-4">
                   <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>
 
                   <div>
                     <label htmlFor="forgot-email" className="block text-sm font-medium text-white mb-2">
-                      {t('login.emailAddress') || 'Email Address'}
+                      {t('login.emailAddress')}
                     </label>
                     <input
                       id="forgot-email"
@@ -196,7 +669,7 @@ export function LoginPage() {
                       value={forgotEmail}
                       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"
-                      placeholder={t('login.emailPlaceholder') || 'your.email@example.com'}
+                      placeholder={t('login.emailPlaceholder')}
                     />
                   </div>
 
@@ -210,7 +683,7 @@ export function LoginPage() {
                         setForgotEmail('');
                       }}
                     >
-                      {t('login.cancel') || 'Cancel'}
+                      {t('login.cancel')}
                     </Button>
                     <Button
                       type="submit"
@@ -218,8 +691,8 @@ export function LoginPage() {
                       disabled={forgotPasswordMutation.isPending}
                     >
                       {forgotPasswordMutation.isPending
-                        ? (t('login.sending') || 'Sending...')
-                        : (t('login.sendResetEmail') || 'Send Reset Email')}
+                        ? t('login.sending')
+                        : t('login.sendResetEmail')}
                     </Button>
                   </div>
                 </form>

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

@@ -28,6 +28,8 @@ import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
 import { FailureDetectionSettings } from '../components/FailureDetectionSettings';
 import { EmailSettings } from '../components/EmailSettings';
 import { LDAPSettings } from '../components/LDAPSettings';
+import { TwoFactorSettings } from '../components/TwoFactorSettings';
+import { OIDCProviderSettings } from '../components/OIDCProviderSettings';
 import { APIBrowser } from '../components/APIBrowser';
 import { Toggle } from '../components/Toggle';
 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;
 type TabType = typeof validTabs[number];
-type UsersSubTab = 'users' | 'email' | 'ldap';
+type UsersSubTab = 'users' | 'email' | 'ldap' | 'twofa' | 'oidc';
 
 const STORAGE_CATEGORY_COLORS: Record<string, string> = {
   database: 'bg-blue-600',
@@ -80,7 +82,7 @@ export function SettingsPage() {
   const [searchParams, setSearchParams] = useSearchParams();
   const { t, i18n } = useTranslation();
   const { showToast } = useToast();
-  const { authEnabled, user, refreshAuth, hasPermission } = useAuth();
+  const { authEnabled, user, isAdmin, refreshAuth, hasPermission } = useAuth();
   const {
     mode,
     darkStyle, darkBackground, darkAccent,
@@ -4403,6 +4405,30 @@ export function SettingsPage() {
                 <span className="w-2 h-2 rounded-full bg-green-400" />
               )}
             </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>
 
           {/* Users Sub-tab */}
@@ -4729,6 +4755,18 @@ export function SettingsPage() {
               <LDAPSettings />
             </div>
           )}
+
+          {usersSubTab === 'twofa' && (
+            <div className="max-w-2xl">
+              <TwoFactorSettings />
+            </div>
+          )}
+
+          {usersSubTab === 'oidc' && isAdmin && (
+            <div className="max-w-3xl">
+              <OIDCProviderSettings />
+            </div>
+          )}
         </div>
       )}
 

+ 6 - 0
pyproject.toml

@@ -79,3 +79,9 @@ filterwarnings = [
 markers = [
     "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
 passlib[bcrypt]>=1.7.4
 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)
 opencv-python-headless>=4.8.0