# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Routes for Microsoft authentication — split Auth app vs Data app. See wiki: concepts/OAuth-Auth-vs-Data-Connection-Konzept.md """ from fastapi import APIRouter, HTTPException, Request, Response, status, Depends, Query from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse import logging import json import time from typing import Dict, Any, Optional from urllib.parse import quote import msal import httpx from jose import jwt as jose_jwt from jose import JWTError from modules.shared.configuration import APP_CONFIG from modules.interfaces.interfaceDbApp import getInterface, getRootInterface from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection from modules.datamodels.datamodelSecurity import Token, TokenPurpose from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM from modules.auth import ( createAccessToken, setAccessTokenCookie, createRefreshToken, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie, ) from modules.auth.tokenManager import TokenManager from modules.auth.oauthProviderConfig import msftAuthScopes, msftDataScopes, msftDataScopesForRefresh from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp from modules.shared.i18nRegistry import apiRouteContext routeApiMsg = apiRouteContext("routeSecurityMsft") logger = logging.getLogger(__name__) _FLOW_LOGIN = "msft_login" _FLOW_CONNECT = "msft_connect" router = APIRouter( prefix="/api/msft", tags=["Security Microsoft"], responses={ 404: {"description": "Not found"}, 400: {"description": "Bad request"}, 401: {"description": "Unauthorized"}, 403: {"description": "Forbidden"}, 500: {"description": "Internal server error"}, }, ) AUTH_CLIENT_ID = APP_CONFIG.get("Service_MSFT_AUTH_CLIENT_ID") AUTH_CLIENT_SECRET = APP_CONFIG.get("Service_MSFT_AUTH_CLIENT_SECRET") AUTH_REDIRECT_URI = APP_CONFIG.get("Service_MSFT_AUTH_REDIRECT_URI") DATA_CLIENT_ID = APP_CONFIG.get("Service_MSFT_DATA_CLIENT_ID") DATA_CLIENT_SECRET = APP_CONFIG.get("Service_MSFT_DATA_CLIENT_SECRET") DATA_REDIRECT_URI = APP_CONFIG.get("Service_MSFT_DATA_REDIRECT_URI") TENANT_ID = APP_CONFIG.get("Service_MSFT_TENANT_ID", "common") AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}" def _issue_oauth_state(claims: Dict[str, Any]) -> str: body = {**claims, "exp": int(time.time()) + 600} return jose_jwt.encode(body, SECRET_KEY, algorithm=ALGORITHM) def _parse_oauth_state(state: str) -> Dict[str, Any]: try: return jose_jwt.decode(state, SECRET_KEY, algorithms=[ALGORITHM]) except JWTError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid OAuth state: {e}" ) from e def _require_msft_auth_config(): if not AUTH_CLIENT_ID or not AUTH_CLIENT_SECRET or not AUTH_REDIRECT_URI: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=routeApiMsg("Microsoft Auth OAuth is not configured (Service_MSFT_AUTH_*)"), ) def _require_msft_data_config(): if not DATA_CLIENT_ID or not DATA_CLIENT_SECRET or not DATA_REDIRECT_URI: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=routeApiMsg("Microsoft Data OAuth is not configured (Service_MSFT_DATA_*)"), ) def _admin_consent_redirect_uri() -> str: if "/auth/connect/callback" in (DATA_REDIRECT_URI or ""): return DATA_REDIRECT_URI.replace("/auth/connect/callback", "/adminconsent/callback") if DATA_REDIRECT_URI: return DATA_REDIRECT_URI.rstrip("/").rsplit("/", 1)[0] + "/adminconsent/callback" return "" def _msft_data_admin_consent_scope_param() -> str: """Space-separated delegated Graph scopes (not .default) for v2.0/adminconsent.""" return " ".join(f"https://graph.microsoft.com/{s}" for s in msftDataScopes) @router.get("/auth/login") @limiter.limit("5/minute") def auth_login(request: Request) -> RedirectResponse: """Start Microsoft login (Auth app — User.Read only).""" try: _require_msft_auth_config() msal_app = msal.ConfidentialClientApplication( AUTH_CLIENT_ID, authority=AUTHORITY, client_credential=AUTH_CLIENT_SECRET, ) state_jwt = _issue_oauth_state({"flow": _FLOW_LOGIN}) auth_url = msal_app.get_authorization_request_url( scopes=msftAuthScopes, redirect_uri=AUTH_REDIRECT_URI, state=state_jwt, prompt="select_account", ) return RedirectResponse(auth_url) except HTTPException: raise except Exception as e: logger.error(f"Error initiating Microsoft auth login: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to initiate Microsoft login: {str(e)}", ) @router.get("/auth/login/callback") async def auth_login_callback( code: str, state: str, request: Request, response: Response ) -> HTMLResponse: state_data = _parse_oauth_state(state) if state_data.get("flow") != _FLOW_LOGIN: raise HTTPException(status_code=400, detail=routeApiMsg("Invalid OAuth flow for this callback")) _require_msft_auth_config() msal_app = msal.ConfidentialClientApplication( AUTH_CLIENT_ID, authority=AUTHORITY, client_credential=AUTH_CLIENT_SECRET, ) token_response = msal_app.acquire_token_by_authorization_code( code, scopes=msftAuthScopes, redirect_uri=AUTH_REDIRECT_URI, ) if "error" in token_response: err = token_response.get("error_description", token_response.get("error")) return HTMLResponse( content=f"

