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:
Sn0rrii
2026-04-25 13:32:42 +02:00
committed by GitHub
parent 4304a42542
commit fdaec47378
22 changed files with 2288 additions and 73 deletions
+24 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
@@ -21,6 +21,15 @@ class OIDCProvider(Base):
"""
__tablename__ = "oidc_providers"
__table_args__ = (
# DB-level enforcement of SEC-1/SEC-6: auto_link is only safe when
# require_email_verified=True AND email_claim='email'. Enforced on new
# installations; existing tables get this via the PostgreSQL-only migration.
CheckConstraint(
"auto_link_existing_accounts = FALSE OR (require_email_verified = TRUE AND email_claim = 'email')",
name="ck_auto_link_requires_verified_email_claim",
),
)
id: Mapped[int] = mapped_column(primary_key=True)
# Human-readable name shown on the login button (e.g. "PocketID", "Google")
@@ -50,6 +59,20 @@ class OIDCProvider(Base):
# operators must explicitly opt-in to prevent an attacker-controlled IdP from
# silently hijacking local accounts via email matching (M-2 fix).
auto_link_existing_accounts: Mapped[bool] = mapped_column(Boolean, default=False)
# JWT claim name used as the email identity (default "email").
# Set to "preferred_username" or "upn" for Azure Entra ID, which does not send
# email_verified — using a custom claim skips the email_verified check entirely
# and is the recommended Azure configuration.
# Has no interaction with require_email_verified when set to a non-"email" value:
# custom claims never perform an email_verified check regardless of that setting.
email_claim: Mapped[str] = mapped_column(String(64), default="email")
# When True (default), the "email" claim is only trusted when email_verified=True.
# Set to False to accept the email even when email_verified is absent — required
# for providers like Azure Entra ID that never send email_verified and where a
# custom claim (email_claim != "email") is not preferred.
# Has no effect when email_claim is not "email": the custom-claim path never
# performs an email_verified check regardless of this setting.
require_email_verified: Mapped[bool] = mapped_column(Boolean, default=True)
# Optional icon URL (SVG/PNG) shown on the login button
icon_url: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())