Skip to content

Commit a01f679

Browse files
itssapirrichardbluestone
authored andcommitted
MS Teams single tenant support (demisto#40611)
* Initial single tenant support * add UTs * Bump version * Add debug logs and multi-tenant note in docs * Add graph_only mode to reset_auth for token expiry errors * fix reset_auth_command description * More doc fixes to reset_auth * Update Packs/MicrosoftTeams/Integrations/MicrosoftTeams/README.md Co-authored-by: Richard Bluestone <[email protected]> * Add debug log for tenant ID being saved --------- Co-authored-by: Richard Bluestone <[email protected]>
1 parent 1ff5dad commit a01f679

File tree

5 files changed

+168
-29
lines changed

5 files changed

+168
-29
lines changed

Packs/MicrosoftTeams/Integrations/MicrosoftTeams/MicrosoftTeams.py

Lines changed: 58 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ class FormType(Enum): # Used for 'send-message', and by the MicrosoftTeamsAsk s
3434
PARAMS: dict = demisto.params()
3535
BOT_ID: str = PARAMS.get("credentials", {}).get("identifier", "") or PARAMS.get("bot_id", "")
3636
BOT_PASSWORD: str = PARAMS.get("credentials", {}).get("password", "") or PARAMS.get("bot_password", "")
37-
TENANT_ID: str = PARAMS.get("tenant_id", "")
3837
APP: Flask = Flask("demisto-teams")
3938
PLAYGROUND_INVESTIGATION_TYPE: int = 9
4039
GRAPH_BASE_URL: str = "https://graph.microsoft.com"
@@ -354,7 +353,7 @@ def error_parser(resp_err: requests.Response, api: str = "graph") -> str:
354353
if api == "graph":
355354
error_codes = response.get("error_codes", [""])
356355
if set(error_codes).issubset(TOKEN_EXPIRED_ERROR_CODES):
357-
reset_graph_auth(error_codes, response.get("error_description", ""))
356+
reset_auth(error_codes, response.get("error_description", ""), graph_only=True)
358357

359358
error = response.get("error", {})
360359
err_str = (
@@ -374,10 +373,13 @@ def error_parser(resp_err: requests.Response, api: str = "graph") -> str:
374373
return resp_err.text
375374

376375

377-
def reset_graph_auth(error_codes: list = [], error_desc: str = ""):
376+
def reset_auth(error_codes: list = [], error_desc: str = "", graph_only: bool = False):
378377
"""
379-
Reset the Graph API authorization in the integration context.
380-
This function clears the current graph authorization data: current_refresh_token, graph_access_token, graph_valid_until
378+
Reset the cached API authorization data in the integration context.
379+
This function clears the current authorization data: current graph/bot tokens, token validity, refresh tokens and bot type
380+
:param error_codes: Error codes to log when resetting after token expiration
381+
:param error_desc: Error description to output when resetting after token expiration
382+
:param graph_only: Boolean to determine if only graph tokens should be reset
381383
"""
382384

383385
integration_context: dict = get_integration_context()
@@ -386,6 +388,10 @@ def reset_graph_auth(error_codes: list = [], error_desc: str = ""):
386388
integration_context.pop("graph_valid_until", "")
387389
integration_context[AUTHCODE_TOKEN_PARAMS] = "{}"
388390
integration_context[CREDENTIALS_TOKEN_PARAMS] = "{}"
391+
if not graph_only:
392+
integration_context.pop("bot_access_token", "")
393+
integration_context.pop("bot_valid_until", "")
394+
integration_context.pop("bot_type", "")
389395
set_integration_context(integration_context)
390396

391397
if error_codes or error_desc:
@@ -397,14 +403,14 @@ def reset_graph_auth(error_codes: list = [], error_desc: str = ""):
397403
"parameter and then run !microsoft-teams-auth-test to re-authenticate"
398404
)
399405

400-
demisto.debug("Successfully reset the current_refresh_token, graph_access_token and graph_valid_until.")
406+
demisto.debug("Successfully reset the cached API authorization data.")
401407

402408

403-
def reset_graph_auth_command():
409+
def reset_auth_command():
404410
"""
405-
A wrapper function for the reset_graph_auth() which resets the Graph API authorization in the integration context.
411+
A wrapper function for the reset_auth() which resets the cached API authorization in the integration context.
406412
"""
407-
reset_graph_auth()
413+
reset_auth()
408414
return_results(CommandResults(readable_output="Authorization was reset successfully."))
409415

410416

@@ -850,20 +856,44 @@ def get_bot_access_token() -> str:
850856
"""
851857
integration_context: dict = get_integration_context()
852858
access_token: str = integration_context.get("bot_access_token", "")
853-
valid_until: int = integration_context.get("bot_valid_until", int)
859+
valid_until: int = integration_context.get("bot_valid_until", 0)
860+
bot_type: str = integration_context.get("bot_type", "multi-tenant")
854861
if access_token and valid_until and epoch_seconds() < valid_until:
855862
return access_token
856-
url: str = "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token"
863+
857864
data: dict = {
858865
"grant_type": "client_credentials",
859866
"client_id": BOT_ID,
860867
"client_secret": BOT_PASSWORD,
861868
"scope": "https://api.botframework.com/.default",
862869
}
863-
response: requests.Response = requests.post(url, data=data, verify=USE_SSL, proxies=PROXIES)
870+
871+
if bot_type == "multi-tenant":
872+
demisto.debug("Attempting authentication to the multi-tenant bot framework url")
873+
url: str = "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token"
874+
response: requests.Response = requests.post(url, data=data, verify=USE_SSL, proxies=PROXIES)
875+
if response.json().get("error", "") == "unauthorized_client":
876+
# Could not find bot-id in the common directory for multi-tenant bots, assume it is a single-tenant bot
877+
demisto.debug("Failed to authenticate, falling back to single-tenant bot type")
878+
bot_type = "single-tenant"
879+
880+
if bot_type == "single-tenant":
881+
tenant_id = integration_context.get("tenant_id")
882+
if not tenant_id:
883+
raise ValueError(MISS_CONFIGURATION_ERROR_MESSAGE)
884+
demisto.debug(f"Attempting authentication to the {tenant_id} tenant specific bot framework url")
885+
url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
886+
response = requests.post(url, data=data, verify=USE_SSL, proxies=PROXIES)
887+
864888
if not response.ok:
889+
if "bot_type" in integration_context: # Clear cached bot type on authentication error to avoid issues
890+
demisto.debug("Authentication failed, resetting cached bot type")
891+
integration_context.pop("bot_type")
892+
set_integration_context(integration_context)
893+
865894
error = error_parser(response, "bot")
866895
raise ValueError(f"Failed to get bot access token [{response.status_code}] - {error}")
896+
867897
try:
868898
response_json: dict = response.json()
869899
access_token = response_json.get("access_token", "")
@@ -874,6 +904,7 @@ def get_bot_access_token() -> str:
874904
expires_in -= time_buffer
875905
integration_context["bot_access_token"] = access_token
876906
integration_context["bot_valid_until"] = time_now + expires_in
907+
integration_context["bot_type"] = bot_type
877908
set_integration_context(integration_context)
878909
return access_token
879910
except ValueError:
@@ -2670,16 +2701,20 @@ def member_added_handler(integration_context: dict, request_body: dict, channel_
26702701
if not service_url:
26712702
raise ValueError("Did not find service URL. Try messaging the bot on Microsoft Teams")
26722703

2704+
integration_context["bot_name"] = recipient_name
2705+
2706+
if tenant_id != integration_context.get("tenant_id"):
2707+
# Update the tenant id in context immediately to avoid errors
2708+
demisto.debug(f"Saving tenant ID to context: {tenant_id=}")
2709+
integration_context["tenant_id"] = tenant_id
2710+
set_integration_context(integration_context)
2711+
26732712
for member in members_added:
26742713
member_id = member.get("id", "")
26752714
if bot_id in member_id:
2676-
# The bot was added to a team, caching team ID and team members
26772715
demisto.info(f"The bot was added to team {team_name}")
26782716
else:
2679-
demisto.info(f"Someone was added to team {team_name}")
2680-
integration_context["tenant_id"] = tenant_id
2681-
integration_context["bot_name"] = recipient_name
2682-
break
2717+
demisto.info(f"A user was added to team {team_name}")
26832718

26842719
team_members: list = get_team_members(service_url, team_id)
26852720

@@ -3358,14 +3393,11 @@ def test_module():
33583393
"""
33593394
if not BOT_ID or not BOT_PASSWORD:
33603395
raise DemistoException("Bot ID and Bot Password must be provided.")
3361-
if "Client" not in AUTH_TYPE:
3362-
raise DemistoException(
3363-
"Test module is available for Client Credentials only."
3364-
" For other authentication types use the !microsoft-teams-auth-test command"
3365-
)
33663396

3367-
get_bot_access_token() # Tests token retrieval for Bot Framework API
3368-
return_results("ok")
3397+
raise DemistoException(
3398+
"Test module is unavailable for the Microsoft Teams Integration."
3399+
" Please use the !microsoft-teams-integration-health command to test connectivity."
3400+
)
33693401

33703402

33713403
def generate_login_url_command():
@@ -3425,7 +3457,7 @@ def auth_type_switch_handling():
34253457
f"The user switched the instance authentication type from {current_auth_type} to {AUTH_TYPE}.\n"
34263458
f"Resetting the integration context."
34273459
)
3428-
reset_graph_auth()
3460+
reset_auth()
34293461
integration_context = get_integration_context()
34303462
demisto.debug(f"Setting the current_auth_type in the integration context to {AUTH_TYPE}.")
34313463
integration_context["current_auth_type"] = AUTH_TYPE
@@ -3560,7 +3592,7 @@ def main(): # pragma: no cover
35603592
"microsoft-teams-channel-user-list": channel_user_list_command,
35613593
"microsoft-teams-user-remove-from-channel": user_remove_from_channel_command,
35623594
"microsoft-teams-generate-login-url": generate_login_url_command,
3563-
"microsoft-teams-auth-reset": reset_graph_auth_command,
3595+
"microsoft-teams-auth-reset": reset_auth_command,
35643596
"microsoft-teams-token-permissions-list": token_permissions_list_command,
35653597
"microsoft-teams-create-messaging-endpoint": create_messaging_endpoint_command,
35663598
"microsoft-teams-message-update": message_update_command,

