"""Microsoft Entra ID (Azure AD) SSO via de OAuth2 authorization-code flow. De ``msal``-bibliotheek wordt bewust *lazy* geïmporteerd: zolang SSO uit staat (``ENTRA_ID_ENABLED=0``) hoeft het pakket niet geïnstalleerd te zijn en blijven de bestaande tests en de kernapplicatie volledig werken. """ from __future__ import annotations from dataclasses import dataclass from django.conf import settings class EntraConfigError(RuntimeError): """Entra ID is ingeschakeld maar niet (volledig) geconfigureerd.""" @dataclass(frozen=True) class EntraConfig: client_id: str client_secret: str tenant_id: str scopes: list[str] allowed_domains: tuple[str, ...] redirect_uri: str | None @property def authority(self) -> str: return f"https://login.microsoftonline.com/{self.tenant_id}" def entra_enabled() -> bool: return bool(getattr(settings, "ENTRA_ID_ENABLED", False)) def load_config() -> EntraConfig: client_id = getattr(settings, "ENTRA_CLIENT_ID", "") or "" client_secret = getattr(settings, "ENTRA_CLIENT_SECRET", "") or "" tenant_id = getattr(settings, "ENTRA_TENANT_ID", "") or "" if not (client_id and client_secret and tenant_id): raise EntraConfigError( "Entra ID staat aan maar ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET en " "ENTRA_TENANT_ID zijn niet allemaal ingesteld." ) scopes = list(getattr(settings, "ENTRA_SCOPES", ["User.Read"])) allowed = tuple( d.lower().lstrip("@") for d in getattr(settings, "ENTRA_ALLOWED_DOMAINS", []) if d ) return EntraConfig( client_id=client_id, client_secret=client_secret, tenant_id=tenant_id, scopes=scopes, allowed_domains=allowed, redirect_uri=getattr(settings, "ENTRA_REDIRECT_URI", "") or None, ) def _build_app(config: EntraConfig): try: import msal # lazy import: alleen nodig als SSO aan staat except ModuleNotFoundError as exc: # pragma: no cover - hangt van omgeving af raise EntraConfigError( "Het pakket 'msal' is niet geïnstalleerd. Voeg het toe (zie pyproject.toml) " "om Entra ID SSO te gebruiken." ) from exc return msal.ConfidentialClientApplication( client_id=config.client_id, client_credential=config.client_secret, authority=config.authority, ) def build_auth_flow(config: EntraConfig, redirect_uri: str) -> dict: """Start de authorization-code flow en geef het flow-dict terug (bewaar in sessie).""" app = _build_app(config) return app.initiate_auth_code_flow(config.scopes, redirect_uri=redirect_uri) def redeem_auth_code(config: EntraConfig, flow: dict, auth_response: dict) -> dict: """Wissel de teruggekeerde code in voor tokens en claims.""" app = _build_app(config) return app.acquire_token_by_auth_code_flow(flow, auth_response) def extract_identity(token_result: dict) -> tuple[str, str]: """Haal (e-mailadres, weergavenaam) uit de id-token-claims.""" claims = token_result.get("id_token_claims") or {} email = ( claims.get("preferred_username") or claims.get("email") or claims.get("upn") or "" ).strip() name = (claims.get("name") or "").strip() return email, name def domain_allowed(config: EntraConfig, email: str) -> bool: if not config.allowed_domains: return True domain = email.rsplit("@", 1)[-1].lower() return domain in config.allowed_domains