"""Microsoft Graph helpers. Delegated access uses the OAuth2 On-Behalf-Of (OBO) flow: the SPA sends the API its access token (audience = this API); the API exchanges it with Entra ID for a Graph token carrying the *signed-in user's* identity, so mail goes out from that user's own mailbox. This keeps Graph scopes off the frontend and needs no extra token plumbing on state-changing requests. """ import logging from functools import partial import anyio import httpx from app.config import get_settings logger = logging.getLogger(__name__) GRAPH_BASE = "https://graph.microsoft.com/v1.0" MAIL_SEND_SCOPE = "https://graph.microsoft.com/Mail.Send" GROUP_READ_SCOPE = "https://graph.microsoft.com/GroupMember.Read.All" _cca = None def _get_cca(): global _cca if _cca is None: import msal # imported lazily so tests never need Entra config settings = get_settings() _cca = msal.ConfidentialClientApplication( settings.entra_client_id, authority=f"https://login.microsoftonline.com/{settings.entra_tenant_id}", client_credential=settings.entra_client_secret, ) return _cca def _acquire_obo_sync(user_token: str, scopes: list[str]) -> str: result = _get_cca().acquire_token_on_behalf_of( user_assertion=user_token, scopes=scopes ) if "access_token" in result: return result["access_token"] raise RuntimeError( f"OBO token exchange failed: {result.get('error')}: " f"{result.get('error_description')}" ) async def acquire_obo_token(user_token: str, scopes: list[str]) -> str: """Exchange the caller's API access token for a delegated Graph token.""" return await anyio.to_thread.run_sync(partial(_acquire_obo_sync, user_token, scopes)) async def send_mail_as_user( user_token: str, subject: str, html_body: str, to_emails: list[str] ) -> None: """Send an email from the signed-in user's mailbox (delegated Mail.Send).""" graph_token = await acquire_obo_token(user_token, [MAIL_SEND_SCOPE]) payload = { "message": { "subject": subject, "body": {"contentType": "HTML", "content": html_body}, "toRecipients": [{"emailAddress": {"address": e}} for e in to_emails], }, "saveToSentItems": True, } async with httpx.AsyncClient(timeout=20) as client: resp = await client.post( f"{GRAPH_BASE}/me/sendMail", json=payload, headers={"Authorization": f"Bearer {graph_token}"}, ) if resp.status_code != 202: raise RuntimeError(f"Graph sendMail returned {resp.status_code}: {resp.text[:300]}") async def check_member_group(user_token: str, group_id: str) -> bool: """Group-overage fallback: ask Graph whether the signed-in user is in the gate group. Requires delegated GroupMember.Read.All (see README).""" graph_token = await acquire_obo_token(user_token, [GROUP_READ_SCOPE]) async with httpx.AsyncClient(timeout=20) as client: resp = await client.post( f"{GRAPH_BASE}/me/checkMemberGroups", json={"groupIds": [group_id]}, headers={"Authorization": f"Bearer {graph_token}"}, ) resp.raise_for_status() return group_id in resp.json().get("value", [])