- New POST /api/auth/microsoft endpoint validates Azure ID token via JWKS - Removed POST /api/auth/login and /change-password - Added azure_oid + nullable password_hash to users (migration 0007) - Auto-provisions all @oliver.agency accounts on first SSO login - Case-insensitive email matching links existing vadymsamoilenko@ account - DEV_AUTH_BYPASS flag for local development without MSAL - Frontend: MSAL loginPopup replaces email/password form - Added scripts/grant_admin.py for role management Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
55 lines
1.8 KiB
Python
55 lines
1.8 KiB
Python
"""Azure AD SSO — validates Microsoft ID tokens via JWKS."""
|
|
import time
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import HTTPException, status
|
|
from jose import JWTError, jwt
|
|
|
|
from src.config import settings
|
|
|
|
_jwks_cache: dict[str, Any] = {}
|
|
_jwks_fetched_at: float = 0.0
|
|
_JWKS_TTL = 3600 # seconds
|
|
|
|
|
|
def _get_jwks() -> dict:
|
|
global _jwks_cache, _jwks_fetched_at
|
|
if time.time() - _jwks_fetched_at < _JWKS_TTL and _jwks_cache:
|
|
return _jwks_cache
|
|
url = f"https://login.microsoftonline.com/{settings.AZURE_TENANT_ID}/discovery/v2.0/keys"
|
|
resp = httpx.get(url, timeout=10)
|
|
resp.raise_for_status()
|
|
_jwks_cache = resp.json()
|
|
_jwks_fetched_at = time.time()
|
|
return _jwks_cache
|
|
|
|
|
|
def validate_microsoft_id_token(id_token: str) -> dict:
|
|
"""Validate Azure AD ID token and return claims. Raises 401 on any failure."""
|
|
if not settings.AZURE_TENANT_ID or not settings.AZURE_CLIENT_ID:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="SSO not configured",
|
|
)
|
|
try:
|
|
jwks = _get_jwks()
|
|
claims = jwt.decode(
|
|
id_token,
|
|
jwks,
|
|
algorithms=["RS256"],
|
|
audience=settings.AZURE_CLIENT_ID,
|
|
issuer=f"https://login.microsoftonline.com/{settings.AZURE_TENANT_ID}/v2.0",
|
|
options={"verify_at_hash": False},
|
|
)
|
|
return claims
|
|
except JWTError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail=f"Invalid Microsoft token: {exc}",
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail=f"Failed to fetch Azure AD keys: {exc}",
|
|
)
|