CardSync/backend/ms_graph.py

275 lines
10 KiB
Python

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()