Authentication Failed

{err}

", status_code=400, ) headers = { "Authorization": f"Bearer {token_response['access_token']}", "Content-Type": "application/json", } async with httpx.AsyncClient() as client: user_info_response = await client.get( "https://graph.microsoft.com/v1.0/me", headers=headers ) if user_info_response.status_code != 200: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=routeApiMsg("Failed to get user info from Microsoft"), ) user_info = user_info_response.json() rootInterface = getRootInterface() user = rootInterface.getUserByUsername(user_info.get("userPrincipalName")) if not user: user = rootInterface.createUser( username=user_info.get("userPrincipalName"), email=user_info.get("mail"), fullName=user_info.get("displayName"), authenticationAuthority=AuthAuthority.MSFT, externalId=user_info.get("id"), externalUsername=user_info.get("userPrincipalName"), externalEmail=user_info.get("mail"), addExternalIdentityConnection=False, ) jwt_token_data = { "sub": user.username, "userId": str(user.id), "authenticationAuthority": AuthAuthority.MSFT.value, } jwt_token, jwt_expires_at = createAccessToken(jwt_token_data) refresh_token_cookie, _refresh_expires = createRefreshToken(jwt_token_data) payload = jose_jwt.decode(jwt_token, SECRET_KEY, algorithms=[ALGORITHM]) jti = payload.get("jti") jwt_token_obj = Token( id=jti, userId=user.id, authority=AuthAuthority.MSFT, tokenPurpose=TokenPurpose.AUTH_SESSION, tokenAccess=jwt_token, tokenType="bearer", expiresAt=jwt_expires_at.timestamp(), createdAt=getUtcTimestamp(), tokenRefresh="", ) appInterface = getInterface(user) appInterface.saveAccessToken(jwt_token_obj) token_dict = jwt_token_obj.model_dump() html_response = HTMLResponse( content=f""" Authentication Successful """ ) setAccessTokenCookie(html_response, jwt_token, expiresDelta=None) setRefreshTokenCookie(html_response, refresh_token_cookie) return html_response @router.get("/auth/connect") @limiter.limit("5/minute") def auth_connect( request: Request, connectionId: str = Query(..., description="UserConnection id"), reauth: Optional[int] = Query(0, description="If 1, force the consent screen so newly added scopes are granted"), currentUser: User = Depends(getCurrentUser), ) -> RedirectResponse: """Start Microsoft Data OAuth for an existing connection. With ``reauth=1`` the consent screen is forced (``prompt=consent``) so the user re-grants permissions and any newly added scopes (e.g. Calendars.Read, Contacts.Read) actually land on the access token. """ try: _require_msft_data_config() interface = getInterface(currentUser) connections = interface.getUserConnections(currentUser.id) connection = None for conn in connections: if conn.id == connectionId and conn.authority == AuthAuthority.MSFT: connection = conn break if not connection: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("Microsoft connection not found") ) msal_app = msal.ConfidentialClientApplication( DATA_CLIENT_ID, authority=AUTHORITY, client_credential=DATA_CLIENT_SECRET, ) state_jwt = _issue_oauth_state( { "flow": _FLOW_CONNECT, "connectionId": connectionId, "userId": str(currentUser.id), } ) login_kwargs: Dict[str, Any] = {"prompt": "select_account", "state": state_jwt} login_hint = connection.externalEmail or connection.externalUsername if login_hint: login_kwargs["login_hint"] = login_hint if "@" in login_hint: login_kwargs["domain_hint"] = login_hint.split("@", 1)[1] login_kwargs["prompt"] = "login" if reauth: login_kwargs["prompt"] = "consent" auth_url = msal_app.get_authorization_request_url( scopes=msftDataScopes, redirect_uri=DATA_REDIRECT_URI, **login_kwargs, ) return RedirectResponse(auth_url) except HTTPException: raise except Exception as e: logger.error(f"Error initiating Microsoft connect: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to initiate Microsoft connect: {str(e)}", ) @router.get("/auth/connect/callback") async def auth_connect_callback( code: str, state: str, request: Request, response: Response ) -> HTMLResponse: state_data = _parse_oauth_state(state) if state_data.get("flow") != _FLOW_CONNECT: raise HTTPException(status_code=400, detail=routeApiMsg("Invalid OAuth flow for this callback")) connection_id = state_data.get("connectionId") user_id = state_data.get("userId") if not connection_id or not user_id: raise HTTPException(status_code=400, detail=routeApiMsg("Missing connection or user in OAuth state")) _require_msft_data_config() msal_app = msal.ConfidentialClientApplication( DATA_CLIENT_ID, authority=AUTHORITY, client_credential=DATA_CLIENT_SECRET, ) token_response = msal_app.acquire_token_by_authorization_code( code, scopes=msftDataScopes, redirect_uri=DATA_REDIRECT_URI, ) if "error" in token_response: err = token_response.get("error_description", token_response.get("error")) return HTMLResponse( content=f""" """, status_code=400, ) headers = { "Authorization": f"Bearer {token_response['access_token']}", "Content-Type": "application/json", } async with httpx.AsyncClient() as client: user_info_response = await client.get( "https://graph.microsoft.com/v1.0/me", headers=headers ) if user_info_response.status_code != 200: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=routeApiMsg("Failed to get user info from Microsoft"), ) user_info = user_info_response.json() scope_str = token_response.get("scope") or msftDataScopesForRefresh() granted_list = scope_str.split() if isinstance(scope_str, str) else list(msftDataScopes) rootInterface = getRootInterface() user = rootInterface.getUser(user_id) if not user: return HTMLResponse( content=""" """, status_code=404, ) interface = getInterface(user) connections = interface.getUserConnections(user_id) connection = None for conn in connections: if conn.id == connection_id: connection = conn break if not connection: return HTMLResponse( content=""" """, status_code=404, ) try: connection.status = ConnectionStatus.ACTIVE connection.lastChecked = getUtcTimestamp() connection.expiresAt = getUtcTimestamp() + token_response.get("expires_in", 0) connection.externalId = user_info.get("id") connection.externalUsername = user_info.get("userPrincipalName") connection.externalEmail = user_info.get("mail") connection.grantedScopes = ( granted_list if isinstance(granted_list, list) else list(msftDataScopes) ) rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump()) token = Token( userId=user.id, authority=AuthAuthority.MSFT, connectionId=connection_id, tokenPurpose=TokenPurpose.DATA_CONNECTION, tokenAccess=token_response["access_token"], tokenRefresh=token_response.get("refresh_token", ""), tokenType=token_response.get("token_type", "bearer"), expiresAt=createExpirationTimestamp(token_response.get("expires_in", 0)), createdAt=getUtcTimestamp(), ) interface.saveConnectionToken(token) return HTMLResponse( content=f""" Connection Successful """ ) except Exception as e: logger.error(f"Error updating Microsoft connection: {str(e)}", exc_info=True) return HTMLResponse( content=f""" """, status_code=500, ) @router.get("/adminconsent") @limiter.limit("5/minute") def adminconsent(request: Request) -> RedirectResponse: """Tenant admin grants delegated Graph permissions for the Data app (msftDataScopes only). Uses the v2.0 admin consent endpoint (not /oauth2/v2.0/authorize with prompt=admin_consent, which returns AADSTS901001). The ``scope`` parameter limits consent to the listed delegated permissions instead of every API permission on the app registration. """ _require_msft_data_config() redirect_uri = _admin_consent_redirect_uri() if not redirect_uri: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=routeApiMsg("Could not derive admin consent redirect URI from Service_MSFT_DATA_REDIRECT_URI"), ) state_jwt = _issue_oauth_state({"flow": "admin_consent"}) scope_param = _msft_data_admin_consent_scope_param() # /v2.0/adminconsent rejects /common and /consumers — use the concrete tenant # or fall back to /organizations (lets Microsoft resolve to the admin's tenant). consent_tenant = TENANT_ID if TENANT_ID not in ("common", "consumers") else "organizations" consent_authority = f"https://login.microsoftonline.com/{consent_tenant}" admin_url = ( f"{consent_authority}/v2.0/adminconsent" f"?client_id={quote(DATA_CLIENT_ID, safe='')}" f"&redirect_uri={quote(redirect_uri, safe='')}" f"&scope={quote(scope_param, safe='')}" f"&state={quote(state_jwt, safe='')}" ) logger.info(f"Redirecting to v2.0 admin consent for tenant: {TENANT_ID}") return RedirectResponse(admin_url) @router.get("/adminconsent/callback") def adminconsent_callback( request: Request, state: Optional[str] = Query(None, description="OAuth state JWT returned by Microsoft"), admin_consent: Optional[str] = Query(None), tenant: Optional[str] = Query(None), error: Optional[str] = Query(None), error_description: Optional[str] = Query(None), ) -> HTMLResponse: """Handle v2.0/adminconsent redirect (admin_consent=True, tenant=..., state=...).""" try: if error: logger.error(f"Admin consent error: {error} - {error_description}") return HTMLResponse( content=f""" Admin Consent Failed

