cc-dashboard/src/sso.py
Vadym Samoilenko 96e6f4ee14 feat: replace local auth with Azure AD SSO (MSAL PKCE)
- 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>
2026-05-07 10:43:19 +01:00

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}",
)