import hmac import hashlib import secrets import time import urllib.parse from fastapi import APIRouter, Depends, Request from fastapi.responses import RedirectResponse from sqlalchemy.orm import Session from src.config.settings import settings from src.application.use_cases.authenticate_microsoft import AuthenticateMicrosoftUserUseCase from src.infrastructure.repositories.sql_user_repository import SqlUserRepository from src.infrastructure.database.connection import get_db from src.core.logger import get_logger from src.core.exceptions import UserNotFoundException, InvalidCredentialsException logger = get_logger() # We map two distinct paths here because of the legacy .env configuration needs router = APIRouter() _STATE_TTL_SECONDS = 600 # 10 minutes — enough time to complete Microsoft login def _sign_state(nonce: str, timestamp: str) -> str: """HMAC-sign a nonce+timestamp pair with SECRET_KEY.""" message = f"{nonce}:{timestamp}" return hmac.new(settings.SECRET_KEY.encode(), message.encode(), hashlib.sha256).hexdigest() def _make_state() -> str: """Generate a self-contained, tamper-proof state token (no session needed).""" nonce = secrets.token_urlsafe(16) timestamp = str(int(time.time())) sig = _sign_state(nonce, timestamp) return f"{nonce}:{timestamp}:{sig}" def _verify_state(state: str) -> bool: """Return True only if the state was signed by us and hasn't expired.""" try: nonce, timestamp_str, received_sig = state.rsplit(":", 2) except ValueError: return False expected_sig = _sign_state(nonce, timestamp_str) if not hmac.compare_digest(expected_sig, received_sig): return False age = int(time.time()) - int(timestamp_str) return 0 <= age <= _STATE_TTL_SECONDS @router.get("/v1/auth/microsoft/login", tags=["auth"]) def login_microsoft_redirect(request: Request): """Generates the secure Microsoft SSO Login URL and redirects the user's browser to Microsoft.""" oauth_state = _make_state() params = { "client_id": settings.MICROSOFT_CLIENT_ID, "response_type": "code", "redirect_uri": settings.MICROSOFT_REDIRECT_URI, "scope": "user.read openid profile email", "response_mode": "query", "state": oauth_state, "prompt": "select_account", } auth_url = ( f"https://login.microsoftonline.com/{settings.MICROSOFT_TENANT}" f"/oauth2/v2.0/authorize?{urllib.parse.urlencode(params)}" ) logger.debug("Generated Microsoft Auth URL, redirecting browser.") return RedirectResponse(auth_url) def get_microsoft_auth_use_case(db: Session = Depends(get_db)) -> AuthenticateMicrosoftUserUseCase: repo = SqlUserRepository(db_session=db) return AuthenticateMicrosoftUserUseCase(user_repo=repo) @router.get("/oauth/outlook/callback", tags=["oauth"]) async def microsoft_callback( request: Request, code: str | None = None, state: str | None = None, error: str | None = None, error_description: str | None = None, use_case: AuthenticateMicrosoftUserUseCase = Depends(get_microsoft_auth_use_case), ): """ Callback from Microsoft OAuth. Validates the signed state (no session/cookie needed), exchanges the code for a JWT, and redirects back to the Flutter frontend. """ flutter_frontend_url = f"{settings.FRONTEND_APP_URL}/#/admin/home" flutter_login_url = f"{settings.FRONTEND_APP_URL}/#/login" if error: logger.error(f"OAuth Callback Error from Microsoft: {error} — {error_description}") return RedirectResponse( f"{flutter_login_url}?error={urllib.parse.quote(error_description or error)}" ) if not state or not _verify_state(state): logger.error(f"State validation failed. state={state!r}") return RedirectResponse(f"{flutter_login_url}?error=Invalid+auth+state") try: token = await use_case.execute(code=code) return RedirectResponse(f"{flutter_frontend_url}?token={token}") except UserNotFoundException as e: logger.warning(f"OAuth login: user not found — {e.message}") return RedirectResponse( f"{flutter_login_url}?error={urllib.parse.quote('Account not found. Contact your administrator.')}" ) except InvalidCredentialsException as e: logger.error(f"OAuth login: Microsoft token exchange failed — {e.message}") return RedirectResponse( f"{flutter_login_url}?error={urllib.parse.quote('Microsoft authentication failed. Check Azure redirect URI registration.')}" ) except Exception as e: logger.error(f"Callback processing failed: {type(e).__name__}: {str(e)}", exc_info=True) return RedirectResponse(f"{flutter_login_url}?error=Authentication+Failed:+{urllib.parse.quote(str(e))}")