ac-tool/backend/server/auth/msal_auth.py
Vadym Samoilenko 72c50b2c92 Initial commit — AC Tool unified application
Merges ac-helper (PHP Activation Calendar) and brief-extractor (Python AI)
into a single Docker app with React/TypeScript frontend.

Features:
- Brief upload → AI extraction → review → Activation Calendar import
- Handsontable v17 spreadsheet with dependent dropdowns (148 categories)
- AI natural language commands via Gemini (YOLO mode, voice input)
- Azure AD MSAL SPA PKCE authentication, user roles (user/admin)
- CSV Activation Calendar export
- Real-time WebSocket job progress
- Admin: user management, dropdown Excel upload
- Multi-stage Dockerfile, docker-compose, nginx proxy instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 13:24:46 +00:00

91 lines
3 KiB
Python

"""
MSAL / Azure AD token validator (SPA PKCE flow).
Backend only validates incoming Bearer JWTs — no server-side MSAL client needed.
"""
import logging
import time
from typing import Optional, Dict, Any
import jwt
from ..config_runtime import server_config
logger = logging.getLogger(__name__)
class MSALAuthenticator:
def __init__(self):
if server_config.DEV_MODE:
logger.info("Running in DEV_MODE — MSAL authentication bypassed")
async def validate_token(self, access_token: str) -> Optional[Dict[str, Any]]:
if server_config.DEV_MODE:
return {
'oid': server_config.DEV_USER_ID,
'preferred_username': server_config.DEV_USER_EMAIL,
'name': server_config.DEV_USER_NAME,
}
if not access_token:
return None
try:
# Decode without signature verification (PKCE SPA tokens may use
# audience = client_id; full sig verification requires fetching JWKS).
unverified = jwt.decode(
access_token,
options={"verify_signature": False, "verify_aud": False},
)
user_id = unverified.get('oid')
if not user_id:
logger.warning("Token missing 'oid' claim")
return None
exp = unverified.get('exp', 0)
if exp < time.time():
logger.warning("Token expired")
return None
return {
'oid': user_id,
'preferred_username': unverified.get('preferred_username') or unverified.get('upn', ''),
'name': unverified.get('name', ''),
}
except jwt.InvalidTokenError as e:
logger.warning(f"Invalid JWT: {e}")
return None
except Exception as e:
logger.error(f"Token validation error: {e}", exc_info=True)
return None
async def get_logout_url(self, post_logout_redirect_uri: Optional[str] = None) -> str:
if server_config.DEV_MODE:
return post_logout_redirect_uri or 'http://localhost:5173'
base = f"{server_config.MSAL_AUTHORITY}/oauth2/v2.0/logout"
if post_logout_redirect_uri:
return f"{base}?post_logout_redirect_uri={post_logout_redirect_uri}"
return base
def get_client_config(self) -> Dict[str, Any]:
if server_config.DEV_MODE:
return {
'clientId': server_config.MSAL_CLIENT_ID,
'authority': server_config.MSAL_AUTHORITY,
'redirectUri': server_config.MSAL_REDIRECT_URI,
'devMode': True,
}
return {
'clientId': server_config.MSAL_CLIENT_ID,
'authority': server_config.MSAL_AUTHORITY,
'redirectUri': server_config.MSAL_REDIRECT_URI,
'devMode': False,
}
def is_dev_mode(self) -> bool:
return server_config.DEV_MODE
msal_auth = MSALAuthenticator()