92 lines
3.2 KiB
Python
92 lines
3.2 KiB
Python
|
|
"""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", [])
|