Admin Consent Failed

Error: {error}

Description: {error_description or 'No description provided'}

""", status_code=400, ) if not state: logger.error("Admin consent success callback missing state") return HTMLResponse( content=""" Admin Consent Failed

Admin Consent Failed

Parameter „state“ fehlt.

""", status_code=400, ) state_data = _parse_oauth_state(state) if state_data.get("flow") != "admin_consent": raise HTTPException(status_code=400, detail=routeApiMsg("Invalid OAuth flow for this callback")) granted = str(admin_consent or "").strip().lower() in ("true", "1", "yes") if not granted: logger.error("Admin consent callback missing admin_consent=true") return HTMLResponse( content=""" Admin Consent Failed

Admin Consent Failed

Die Administratorzustimmung wurde nicht bestätigt (admin_consent fehlt oder ist falsch).

""", status_code=400, ) if not tenant: logger.error("Admin consent callback missing tenant id") return HTMLResponse( content=""" Admin Consent Failed

Admin Consent Failed

Keine Tenant-ID in der Antwort (tenant fehlt).

""", status_code=400, ) logger.info(f"Admin consent granted for tenant: {tenant}") return HTMLResponse( content=f""" Admin Consent Successful

Admin Consent Successful

Die Berechtigungen wurden für den Tenant erteilt.

