"""Security utilities: JWT, Entra ID OIDC, password hashing helpers.""" from datetime import datetime, timedelta, timezone from typing import Any import httpx from jose import JWTError, jwt from app.core.config import settings from app.core.logging import get_logger logger = get_logger("security") def create_access_token(data: dict[str, Any]) -> str: to_encode = data.copy() expire = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES) to_encode.update({"exp": expire}) return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) def decode_access_token(token: str) -> dict[str, Any] | None: try: return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM]) except JWTError: return None async def exchange_entra_code(code: str) -> dict[str, Any]: """Exchange an authorization code for tokens from Entra ID. Raises AuthError with the Microsoft error message if the exchange fails. """ data = { "client_id": settings.ENTRA_CLIENT_ID, "client_secret": settings.ENTRA_CLIENT_SECRET, "code": code, "redirect_uri": settings.ENTRA_REDIRECT_URI, "grant_type": "authorization_code", "scope": "openid profile email offline_access", } async with httpx.AsyncClient() as client: resp = await client.post(settings.entra_token_url, data=data, timeout=15) if resp.status_code != 200: try: error_body = resp.json() error_desc = error_body.get("error_description", error_body.get("error", "Unknown error")) except Exception: error_desc = resp.text[:500] logger.error("entra_token_exchange_failed", status_code=resp.status_code, error=error_desc) from app.core.exceptions import AuthError raise AuthError(f"Entra token exchange failed: {error_desc}") return resp.json() async def fetch_entra_userinfo(access_token: str) -> dict[str, Any]: """Fetch user claims from the Entra /userinfo endpoint.""" async with httpx.AsyncClient() as client: resp = await client.get( "https://graph.microsoft.com/oidc/userinfo", headers={"Authorization": f"Bearer {access_token}"}, timeout=15, ) if resp.status_code != 200: logger.error("entra_userinfo_failed", status_code=resp.status_code) from app.core.exceptions import AuthError raise AuthError("Failed to fetch user info from Entra") return resp.json() def validate_entra_id_token(id_token: str) -> dict[str, Any] | None: """ Validate the Entra ID token by decoding it. In production, the JWT signature should be validated against Entra JWKS. For MVP we verify the issuer claim to restrict to the approved tenant. """ try: decoded = jwt.get_unverified_claims(id_token) except Exception as e: logger.error("entra_id_token_decode_failed", error=str(e)) return None # With "common" tenant, the token issuer will be the actual tenant-specific URL, # not the /common/ endpoint. Accept any valid Entra issuer. if settings.ENTRA_TENANT_ID == "common": if not decoded.get("iss", "").startswith("https://login.microsoftonline.com/"): logger.warning("entra_issuer_rejected", issuer=decoded.get("iss")) return None else: # Single-tenant mode: strict issuer + tid match expected_issuer = settings.ENTRA_ISSUER or ( f"https://login.microsoftonline.com/{settings.ENTRA_TENANT_ID}/v2.0" ) if decoded.get("iss") != expected_issuer: logger.warning("entra_issuer_rejected", issuer=decoded.get("iss"), expected=expected_issuer) return None # Tenant check: tid claim should match if decoded.get("tid") and decoded.get("tid") != settings.ENTRA_TENANT_ID: logger.warning("entra_tenant_rejected", tid=decoded.get("tid"), expected=settings.ENTRA_TENANT_ID) return None return decoded