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>
91 lines
3 KiB
Python
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()
|