Tenant: {tenant}

""" ) except HTTPException: raise except Exception as e: logger.error(f"Error in admin consent callback: {str(e)}", exc_info=True) return HTMLResponse( content=f"

Error

{str(e)}

", status_code=500, ) @router.get("/me", response_model=User) @limiter.limit("30/minute") def get_current_user( request: Request, currentUser: User = Depends(getCurrentUser), ) -> User: return currentUser @router.post("/logout") @limiter.limit("10/minute") def logout( request: Request, currentUser: User = Depends(getCurrentUser), ) -> JSONResponse: """ End only the PowerOn gateway session (JWT + DB token row). Does not sign the user out of Microsoft in the browser (no AAD logout redirect). """ try: appInterface = getInterface(currentUser) token = request.cookies.get("auth_token") if not token: auth_header = request.headers.get("Authorization") if auth_header and auth_header.lower().startswith("bearer "): token = auth_header.split(" ", 1)[1].strip() if not token: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg("No token found"), ) try: payload = jose_jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) session_id = payload.get("sid") or payload.get("sessionId") jti = payload.get("jti") except Exception as e: logger.error(f"Failed to decode JWT on Microsoft logout: {str(e)}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg("Invalid token"), ) revoked = 0 if session_id: revoked = appInterface.revokeTokensBySessionId( session_id, currentUser.id, AuthAuthority.MSFT, revokedBy=currentUser.id, reason="logout", ) elif jti: appInterface.revokeTokenById(jti, revokedBy=currentUser.id, reason="logout") revoked = 1 try: from modules.shared.auditLogger import audit_logger audit_logger.logUserAccess( userId=str(currentUser.id), mandateId="system", action="logout", successInfo=f"microsoft_gateway_logout revoked={revoked}", ipAddress=request.client.host if request.client else None, userAgent=request.headers.get("user-agent"), success=True, ) except Exception: pass json_response = JSONResponse( { "message": "Successfully logged out from application (Microsoft account stays signed in elsewhere)", "revokedTokens": revoked, } ) clearAccessTokenCookie(json_response) clearRefreshTokenCookie(json_response) return json_response except HTTPException: raise except Exception as e: logger.error(f"Error during logout: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to logout: {str(e)}", ) @router.post("/refresh") @limiter.limit("10/minute") async def refresh_token( request: Request, currentUser: User = Depends(getCurrentUser), ) -> Dict[str, Any]: try: appInterface = getInterface(currentUser) payload = {} try: payload = await request.json() except Exception: payload = {} requested_connection_id = ( payload.get("connectionId") if isinstance(payload, dict) else None ) connections = appInterface.getUserConnections(currentUser.id) msft_connection = None if requested_connection_id: for conn in connections: if conn.id == requested_connection_id and conn.authority == AuthAuthority.MSFT: msft_connection = conn break if not msft_connection: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("Requested Microsoft connection not found for current user"), ) else: for conn in connections: if conn.authority == AuthAuthority.MSFT: msft_connection = conn break if not msft_connection: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("No Microsoft connection found for current user"), ) current_token = TokenManager().getFreshToken(msft_connection.id) if not current_token: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("No Microsoft token found for this connection"), ) token_manager = TokenManager() refreshed_token = token_manager.refreshToken(current_token) if refreshed_token: appInterface.saveConnectionToken(refreshed_token) expires_at_val = parseTimestamp(refreshed_token.expiresAt) if expires_at_val: msft_connection.expiresAt = expires_at_val msft_connection.lastChecked = getUtcTimestamp() msft_connection.status = ConnectionStatus.ACTIVE appInterface.db.recordModify( UserConnection, msft_connection.id, msft_connection.model_dump() ) current_time = getUtcTimestamp() expires_at = parseTimestamp(refreshed_token.expiresAt) expires_in = int(expires_at - current_time) if expires_at else 0 return { "message": "Token refreshed successfully", "expires_at": expires_at, "expires_in_seconds": expires_in, } raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=routeApiMsg("Failed to refresh token"), ) except HTTPException: raise except Exception as e: logger.error(f"Error refreshing Microsoft token: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to refresh token: {str(e)}", )