Packs/MicrosoftTeams/Integrations/MicrosoftTeams/MicrosoftTeams_test.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,8 @@ def test_mirror_investigation(mocker, requests_mock):
307307
set_integration_context[0].pop(CREDENTIALS_TOKEN_PARAMS)
308308
set_integration_context[0].pop("bot_access_token")
309309
set_integration_context[0].pop("bot_valid_until")
310-
assert set_integration_context[0] == expected_integration_context
310+
for key, value in expected_integration_context.items():
311+
assert set_integration_context[0].get(key) == value
311312
results = demisto.results.call_args[0]
312313
assert len(results) == 1
313314
assert results[0] == "Investigation mirrored successfully in channel incident-2."
@@ -3580,3 +3581,100 @@ def test_send_notification_with_raw_adaptive_card_from_TeamAsk(mocker, requests_
35803581

35813582
send_message()
35823583
assert send_message_request.last_request.json() == {"type": "message", "attachments": [expected_request_attachment]}
3584+
3585+
3586+
def test_get_bot_access_token_multi_tenant_success(mocker, requests_mock):
3587+
"""
3588+
Given:
3589+
- A multi-tenant bot configuration.
3590+
When:
3591+
- Calling get_bot_access_token.
3592+
Then:
3593+
- Ensure a token is successfully retrieved using the multi-tenant endpoint.
3594+
"""
3595+
from MicrosoftTeams import get_bot_access_token
3596+
3597+
mocker.patch.object(demisto, "getIntegrationContext", return_value={})
3598+
mocker.patch.object(demisto, "setIntegrationContext")
3599+
3600+
requests_mock.post(
3601+
"https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token",
3602+
json={"access_token": "multi_tenant_token", "expires_in": 3600},
3603+
)
3604+
3605+
token = get_bot_access_token()
3606+
assert token == "multi_tenant_token"
3607+
3608+
3609+
def test_get_bot_access_token_single_tenant_success(mocker, requests_mock):
3610+
"""
3611+
Given:
3612+
- A single-tenant bot configuration with a tenant_id.
3613+
When:
3614+
- Calling get_bot_access_token.
3615+
Then:
3616+
- Ensure a token is successfully retrieved using the single-tenant endpoint.
3617+
"""
3618+
from MicrosoftTeams import get_bot_access_token
3619+
3620+
mocker.patch.object(demisto, "getIntegrationContext", return_value={"bot_type": "single-tenant", "tenant_id": tenant_id})
3621+
mocker.patch.object(demisto, "setIntegrationContext")
3622+
3623+
requests_mock.post(
3624+
f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token",
3625+
json={"access_token": "single_tenant_token", "expires_in": 3600},
3626+
)
3627+
3628+
token = get_bot_access_token()
3629+
assert token == "single_tenant_token"
3630+
3631+
3632+
def test_get_bot_access_token_fallback_to_single_tenant(mocker, requests_mock):
3633+
"""
3634+
Given:
3635+
- A multi-tenant bot configuration.
3636+
- The multi-tenant endpoint returns an 'unauthorized_client' error.
3637+
- A tenant_id is available in the context.
3638+
When:
3639+
- Calling get_bot_access_token.
3640+
Then:
3641+
- Ensure the code falls back to the single-tenant endpoint and retrieves a token.
3642+
"""
3643+
from MicrosoftTeams import get_bot_access_token
3644+
3645+
mocker.patch.object(demisto, "getIntegrationContext", return_value={"tenant_id": tenant_id})
3646+
set_context_mocker = mocker.patch.object(demisto, "setIntegrationContext")
3647+
3648+
requests_mock.post(
3649+
"https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token",
3650+
json={"error": "unauthorized_client"},
3651+
status_code=400,
3652+
)
3653+
requests_mock.post(
3654+
f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token",
3655+
json={"access_token": "fallback_token", "expires_in": 3600},
3656+
)
3657+
3658+
token = get_bot_access_token()
3659+
assert token == "fallback_token"
3660+
# Verify that the bot_type was updated in the context for future use
3661+
updated_context = set_context_mocker.call_args[0][0]
3662+
assert updated_context.get("bot_type") == "single-tenant"
3663+
3664+
3665+
def test_get_bot_access_token_single_tenant_no_tenant_id(mocker):
3666+
"""
3667+
Given:
3668+
- A single-tenant bot configuration but no tenant_id.
3669+
When:
3670+
- Calling get_bot_access_token.
3671+
Then:
3672+
- Ensure a ValueError is raised.
3673+
"""
3674+
from MicrosoftTeams import get_bot_access_token, MISS_CONFIGURATION_ERROR_MESSAGE
3675+
3676+
mocker.patch.object(demisto, "getIntegrationContext", return_value={"bot_type": "single-tenant"})
3677+
mocker.patch.object(demisto, "setIntegrationContext")
3678+
3679+
with pytest.raises(ValueError, match=MISS_CONFIGURATION_ERROR_MESSAGE):
3680+
get_bot_access_token()

Packs/MicrosoftTeams/Integrations/MicrosoftTeams/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ Creating the Demisto Bot using Microsoft Azure Portal:
4545
1. Navigate to the [Create an Azure Bot page](https://portal.azure.com/#create/Microsoft.AzureBot).
4646
2. In the Bot Handle field, type **Demisto Bot**.
4747
3. Fill in the required Subscription and Resource Group, relevant links: [Subscription](https://learn.microsoft.com/en-us/azure/cost-management-billing/manage/create-subscription), [Resource Groups](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-portal).
48-
4. For Type of App, select **Multi Tenant**.
48+
4. For Type of App, select **Single Tenant**.
49+
- **Note ⚠️:** The **Multi Tenant** App type was deprecated by Microsoft.
50+
Existing apps remain functional and do no require any changes.
51+
You can change existing apps to a Single Tenant in the Azure portal's bot configuration, but it is not required.
4952
5. For Creation type, select **Create new Microsoft App ID** for Creation Type if you don't already have an app registration, otherwise, select **Use existing app registration**, and fill in you App ID.
5053
- **Note ⚠️:** if you choose **Use existing app registration**, make sure to delete the previous created bot with the same app id, remove it from the team it was added to as well.
5154
6. Click **Review + Create**, and wait for the validation to pass.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
2+
#### Integrations
3+
4+
##### Microsoft Teams
5+
6+
Added support for single-tenant Azure bot applications.

Packs/MicrosoftTeams/pack_metadata.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "Microsoft Teams",
33
"description": "Send messages and notifications to your team members.",
44
"support": "xsoar",
5-
"currentVersion": "1.5.38",
5+
"currentVersion": "1.5.39",
66
"author": "Cortex XSOAR",
77
"url": "https://www.paloaltonetworks.com/cortex",
88
"email": "",

0 commit comments

Comments
 (0)