diff --git a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py index 93fac05b9..c24e4b904 100644 --- a/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py +++ b/libraries/botbuilder-adapters-slack/botbuilder/adapters/slack/slack_adapter.py @@ -130,8 +130,9 @@ async def continue_conversation( self, reference: ConversationReference, callback: Callable, - bot_id: str = None, # pylint: disable=unused-argument - claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument + bot_id: str = None, + claims_identity: ClaimsIdentity = None, + audience: str = None, ): """ Sends a proactive message to a conversation. Call this method to proactively send a message to a conversation. diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 595871846..7df9c2506 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -164,7 +164,8 @@ async def continue_conversation( reference: ConversationReference, callback: Callable, bot_id: str = None, - claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument + claims_identity: ClaimsIdentity = None, + audience: str = None, ): """ The `TestAdapter` just calls parent implementation. @@ -175,7 +176,7 @@ async def continue_conversation( :return: """ await super().continue_conversation( - reference, callback, bot_id, claims_identity + reference, callback, bot_id, claims_identity, audience ) async def receive_activity(self, activity): diff --git a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py index f97030879..ca9a649bc 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_adapter.py @@ -1,113 +1,120 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from abc import ABC, abstractmethod -from typing import List, Callable, Awaitable -from botbuilder.schema import Activity, ConversationReference, ResourceResponse -from botframework.connector.auth import ClaimsIdentity - -from . import conversation_reference_extension -from .bot_assert import BotAssert -from .turn_context import TurnContext -from .middleware_set import MiddlewareSet - - -class BotAdapter(ABC): - def __init__( - self, on_turn_error: Callable[[TurnContext, Exception], Awaitable] = None - ): - self._middleware = MiddlewareSet() - self.on_turn_error = on_turn_error - - @abstractmethod - async def send_activities( - self, context: TurnContext, activities: List[Activity] - ) -> List[ResourceResponse]: - """ - Sends a set of activities to the user. An array of responses from the server will be returned. - :param context: - :param activities: - :return: - """ - raise NotImplementedError() - - @abstractmethod - async def update_activity(self, context: TurnContext, activity: Activity): - """ - Replaces an existing activity. - :param context: - :param activity: - :return: - """ - raise NotImplementedError() - - @abstractmethod - async def delete_activity( - self, context: TurnContext, reference: ConversationReference - ): - """ - Deletes an existing activity. - :param context: - :param reference: - :return: - """ - raise NotImplementedError() - - def use(self, middleware): - """ - Registers a middleware handler with the adapter. - :param middleware: - :return: - """ - self._middleware.use(middleware) - return self - - async def continue_conversation( - self, - reference: ConversationReference, - callback: Callable, - bot_id: str = None, # pylint: disable=unused-argument - claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument - ): - """ - Sends a proactive message to a conversation. Call this method to proactively send a message to a conversation. - Most _channels require a user to initiate a conversation with a bot before the bot can send activities - to the user. - :param bot_id: The application ID of the bot. This parameter is ignored in - single tenant the Adpters (Console, Test, etc) but is critical to the BotFrameworkAdapter - which is multi-tenant aware. - :param reference: A reference to the conversation to continue. - :param callback: The method to call for the resulting bot turn. - :param claims_identity: - """ - context = TurnContext( - self, conversation_reference_extension.get_continuation_activity(reference) - ) - return await self.run_pipeline(context, callback) - - async def run_pipeline( - self, context: TurnContext, callback: Callable[[TurnContext], Awaitable] = None - ): - """ - Called by the parent class to run the adapters middleware set and calls the passed in `callback()` handler at - the end of the chain. - :param context: - :param callback: - :return: - """ - BotAssert.context_not_none(context) - - if context.activity is not None: - try: - return await self._middleware.receive_activity_with_status( - context, callback - ) - except Exception as error: - if self.on_turn_error is not None: - await self.on_turn_error(context, error) - else: - raise error - else: - # callback to caller on proactive case - if callback is not None: - await callback(context) +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod +from typing import List, Callable, Awaitable +from botbuilder.schema import Activity, ConversationReference, ResourceResponse +from botframework.connector.auth import ClaimsIdentity + +from . import conversation_reference_extension +from .bot_assert import BotAssert +from .turn_context import TurnContext +from .middleware_set import MiddlewareSet + + +class BotAdapter(ABC): + BOT_IDENTITY_KEY = "BotIdentity" + BOT_OAUTH_SCOPE_KEY = "OAuthScope" + BOT_CONNECTOR_CLIENT_KEY = "ConnectorClient" + BOT_CALLBACK_HANDLER_KEY = "BotCallbackHandler" + + def __init__( + self, on_turn_error: Callable[[TurnContext, Exception], Awaitable] = None + ): + self._middleware = MiddlewareSet() + self.on_turn_error = on_turn_error + + @abstractmethod + async def send_activities( + self, context: TurnContext, activities: List[Activity] + ) -> List[ResourceResponse]: + """ + Sends a set of activities to the user. An array of responses from the server will be returned. + :param context: + :param activities: + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def update_activity(self, context: TurnContext, activity: Activity): + """ + Replaces an existing activity. + :param context: + :param activity: + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def delete_activity( + self, context: TurnContext, reference: ConversationReference + ): + """ + Deletes an existing activity. + :param context: + :param reference: + :return: + """ + raise NotImplementedError() + + def use(self, middleware): + """ + Registers a middleware handler with the adapter. + :param middleware: + :return: + """ + self._middleware.use(middleware) + return self + + async def continue_conversation( + self, + reference: ConversationReference, + callback: Callable, + bot_id: str = None, # pylint: disable=unused-argument + claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument + audience: str = None, # pylint: disable=unused-argument + ): + """ + Sends a proactive message to a conversation. Call this method to proactively send a message to a conversation. + Most _channels require a user to initiate a conversation with a bot before the bot can send activities + to the user. + :param bot_id: The application ID of the bot. This parameter is ignored in + single tenant the Adpters (Console, Test, etc) but is critical to the BotFrameworkAdapter + which is multi-tenant aware. + :param reference: A reference to the conversation to continue. + :param callback: The method to call for the resulting bot turn. + :param claims_identity: + :param audience: + """ + context = TurnContext( + self, conversation_reference_extension.get_continuation_activity(reference) + ) + return await self.run_pipeline(context, callback) + + async def run_pipeline( + self, context: TurnContext, callback: Callable[[TurnContext], Awaitable] = None + ): + """ + Called by the parent class to run the adapters middleware set and calls the passed in `callback()` handler at + the end of the chain. + :param context: + :param callback: + :return: + """ + BotAssert.context_not_none(context) + + if context.activity is not None: + try: + return await self._middleware.receive_activity_with_status( + context, callback + ) + except Exception as error: + if self.on_turn_error is not None: + await self.on_turn_error(context, error) + else: + raise error + else: + # callback to caller on proactive case + if callback is not None: + await callback(context) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index c2a9aa869..8300bfefe 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -7,6 +7,7 @@ import base64 import json import os +import uuid from typing import List, Callable, Awaitable, Union, Dict from msrest.serialization import Model @@ -22,10 +23,12 @@ GovernmentConstants, MicrosoftAppCredentials, JwtTokenValidation, + CredentialProvider, SimpleCredentialProvider, SkillValidation, - CertificateAppCredentials, AppCredentials, + SimpleChannelProvider, + MicrosoftGovernmentAppCredentials, ) from botframework.connector.token_api import TokenApiClient from botbuilder.schema import ( @@ -48,7 +51,6 @@ USER_AGENT = f"Microsoft-BotFramework/3.1 (BotBuilder Python/{__version__})" OAUTH_ENDPOINT = "https://api.botframework.com" US_GOV_OAUTH_ENDPOINT = "https://api.botframework.azure.us" -BOT_IDENTITY_KEY = "BotIdentity" class TokenExchangeState(Model): @@ -83,11 +85,10 @@ def __init__( channel_auth_tenant: str = None, oauth_endpoint: str = None, open_id_metadata: str = None, - channel_service: str = None, channel_provider: ChannelProvider = None, auth_configuration: AuthenticationConfiguration = None, - certificate_thumbprint: str = None, - certificate_private_key: str = None, + app_credentials: AppCredentials = None, + credential_provider: CredentialProvider = None, ): """ Contains the settings used to initialize a :class:`BotFrameworkAdapter` instance. @@ -103,32 +104,37 @@ def __init__( :type oauth_endpoint: str :param open_id_metadata: :type open_id_metadata: str - :param channel_service: - :type channel_service: str :param channel_provider: The channel provider - :type channel_provider: :class:`botframework.connector.auth.ChannelProvider` + :type channel_provider: :class:`botframework.connector.auth.ChannelProvider`. Defaults to SimpleChannelProvider + if one isn't specified. :param auth_configuration: :type auth_configuration: :class:`botframework.connector.auth.AuthenticationConfiguration` - :param certificate_thumbprint: X509 thumbprint - :type certificate_thumbprint: str - :param certificate_private_key: X509 private key - :type certificate_private_key: str - - .. remarks:: - For credentials authorization, both app_id and app_password are required. - For certificate authorization, app_id, certificate_thumbprint, and certificate_private_key are required. - + :param credential_provider: Defaults to SimpleCredentialProvider if one isn't specified. + :param app_credentials: Allows for a custom AppCredentials. Used, for example, for CertificateAppCredentials. """ + self.app_id = app_id self.app_password = app_password + self.app_credentials = app_credentials self.channel_auth_tenant = channel_auth_tenant self.oauth_endpoint = oauth_endpoint - self.open_id_metadata = open_id_metadata - self.channel_service = channel_service - self.channel_provider = channel_provider + self.channel_provider = ( + channel_provider if channel_provider else SimpleChannelProvider() + ) + self.credential_provider = ( + credential_provider + if credential_provider + else SimpleCredentialProvider(self.app_id, self.app_password) + ) self.auth_configuration = auth_configuration or AuthenticationConfiguration() - self.certificate_thumbprint = certificate_thumbprint - self.certificate_private_key = certificate_private_key + + # If no open_id_metadata values were passed in the settings, check the + # process' Environment Variable. + self.open_id_metadata = ( + open_id_metadata + if open_id_metadata + else os.environ.get(AuthenticationConstants.BOT_OPEN_ID_METADATA_KEY) + ) class BotFrameworkAdapter(BotAdapter, UserTokenProvider): @@ -158,41 +164,14 @@ def __init__(self, settings: BotFrameworkAdapterSettings): super(BotFrameworkAdapter, self).__init__() self.settings = settings or BotFrameworkAdapterSettings("", "") - # If settings.certificate_thumbprint & settings.certificate_private_key are provided, - # use CertificateAppCredentials. - if self.settings.certificate_thumbprint and settings.certificate_private_key: - self._credentials = CertificateAppCredentials( - self.settings.app_id, - self.settings.certificate_thumbprint, - self.settings.certificate_private_key, - self.settings.channel_auth_tenant, - ) - self._credential_provider = SimpleCredentialProvider( - self.settings.app_id, "" - ) - else: - self._credentials = MicrosoftAppCredentials( - self.settings.app_id, - self.settings.app_password, - self.settings.channel_auth_tenant, - ) - self._credential_provider = SimpleCredentialProvider( - self.settings.app_id, self.settings.app_password - ) + self._credentials = self.settings.app_credentials + self._credential_provider = SimpleCredentialProvider( + self.settings.app_id, self.settings.app_password + ) - self._is_emulating_oauth_cards = False + self._channel_provider = self.settings.channel_provider - # If no channel_service or open_id_metadata values were passed in the settings, check the - # process' Environment Variables for values. - # These values may be set when a bot is provisioned on Azure and if so are required for - # the bot to properly work in Public Azure or a National Cloud. - self.settings.channel_service = self.settings.channel_service or os.environ.get( - AuthenticationConstants.CHANNEL_SERVICE - ) - self.settings.open_id_metadata = ( - self.settings.open_id_metadata - or os.environ.get(AuthenticationConstants.BOT_OPEN_ID_METADATA_KEY) - ) + self._is_emulating_oauth_cards = False if self.settings.open_id_metadata: ChannelValidation.open_id_metadata_endpoint = self.settings.open_id_metadata @@ -200,22 +179,19 @@ def __init__(self, settings: BotFrameworkAdapterSettings): self.settings.open_id_metadata ) - if JwtTokenValidation.is_government(self.settings.channel_service): - self._credentials.oauth_endpoint = ( - GovernmentConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL - ) - self._credentials.oauth_scope = ( - GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE - ) - + # There is a significant boost in throughput if we reuse a ConnectorClient self._connector_client_cache: Dict[str, ConnectorClient] = {} + # Cache for appCredentials to speed up token acquisition (a token is not requested unless is expired) + self._app_credential_map: Dict[str, AppCredentials] = {} + async def continue_conversation( self, reference: ConversationReference, callback: Callable, bot_id: str = None, - claims_identity: ClaimsIdentity = None, # pylint: disable=unused-argument + claims_identity: ClaimsIdentity = None, + audience: str = None, ): """ Continues a conversation with a user. @@ -229,6 +205,8 @@ async def continue_conversation( :type bot_id: :class:`typing.str` :param claims_identity: The bot claims identity :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity` + :param audience: + :type audience: :class:`typing.str` :raises: It raises an argument null exception. @@ -239,11 +217,20 @@ async def continue_conversation( send messages to a conversation or user that are already in a communication. Scenarios such as sending notifications or coupons to a user are enabled by this function. """ - # TODO: proactive messages - if not claims_identity: - if not bot_id: - raise TypeError("Expected bot_id: str but got None instead") + if not reference: + raise TypeError( + "Expected reference: ConversationReference but got None instead" + ) + if not callback: + raise TypeError("Expected callback: Callable but got None instead") + + # This has to have either a bot_id, in which case a ClaimsIdentity will be created, or + # a ClaimsIdentity. In either case, if an audience isn't supplied one will be created. + if not (bot_id or claims_identity): + raise TypeError("Expected bot_id or claims_identity") + + if bot_id and not claims_identity: claims_identity = ClaimsIdentity( claims={ AuthenticationConstants.AUDIENCE_CLAIM: bot_id, @@ -252,12 +239,24 @@ async def continue_conversation( is_authenticated=True, ) + if not audience: + audience = self.__get_botframework_oauth_scope() + context = TurnContext(self, get_continuation_activity(reference)) - context.turn_state[BOT_IDENTITY_KEY] = claims_identity - context.turn_state["BotCallbackHandler"] = callback - await self._ensure_channel_connector_client_is_created( - reference.service_url, claims_identity + context.turn_state[BotAdapter.BOT_IDENTITY_KEY] = claims_identity + context.turn_state[BotAdapter.BOT_CALLBACK_HANDLER_KEY] = callback + context.turn_state[BotAdapter.BOT_OAUTH_SCOPE_KEY] = audience + + # Add the channel service URL to the trusted services list so we can send messages back. + # the service URL for skills is trusted because it is applied by the SkillHandler based + # on the original request received by the root bot + AppCredentials.trust_service_url(reference.service_url) + + client = await self.create_connector_client( + reference.service_url, claims_identity, audience ) + context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] = client + return await self.run_pipeline(context, callback) async def create_conversation( @@ -265,6 +264,9 @@ async def create_conversation( reference: ConversationReference, logic: Callable[[TurnContext], Awaitable] = None, conversation_parameters: ConversationParameters = None, + channel_id: str = None, + service_url: str = None, + credentials: AppCredentials = None, ): """ Starts a new conversation with a user. Used to direct message to a member of a group. @@ -275,6 +277,12 @@ async def create_conversation( :type logic: :class:`typing.Callable` :param conversation_parameters: The information to use to create the conversation :type conversation_parameters: + :param channel_id: The ID for the channel. + :type channel_id: :class:`typing.str` + :param service_url: The channel's service URL endpoint. + :type service_url: :class:`typing.str` + :param credentials: The application credentials for the bot. + :type credentials: :class:`botframework.connector.auth.AppCredentials` :raises: It raises a generic exception error. @@ -288,15 +296,23 @@ async def create_conversation( then sends a conversation update activity through its middleware pipeline to the the callback method. If the conversation is established with the specified users, the ID of the activity - will contain the ID of the new conversation. + will contain the ID of the new conversation. """ try: - if reference.service_url is None: - raise TypeError( - "BotFrameworkAdapter.create_conversation(): reference.service_url cannot be None." - ) + if not service_url: + service_url = reference.service_url + if not service_url: + raise TypeError( + "BotFrameworkAdapter.create_conversation(): service_url or reference.service_url is required." + ) + + if not channel_id: + channel_id = reference.channel_id + if not channel_id: + raise TypeError( + "BotFrameworkAdapter.create_conversation(): channel_id or reference.channel_id is required." + ) - # Create conversation parameters = ( conversation_parameters if conversation_parameters @@ -304,12 +320,9 @@ async def create_conversation( bot=reference.bot, members=[reference.user], is_group=False ) ) - client = await self.create_connector_client(reference.service_url) - resource_response = await client.conversations.create_conversation( - parameters - ) + # Mix in the tenant ID if specified. This is required for MS Teams. - if reference.conversation is not None and reference.conversation.tenant_id: + if reference.conversation and reference.conversation.tenant_id: # Putting tenant_id in channel_data is a temporary while we wait for the Teams API to be updated parameters.channel_data = { "tenant": {"id": reference.conversation.tenant_id} @@ -318,19 +331,51 @@ async def create_conversation( # Permanent solution is to put tenant_id in parameters.tenant_id parameters.tenant_id = reference.conversation.tenant_id - request = TurnContext.apply_conversation_reference( - Activity(type=ActivityTypes.event, name="CreateConversation"), - reference, - is_incoming=True, + # This is different from C# where credentials are required in the method call. + # Doing this for compatibility. + app_credentials = ( + credentials + if credentials + else await self.__get_app_credentials( + self.settings.app_id, self.__get_botframework_oauth_scope() + ) ) - request.conversation = ConversationAccount( - id=resource_response.id, tenant_id=parameters.tenant_id + + # Create conversation + client = self._get_or_create_connector_client(service_url, app_credentials) + + resource_response = await client.conversations.create_conversation( + parameters + ) + + event_activity = Activity( + type=ActivityTypes.event, + name="CreateConversation", + channel_id=channel_id, + service_url=service_url, + id=resource_response.activity_id + if resource_response.activity_id + else str(uuid.uuid4()), + conversation=ConversationAccount( + id=resource_response.id, tenant_id=parameters.tenant_id, + ), + channel_data=parameters.channel_data, + recipient=parameters.bot, ) - request.channel_data = parameters.channel_data - if resource_response.service_url: - request.service_url = resource_response.service_url - context = self.create_context(request) + context = self._create_context(event_activity) + context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] = client + + claims_identity = ClaimsIdentity( + claims={ + AuthenticationConstants.AUDIENCE_CLAIM: app_credentials.microsoft_app_id, + AuthenticationConstants.APP_ID_CLAIM: app_credentials.microsoft_app_id, + AuthenticationConstants.SERVICE_URL_CLAIM: service_url, + }, + is_authenticated=True, + ) + context.turn_state[BotAdapter.BOT_IDENTITY_KEY] = claims_identity + return await self.run_pipeline(context, logic) except Exception as error: @@ -359,10 +404,30 @@ async def process_activity(self, req, auth_header: str, logic: Callable): """ activity = await self.parse_request(req) auth_header = auth_header or "" + identity = await self._authenticate_request(activity, auth_header) + return await self.process_activity_with_identity(activity, identity, logic) - identity = await self.authenticate_request(activity, auth_header) - context = self.create_context(activity) - context.turn_state[BOT_IDENTITY_KEY] = identity + async def process_activity_with_identity( + self, activity: Activity, identity: ClaimsIdentity, logic: Callable + ): + context = self._create_context(activity) + context.turn_state[BotAdapter.BOT_IDENTITY_KEY] = identity + context.turn_state[BotAdapter.BOT_CALLBACK_HANDLER_KEY] = logic + + # To create the correct cache key, provide the OAuthScope when calling CreateConnectorClientAsync. + # The OAuthScope is also stored on the TurnState to get the correct AppCredentials if fetching a token + # is required. + scope = ( + self.__get_botframework_oauth_scope() + if not SkillValidation.is_skill_claim(identity.claims) + else JwtTokenValidation.get_app_id_from_claims(identity.claims) + ) + context.turn_state[BotAdapter.BOT_OAUTH_SCOPE_KEY] = scope + + client = await self.create_connector_client( + activity.service_url, identity, scope + ) + context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] = client # Fix to assign tenant_id from channelData to Conversation.tenant_id. # MS Teams currently sends the tenant ID in channelData and the correct behavior is to expose @@ -393,7 +458,7 @@ async def process_activity(self, req, auth_header: str, logic: Callable): return None - async def authenticate_request( + async def _authenticate_request( self, request: Activity, auth_header: str ) -> ClaimsIdentity: """ @@ -412,7 +477,7 @@ async def authenticate_request( request, auth_header, self._credential_provider, - self.settings.channel_service, + await self.settings.channel_provider.get_channel_service(), self.settings.auth_configuration, ) @@ -421,7 +486,7 @@ async def authenticate_request( return claims - def create_context(self, activity): + def _create_context(self, activity): """ Allows for the overriding of the context object in unit tests and derived adapters. :param activity: @@ -495,8 +560,7 @@ async def update_activity(self, context: TurnContext, activity: Activity): of the activity to replace. """ try: - identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) - client = await self.create_connector_client(activity.service_url, identity) + client = context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] return await client.conversations.update_activity( activity.conversation.id, activity.id, activity ) @@ -524,8 +588,7 @@ async def delete_activity( The activity_id of the :class:`botbuilder.schema.ConversationReference` identifies the activity to delete. """ try: - identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) - client = await self.create_connector_client(reference.service_url, identity) + client = context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] await client.conversations.delete_activity( reference.conversation.id, reference.activity_id ) @@ -566,17 +629,15 @@ async def send_activities( "BotFrameworkAdapter.send_activity(): conversation.id can not be None." ) - identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) - client = await self.create_connector_client( - activity.service_url, identity - ) if activity.type == "trace" and activity.channel_id != "emulator": pass elif activity.reply_to_id: + client = context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] response = await client.conversations.reply_to_activity( activity.conversation.id, activity.reply_to_id, activity ) else: + client = context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] response = await client.conversations.send_to_conversation( activity.conversation.id, activity ) @@ -617,12 +678,10 @@ async def delete_conversation_member( "BotFrameworkAdapter.delete_conversation_member(): missing conversation or " "conversation.id" ) - service_url = context.activity.service_url - conversation_id = context.activity.conversation.id - identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) - client = await self.create_connector_client(service_url, identity) + + client = context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] return await client.conversations.delete_conversation_member( - conversation_id, member_id + context.activity.conversation.id, member_id ) except AttributeError as attr_e: raise attr_e @@ -661,12 +720,10 @@ async def get_activity_members(self, context: TurnContext, activity_id: str): "BotFrameworkAdapter.get_activity_member(): missing both activity_id and " "context.activity.id" ) - service_url = context.activity.service_url - conversation_id = context.activity.conversation.id - identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) - client = await self.create_connector_client(service_url, identity) + + client = context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] return await client.conversations.get_activity_members( - conversation_id, activity_id + context.activity.conversation.id, activity_id ) except Exception as error: raise error @@ -695,15 +752,20 @@ async def get_conversation_members(self, context: TurnContext): "BotFrameworkAdapter.get_conversation_members(): missing conversation or " "conversation.id" ) - service_url = context.activity.service_url - conversation_id = context.activity.conversation.id - identity: ClaimsIdentity = context.turn_state.get(BOT_IDENTITY_KEY) - client = await self.create_connector_client(service_url, identity) - return await client.conversations.get_conversation_members(conversation_id) + + client = context.turn_state[BotAdapter.BOT_CONNECTOR_CLIENT_KEY] + return await client.conversations.get_conversation_members( + context.activity.conversation.id + ) except Exception as error: raise error - async def get_conversations(self, service_url: str, continuation_token: str = None): + async def get_conversations( + self, + service_url: str, + credentials: AppCredentials, + continuation_token: str = None, + ): """ Lists the Conversations in which this bot has participated for a given channel server. @@ -725,7 +787,7 @@ async def get_conversations(self, service_url: str, continuation_token: str = No This overload may be called from outside the context of a conversation, as only the bot's service URL and credentials are required. """ - client = await self.create_connector_client(service_url) + client = self._get_or_create_connector_client(service_url, credentials) return await client.conversations.get_conversations(continuation_token) async def get_user_token( @@ -767,7 +829,7 @@ async def get_user_token( "get_user_token() requires a connection_name but none was provided." ) - client = self._create_token_api_client(context, oauth_app_credentials) + client = await self._create_token_api_client(context, oauth_app_credentials) result = client.user_token.get_token( context.activity.from_property.id, @@ -807,7 +869,7 @@ async def sign_out_user( if not user_id: user_id = context.activity.from_property.id - client = self._create_token_api_client(context, oauth_app_credentials) + client = await self._create_token_api_client(context, oauth_app_credentials) client.user_token.sign_out( user_id, connection_name, context.activity.channel_id ) @@ -833,7 +895,7 @@ async def get_oauth_sign_in_link( :return: If the task completes successfully, the result contains the raw sign-in link """ - client = self._create_token_api_client(context, oauth_app_credentials) + client = await self._create_token_api_client(context, oauth_app_credentials) conversation = TurnContext.get_conversation_reference(context.activity) state = TokenExchangeState( @@ -881,7 +943,7 @@ async def get_token_status( "BotFrameworkAdapter.get_token_status(): missing from_property or from_property.id" ) - client = self._create_token_api_client(context, oauth_app_credentials) + client = await self._create_token_api_client(context, oauth_app_credentials) user_id = user_id or context.activity.from_property.id return client.user_token.get_token_status( @@ -919,7 +981,7 @@ async def get_aad_tokens( "BotFrameworkAdapter.get_aad_tokens(): missing from_property or from_property.id" ) - client = self._create_token_api_client(context, oauth_app_credentials) + client = await self._create_token_api_client(context, oauth_app_credentials) return client.user_token.get_aad_tokens( context.activity.from_property.id, connection_name, @@ -928,53 +990,62 @@ async def get_aad_tokens( ) async def create_connector_client( - self, service_url: str, identity: ClaimsIdentity = None + self, service_url: str, identity: ClaimsIdentity = None, audience: str = None ) -> ConnectorClient: - """Allows for mocking of the connector client in unit tests + """ + Creates the connector client :param service_url: The service URL :param identity: The claims identity + :param audience: :return: An instance of the :class:`ConnectorClient` class """ + if not identity: + # This is different from C# where an exception is raised. In this case + # we are creating a ClaimsIdentity to retain compatibility with this + # method. + identity = ClaimsIdentity( + claims={ + AuthenticationConstants.AUDIENCE_CLAIM: self.settings.app_id, + AuthenticationConstants.APP_ID_CLAIM: self.settings.app_id, + }, + is_authenticated=True, + ) + + # For requests from channel App Id is in Audience claim of JWT token. For emulator it is in AppId claim. + # For unauthenticated requests we have anonymous claimsIdentity provided auth is disabled. + # For Activities coming from Emulator AppId claim contains the Bot's AAD AppId. + bot_app_id = identity.claims.get( + AuthenticationConstants.AUDIENCE_CLAIM + ) or identity.claims.get(AuthenticationConstants.APP_ID_CLAIM) + # Anonymous claims and non-skill claims should fall through without modifying the scope. - credentials = self._credentials - - if identity: - bot_app_id_claim = identity.claims.get( - AuthenticationConstants.AUDIENCE_CLAIM - ) or identity.claims.get(AuthenticationConstants.APP_ID_CLAIM) - - if bot_app_id_claim and SkillValidation.is_skill_claim(identity.claims): - scope = JwtTokenValidation.get_app_id_from_claims(identity.claims) - - # Do nothing, if the current credentials and its scope are valid for the skill. - # i.e. the adapter instance is pre-configured to talk with one skill. - # Otherwise we will create a new instance of the AppCredentials - # so self._credentials.oauth_scope isn't overridden. - if self._credentials.oauth_scope != scope: - password = await self._credential_provider.get_app_password( - bot_app_id_claim - ) - credentials = MicrosoftAppCredentials( - bot_app_id_claim, password, oauth_scope=scope - ) - if ( - self.settings.channel_provider - and self.settings.channel_provider.is_government() - ): - credentials.oauth_endpoint = ( - GovernmentConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL - ) - credentials.oauth_scope = ( - GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE - ) + credentials = None + if bot_app_id: + scope = audience + if not scope: + scope = ( + JwtTokenValidation.get_app_id_from_claims(identity.claims) + if SkillValidation.is_skill_claim(identity.claims) + else self.__get_botframework_oauth_scope() + ) + + credentials = await self.__get_app_credentials(bot_app_id, scope) + + return self._get_or_create_connector_client(service_url, credentials) + + def _get_or_create_connector_client( + self, service_url: str, credentials: AppCredentials + ) -> ConnectorClient: + if not credentials: + credentials = MicrosoftAppCredentials.empty() - client_key = ( - f"{service_url}{credentials.microsoft_app_id if credentials else ''}" + # Get ConnectorClient from cache or create. + client_key = BotFrameworkAdapter.key_for_connector_client( + service_url, credentials.microsoft_app_id, credentials.oauth_scope ) client = self._connector_client_cache.get(client_key) - if not client: client = ConnectorClient(credentials, base_url=service_url) client.config.add_user_agent(USER_AGENT) @@ -982,29 +1053,44 @@ async def create_connector_client( return client - def _create_token_api_client( - self, - url_or_context: Union[TurnContext, str], - oauth_app_credentials: AppCredentials = None, + @staticmethod + def key_for_connector_client(service_url: str, app_id: str, scope: str): + return f"{service_url}:{app_id}:{scope}" + + async def _create_token_api_client( + self, context: TurnContext, oauth_app_credentials: AppCredentials = None, ) -> TokenApiClient: - if isinstance(url_or_context, str): - app_credentials = ( - oauth_app_credentials if oauth_app_credentials else self._credentials - ) - client = TokenApiClient(app_credentials, url_or_context) - client.config.add_user_agent(USER_AGENT) - return client + if ( + not self._is_emulating_oauth_cards + and context.activity.channel_id == "emulator" + and await self._credential_provider.is_authentication_disabled() + ): + self._is_emulating_oauth_cards = True - self.__check_emulating_oauth_cards(url_or_context) - url = self.__oauth_api_url(url_or_context) - return self._create_token_api_client(url) + app_id = self.__get_app_id(context) + scope = context.turn_state[BotAdapter.BOT_OAUTH_SCOPE_KEY] + app_credentials = oauth_app_credentials or await self.__get_app_credentials( + app_id, scope + ) - async def __emulate_oauth_cards( - self, context_or_service_url: Union[TurnContext, str], emulate: bool - ): - self._is_emulating_oauth_cards = emulate - url = self.__oauth_api_url(context_or_service_url) - await EmulatorApiClient.emulate_oauth_cards(self._credentials, url, emulate) + if ( + not self._is_emulating_oauth_cards + and context.activity.channel_id == "emulator" + and await self._credential_provider.is_authentication_disabled() + ): + self._is_emulating_oauth_cards = True + + # TODO: token_api_client cache + + url = self.__oauth_api_url(context) + client = TokenApiClient(app_credentials, url) + client.config.add_user_agent(USER_AGENT) + + if self._is_emulating_oauth_cards: + # intentionally not awaiting this call + EmulatorApiClient.emulate_oauth_cards(app_credentials, url, True) + + return client def __oauth_api_url(self, context_or_service_url: Union[TurnContext, str]) -> str: url = None @@ -1020,47 +1106,76 @@ def __oauth_api_url(self, context_or_service_url: Union[TurnContext, str]) -> st else: url = ( US_GOV_OAUTH_ENDPOINT - if JwtTokenValidation.is_government(self.settings.channel_service) + if self.settings.channel_provider.is_government() else OAUTH_ENDPOINT ) return url - def __check_emulating_oauth_cards(self, context: TurnContext): - if ( - not self._is_emulating_oauth_cards - and context.activity.channel_id == "emulator" - and ( - not self._credentials.microsoft_app_id - or not self._credentials.microsoft_app_password + @staticmethod + def key_for_app_credentials(app_id: str, scope: str): + return f"{app_id}:{scope}" + + async def __get_app_credentials( + self, app_id: str, oauth_scope: str + ) -> AppCredentials: + if not app_id: + return MicrosoftAppCredentials.empty() + + # get from the cache if it's there + cache_key = BotFrameworkAdapter.key_for_app_credentials(app_id, oauth_scope) + app_credentials = self._app_credential_map.get(cache_key) + if app_credentials: + return app_credentials + + # If app credentials were provided, use them as they are the preferred choice moving forward + if self._credentials: + self._app_credential_map[cache_key] = self._credentials + return self._credentials + + # Credentials not found in cache, build them + app_credentials = await self.__build_credentials(app_id, oauth_scope) + + # Cache the credentials for later use + self._app_credential_map[cache_key] = app_credentials + + return app_credentials + + async def __build_credentials( + self, app_id: str, oauth_scope: str = None + ) -> AppCredentials: + app_password = await self._credential_provider.get_app_password(app_id) + + if self._channel_provider.is_government(): + return MicrosoftGovernmentAppCredentials( + app_id, + app_password, + self.settings.channel_auth_tenant, + scope=oauth_scope, ) - ): - self._is_emulating_oauth_cards = True - async def _ensure_channel_connector_client_is_created( - self, service_url: str, claims_identity: ClaimsIdentity - ): - # Ensure we have a default ConnectorClient and MSAppCredentials instance for the audience. - audience = claims_identity.claims.get(AuthenticationConstants.AUDIENCE_CLAIM) + return MicrosoftAppCredentials( + app_id, + app_password, + self.settings.channel_auth_tenant, + oauth_scope=oauth_scope, + ) + def __get_botframework_oauth_scope(self) -> str: if ( - not audience - or AuthenticationConstants.TO_BOT_FROM_CHANNEL_TOKEN_ISSUER != audience + self.settings.channel_provider + and self.settings.channel_provider.is_government() ): - # We create a default connector for audiences that are not coming from - # the default https://api.botframework.com audience. - # We create a default claim that contains only the desired audience. - default_connector_claims = { - AuthenticationConstants.AUDIENCE_CLAIM: audience - } - connector_claims_identity = ClaimsIdentity( - claims=default_connector_claims, is_authenticated=True - ) + return GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + return AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + + def __get_app_id(self, context: TurnContext) -> str: + identity = context.turn_state[BotAdapter.BOT_IDENTITY_KEY] + if not identity: + raise Exception("An IIdentity is required in TurnState for this operation.") - await self.create_connector_client(service_url, connector_claims_identity) + app_id = identity.claims.get(AuthenticationConstants.AUDIENCE_CLAIM) + if not app_id: + raise Exception("Unable to get the bot AppId from the audience claim.") - if SkillValidation.is_skill_claim(claims_identity.claims): - # Add the channel service URL to the trusted services list so we can send messages back. - # the service URL for skills is trusted because it is applied by the - # SkillHandler based on the original request received by the root bot - MicrosoftAppCredentials.trust_service_url(service_url) + return app_id diff --git a/libraries/botbuilder-core/tests/teams/test_teams_info.py b/libraries/botbuilder-core/tests/teams/test_teams_info.py index 27a68c1f2..41c3e5439 100644 --- a/libraries/botbuilder-core/tests/teams/test_teams_info.py +++ b/libraries/botbuilder-core/tests/teams/test_teams_info.py @@ -35,7 +35,7 @@ def create_conversation(): class TestTeamsActivityHandler(TeamsActivityHandler): async def on_turn(self, turn_context: TurnContext): - super().on_turn(turn_context) + await super().on_turn(turn_context) if turn_context.activity.text == "test_send_message_to_teams_channel": await self.call_send_message_to_teams(turn_context) diff --git a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py index 528bbf719..e53148d94 100644 --- a/libraries/botbuilder-core/tests/test_bot_framework_adapter.py +++ b/libraries/botbuilder-core/tests/test_bot_framework_adapter.py @@ -1,293 +1,574 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from copy import copy, deepcopy -from unittest.mock import Mock -import unittest -import uuid -import aiounittest - -from botbuilder.core import ( - BotFrameworkAdapter, - BotFrameworkAdapterSettings, - TurnContext, -) -from botbuilder.schema import ( - Activity, - ActivityTypes, - ConversationAccount, - ConversationReference, - ConversationResourceResponse, - ChannelAccount, -) -from botframework.connector.aio import ConnectorClient -from botframework.connector.auth import ClaimsIdentity - -REFERENCE = ConversationReference( - activity_id="1234", - channel_id="test", - service_url="https://example.org/channel", - user=ChannelAccount(id="user", name="User Name"), - bot=ChannelAccount(id="bot", name="Bot Name"), - conversation=ConversationAccount(id="convo1"), -) - -TEST_ACTIVITY = Activity(text="test", type=ActivityTypes.message) - -INCOMING_MESSAGE = TurnContext.apply_conversation_reference( - copy(TEST_ACTIVITY), REFERENCE, True -) -OUTGOING_MESSAGE = TurnContext.apply_conversation_reference( - copy(TEST_ACTIVITY), REFERENCE -) -INCOMING_INVOKE = TurnContext.apply_conversation_reference( - Activity(type=ActivityTypes.invoke), REFERENCE, True -) - - -class AdapterUnderTest(BotFrameworkAdapter): - def __init__(self, settings=None): - super().__init__(settings) - self.tester = aiounittest.AsyncTestCase() - self.fail_auth = False - self.fail_operation = False - self.expect_auth_header = "" - self.new_service_url = None - - def aux_test_authenticate_request(self, request: Activity, auth_header: str): - return super().authenticate_request(request, auth_header) - - async def aux_test_create_connector_client(self, service_url: str): - return await super().create_connector_client(service_url) - - async def authenticate_request(self, request: Activity, auth_header: str): - self.tester.assertIsNotNone( - request, "authenticate_request() not passed request." - ) - self.tester.assertEqual( - auth_header, - self.expect_auth_header, - "authenticateRequest() not passed expected authHeader.", - ) - return not self.fail_auth - - async def create_connector_client( - self, - service_url: str, - identity: ClaimsIdentity = None, # pylint: disable=unused-argument - ) -> ConnectorClient: - self.tester.assertIsNotNone( - service_url, "create_connector_client() not passed service_url." - ) - connector_client_mock = Mock() - - async def mock_reply_to_activity(conversation_id, activity_id, activity): - nonlocal self - self.tester.assertIsNotNone( - conversation_id, "reply_to_activity not passed conversation_id" - ) - self.tester.assertIsNotNone( - activity_id, "reply_to_activity not passed activity_id" - ) - self.tester.assertIsNotNone( - activity, "reply_to_activity not passed activity" - ) - return not self.fail_auth - - async def mock_send_to_conversation(conversation_id, activity): - nonlocal self - self.tester.assertIsNotNone( - conversation_id, "send_to_conversation not passed conversation_id" - ) - self.tester.assertIsNotNone( - activity, "send_to_conversation not passed activity" - ) - return not self.fail_auth - - async def mock_update_activity(conversation_id, activity_id, activity): - nonlocal self - self.tester.assertIsNotNone( - conversation_id, "update_activity not passed conversation_id" - ) - self.tester.assertIsNotNone( - activity_id, "update_activity not passed activity_id" - ) - self.tester.assertIsNotNone(activity, "update_activity not passed activity") - return not self.fail_auth - - async def mock_delete_activity(conversation_id, activity_id): - nonlocal self - self.tester.assertIsNotNone( - conversation_id, "delete_activity not passed conversation_id" - ) - self.tester.assertIsNotNone( - activity_id, "delete_activity not passed activity_id" - ) - return not self.fail_auth - - async def mock_create_conversation(parameters): - nonlocal self - self.tester.assertIsNotNone( - parameters, "create_conversation not passed parameters" - ) - response = ConversationResourceResponse( - activity_id=REFERENCE.activity_id, - service_url=REFERENCE.service_url, - id=uuid.uuid4(), - ) - return response - - connector_client_mock.conversations.reply_to_activity.side_effect = ( - mock_reply_to_activity - ) - connector_client_mock.conversations.send_to_conversation.side_effect = ( - mock_send_to_conversation - ) - connector_client_mock.conversations.update_activity.side_effect = ( - mock_update_activity - ) - connector_client_mock.conversations.delete_activity.side_effect = ( - mock_delete_activity - ) - connector_client_mock.conversations.create_conversation.side_effect = ( - mock_create_conversation - ) - - return connector_client_mock - - -async def process_activity( - channel_id: str, channel_data_tenant_id: str, conversation_tenant_id: str -): - activity = None - mock_claims = unittest.mock.create_autospec(ClaimsIdentity) - mock_credential_provider = unittest.mock.create_autospec( - BotFrameworkAdapterSettings - ) - - sut = BotFrameworkAdapter(mock_credential_provider) - - async def aux_func(context): - nonlocal activity - activity = context.Activity - - await sut.process_activity( - Activity( - channel_id=channel_id, - service_url="https://smba.trafficmanager.net/amer/", - channel_data={"tenant": {"id": channel_data_tenant_id}}, - conversation=ConversationAccount(tenant_id=conversation_tenant_id), - ), - mock_claims, - aux_func, - ) - return activity - - -class TestBotFrameworkAdapter(aiounittest.AsyncTestCase): - async def test_should_create_connector_client(self): - adapter = AdapterUnderTest() - client = await adapter.aux_test_create_connector_client(REFERENCE.service_url) - self.assertIsNotNone(client, "client not returned.") - self.assertIsNotNone(client.conversations, "invalid client returned.") - - async def test_should_process_activity(self): - called = False - adapter = AdapterUnderTest() - - async def aux_func_assert_context(context): - self.assertIsNotNone(context, "context not passed.") - nonlocal called - called = True - - await adapter.process_activity(INCOMING_MESSAGE, "", aux_func_assert_context) - self.assertTrue(called, "bot logic not called.") - - async def test_should_update_activity(self): - adapter = AdapterUnderTest() - context = TurnContext(adapter, INCOMING_MESSAGE) - self.assertTrue( - await adapter.update_activity(context, INCOMING_MESSAGE), - "Activity not updated.", - ) - - async def test_should_fail_to_update_activity_if_service_url_missing(self): - adapter = AdapterUnderTest() - context = TurnContext(adapter, INCOMING_MESSAGE) - cpy = deepcopy(INCOMING_MESSAGE) - cpy.service_url = None - with self.assertRaises(Exception) as _: - await adapter.update_activity(context, cpy) - - async def test_should_fail_to_update_activity_if_conversation_missing(self): - adapter = AdapterUnderTest() - context = TurnContext(adapter, INCOMING_MESSAGE) - cpy = deepcopy(INCOMING_MESSAGE) - cpy.conversation = None - with self.assertRaises(Exception) as _: - await adapter.update_activity(context, cpy) - - async def test_should_fail_to_update_activity_if_activity_id_missing(self): - adapter = AdapterUnderTest() - context = TurnContext(adapter, INCOMING_MESSAGE) - cpy = deepcopy(INCOMING_MESSAGE) - cpy.id = None - with self.assertRaises(Exception) as _: - await adapter.update_activity(context, cpy) - - async def test_should_migrate_tenant_id_for_msteams(self): - incoming = TurnContext.apply_conversation_reference( - activity=Activity( - type=ActivityTypes.message, - text="foo", - channel_data={"tenant": {"id": "1234"}}, - ), - reference=REFERENCE, - is_incoming=True, - ) - - incoming.channel_id = "msteams" - adapter = AdapterUnderTest() - - async def aux_func_assert_tenant_id_copied(context): - self.assertEqual( - context.activity.conversation.tenant_id, - "1234", - "should have copied tenant id from " - "channel_data to conversation address", - ) - - await adapter.process_activity(incoming, "", aux_func_assert_tenant_id_copied) - - async def test_should_create_valid_conversation_for_msteams(self): - - tenant_id = "testTenant" - - reference = deepcopy(REFERENCE) - reference.conversation.tenant_id = tenant_id - reference.channel_data = {"tenant": {"id": tenant_id}} - adapter = AdapterUnderTest() - - called = False - - async def aux_func_assert_valid_conversation(context): - self.assertIsNotNone(context, "context not passed") - self.assertIsNotNone(context.activity, "context has no request") - self.assertIsNotNone( - context.activity.conversation, "request has invalid conversation" - ) - self.assertEqual( - context.activity.conversation.tenant_id, - tenant_id, - "request has invalid tenant_id on conversation", - ) - self.assertEqual( - context.activity.channel_data["tenant"]["id"], - tenant_id, - "request has invalid tenant_id in channel_data", - ) - nonlocal called - called = True - - await adapter.create_conversation(reference, aux_func_assert_valid_conversation) - self.assertTrue(called, "bot logic not called.") +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from copy import copy, deepcopy +from unittest.mock import Mock +import unittest +import uuid +import aiounittest + +from botbuilder.core import ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + TurnContext, +) +from botbuilder.schema import ( + Activity, + ActivityTypes, + ConversationAccount, + ConversationReference, + ConversationResourceResponse, + ChannelAccount, +) +from botframework.connector.aio import ConnectorClient +from botframework.connector.auth import ( + ClaimsIdentity, + AuthenticationConstants, + AppCredentials, + CredentialProvider, +) + +REFERENCE = ConversationReference( + activity_id="1234", + channel_id="test", + service_url="https://example.org/channel", + user=ChannelAccount(id="user", name="User Name"), + bot=ChannelAccount(id="bot", name="Bot Name"), + conversation=ConversationAccount(id="convo1"), +) + +TEST_ACTIVITY = Activity(text="test", type=ActivityTypes.message) + +INCOMING_MESSAGE = TurnContext.apply_conversation_reference( + copy(TEST_ACTIVITY), REFERENCE, True +) +OUTGOING_MESSAGE = TurnContext.apply_conversation_reference( + copy(TEST_ACTIVITY), REFERENCE +) +INCOMING_INVOKE = TurnContext.apply_conversation_reference( + Activity(type=ActivityTypes.invoke), REFERENCE, True +) + + +class AdapterUnderTest(BotFrameworkAdapter): + def __init__(self, settings=None): + super().__init__(settings) + self.tester = aiounittest.AsyncTestCase() + self.fail_auth = False + self.fail_operation = False + self.expect_auth_header = "" + self.new_service_url = None + + def aux_test_authenticate_request(self, request: Activity, auth_header: str): + return super()._authenticate_request(request, auth_header) + + async def aux_test_create_connector_client(self, service_url: str): + return await super().create_connector_client(service_url) + + async def _authenticate_request( + self, request: Activity, auth_header: str + ) -> ClaimsIdentity: + self.tester.assertIsNotNone( + request, "authenticate_request() not passed request." + ) + self.tester.assertEqual( + auth_header, + self.expect_auth_header, + "authenticateRequest() not passed expected authHeader.", + ) + + if self.fail_auth: + raise PermissionError("Unauthorized Access. Request is not authorized") + + return ClaimsIdentity( + claims={ + AuthenticationConstants.AUDIENCE_CLAIM: self.settings.app_id, + AuthenticationConstants.APP_ID_CLAIM: self.settings.app_id, + }, + is_authenticated=True, + ) + + async def create_connector_client( + self, + service_url: str, + identity: ClaimsIdentity = None, # pylint: disable=unused-argument + audience: str = None, # pylint: disable=unused-argument + ) -> ConnectorClient: + return self._get_or_create_connector_client(service_url, None) + + def _get_or_create_connector_client( + self, service_url: str, credentials: AppCredentials + ) -> ConnectorClient: + self.tester.assertIsNotNone( + service_url, "create_connector_client() not passed service_url." + ) + connector_client_mock = Mock() + + async def mock_reply_to_activity(conversation_id, activity_id, activity): + nonlocal self + self.tester.assertIsNotNone( + conversation_id, "reply_to_activity not passed conversation_id" + ) + self.tester.assertIsNotNone( + activity_id, "reply_to_activity not passed activity_id" + ) + self.tester.assertIsNotNone( + activity, "reply_to_activity not passed activity" + ) + return not self.fail_auth + + async def mock_send_to_conversation(conversation_id, activity): + nonlocal self + self.tester.assertIsNotNone( + conversation_id, "send_to_conversation not passed conversation_id" + ) + self.tester.assertIsNotNone( + activity, "send_to_conversation not passed activity" + ) + return not self.fail_auth + + async def mock_update_activity(conversation_id, activity_id, activity): + nonlocal self + self.tester.assertIsNotNone( + conversation_id, "update_activity not passed conversation_id" + ) + self.tester.assertIsNotNone( + activity_id, "update_activity not passed activity_id" + ) + self.tester.assertIsNotNone(activity, "update_activity not passed activity") + return not self.fail_auth + + async def mock_delete_activity(conversation_id, activity_id): + nonlocal self + self.tester.assertIsNotNone( + conversation_id, "delete_activity not passed conversation_id" + ) + self.tester.assertIsNotNone( + activity_id, "delete_activity not passed activity_id" + ) + return not self.fail_auth + + async def mock_create_conversation(parameters): + nonlocal self + self.tester.assertIsNotNone( + parameters, "create_conversation not passed parameters" + ) + response = ConversationResourceResponse( + activity_id=REFERENCE.activity_id, + service_url=REFERENCE.service_url, + id=uuid.uuid4(), + ) + return response + + connector_client_mock.conversations.reply_to_activity.side_effect = ( + mock_reply_to_activity + ) + connector_client_mock.conversations.send_to_conversation.side_effect = ( + mock_send_to_conversation + ) + connector_client_mock.conversations.update_activity.side_effect = ( + mock_update_activity + ) + connector_client_mock.conversations.delete_activity.side_effect = ( + mock_delete_activity + ) + connector_client_mock.conversations.create_conversation.side_effect = ( + mock_create_conversation + ) + + return connector_client_mock + + +async def process_activity( + channel_id: str, channel_data_tenant_id: str, conversation_tenant_id: str +): + activity = None + mock_claims = unittest.mock.create_autospec(ClaimsIdentity) + mock_credential_provider = unittest.mock.create_autospec( + BotFrameworkAdapterSettings + ) + + sut = BotFrameworkAdapter(mock_credential_provider) + + async def aux_func(context): + nonlocal activity + activity = context.Activity + + await sut.process_activity( + Activity( + channel_id=channel_id, + service_url="https://smba.trafficmanager.net/amer/", + channel_data={"tenant": {"id": channel_data_tenant_id}}, + conversation=ConversationAccount(tenant_id=conversation_tenant_id), + ), + mock_claims, + aux_func, + ) + return activity + + +class TestBotFrameworkAdapter(aiounittest.AsyncTestCase): + async def test_should_create_connector_client(self): + adapter = AdapterUnderTest() + client = await adapter.aux_test_create_connector_client(REFERENCE.service_url) + self.assertIsNotNone(client, "client not returned.") + self.assertIsNotNone(client.conversations, "invalid client returned.") + + async def test_should_process_activity(self): + called = False + adapter = AdapterUnderTest() + + async def aux_func_assert_context(context): + self.assertIsNotNone(context, "context not passed.") + nonlocal called + called = True + + await adapter.process_activity(INCOMING_MESSAGE, "", aux_func_assert_context) + self.assertTrue(called, "bot logic not called.") + + async def test_should_fail_to_update_activity_if_service_url_missing(self): + adapter = AdapterUnderTest() + context = TurnContext(adapter, INCOMING_MESSAGE) + cpy = deepcopy(INCOMING_MESSAGE) + cpy.service_url = None + with self.assertRaises(Exception) as _: + await adapter.update_activity(context, cpy) + + async def test_should_fail_to_update_activity_if_conversation_missing(self): + adapter = AdapterUnderTest() + context = TurnContext(adapter, INCOMING_MESSAGE) + cpy = deepcopy(INCOMING_MESSAGE) + cpy.conversation = None + with self.assertRaises(Exception) as _: + await adapter.update_activity(context, cpy) + + async def test_should_fail_to_update_activity_if_activity_id_missing(self): + adapter = AdapterUnderTest() + context = TurnContext(adapter, INCOMING_MESSAGE) + cpy = deepcopy(INCOMING_MESSAGE) + cpy.id = None + with self.assertRaises(Exception) as _: + await adapter.update_activity(context, cpy) + + async def test_should_migrate_tenant_id_for_msteams(self): + incoming = TurnContext.apply_conversation_reference( + activity=Activity( + type=ActivityTypes.message, + text="foo", + channel_data={"tenant": {"id": "1234"}}, + ), + reference=REFERENCE, + is_incoming=True, + ) + + incoming.channel_id = "msteams" + adapter = AdapterUnderTest() + + async def aux_func_assert_tenant_id_copied(context): + self.assertEqual( + context.activity.conversation.tenant_id, + "1234", + "should have copied tenant id from " + "channel_data to conversation address", + ) + + await adapter.process_activity(incoming, "", aux_func_assert_tenant_id_copied) + + async def test_should_create_valid_conversation_for_msteams(self): + + tenant_id = "testTenant" + + reference = deepcopy(REFERENCE) + reference.conversation.tenant_id = tenant_id + reference.channel_data = {"tenant": {"id": tenant_id}} + adapter = AdapterUnderTest() + + called = False + + async def aux_func_assert_valid_conversation(context): + self.assertIsNotNone(context, "context not passed") + self.assertIsNotNone(context.activity, "context has no request") + self.assertIsNotNone( + context.activity.conversation, "request has invalid conversation" + ) + self.assertEqual( + context.activity.conversation.tenant_id, + tenant_id, + "request has invalid tenant_id on conversation", + ) + self.assertEqual( + context.activity.channel_data["tenant"]["id"], + tenant_id, + "request has invalid tenant_id in channel_data", + ) + nonlocal called + called = True + + await adapter.create_conversation(reference, aux_func_assert_valid_conversation) + self.assertTrue(called, "bot logic not called.") + + @staticmethod + def get_creds_and_assert_values( + turn_context: TurnContext, + expected_app_id: str, + expected_scope: str, + creds_count: int = None, + ): + # pylint: disable=protected-access + credential_cache = turn_context.adapter._app_credential_map + cache_key = BotFrameworkAdapter.key_for_app_credentials( + expected_app_id, expected_scope + ) + credentials = credential_cache.get(cache_key) + assert credentials + + TestBotFrameworkAdapter.assert_credentials_values( + credentials, expected_app_id, expected_scope + ) + + if creds_count: + assert creds_count == len(credential_cache) + + @staticmethod + def get_client_and_assert_values( + turn_context: TurnContext, + expected_app_id: str, + expected_scope: str, + expected_url: str, + client_count: int = None, + ): + # pylint: disable=protected-access + client_cache = turn_context.adapter._connector_client_cache + cache_key = BotFrameworkAdapter.key_for_connector_client( + expected_url, expected_app_id, expected_scope + ) + client = client_cache[cache_key] + assert client + + TestBotFrameworkAdapter.assert_connectorclient_vaules( + client, expected_app_id, expected_url, expected_scope + ) + + if client_count: + assert client_count == len(client_cache) + + @staticmethod + def assert_connectorclient_vaules( + client: ConnectorClient, + expected_app_id, + expected_service_url: str, + expected_scope=AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + ): + creds = client.config.credentials + assert expected_app_id == creds.microsoft_app_id + assert expected_scope == creds.oauth_scope + assert expected_service_url == client.config.base_url + + @staticmethod + def assert_credentials_values( + credentials: AppCredentials, + expected_app_id: str, + expected_scope: str = AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + ): + assert expected_app_id == credentials.microsoft_app_id + assert expected_scope == credentials.oauth_scope + + async def test_process_activity_creates_correct_creds_and_client(self): + bot_app_id = "00000000-0000-0000-0000-000000000001" + identity = ClaimsIdentity( + claims={ + AuthenticationConstants.AUDIENCE_CLAIM: bot_app_id, + AuthenticationConstants.APP_ID_CLAIM: bot_app_id, + AuthenticationConstants.VERSION_CLAIM: "1.0", + }, + is_authenticated=True, + ) + + service_url = "https://smba.trafficmanager.net/amer/" + + async def callback(context: TurnContext): + TestBotFrameworkAdapter.get_creds_and_assert_values( + context, + bot_app_id, + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + 1, + ) + TestBotFrameworkAdapter.get_client_and_assert_values( + context, + bot_app_id, + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + service_url, + 1, + ) + + scope = context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] + assert AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE == scope + + settings = BotFrameworkAdapterSettings(bot_app_id) + sut = BotFrameworkAdapter(settings) + await sut.process_activity_with_identity( + Activity(channel_id="emulator", service_url=service_url, text="test",), + identity, + callback, + ) + + async def test_process_activity_for_forwarded_activity(self): + bot_app_id = "00000000-0000-0000-0000-000000000001" + skill_1_app_id = "00000000-0000-0000-0000-000000skill1" + identity = ClaimsIdentity( + claims={ + AuthenticationConstants.AUDIENCE_CLAIM: skill_1_app_id, + AuthenticationConstants.APP_ID_CLAIM: bot_app_id, + AuthenticationConstants.VERSION_CLAIM: "1.0", + }, + is_authenticated=True, + ) + + service_url = "https://root-bot.test.azurewebsites.net/" + + async def callback(context: TurnContext): + TestBotFrameworkAdapter.get_creds_and_assert_values( + context, skill_1_app_id, bot_app_id, 1, + ) + TestBotFrameworkAdapter.get_client_and_assert_values( + context, skill_1_app_id, bot_app_id, service_url, 1, + ) + + scope = context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] + assert bot_app_id == scope + + settings = BotFrameworkAdapterSettings(bot_app_id) + sut = BotFrameworkAdapter(settings) + await sut.process_activity_with_identity( + Activity(channel_id="emulator", service_url=service_url, text="test",), + identity, + callback, + ) + + async def test_continue_conversation_without_audience(self): + mock_credential_provider = unittest.mock.create_autospec(CredentialProvider) + + settings = BotFrameworkAdapterSettings( + app_id="bot_id", credential_provider=mock_credential_provider + ) + adapter = BotFrameworkAdapter(settings) + + skill_1_app_id = "00000000-0000-0000-0000-000000skill1" + skill_2_app_id = "00000000-0000-0000-0000-000000skill2" + + skills_identity = ClaimsIdentity( + claims={ + AuthenticationConstants.AUDIENCE_CLAIM: skill_1_app_id, + AuthenticationConstants.APP_ID_CLAIM: skill_2_app_id, + AuthenticationConstants.VERSION_CLAIM: "1.0", + }, + is_authenticated=True, + ) + + channel_service_url = "https://smba.trafficmanager.net/amer/" + + async def callback(context: TurnContext): + TestBotFrameworkAdapter.get_creds_and_assert_values( + context, + skill_1_app_id, + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + 1, + ) + TestBotFrameworkAdapter.get_client_and_assert_values( + context, + skill_1_app_id, + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + channel_service_url, + 1, + ) + + # pylint: disable=protected-access + client_cache = context.adapter._connector_client_cache + client = client_cache.get( + BotFrameworkAdapter.key_for_connector_client( + channel_service_url, + skill_1_app_id, + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + ) + ) + assert client + + turn_state_client = context.turn_state.get( + BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY + ) + assert turn_state_client + client_creds = turn_state_client.config.credentials + + assert skill_1_app_id == client_creds.microsoft_app_id + assert ( + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + == client_creds.oauth_scope + ) + assert client.config.base_url == turn_state_client.config.base_url + + scope = context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] + assert AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE == scope + + refs = ConversationReference(service_url=channel_service_url) + + await adapter.continue_conversation( + refs, callback, claims_identity=skills_identity + ) + + async def test_continue_conversation_with_audience(self): + mock_credential_provider = unittest.mock.create_autospec(CredentialProvider) + + settings = BotFrameworkAdapterSettings( + app_id="bot_id", credential_provider=mock_credential_provider + ) + adapter = BotFrameworkAdapter(settings) + + skill_1_app_id = "00000000-0000-0000-0000-000000skill1" + skill_2_app_id = "00000000-0000-0000-0000-000000skill2" + + skills_identity = ClaimsIdentity( + claims={ + AuthenticationConstants.AUDIENCE_CLAIM: skill_1_app_id, + AuthenticationConstants.APP_ID_CLAIM: skill_2_app_id, + AuthenticationConstants.VERSION_CLAIM: "1.0", + }, + is_authenticated=True, + ) + + skill_2_service_url = "https://skill2.com/api/skills/" + + async def callback(context: TurnContext): + TestBotFrameworkAdapter.get_creds_and_assert_values( + context, skill_1_app_id, skill_2_app_id, 1, + ) + TestBotFrameworkAdapter.get_client_and_assert_values( + context, skill_1_app_id, skill_2_app_id, skill_2_service_url, 1, + ) + + # pylint: disable=protected-access + client_cache = context.adapter._connector_client_cache + client = client_cache.get( + BotFrameworkAdapter.key_for_connector_client( + skill_2_service_url, skill_1_app_id, skill_2_app_id, + ) + ) + assert client + + turn_state_client = context.turn_state.get( + BotFrameworkAdapter.BOT_CONNECTOR_CLIENT_KEY + ) + assert turn_state_client + client_creds = turn_state_client.config.credentials + + assert skill_1_app_id == client_creds.microsoft_app_id + assert skill_2_app_id == client_creds.oauth_scope + assert client.config.base_url == turn_state_client.config.base_url + + scope = context.turn_state[BotFrameworkAdapter.BOT_OAUTH_SCOPE_KEY] + assert skill_2_app_id == scope + + refs = ConversationReference(service_url=skill_2_service_url) + + await adapter.continue_conversation( + refs, callback, claims_identity=skills_identity, audience=skill_2_app_id + ) diff --git a/libraries/botframework-connector/botframework/connector/auth/__init__.py b/libraries/botframework-connector/botframework/connector/auth/__init__.py index bc97e67dc..6b9b6d925 100644 --- a/libraries/botframework-connector/botframework/connector/auth/__init__.py +++ b/libraries/botframework-connector/botframework/connector/auth/__init__.py @@ -14,6 +14,7 @@ from .channel_provider import * from .simple_channel_provider import * from .microsoft_app_credentials import * +from .microsoft_government_app_credentials import * from .certificate_app_credentials import * from .claims_identity import * from .jwt_token_validation import * diff --git a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py index 70bfba050..2d21c4af1 100644 --- a/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py +++ b/libraries/botframework-connector/botframework/connector/auth/jwt_token_validation.py @@ -174,7 +174,7 @@ def is_government(channel_service: str) -> bool: ) @staticmethod - def get_app_id_from_claims(claims: Dict[str, object]) -> bool: + def get_app_id_from_claims(claims: Dict[str, object]) -> str: app_id = None # Depending on Version, the is either in the diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py index 35fa21566..d625d6ede 100644 --- a/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_app_credentials.py @@ -33,7 +33,17 @@ def __init__( self.microsoft_app_password = password self.app = None - self.scopes = [self.oauth_scope] + + # This check likely needs to be more nuanced than this. Assuming + # "/.default" precludes other valid suffixes + scope = self.oauth_scope + if oauth_scope and not scope.endswith("/.default"): + scope += "/.default" + self.scopes = [scope] + + @staticmethod + def empty(): + return MicrosoftAppCredentials("", "") def get_access_token(self, force_refresh: bool = False) -> str: """ diff --git a/libraries/botframework-connector/botframework/connector/auth/microsoft_government_app_credentials.py b/libraries/botframework-connector/botframework/connector/auth/microsoft_government_app_credentials.py new file mode 100644 index 000000000..17403b414 --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/auth/microsoft_government_app_credentials.py @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botframework.connector.auth import MicrosoftAppCredentials, GovernmentConstants + + +class MicrosoftGovernmentAppCredentials(MicrosoftAppCredentials): + """ + MicrosoftGovernmentAppCredentials auth implementation. + """ + + def __init__( + self, + app_id: str, + app_password: str, + channel_auth_tenant: str = None, + scope: str = GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + ): + super().__init__(app_id, app_password, channel_auth_tenant, scope) + self.oauth_endpoint = GovernmentConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL + + @staticmethod + def empty(): + return MicrosoftGovernmentAppCredentials("", "") diff --git a/libraries/botframework-connector/botframework/connector/emulator_api_client.py b/libraries/botframework-connector/botframework/connector/emulator_api_client.py index 6012456ca..ad83f96f7 100644 --- a/libraries/botframework-connector/botframework/connector/emulator_api_client.py +++ b/libraries/botframework-connector/botframework/connector/emulator_api_client.py @@ -2,13 +2,13 @@ # Licensed under the MIT License. import requests -from .auth import MicrosoftAppCredentials +from .auth import AppCredentials class EmulatorApiClient: @staticmethod async def emulate_oauth_cards( - credentials: MicrosoftAppCredentials, emulator_url: str, emulate: bool + credentials: AppCredentials, emulator_url: str, emulate: bool ) -> bool: token = await credentials.get_token() request_url = (