auth scopes

This commit is contained in:
ValueOn AG 2025-08-17 01:23:46 +02:00
parent d642fa0f07
commit 2d269137a5
3 changed files with 574 additions and 42 deletions

View file

@ -643,9 +643,16 @@ class MethodDocument(MethodBase):
# Clean up the AI response and ensure it's valid HTML
if not aiReport.strip().startswith('<html'):
# Check if AI response already contains a title/header
has_title = any(title.lower() in aiReport.lower() for title in [title, "outlook", "report", "status"])
# Wrap the AI content in proper HTML structure
html = ["<html><head><meta charset='utf-8'><title>" + title + "</title></head><body>"]
html.append(f"<h1>{title}</h1>")
# Only add the title if the AI response doesn't already have one
if not has_title:
html.append(f"<h1>{title}</h1>")
html.append(f"<p><b>Generated:</b> {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}</p>")
html.append(f"<p><b>Total Documents Analyzed:</b> {len(validDocuments)}</p>")
html.append("<hr>")
@ -666,7 +673,25 @@ class MethodDocument(MethodBase):
Generate a basic HTML report as fallback when AI generation fails.
"""
html = ["<html><head><meta charset='utf-8'><title>" + title + "</title></head><body>"]
html.append(f"<h1>{title}</h1>")
# Check if any document content already contains a title/header
has_title = False
for doc in chatDocuments:
if hasattr(doc, 'fileId') and doc.fileId:
try:
file_data = self.service.getFileData(doc.fileId)
if file_data:
content = file_data.decode('utf-8') if isinstance(file_data, bytes) else str(file_data)
if any(title.lower() in content.lower() for title in [title, "outlook", "report", "status"]):
has_title = True
break
except Exception:
continue
# Only add the title if no document content already has one
if not has_title:
html.append(f"<h1>{title}</h1>")
html.append(f"<p><b>Generated:</b> {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}</p>")
html.append(f"<p><b>Total Documents:</b> {len(chatDocuments)}</p>")

View file

@ -10,6 +10,7 @@ import json
import uuid
from modules.chat.methodBase import MethodBase, ActionResult, action
from modules.interfaces.interfaceAppModel import ConnectionStatus
logger = logging.getLogger(__name__)
@ -23,35 +24,31 @@ class MethodOutlook(MethodBase):
self.description = "Handle Microsoft Outlook email operations"
def _getMicrosoftConnection(self, connectionReference: str) -> Optional[Dict[str, Any]]:
"""Get Microsoft connection from connection reference"""
"""
Helper function to get Microsoft connection details.
"""
try:
userConnection = self.service.getUserConnectionFromConnectionReference(connectionReference)
# Get the connection from the service
userConnection = self.service.getUserConnection(connectionReference)
if not userConnection:
logger.warning(f"No user connection found for reference: {connectionReference}")
return None
if userConnection.authority.value != "msft":
logger.warning(f"Connection {userConnection.id} is not Microsoft (authority: {userConnection.authority.value})")
logger.error(f"Connection not found: {connectionReference}")
return None
# Check if connection is active or pending (pending means OAuth in progress)
if userConnection.status.value not in ["active", "pending"]:
logger.warning(f"Connection {userConnection.id} status is not active/pending: {userConnection.status.value}")
return None
# Get the corresponding token for this user and authority
token = self.service.interfaceApp.getToken(userConnection.authority.value)
# Get the token for this connection
token = self.service.getTokenForConnection(userConnection.id)
if not token:
logger.warning(f"No token found for user {userConnection.userId} and authority {userConnection.authority.value}")
logger.error(f"Token not found for connection: {userConnection.id}")
return None
# Check if token is expired
if hasattr(token, 'expiresAt') and token.expiresAt:
import time
current_time = time.time()
if current_time > token.expiresAt:
logger.warning(f"Token for connection {userConnection.id} is expired (expiresAt: {token.expiresAt}, current: {current_time})")
return None
# Check if token is valid
if not token.isValid():
logger.error(f"Token is invalid for connection: {userConnection.id}")
return None
# Check if connection is active
if userConnection.status != ConnectionStatus.active:
logger.error(f"Connection is not active: {userConnection.id}, status: {userConnection.status.value}")
return None
logger.info(f"Successfully retrieved Microsoft connection: {userConnection.id}, status: {userConnection.status.value}, externalId: {userConnection.externalId}")
@ -59,11 +56,43 @@ class MethodOutlook(MethodBase):
"id": userConnection.id,
"accessToken": token.tokenAccess,
"refreshToken": token.tokenRefresh,
"scopes": ["Mail.ReadWrite", "User.Read"] # Default Microsoft scopes
"scopes": ["Mail.ReadWrite", "Mail.Send", "Mail.ReadWrite.Shared", "User.Read"] # Valid Microsoft Graph API scopes
}
except Exception as e:
logger.error(f"Error getting Microsoft connection: {str(e)}")
return None
async def _checkPermissions(self, connection: Dict[str, Any]) -> bool:
"""
Check if the current connection has the necessary permissions for Outlook operations.
"""
try:
import requests
graph_url = "https://graph.microsoft.com/v1.0"
headers = {
"Authorization": f"Bearer {connection['accessToken']}",
"Content-Type": "application/json"
}
# Test permissions by trying to access the user's mail folder
test_url = f"{graph_url}/me/mailFolders"
response = requests.get(test_url, headers=headers)
if response.status_code == 200:
logger.info("✅ Permission check passed - connection has necessary mail permissions")
return True
elif response.status_code == 403:
logger.error("❌ Permission denied - connection lacks necessary mail permissions")
logger.error("Required scopes: Mail.ReadWrite, Mail.Send, Mail.ReadWrite.Shared")
return False
else:
logger.warning(f"⚠️ Permission check returned status {response.status_code}")
return False
except Exception as e:
logger.error(f"Error checking permissions: {str(e)}")
return False
def _sanitizeSearchQuery(self, query: str) -> str:
"""
@ -104,14 +133,15 @@ class MethodOutlook(MethodBase):
while avoiding conflicts between $search and $filter parameters.
"""
params = {
"$top": limit,
"$orderby": "receivedDateTime desc"
"$top": limit
}
if not query or not query.strip():
# No query specified, just get emails from folder
if folder and folder.lower() != "all":
params["$filter"] = f"parentFolderId eq '{folder}'"
# Add orderby for basic queries
params["$orderby"] = "receivedDateTime desc"
return params
clean_query = self._sanitizeSearchQuery(query)
@ -123,22 +153,28 @@ class MethodOutlook(MethodBase):
params["$search"] = f'"{clean_query}"'
logger.info(f"Using advanced search query: {clean_query}")
# Note: When using $search, we cannot combine it with $filter for folder
# Note: When using $search, we cannot combine it with $orderby or $filter for folder
# We'll need to filter results after the API call
if folder and folder.lower() != "all":
logger.info(f"Will filter results by folder '{folder}' after search")
else:
# Use $filter for basic text search in subject and body
# Microsoft Graph API supports contains() for text search
filter_parts = [f"contains(subject,'{clean_query}') or contains(body/content,'{clean_query}')"]
# Use $filter for basic text search, but keep it simple to avoid "InefficientFilter" error
# Microsoft Graph API has limitations on complex filters
if len(clean_query) > 50:
# If query is too long, truncate it to avoid complex filter issues
clean_query = clean_query[:50]
logger.info(f"Query truncated to avoid complex filter: {clean_query}")
# Use only subject search to keep filter simple
params["$filter"] = f"contains(subject,'{clean_query}')"
# Add folder filter if specified
if folder and folder.lower() != "all":
filter_parts.append(f"parentFolderId eq '{folder}'")
params["$filter"] = f"{params['$filter']} and parentFolderId eq '{folder}'"
# Combine all filter parts
params["$filter"] = " and ".join(f"({part})" for part in filter_parts)
logger.info(f"Using basic text search filter: {clean_query}")
# Add orderby for basic queries
params["$orderby"] = "receivedDateTime desc"
logger.info(f"Using simple text search filter: {clean_query}")
return params
@ -163,11 +199,36 @@ class MethodOutlook(MethodBase):
if response.status_code == 200:
folders_data = response.json()
for folder in folders_data.get("value", []):
all_folders = folders_data.get("value", [])
# Log all available folders for debugging
logger.info(f"Available folders: {[f.get('displayName', 'Unknown') for f in all_folders]}")
# Try exact match first
for folder in all_folders:
if folder.get("displayName", "").lower() == folder_name.lower():
logger.info(f"Found folder '{folder_name}' with ID: {folder.get('id')}")
return folder.get("id")
logger.warning(f"Folder '{folder_name}' not found")
# Try common variations for Drafts folder
if folder_name.lower() == "drafts":
draft_variations = ["drafts", "draft", "entwürfe", "entwurf", "brouillons", "brouillon"]
for folder in all_folders:
folder_display_name = folder.get("displayName", "").lower()
if any(variation in folder_display_name for variation in draft_variations):
logger.info(f"Found Drafts folder variation '{folder.get('displayName')}' with ID: {folder.get('id')}")
return folder.get("id")
# Try common variations for other folders
if folder_name.lower() == "sent items":
sent_variations = ["sent items", "sent", "gesendete elemente", "éléments envoyés"]
for folder in all_folders:
folder_display_name = folder.get("displayName", "").lower()
if any(variation in folder_display_name for variation in sent_variations):
logger.info(f"Found Sent Items folder variation '{folder.get('displayName')}' with ID: {folder.get('id')}")
return folder.get("id")
logger.warning(f"Folder '{folder_name}' not found. Available folders: {[f.get('displayName', 'Unknown') for f in all_folders]}")
return None
else:
logger.warning(f"Could not retrieve folders: {response.status_code}")
@ -372,7 +433,16 @@ class MethodOutlook(MethodBase):
return self._createResult(
success=False,
data={},
error="No valid Microsoft connection found for the provided connection reference"
error="Failed to get Microsoft connection"
)
# Check permissions before proceeding
permissions_ok = await self._checkPermissions(connection)
if not permissions_ok:
return self._createResult(
success=False,
data={},
error="Connection lacks necessary permissions. Please re-authenticate with updated permissions."
)
# Create email draft using Microsoft Graph API
@ -428,10 +498,24 @@ class MethodOutlook(MethodBase):
api_url = f"{graph_url}/me/messages"
logger.warning("Could not find Drafts folder, creating draft in default location")
logger.info(f"Creating draft with API URL: {api_url}")
logger.info(f"Draft message data: {json.dumps(message, indent=2)}")
response = requests.post(api_url, headers=headers, json=message)
response.raise_for_status()
draft_data = response.json()
logger.info(f"Draft creation response: {json.dumps(draft_data, indent=2)}")
# Verify the draft was created in the correct folder
created_folder_id = draft_data.get("parentFolderId")
if created_folder_id:
if drafts_folder_id and created_folder_id == drafts_folder_id:
logger.info(f"✅ Draft successfully created in Drafts folder (ID: {created_folder_id})")
else:
logger.warning(f"⚠️ Draft created in different folder than expected. Expected: {drafts_folder_id}, Actual: {created_folder_id}")
else:
logger.warning("⚠️ Draft created but no folder ID returned")
# Get the actual folder information for the created draft
actual_folder = "Drafts"
@ -454,12 +538,25 @@ class MethodOutlook(MethodBase):
"attachments": len(attachments) if attachments else 0,
"draftLocation": actual_folder,
"draftsFolderId": drafts_folder_id,
"createdFolderId": created_folder_id,
"apiResponse": response.status_code,
"draftData": draft_data
}
logger.info(f"Successfully created email draft for {len(to)} recipients with {len(attachments) if attachments else 0} attachments in {actual_folder}")
# Additional verification: try to retrieve the draft to confirm it exists
try:
verify_url = f"{graph_url}/me/messages/{draft_data.get('id')}"
verify_response = requests.get(verify_url, headers=headers)
if verify_response.status_code == 200:
verify_data = verify_response.json()
logger.info(f"✅ Draft verification successful - Draft ID: {verify_data.get('id')}, Subject: {verify_data.get('subject')}")
else:
logger.warning(f"⚠️ Draft verification failed - Status: {verify_response.status_code}")
except Exception as e:
logger.warning(f"⚠️ Draft verification error: {str(e)}")
except ImportError:
logger.error("requests module not available, falling back to simulation")
# Fallback to simulation if requests module is not available
@ -953,6 +1050,410 @@ class MethodOutlook(MethodBase):
except Exception as e:
logger.error(f"Error listing drafts: {str(e)}")
return self._createResult(
success=False,
data={},
error=str(e)
)
@action
async def findDrafts(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Find email drafts across all folders in Outlook
Parameters:
connectionReference (str): Reference to the Microsoft connection
limit (int, optional): Maximum number of drafts to find (default: 50)
expectedDocumentFormats (list, optional): Expected document formats with extension, mimeType, description
"""
try:
connectionReference = parameters.get("connectionReference")
limit = parameters.get("limit", 50)
expectedDocumentFormats = parameters.get("expectedDocumentFormats", [])
if not connectionReference:
return self._createResult(
success=False,
data={},
error="Connection reference is required"
)
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return self._createResult(
success=False,
data={},
error="No valid Microsoft connection found for the provided connection reference"
)
# Find drafts using Microsoft Graph API
try:
import requests
# Microsoft Graph API endpoint for messages
graph_url = "https://graph.microsoft.com/v1.0"
headers = {
"Authorization": f"Bearer {connection['accessToken']}",
"Content-Type": "application/json"
}
# Get all messages and filter for drafts
api_url = f"{graph_url}/me/messages"
params = {
"$top": limit,
"$select": "id,subject,from,toRecipients,ccRecipients,bccRecipients,receivedDateTime,lastModifiedDateTime,parentFolderId,isDraft,webLink",
"$filter": "isDraft eq true"
}
logger.info(f"Searching for drafts across all folders")
# Make the API call
response = requests.get(api_url, headers=headers, params=params)
response.raise_for_status()
messages_data = response.json()
drafts = messages_data.get("value", [])
# Get folder information for each draft
for draft in drafts:
if "parentFolderId" in draft:
folder_info = self._getFolderNameById(draft["parentFolderId"], connection)
draft["folderName"] = folder_info
drafts_result = {
"totalDrafts": len(drafts),
"drafts": drafts,
"limit": limit,
"apiResponse": messages_data
}
logger.info(f"Successfully found {len(drafts)} drafts across all folders")
except ImportError:
logger.error("requests module not available, falling back to simulation")
# Fallback to simulation
drafts_prompt = f"""
Simulate finding email drafts in Microsoft Outlook.
Connection: {connection['id']}
Limit: {limit}
Please provide:
1. List of email drafts with subject, recipients, and location
2. Draft status and folder information
3. Summary of draft statistics
"""
drafts_result = await self.service.interfaceAiCalls.callAiTextAdvanced(drafts_prompt)
except Exception as e:
logger.error(f"Error finding drafts via Microsoft Graph API: {str(e)}")
# Fallback to simulation on API error
drafts_prompt = f"""
Simulate finding email drafts in Microsoft Outlook.
Connection: {connection['id']}
Limit: {limit}
Please provide:
1. List of email drafts with subject, recipients, and location
2. Draft status and folder information
3. Summary of draft statistics
"""
drafts_result = await self.service.interfaceAiCalls.callAiTextAdvanced(drafts_prompt)
# Create result data
result_data = {
"connectionReference": connectionReference,
"limit": limit,
"draftsResult": drafts_result,
"connection": {
"id": connection["id"],
"authority": "microsoft",
"reference": connectionReference
},
"timestamp": datetime.now(UTC).isoformat()
}
# Determine output format based on expected formats
output_extension = ".json" # Default
output_mime_type = "application/json" # Default
if expectedDocumentFormats and len(expectedDocumentFormats) > 0:
# Use the first expected format
expected_format = expectedDocumentFormats[0]
output_extension = expected_format.get("extension", ".json")
output_mime_type = expected_format.get("mimeType", "application/json")
logger.info(f"Using expected format: {output_extension} ({output_mime_type})")
else:
logger.info("No expected format specified, using default .json format")
return self._createResult(
success=True,
data={
"documents": [
{
"documentName": f"outlook_drafts_found_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}{output_extension}",
"documentData": result_data,
"mimeType": output_mime_type
}
]
}
)
except Exception as e:
logger.error(f"Error finding drafts: {str(e)}")
return self._createResult(
success=False,
data={},
error=str(e)
)
def _getFolderNameById(self, folder_id: str, connection: Dict[str, Any]) -> str:
"""
Get folder name by folder ID
This is a helper method to identify which folder a draft is in
"""
try:
import requests
graph_url = "https://graph.microsoft.com/v1.0"
headers = {
"Authorization": f"Bearer {connection['accessToken']}",
"Content-Type": "application/json"
}
# Get folder information
api_url = f"{graph_url}/me/mailFolders/{folder_id}"
response = requests.get(api_url, headers=headers)
if response.status_code == 200:
folder_data = response.json()
return folder_data.get("displayName", f"Unknown Folder ({folder_id})")
else:
return f"Unknown Folder ({folder_id})"
except Exception as e:
logger.warning(f"Error getting folder name for ID '{folder_id}': {str(e)}")
return f"Unknown Folder ({folder_id})"
@action
async def checkDraftsFolder(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Check the contents of the Drafts folder directly
Parameters:
connectionReference (str): Reference to the Microsoft connection
limit (int, optional): Maximum number of drafts to check (default: 20)
expectedDocumentFormats (list, optional): Expected document formats with extension, mimeType, description
"""
try:
connectionReference = parameters.get("connectionReference")
limit = parameters.get("limit", 20)
expectedDocumentFormats = parameters.get("expectedDocumentFormats", [])
if not connectionReference:
return self._createResult(
success=False,
data={},
error="Connection reference is required"
)
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return self._createResult(
success=False,
data={},
error="No valid Microsoft connection found for the provided connection reference"
)
# Check Drafts folder directly
try:
import requests
# Microsoft Graph API endpoint for messages
graph_url = "https://graph.microsoft.com/v1.0"
headers = {
"Authorization": f"Bearer {connection['accessToken']}",
"Content-Type": "application/json"
}
# Get the Drafts folder ID
drafts_folder_id = self._getFolderId("Drafts", connection)
if not drafts_folder_id:
return self._createResult(
success=False,
data={},
error="Could not find Drafts folder"
)
# Get messages directly from Drafts folder
api_url = f"{graph_url}/me/mailFolders/{drafts_folder_id}/messages"
params = {
"$top": limit,
"$select": "id,subject,from,toRecipients,ccRecipients,bccRecipients,receivedDateTime,lastModifiedDateTime,isDraft,webLink",
"$orderby": "lastModifiedDateTime desc"
}
logger.info(f"Checking Drafts folder directly: {api_url}")
# Make the API call
response = requests.get(api_url, headers=headers, params=params)
response.raise_for_status()
messages_data = response.json()
drafts = messages_data.get("value", [])
# Log detailed information about each draft
for i, draft in enumerate(drafts):
logger.info(f"Draft {i+1}: ID={draft.get('id')}, Subject='{draft.get('subject')}', Modified={draft.get('lastModifiedDateTime')}")
drafts_result = {
"draftsFolderId": drafts_folder_id,
"totalDrafts": len(drafts),
"drafts": drafts,
"limit": limit,
"apiResponse": messages_data,
"apiUrl": api_url
}
logger.info(f"Successfully checked Drafts folder: found {len(drafts)} drafts")
except ImportError:
logger.error("requests module not available, falling back to simulation")
# Fallback to simulation
drafts_prompt = f"""
Simulate checking Drafts folder in Microsoft Outlook.
Connection: {connection['id']}
Limit: {limit}
Please provide:
1. List of email drafts in the Drafts folder
2. Draft details and status
3. Summary of draft contents
"""
drafts_result = await self.service.interfaceAiCalls.callAiTextAdvanced(drafts_prompt)
except Exception as e:
logger.error(f"Error checking Drafts folder via Microsoft Graph API: {str(e)}")
# Fallback to simulation on API error
drafts_prompt = f"""
Simulate checking Drafts folder in Microsoft Outlook.
Connection: {connection['id']}
Limit: {limit}
Please provide:
1. List of email drafts in the Drafts folder
2. Draft details and status
3. Summary of draft contents
"""
drafts_result = await self.service.interfaceAiCalls.callAiTextAdvanced(drafts_prompt)
# Create result data
result_data = {
"connectionReference": connectionReference,
"limit": limit,
"draftsResult": drafts_result,
"connection": {
"id": connection["id"],
"authority": "microsoft",
"reference": connectionReference
},
"timestamp": datetime.now(UTC).isoformat()
}
# Determine output format based on expected formats
output_extension = ".json" # Default
output_mime_type = "application/json" # Default
if expectedDocumentFormats and len(expectedDocumentFormats) > 0:
# Use the first expected format
expected_format = expectedDocumentFormats[0]
output_extension = expected_format.get("extension", ".json")
output_mime_type = expected_format.get("mimeType", "application/json")
logger.info(f"Using expected format: {output_extension} ({output_mime_type})")
else:
logger.info("No expected format specified, using default .json format")
return self._createResult(
success=True,
data={
"documents": [
{
"documentName": f"outlook_drafts_folder_check_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}{output_extension}",
"documentData": result_data,
"mimeType": output_mime_type
}
]
}
)
except Exception as e:
logger.error(f"Error checking Drafts folder: {str(e)}")
return self._createResult(
success=False,
data={},
error=str(e)
)
@action
async def checkPermissions(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Check if the current Microsoft connection has the necessary permissions for Outlook operations.
Parameters:
connectionReference (str): Reference to the Microsoft connection to check
"""
try:
connectionReference = parameters.get("connectionReference")
if not connectionReference:
return self._createResult(
success=False,
data={},
error="Connection reference is required"
)
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return self._createResult(
success=False,
data={},
error="Failed to get Microsoft connection"
)
# Check permissions
permissions_ok = await self._checkPermissions(connection)
if permissions_ok:
return self._createResult(
success=True,
data={
"permissions": "✅ All necessary permissions are available",
"scopes": connection.get("scopes", []),
"connectionId": connection.get("id"),
"status": "ready"
}
)
else:
return self._createResult(
success=False,
data={
"permissions": "❌ Missing necessary permissions",
"requiredScopes": ["Mail.ReadWrite", "Mail.Send", "Mail.ReadWrite.Shared", "User.Read"],
"currentScopes": connection.get("scopes", []),
"connectionId": connection.get("id"),
"status": "needs_reauthentication",
"message": "Please re-authenticate your Microsoft connection to get updated permissions."
},
error="Connection lacks necessary permissions for Outlook operations"
)
except Exception as e:
logger.error(f"Error checking permissions: {str(e)}")
return self._createResult(
success=False,
data={},

View file

@ -39,7 +39,12 @@ CLIENT_SECRET = APP_CONFIG.get("Service_MSFT_CLIENT_SECRET")
TENANT_ID = APP_CONFIG.get("Service_MSFT_TENANT_ID", "common")
REDIRECT_URI = APP_CONFIG.get("Service_MSFT_REDIRECT_URI")
AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
SCOPES = ["Mail.ReadWrite", "User.Read"]
SCOPES = [
"Mail.ReadWrite", # Read and write mail
"Mail.Send", # Send mail
"Mail.ReadWrite.Shared", # Access shared mailboxes
"User.Read" # Read user profile
]
@router.get("/login")
@limiter.limit("5/minute")
@ -69,8 +74,9 @@ async def login(
"connectionId": connectionId
})
# MSAL automatically adds openid, profile, offline_access - we just need to provide our business scopes
auth_url = msal_app.get_authorization_request_url(
scopes=SCOPES,
scopes=SCOPES, # Only our business scopes - MSAL adds the required ones automatically
redirect_uri=REDIRECT_URI,
state=state_param,
prompt="select_account" # Force account selection screen
@ -104,10 +110,10 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse
client_credential=CLIENT_SECRET
)
# Get token from code
# Get token from code - MSAL automatically handles the required scopes
token_response = msal_app.acquire_token_by_authorization_code(
code,
scopes=SCOPES,
scopes=SCOPES, # Only our business scopes - MSAL adds the required ones automatically
redirect_uri=REDIRECT_URI
)