10000 SSO Token Exchange Middleware (#1641) (#1642) · andreikop/botbuilder-python@0a37358 · GitHub
[go: up one dir, main page]

Skip to content

Commit 0a37358

Browse files
SSO Token Exchange Middleware (microsoft#1641) (microsoft#1642)
* SSO Token Exchange Middleware * Teams SSO middleware updates * update teams sso exchange middleware cosmosdb message * Black formatting * removing unnessary pass Co-authored-by: Eric Dahlvang <erdahlva@microsoft.com> Co-authored-by: Eric Dahlvang <erdahlva@microsoft.com>
1 parent 2a84dc5 commit 0a37358

File tree

4 files changed

+196
-3
lines changed

4 files changed

+196
-3
lines changed

libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,9 @@ async def process_activity_with_identity(
512512
return InvokeResponse(status=int(HTTPStatus.NOT_IMPLEMENTED))
513513
return InvokeResponse(
514514
status=invoke_response.value.status,
515-
body=invoke_response.value.body.serialize(),
515+
body=invoke_response.value.body.serialize()
516+
if invoke_response.value.body
517+
else None,
516518
)
517519

518520
return None
@@ -1278,7 +1280,7 @@ async def exchange_token_from_credentials(
12781280
token=result.token,
12791281
expiration=result.expiration,
12801282
)
1281-
raise TypeError(f"exchange_async returned improper result: {type(result)}")
1283+
raise TypeError(f"exchange token returned improper result: {type(result)}")
12821284

12831285
@staticmethod
12841286
def key_for_connector_client(service_url: str, app_id: str, scope: str):

libraries/botbuilder-core/botbuilder/core/memory_storage.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ async def write(self, changes: Dict[str, StoreItem]):
6767
old_state_etag is not None
6868
and new_value_etag is not None
6969
and new_value_etag != "*"
70-
and new_value_etag < old_state_etag
70+
and new_value_etag != old_state_etag
7171
):
7272
raise KeyError(
7373
"Etag conflict.\nOriginal: %s\r\nCurrent: %s"

libraries/botbuilder-core/botbuilder/core/teams/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@
1212
teams_get_team_info,
1313
teams_notify_user,
1414
)
15+
from .teams_sso_token_exchange_middleware import TeamsSSOTokenExchangeMiddleware
1516

1617
__all__ = [
1718
"TeamsActivityHandler",
1819
"TeamsInfo",
20+
"TeamsSSOTokenExchangeMiddleware",
1921
"teams_get_channel_id",
2022
"teams_get_team_info",
2123
"teams_notify_user",
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import traceback
5+
6+
from http import HTTPStatus
7+
from typing import Awaitable, Callable
8+
from botframework.connector.channels import Channels
9+
10+
from botframework.connector.token_api.models import (
11+
TokenResponse,
12+
TokenExchangeRequest,
13+
)
14+
from botbuilder.schema import (
15+
Activity,
16+
ActivityTypes,
17+
SignInConstants,
18+
TokenExchangeInvokeRequest,
19+
TokenExchangeInvokeResponse,
20+
)
21+
from botbuilder.core import (
22+
ExtendedUserTokenProvider,
23+
Middleware,
24+
InvokeResponse,
25+
Storage,
26+
StoreItem,
27+
TurnContext,
28+
)
29+
30+
31+
class _TokenStoreItem(StoreItem):
32+
def __init__(self, **kwargs):
33+
self.e_tag: str = None
34+
super().__init__(**kwargs)
35+
36+
@staticmethod
37+
def get_storage_key(turn_context: TurnContext):
38+
activity = turn_context.activity
39+
if not activity.channel_id:
40+
raise TypeError("invalid activity-missing channel_id")
41+
42+
if not activity.conversation or not activity.conversation.id:
43+
raise TypeError("invalid activity-missing conversation.id")
44+
45+
channel_id = activity.channel_id
46+
conversation_id = activity.conversation.id
47+
48+
value = activity.value
49+
if not value or "id" not in value:
50+
raise Exception("Invalid signin/tokenExchange. Missing activity.value[id]")
51+
52+
return f"{channel_id}/{conversation_id}/{value['id']}"
53+
54+
55+
class TeamsSSOTokenExchangeMiddleware(Middleware):
56+
"""
57+
If the activity name is signin/tokenExchange, self middleware will attempt to
58+
exchange the token, and deduplicate the incoming call, ensuring only one
59+
exchange request is processed.
60+
61+
.. remarks::
62+
If a user is signed into multiple Teams clients, the Bot could receive a
63+
"signin/tokenExchange" from each client. Each token exchange request for a
64+
specific user login will have an identical Activity.Value.Id.
65+
66+
Only one of these token exchange requests should be processed by the bot.
67+
The others return <see cref="System.Net.HttpStatusCode.PreconditionFailed"/>.
68+
For a distributed bot in production, self requires a distributed storage
69+
ensuring only one token exchange is processed. self middleware supports
70+
CosmosDb storage found in Microsoft.Bot.Builder.Azure, or MemoryStorage for
71+
local development. IStorage's ETag implementation for token exchange activity
72+
deduplication.
73+
"""
74+
75+
def __init__(self, storage: Storage, connection_name: str):
76+
"""
77+
Initializes a instance of the <see cref="TeamsSSOTokenExchangeMiddleware"/> class.
78+
79+
:param storage: The Storage to use for deduplication.
80+
:param connection_name: The connection name to use for the single
81+
sign on token exchange.
82+
"""
83+
if storage is None:
84+
raise TypeError("storage cannot be None")
85+
86+
if connection_name is None:
87+
raise TypeError("connection name cannot be None")
88+
89+
self._oauth_connection_name = connection_name
90+
self._storage = storage
91+
92+
async def on_turn(
93+
self, context: TurnContext, logic: Callable[[TurnContext], Awaitable]
94+
):
95+
if (
96+
context.activity.channel_id == Channels.ms_teams
97+
and context.activity.name == SignInConstants.token_exchange_operation_name
98+
):
99+
# If the TokenExchange is NOT successful, the response will have already been sent by _exchanged_token
100+
if not await self._exchanged_token(context):
101+
return
102+
103+
# Only one token exchange should proceed from here. Deduplication is performed second because in the case
104+
# of failure due to consent required, every caller needs to receive the
105+
if not await self._deduplicated_token_exchange_id(context):
106+
# If the token is not exchangeable, do not process this activity further.
107+
return
108+
109+
await logic()
110+
111+
async def _deduplicated_token_exchange_id(self, turn_context: TurnContext) -> bool:
112+
# Create a StoreItem with Etag of the unique 'signin/tokenExchange' request
113+
store_item = _TokenStoreItem(e_tag=turn_context.activity.value.get("id", None))
114+
115+
store_items = {_TokenStoreItem.get_storage_key(turn_context): store_item}
116+
try:
117+
# Writing the IStoreItem with ETag of unique id will succeed only once
118+
await self._storage.write(store_items)
119+
except Exception as error:
120+
# Memory storage throws a generic exception with a Message of 'Etag conflict. [other error info]'
121+
# CosmosDbPartitionedStorage throws: ex.Message.Contains("precondition is not met")
122+
if "Etag conflict" in str(error) or "precondition is not met" in str(error):
123+
# Do NOT proceed processing self message, some other thread or machine already has processed it.
124+
125+
# Send 200 invoke response.
126+
await self._send_invoke_response(turn_context)
127+
return False
128+
129+
raise error
130+
131+
return True
132+
133+
async def _send_invoke_response(
134+
self,
135+
turn_context: TurnContext,
136+
body: object = None,
137+
http_status_code=HTTPStatus.OK,
138+
):
139+
await turn_context.send_activity(
140+
Activity(
141+
type=ActivityTypes.invoke_response,
142+
value=InvokeResponse(status=http_status_code, body=body),
143+
)
144+
)
145+
146+
async def _exchanged_token(self, turn_context: TurnContext) -> bool:
147+
token_exchange_response: TokenResponse = None
148+
aux_dict = {}
149+
if turn_context.activity.value:
150+
for prop in ["id", "connection_name", "token", "properties"]:
151+
aux_dict[prop] = turn_context.activity.value.get(prop)
152+
token_exchange_request = TokenExchangeInvokeRequest(
153+
id=aux_dict["id"],
154+
connection_name=aux_dict["connection_name"],
155+
token=aux_dict["token"],
156+
properties=aux_dict["properties"],
157+
)
158+
try:
159+
adapter = turn_context.adapter
160+
if isinstance(turn_context.adapter, ExtendedUserTokenProvider):
161+
token_exchange_response = await adapter.exchange_token(
162+
turn_context,
163+
self._oauth_connection_name,
164+
turn_context.activity.from_property.id,
165+
TokenExchangeRequest(token=token_exchange_request.token),
166+
)
167+
else:
168+
raise Exception(
169+
"Not supported: Token Exchange is not supported by the current adapter."
170+
)
171+
except:
172+
traceback.print_exc()
173+
if not token_exchange_response or not token_exchange_response.token:
174+
# The token could not be exchanged (which could be due to a consent requirement)
175+
# Notify the sender that PreconditionFailed so they can respond accordingly.
176+
177+
invoke_response = TokenExchangeInvokeResponse(
178+
id=token_exchange_request.id,
179+
connection_name=self._oauth_connection_name,
180+
failure_detail="The bot is unable to exchange token. Proceed with regular login.",
181+
)
182+
183+
await self._send_invoke_response(
184+
turn_context, invoke_response, HTTPStatus.PRECONDITION_FAILED
185+
)
186+
187+
return False
188+
189+
return True

0 commit comments

Comments
 (0)
0