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