diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py
index 19b44f2c7..e18297b53 100644
--- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py
+++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py
@@ -512,7 +512,9 @@ async def process_activity_with_identity(
return InvokeResponse(status=int(HTTPStatus.NOT_IMPLEMENTED))
return InvokeResponse(
status=invoke_response.value.status,
- body=invoke_response.value.body.serialize(),
+ body=invoke_response.value.body.serialize()
+ if invoke_response.value.body
+ else None,
)
return None
@@ -1278,7 +1280,7 @@ async def exchange_token_from_credentials(
token=result.token,
expiration=result.expiration,
)
- raise TypeError(f"exchange_async returned improper result: {type(result)}")
+ raise TypeError(f"exchange token returned improper result: {type(result)}")
@staticmethod
def key_for_connector_client(service_url: str, app_id: str, scope: str):
diff --git a/libraries/botbuilder-core/botbuilder/core/memory_storage.py b/libraries/botbuilder-core/botbuilder/core/memory_storage.py
index c61b053c7..b1ec20f75 100644
--- a/libraries/botbuilder-core/botbuilder/core/memory_storage.py
+++ b/libraries/botbuilder-core/botbuilder/core/memory_storage.py
@@ -67,7 +67,7 @@ async def write(self, changes: Dict[str, StoreItem]):
old_state_etag is not None
and new_value_etag is not None
and new_value_etag != "*"
- and new_value_etag < old_state_etag
+ and new_value_etag != old_state_etag
):
raise KeyError(
"Etag conflict.\nOriginal: %s\r\nCurrent: %s"
diff --git a/libraries/botbuilder-core/botbuilder/core/teams/__init__.py b/libraries/botbuilder-core/botbuilder/core/teams/__init__.py
index d9d4847e8..9d3c4d43d 100644
--- a/libraries/botbuilder-core/botbuilder/core/teams/__init__.py
+++ b/libraries/botbuilder-core/botbuilder/core/teams/__init__.py
@@ -12,10 +12,12 @@
teams_get_team_info,
teams_notify_user,
)
+from .teams_sso_token_exchange_middleware import TeamsSSOTokenExchangeMiddleware
__all__ = [
"TeamsActivityHandler",
"TeamsInfo",
+ "TeamsSSOTokenExchangeMiddleware",
"teams_get_channel_id",
"teams_get_team_info",
"teams_notify_user",
diff --git a/libraries/botbuilder-core/botbuilder/core/teams/teams_sso_token_exchange_middleware.py b/libraries/botbuilder-core/botbuilder/core/teams/teams_sso_token_exchange_middleware.py
new file mode 100644
index 000000000..1dec1210a
--- /dev/null
+++ b/libraries/botbuilder-core/botbuilder/core/teams/teams_sso_token_exchange_middleware.py
@@ -0,0 +1,189 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+import traceback
+
+from http import HTTPStatus
+from typing import Awaitable, Callable
+from botframework.connector.channels import Channels
+
+from botframework.connector.token_api.models import (
+ TokenResponse,
+ TokenExchangeRequest,
+)
+from botbuilder.schema import (
+ Activity,
+ ActivityTypes,
+ SignInConstants,
+ TokenExchangeInvokeRequest,
+ TokenExchangeInvokeResponse,
+)
+from botbuilder.core import (
+ ExtendedUserTokenProvider,
+ Middleware,
+ InvokeResponse,
+ Storage,
+ StoreItem,
+ TurnContext,
+)
+
+
+class _TokenStoreItem(StoreItem):
+ def __init__(self, **kwargs):
+ self.e_tag: str = None
+ super().__init__(**kwargs)
+
+ @staticmethod
+ def get_storage_key(turn_context: TurnContext):
+ activity = turn_context.activity
+ if not activity.channel_id:
+ raise TypeError("invalid activity-missing channel_id")
+
+ if not activity.conversation or not activity.conversation.id:
+ raise TypeError("invalid activity-missing conversation.id")
+
+ channel_id = activity.channel_id
+ conversation_id = activity.conversation.id
+
+ value = activity.value
+ if not value or "id" not in value:
+ raise Exception("Invalid signin/tokenExchange. Missing activity.value[id]")
+
+ return f"{channel_id}/{conversation_id}/{value['id']}"
+
+
+class TeamsSSOTokenExchangeMiddleware(Middleware):
+ """
+ If the activity name is signin/tokenExchange, self middleware will attempt to
+ exchange the token, and deduplicate the incoming call, ensuring only one
+ exchange request is processed.
+
+ .. remarks::
+ If a user is signed into multiple Teams clients, the Bot could receive a
+ "signin/tokenExchange" from each client. Each token exchange request for a
+ specific user login will have an identical Activity.Value.Id.
+
+ Only one of these token exchange requests should be processed by the bot.
+ The others return .
+ For a distributed bot in production, self requires a distributed storage
+ ensuring only one token exchange is processed. self middleware supports
+ CosmosDb storage found in Microsoft.Bot.Builder.Azure, or MemoryStorage for
+ local development. IStorage's ETag implementation for token exchange activity
+ deduplication.
+ """
+
+ def __init__(self, storage: Storage, connection_name: str):
+ """
+ Initializes a instance of the class.
+
+ :param storage: The Storage to use for deduplication.
+ :param connection_name: The connection name to use for the single
+ sign on token exchange.
+ """
+ if storage is None:
+ raise TypeError("storage cannot be None")
+
+ if connection_name is None:
+ raise TypeError("connection name cannot be None")
+
+ self._oauth_connection_name = connection_name
+ self._storage = storage
+
+ async def on_turn(
+ self, context: TurnContext, logic: Callable[[TurnContext], Awaitable]
+ ):
+ if (
+ context.activity.channel_id == Channels.ms_teams
+ and context.activity.name == SignInConstants.token_exchange_operation_name
+ ):
+ # If the TokenExchange is NOT successful, the response will have already been sent by _exchanged_token
+ if not await self._exchanged_token(context):
+ return
+
+ # Only one token exchange should proceed from here. Deduplication is performed second because in the case
+ # of failure due to consent required, every caller needs to receive the
+ if not await self._deduplicated_token_exchange_id(context):
+ # If the token is not exchangeable, do not process this activity further.
+ return
+
+ await logic()
+
+ async def _deduplicated_token_exchange_id(self, turn_context: TurnContext) -> bool:
+ # Create a StoreItem with Etag of the unique 'signin/tokenExchange' request
+ store_item = _TokenStoreItem(e_tag=turn_context.activity.value.get("id", None))
+
+ store_items = {_TokenStoreItem.get_storage_key(turn_context): store_item}
+ try:
+ # Writing the IStoreItem with ETag of unique id will succeed only once
+ await self._storage.write(store_items)
+ except Exception as error:
+ # Memory storage throws a generic exception with a Message of 'Etag conflict. [other error info]'
+ # CosmosDbPartitionedStorage throws: ex.Message.Contains("precondition is not met")
+ if "Etag conflict" in str(error) or "precondition is not met" in str(error):
+ # Do NOT proceed processing self message, some other thread or machine already has processed it.
+
+ # Send 200 invoke response.
+ await self._send_invoke_response(turn_context)
+ return False
+
+ raise error
+
+ return True
+
+ async def _send_invoke_response(
+ self,
+ turn_context: TurnContext,
+ body: object = None,
+ http_status_code=HTTPStatus.OK,
+ ):
+ await turn_context.send_activity(
+ Activity(
+ type=ActivityTypes.invoke_response,
+ value=InvokeResponse(status=http_status_code, body=body),
+ )
+ )
+
+ async def _exchanged_token(self, turn_context: TurnContext) -> bool:
+ token_exchange_response: TokenResponse = None
+ aux_dict = {}
+ if turn_context.activity.value:
+ for prop in ["id", "connection_name", "token", "properties"]:
+ aux_dict[prop] = turn_context.activity.value.get(prop)
+ token_exchange_request = TokenExchangeInvokeRequest(
+ id=aux_dict["id"],
+ connection_name=aux_dict["connection_name"],
+ token=aux_dict["token"],
+ properties=aux_dict["properties"],
+ )
+ try:
+ adapter = turn_context.adapter
+ if isinstance(turn_context.adapter, ExtendedUserTokenProvider):
+ token_exchange_response = await adapter.exchange_token(
+ turn_context,
+ self._oauth_connection_name,
+ turn_context.activity.from_property.id,
+ TokenExchangeRequest(token=token_exchange_request.token),
+ )
+ else:
+ raise Exception(
+ "Not supported: Token Exchange is not supported by the current adapter."
+ )
+ except:
+ traceback.print_exc()
+ if not token_exchange_response or not token_exchange_response.token:
+ # The token could not be exchanged (which could be due to a consent requirement)
+ # Notify the sender that PreconditionFailed so they can respond accordingly.
+
+ invoke_response = TokenExchangeInvokeResponse(
+ id=token_exchange_request.id,
+ connection_name=self._oauth_connection_name,
+ failure_detail="The bot is unable to exchange token. Proceed with regular login.",
+ )
+
+ await self._send_invoke_response(
+ turn_context, invoke_response, HTTPStatus.PRECONDITION_FAILED
+ )
+
+ return False
+
+ return True