diff --git a/Packs/MicrosoftGraphUser/Integrations/MicrosoftGraphUser/MicrosoftGraphUser.py b/Packs/MicrosoftGraphUser/Integrations/MicrosoftGraphUser/MicrosoftGraphUser.py
index 97bd79ab38a0..ce4e2f47b7cd 100644
--- a/Packs/MicrosoftGraphUser/Integrations/MicrosoftGraphUser/MicrosoftGraphUser.py
+++ b/Packs/MicrosoftGraphUser/Integrations/MicrosoftGraphUser/MicrosoftGraphUser.py
@@ -17,6 +17,8 @@
APP_NAME = "ms-graph-user"
INVALID_USER_CHARS_REGEX = re.compile(r"[%&*+/=?`{|}]")
API_VERSION: str = "v1.0"
+MFA_APP_ID = '981f26a1-7f43-403b-a875-f8b09b8cd720' # MFA app ID (not a secret, same app ID for all azure tenants)
+MAX_TIMEOUT_LIMIT = 60
def camel_case_to_readable(text):
@@ -325,6 +327,218 @@ def delete_tap_policy(self, user_id, policy_id):
"""
url_suffix = f"users/{quote(user_id)}/authentication/temporaryAccessPassMethods/{quote(policy_id)}"
self.ms_client.http_request(method="DELETE", url_suffix=url_suffix, resp_type="text")
+
+
+ def request_mfa_app_secret(self) -> str:
+ """
+ The function utilizes the MFA application ID (981f26a1-7f43-403b-a875-f8b09b8cd720) to retrieve the service principal ID.
+ Which then uses the service principal ID to retrieve the client secret.
+ The client secret has an expiration of 2 years and therefore the generation will occur only once and then retrieved from the integration context.
+ TODO: add a mechanism for renewal of the client secret if expired.
+
+ Args:
+ None.
+
+ Returns:
+ (str): the MFA application client secret.
+ """
+
+ # if not exist, generate a new client secret.
+ # Search for the service principal with the MFA application ID.
+ sp_endpoint_url_suffix = f'servicePrincipals?$filter=appId eq \'{MFA_APP_ID}\'&$select=id'
+ demisto.debug(f"Searching for Service Principal with appId: {MFA_APP_ID}...")
+ sp_data = self.ms_client.http_request(method="GET", url_suffix=sp_endpoint_url_suffix)
+
+ if not sp_data.get('value'):
+ raise DemistoException(f"Error: Service Principal with appId {MFA_APP_ID} not found.")
+
+ # Extract the Service Principal Object ID
+ service_principal_id = sp_data['value'][0]['id']
+ demisto.debug(f"Service Principal ID (Object ID) found: {service_principal_id}")
+
+ # Send request for new client secret.
+ SECRET_DISPLAY_NAME = "MFA App Secret"
+
+ # Endpoint to add a password credential to the service principal
+ secret_genertion_endpoint_url_suffix = f'servicePrincipals/{service_principal_id}/addPassword'
+
+ # Request body for adding the secret
+ secret_body = {
+ "passwordCredential": {
+ "displayName": SECRET_DISPLAY_NAME # The display name for the secret, not mandatory
+ }
+ }
+
+ demisto.debug(f"Adding new client secret with display name '{SECRET_DISPLAY_NAME}'")
+ secret_result = self.ms_client.http_request(method="POST",
+ url_suffix=secret_genertion_endpoint_url_suffix,
+ data=json.dumps(secret_body))
+
+ # The 'secretText' is the actual secret value, which is only returned *once* on creation.
+ new_secret_value = secret_result.get('secretText')
+
+ demisto.debug(f"A new client secret with the name {secret_result.get('displayName')} was created successfully. the"
+ f"secret is valid until {secret_result.get('endDateTime')}")
+
+ return new_secret_value
+
+ def get_mfa_app_client_token(self) -> str:
+ ctx = get_integration_context()
+ demisto.debug(ctx)
+ if (not (mfa_access_token := ctx.get("mfa_access_token"))) or (not (int(mfa_access_token.get("valid_until", 0)) > self.ms_client.epoch_seconds())):
+ demisto.debug("Creating new MFA access token.")
+ if not (client_secret := ctx.get("mfa_app_client_secret")):
+ raise DemistoException("There's no MFA app client secret, please run msgraph-user-create-mfa-client-secret command and retry.")
+
+ # Hardcoded endpoints
+ RESOURCE = 'https://adnotifications.windowsazure.com/StrongAuthenticationService.svc/Connector'
+ AUTH_ENDPOINT = f'https://login.microsoftonline.com/{self.ms_client.tenant_id}/oauth2/token'
+
+ # Generating MFA app access token.
+ demisto.debug("Getting MFA Client Access Token...")
+
+ token_body = {
+ 'resource': RESOURCE,
+ 'client_id': MFA_APP_ID,
+ 'client_secret': client_secret,
+ 'grant_type': "client_credentials",
+ 'scope': "openid"
+ }
+
+
+ try:
+ token_response = requests.post(AUTH_ENDPOINT, data=token_body)
+ token_response.raise_for_status()
+ res = token_response.json()
+ demisto.debug("Access token obtained successfully.")
+ access_token = res.get('access_token')
+ valid_until = res.get("expires_on")
+ mfa_access_token = {"valid_until": valid_until, "access_token": access_token}
+ ctx["mfa_access_token"] = mfa_access_token
+ set_integration_context(context=ctx)
+ demisto.debug("Access token successfully saved to context.")
+ return access_token
+
+ except requests.exceptions.HTTPError as e:
+ raise DemistoException(f"Error obtaining access token: {e}\nResponse: {token_response.text}")
+ else:
+ return mfa_access_token.get("access_token")
+
+
+ def push_mfa_notification(self, user_principal_name: str, timeout: int):
+ """
+ Attempt to generate access token the MFA app using the given client secret.
+ Then send MFA push notification to the user with the given UPN.
+ If user Approve - return None, otherwise raise an error.
+ Args:
+ client_secret (str): The client secret of the MFA app.
+ user_principal_name (str): The user principal name of the user to send the MFA notification to.
+
+ Returns:
+ None.
+ """
+ import requests
+ import xml.etree.ElementTree as ET
+ import uuid
+
+ # Hardcoded endpoints
+ MFA_SERVICE_URI = 'https://strongauthenticationservice.auth.microsoft.com/StrongAuthenticationService.svc/Connector//BeginTwoWayAuthentication'
+
+ demisto.debug("\nSending MFA challenge to the user...")
+
+ # Generate a unique GUID for ContextId
+ context_id = str(uuid.uuid4())
+
+ # Define the XML payload
+ # The ContextId is dynamically replaced with a new GUID
+ # The UPN is the user principal name of the user to send the MFA notification to.
+ xml_payload = f"""
+
+ 1.0
+ {user_principal_name}
+ en-us
+
+
+ OverrideVoiceOtp
+ false
+
+
+ {context_id}
+ true
+ true
+ radius
+ UNKNOWN:
+
+ """
+ mfa_client_token = self.get_mfa_app_client_token()
+ headers = {
+ "Authorization": f"Bearer {mfa_client_token}",
+ "Content-Type": "application/xml"
+ }
+
+ try:
+ # Send the request to the strong authentication service
+ mfa_result = requests.post(
+ MFA_SERVICE_URI,
+ headers=headers,
+ data=xml_payload.strip().encode('utf-8'),
+ timeout=timeout
+ )
+ mfa_result.raise_for_status()
+
+ demisto.debug("MFA Challenge Request Sent. Waiting for response...")
+
+ # PARSE THE XML RESPONSE AND OUTPUT RESULTS
+
+ # 1. Parse the XML string into an ElementTree root object
+ root = ET.fromstring(mfa_result.text)
+
+ # 2. Extract crucial nodes directly from the root element
+ result_node = root.find('./Result')
+ auth_result_node = root.find('./AuthenticationResult')
+
+ # Ensure the core nodes exist before accessing their text
+ if result_node is not None and auth_result_node is not None:
+
+ # Get the core values
+ result_value = result_node.find('./Value').text
+ # AuthenticationResult is a string "true" or "false"
+ mfa_challenge_received = auth_result_node.text.lower() == 'true'
+
+ # Get message, correctly handling the XML schema 'nil' attribute
+ message_node = result_node.find('./Message')
+ is_nil = message_node is not None and message_node.get('{http://www.w3.org/2001/XMLSchema-instance}nil') == 'true'
+ result_message = message_node.text if message_node is not None and not is_nil else "No specific message"
+
+ # Determine final status
+ mfa_challenge_approved = (result_value == "Success")
+ mfa_challenge_denied = (result_value == "PhoneAppDenied")
+ mfa_challenge_timeout = (result_value == "PhoneAppNoResponse")
+
+ demisto.debug("--- MFA CHALLENGE RESULT ---")
+ demisto.debug(f"Raw Result Value: {result_value}")
+ demisto.debug(f"Message: {result_message}")
+
+ if mfa_challenge_approved and mfa_challenge_received:
+ return "Status: User Approved MFA Request 🎉"
+ elif mfa_challenge_denied:
+ raise DemistoException("Status: User Denied Request ❌")
+ elif mfa_challenge_timeout:
+ raise DemistoException("Status: MFA Request Timed Out ⏳")
+ else:
+ # Covers "NoDefaultAuthenticationMethodIsConfigured" or other failures
+ raise DemistoException("Status: MFA Request Failed. Check user setup or raw XML for details.")
+
+ else:
+ # Fallback if XML structure is unexpected or empty
+ raise DemistoException(f"Error: Could not find core or elements in XML.\nRaw Response:\n{mfa_result.text}")
+
+ except requests.exceptions.HTTPError as e:
+ raise DemistoException(f"Error sending MFA challenge: {e}\n Response: {mfa_result.text}")
+ except ET.ParseError as e:
+ raise DemistoException(f"FATAL XML PARSE ERROR (Structure): {e}\nRaw Response: {mfa_result.text}")
+ except Exception as e:
+ raise DemistoException(f"An unexpected error occurred: {e}")
def suppress_errors_with_404_code(func):
@@ -730,6 +944,38 @@ def delete_tap_policy_command(client: MsGraphClient, args: dict) -> CommandResul
return CommandResults(readable_output=human_readable)
+def create_client_secret_command(client: MsGraphClient, args: dict) -> CommandResults:
+ ctx = get_integration_context()
+ if ctx.get('mfa_app_client_secret'):
+ return CommandResults(readable_output="Client secret already exist, skipping creating a new one.")
+ else:
+ mfa_app_secret = client.request_mfa_app_secret()
+ ctx["mfa_app_client_secret"] = mfa_app_secret
+ set_integration_context(context=ctx)
+ return CommandResults(readable_output="A new client secret has been added, you might need to wait 30-60 seconds before the secret will be activated.")
+
+
+def request_mfa_command(client: MsGraphClient, args: dict) -> None:
+ """
+ Pops a request to MFA for the given user.
+
+ Args:
+ client (MsGraphClient): The Microsoft Graph client used to make the API request.
+ args (dict): A dictionary of arguments, which must include:
+ - user_mail (str): The mail of the user to pop the MFA to.
+
+ Returns:
+ CommandResults: Pops a request to MFA for the given user.
+ """
+ user_mail = args.get("user_mail", "")
+ timeout = min(MAX_TIMEOUT_LIMIT, arg_to_number(args.get("timeout", MAX_ALLOWED_ENTRY_SIZE)))
+ try:
+ client.push_mfa_notification(user_mail, timeout)
+ except Exception as e:
+ raise DemistoException(f"Failed to pop MFA request for user {user_mail}: {e}")
+
+ return CommandResults(readable_output=f"MFA request was successfully popped for user {user_mail}")
+
def create_zip_with_password(generated_tap_password: str, zip_password: str):
"""
Creates a password-protected zip file containing the TAP policy password.
@@ -833,6 +1079,8 @@ def main():
"msgraph-user-tap-policy-list": list_tap_policy_command,
"msgraph-user-tap-policy-create": create_tap_policy_command,
"msgraph-user-tap-policy-delete": delete_tap_policy_command,
+ "msgraph-user-request-mfa": request_mfa_command,
+ "msgraph-user-create-mfa-client-secret": create_client_secret_command,
}
command = demisto.command()
LOG(f"Command being called is {command}")
diff --git a/Packs/MicrosoftGraphUser/Integrations/MicrosoftGraphUser/MicrosoftGraphUser.yml b/Packs/MicrosoftGraphUser/Integrations/MicrosoftGraphUser/MicrosoftGraphUser.yml
index 4e074dd85938..fb7a4f114194 100644
--- a/Packs/MicrosoftGraphUser/Integrations/MicrosoftGraphUser/MicrosoftGraphUser.yml
+++ b/Packs/MicrosoftGraphUser/Integrations/MicrosoftGraphUser/MicrosoftGraphUser.yml
@@ -761,6 +761,18 @@ script:
- description: Run this command if for some reason you need to rerun the authentication process.
name: msgraph-user-auth-reset
arguments: []
+ - arguments:
+ - description: The user mail to pop the MFA to.
+ name: user_mail
+ required: true
+ - description: The timeout for the MFA request.
+ name: timeout
+ required: false
+ defaultValue: 60
+ description: pops a request to MFA for the given user.
+ name: msgraph-user-request-mfa
+ - name: msgraph-user-create-mfa-client-secret
+ description: Issue a new client secret for the MFA app.
dockerimage: demisto/crypto:1.0.0.4578119
runonce: false
script: '-'