import os import httpx from datetime import datetime, timezone, timedelta from typing import Optional import logging import asyncio logger = logging.getLogger(__name__) MS_CLIENT_ID = os.getenv("MS_CLIENT_ID") MS_CLIENT_SECRET = os.getenv("MS_CLIENT_SECRET") MS_TENANT_ID = os.getenv("MS_TENANT_ID", "common") APP_BASE_URL = os.getenv("APP_BASE_URL", "http://localhost") REDIRECT_URI = f"{APP_BASE_URL}/auth/callback" SCOPES = "openid profile email offline_access Contacts.ReadWrite" AUTH_URL = f"https://login.microsoftonline.com/{MS_TENANT_ID}/oauth2/v2.0/authorize" TOKEN_URL = f"https://login.microsoftonline.com/{MS_TENANT_ID}/oauth2/v2.0/token" GRAPH_BASE = "https://graph.microsoft.com/v1.0" # ── App-Token-Cache (in-memory) ──────────────────────────────────────────── _app_token_cache: dict = {"token": None, "expires_at": None} def get_auth_url(state: str = "") -> str: params = { "client_id": MS_CLIENT_ID, "response_type": "code", "redirect_uri": REDIRECT_URI, "scope": SCOPES, "response_mode": "query", "state": state, } query = "&".join(f"{k}={v}" for k, v in params.items()) return f"{AUTH_URL}?{query}" async def exchange_code_for_tokens(code: str) -> dict: async with httpx.AsyncClient() as client: resp = await client.post(TOKEN_URL, data={ "client_id": MS_CLIENT_ID, "client_secret": MS_CLIENT_SECRET, "code": code, "redirect_uri": REDIRECT_URI, "grant_type": "authorization_code", }) resp.raise_for_status() return resp.json() async def refresh_access_token(refresh_token: str) -> Optional[dict]: try: async with httpx.AsyncClient() as client: resp = await client.post(TOKEN_URL, data={ "client_id": MS_CLIENT_ID, "client_secret": MS_CLIENT_SECRET, "refresh_token": refresh_token, "grant_type": "refresh_token", "scope": SCOPES, }) resp.raise_for_status() return resp.json() except Exception as e: logger.error(f"Token refresh failed: {e}") return None async def get_ms_user_info(access_token: str) -> dict: async with httpx.AsyncClient() as client: resp = await client.get( f"{GRAPH_BASE}/me", headers={"Authorization": f"Bearer {access_token}"} ) resp.raise_for_status() return resp.json() async def get_valid_token(user, db) -> Optional[str]: """Gibt ein gültiges Access Token zurück. 1. Wenn der User per Gruppen-Import angelegt wurde: App-Token (Application Permissions) 2. Wenn der User OAuth-Login gemacht hat: User-Token (mit Refresh) """ # Gruppen-Import User → App-Token nutzen if getattr(user, "source", "oauth") == "group_import": return await get_app_token() now = datetime.now(timezone.utc) expires = user.token_expires_at if expires and expires.tzinfo is None: expires = expires.replace(tzinfo=timezone.utc) if expires and now < expires - timedelta(minutes=5): return user.ms_access_token if not user.ms_refresh_token: # Kein Refresh-Token? Versuche App-Token als Fallback return await get_app_token() tokens = await refresh_access_token(user.ms_refresh_token) if not tokens: return await get_app_token() user.ms_access_token = tokens["access_token"] if "refresh_token" in tokens: user.ms_refresh_token = tokens["refresh_token"] user.token_expires_at = datetime.now(timezone.utc) + timedelta(seconds=tokens.get("expires_in", 3600)) db.commit() return user.ms_access_token # ── Application Permissions (Client Credentials Flow) ────────────────────── async def get_app_token() -> Optional[str]: """Holt ein App-Token via Client Credentials Flow. Wird gecacht und automatisch erneuert. Voraussetzung: In Azure App-Registrierung Application Permissions vergeben + Admin-Consent erteilt: - Contacts.ReadWrite (Application) - User.Read.All (Application) - GroupMember.Read.All (Application) """ now = datetime.now(timezone.utc) cached = _app_token_cache.get("token") expires = _app_token_cache.get("expires_at") if cached and expires and now < expires - timedelta(minutes=5): return cached try: async with httpx.AsyncClient() as client: resp = await client.post(TOKEN_URL, data={ "client_id": MS_CLIENT_ID, "client_secret": MS_CLIENT_SECRET, "scope": "https://graph.microsoft.com/.default", "grant_type": "client_credentials", }) if resp.status_code != 200: logger.error(f"App-Token Anfrage fehlgeschlagen: {resp.status_code} {resp.text}") return None data = resp.json() token = data["access_token"] _app_token_cache["token"] = token _app_token_cache["expires_at"] = now + timedelta(seconds=data.get("expires_in", 3600)) return token except Exception as e: logger.error(f"App-Token Fehler: {e}") return None # ── Group / User Lookup (App-Token) ───────────────────────────────────────── async def get_group_info(group_id: str) -> Optional[dict]: """Liest Gruppen-Metadaten (Name etc.)""" token = await get_app_token() if not token: return None async with httpx.AsyncClient() as client: resp = await client.get( f"{GRAPH_BASE}/groups/{group_id}?$select=id,displayName,description", headers={"Authorization": f"Bearer {token}"} ) if resp.status_code != 200: logger.error(f"get_group_info failed: {resp.status_code} {resp.text}") return None return resp.json() async def get_group_members(group_id: str) -> list[dict]: """Liest alle Mitglieder einer Azure AD Gruppe. Folgt automatisch der Paginierung (@odata.nextLink). Filtert auf User-Objekte (keine Gruppen-in-Gruppen, keine Service Principals). """ token = await get_app_token() if not token: return [] members = [] url = f"{GRAPH_BASE}/groups/{group_id}/members?$select=id,displayName,mail,userPrincipalName&$top=100" async with httpx.AsyncClient() as client: while url: resp = await client.get(url, headers={"Authorization": f"Bearer {token}"}) if resp.status_code != 200: logger.error(f"get_group_members failed: {resp.status_code} {resp.text}") break data = resp.json() for m in data.get("value", []): # Nur echte User if m.get("@odata.type", "").lower().endswith("user"): members.append(m) elif "userPrincipalName" in m: members.append(m) url = data.get("@odata.nextLink") return members # ── Microsoft Graph: Contacts ────────────────────────────────────────────── def _contacts_endpoint(user_principal: Optional[str]) -> str: """Wählt den richtigen Contacts-Endpoint. user_principal=None → /me/contacts (Delegated) user_principal=email/id → /users/{principal}/contacts (Application) """ if user_principal: return f"{GRAPH_BASE}/users/{user_principal}/contacts" return f"{GRAPH_BASE}/me/contacts" CONTACT_SELECT = "id,displayName,emailAddresses,businessPhones,mobilePhone,homePhones,jobTitle,companyName,department,businessAddress,homeAddress,birthday,personalNotes,givenName,surname,middleName,nickName,title,generation,imAddresses,fileAs,initials,businessHomePage,spouseName,categories" async def graph_get_contacts(access_token: str, user_principal: Optional[str] = None) -> list[dict]: """Alle Kontakte abrufen. user_principal nur bei App-Token nötig.""" contacts = [] url = f"{_contacts_endpoint(user_principal)}?$top=100&$select={CONTACT_SELECT}" headers = {"Authorization": f"Bearer {access_token}"} async with httpx.AsyncClient() as client: while url: resp = await client.get(url, headers=headers) resp.raise_for_status() data = resp.json() contacts.extend(data.get("value", [])) url = data.get("@odata.nextLink") return contacts async def graph_create_contact(access_token: str, contact_data: dict, user_principal: Optional[str] = None) -> dict: async with httpx.AsyncClient() as client: resp = await client.post( _contacts_endpoint(user_principal), headers={ "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", }, json=contact_data, ) if resp.status_code >= 400: logger.error(f"Graph create_contact failed ({resp.status_code}): {resp.text}") logger.error(f"Payload was: {contact_data}") resp.raise_for_status() return resp.json() async def graph_update_contact(access_token: str, contact_id: str, contact_data: dict, user_principal: Optional[str] = None) -> dict: async with httpx.AsyncClient() as client: resp = await client.patch( f"{_contacts_endpoint(user_principal)}/{contact_id}", headers={ "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", }, json=contact_data, ) if resp.status_code >= 400: logger.error(f"Graph update_contact failed ({resp.status_code}): {resp.text}") logger.error(f"Payload was: {contact_data}") resp.raise_for_status() return resp.json() async def graph_delete_contact(access_token: str, contact_id: str, user_principal: Optional[str] = None): async with httpx.AsyncClient() as client: resp = await client.delete( f"{_contacts_endpoint(user_principal)}/{contact_id}", headers={"Authorization": f"Bearer {access_token}"} ) resp.raise_for_status()