"""Auth router — Microsoft Entra ID OIDC flow.""" from urllib.parse import quote_plus, urlencode from fastapi import APIRouter, Depends, Request, Response from fastapi.responses import RedirectResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.config import settings from app.core.exceptions import AuthError from app.core.logging import get_logger from app.core.rbac import Role from app.core.security import ( create_access_token, exchange_entra_code, validate_entra_id_token, ) from app.db.base import get_db from app.models import RoleModel, UserModel from app.services.audit import log_audit router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) logger = get_logger("auth") # Frontend URL for post-login redirect FRONTEND_URL = ( settings.cors_origins_list[0] if settings.cors_origins_list else "http://localhost:8080" ) @router.get("/login") async def login() -> dict: """Return the Entra ID login redirect URL.""" params = { "client_id": settings.ENTRA_CLIENT_ID, "response_type": "code", "redirect_uri": settings.ENTRA_REDIRECT_URI, "response_mode": "query", "scope": "openid profile email offline_access", "prompt": "select_account", } url = f"{settings.entra_auth_url}?{urlencode(params)}" return {"redirect_url": url} @router.get("/callback") async def callback( code: str | None = None, error: str | None = None, error_description: str | None = None, request: Request = None, response: Response = None, db: AsyncSession = Depends(get_db), ): """Handle OAuth callback from Entra ID. On success: redirects to frontend with token in URL hash. On failure: redirects to frontend with error in query param. """ # Check if Entra returned an error directly if error: err_msg = error_description or error logger.warning( "entra_callback_error", error=error, description=error_description, ) return RedirectResponse( url=f"{FRONTEND_URL}/?error={quote_plus(err_msg[:200])}" ) if not code: return RedirectResponse( url=f"{FRONTEND_URL}/?error=no_auth_code" ) try: # Exchange code for tokens tokens = await exchange_entra_code(code) id_token = tokens.get("id_token") if not id_token: raise AuthError("No id_token in token response") # Validate the ID token claims = validate_entra_id_token(id_token) if not claims: raise AuthError("ID token validation failed") entra_oid = claims.get("oid") or claims.get("sub") email = claims.get("email") or claims.get("preferred_username", "") full_name = claims.get( "name", email.split("@")[0] if email else "Unknown" ) if not entra_oid or not email: raise AuthError("Missing required claims in token") logger.info( "entra_auth_success", oid=entra_oid, email=email, name=full_name, ) # Find or create user result = await db.execute( select(UserModel) .where(UserModel.entra_oid == entra_oid) .options(selectinload(UserModel.roles)) ) user = result.scalar_one_or_none() if not user: # Assign default learner role role_result = await db.execute( select(RoleModel).where(RoleModel.name == Role.LEARNER.value) ) learner_role = role_result.scalar_one_or_none() # Provision new user — default to learner role user = UserModel( entra_oid=entra_oid, email=email, full_name=full_name, display_name=full_name, roles=[learner_role] if learner_role else [], ) db.add(user) await db.flush() await log_audit( db, user.id, "user_provisioned", "user", user.id, details={"email": email, "entra_oid": entra_oid}, ) else: # Update name if changed if user.full_name != full_name: user.full_name = full_name user.display_name = full_name await db.commit() # Re-fetch user with roles eagerly loaded to prevent MissingGreenlet error in async session result = await db.execute( select(UserModel) .where(UserModel.id == user.id) .options(selectinload(UserModel.roles)) ) user = result.scalar_one() # Create internal JWT role_names = [r.name for r in user.roles] access_jwt = create_access_token({ "sub": str(user.id), "email": user.email, "roles": role_names, }) logger.info( "user_session_created", user_id=str(user.id), email=user.email, roles=role_names, ) # Determine dynamic target frontend URL (handles localhost and LAN IP e.g. 192.168.1.72) target_frontend = FRONTEND_URL if "localhost" in FRONTEND_URL or "127.0.0.1" in FRONTEND_URL: if request and request.headers.get("host"): host_header = request.headers.get("host", "").split(":")[0] if host_header and host_header not in ("localhost", "127.0.0.1"): target_frontend = f"http://{host_header}:8080" # Redirect to frontend with token in query parameter redirect_url = f"{target_frontend}/?access_token={access_jwt}" resp = RedirectResponse(url=redirect_url) resp.set_cookie( key=settings.SESSION_COOKIE_NAME, value=access_jwt, httponly=True, secure=settings.SESSION_COOKIE_SECURE, samesite="lax", max_age=settings.JWT_EXPIRE_MINUTES * 60, ) return resp except AuthError as e: logger.warning("auth_callback_auth_error", error=str(e)) return RedirectResponse( url=f"{FRONTEND_URL}/?error={quote_plus(str(e)[:200])}" ) except Exception as e: logger.error( "auth_callback_unexpected_error", error=str(e), error_type=type(e).__name__, ) return RedirectResponse( url=f"{FRONTEND_URL}/?error={quote_plus(f'Authentication failed: {type(e).__name__}')}" ) @router.post("/logout") async def logout(response: Response) -> dict: """Clear the session cookie and return Entra logout URL.""" response.delete_cookie(settings.SESSION_COOKIE_NAME) logout_url = f"{settings.entra_authority}/oauth2/v2.0/logout" return {"message": "Logged out", "entra_logout_url": logout_url}