From ba30c8299ecd87e62582b803ba133237e9434c15 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 23 Sep 2025 14:53:02 -0700 Subject: [PATCH 01/36] Implement asynchronous token retrieval methods in AgenticMsalAuth class --- .../authentication/msal/agentic_msal_auth.py | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py new file mode 100644 index 00000000..eabeabcd --- /dev/null +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import logging +from typing import Optional + +import aiohttp +from msal import ConfidentialClientApplication + +from .msal_auth import MsalAuth + +logger = logging.getLogger(__name__) + + +class AgenticMsalAuth(MsalAuth): + + # the call to MSAL is blocking, but in the future we want to create an asyncio task + # to avoid this + async def get_agentic_application_token( + self, agent_app_instance_id: str + ) -> Optional[str]: + + if not agent_app_instance_id: + raise ValueError("Agent application instance Id must be provided.") + + msal_auth_client = self._create_client_application() + + if isinstance(msal_auth_client, ConfidentialClientApplication): + + # https://github.dev/AzureAD/microsoft-authentication-library-for-dotnet + auth_result_payload = msal_auth_client.acquire_token_for_client( + ["api://AzureAdTokenExchange/.default"], + data={"fmi_path": agent_app_instance_id}, + ) + + if auth_result_payload: + return auth_result_payload.get("access_token") + + return None + + async def get_agentic_instance_token(self, agent_app_instance_id: str) -> str: + + if not agent_app_instance_id: + raise ValueError("Agent application instance Id must be provided.") + + agent_token_result = await self.get_agentic_application_token( + agent_app_instance_id + ) + + authority = ( + f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}" + ) + + instance_app = ConfidentialClientApplication( + client_id=agent_app_instance_id, + authority=authority, + client_credential={"client_assertion": agent_token_result}, + ) + + agent_instance_token = instance_app.acquire_token_for_client( + ["api://AzureAdTokenExchange/.default"] + ) + + assert agent_instance_token + return agent_instance_token["access_token"] + + # async def get_agentic_user_token(self, agent_app_instance_id: str, upn: str, scopes: list[str]) -> Optional[str]: + + # if not agent_app_instance_id or not upn: + # raise ValueError("Agent application instance Id and user principal name must be provided.") + + # agent_token = await self.get_agentic_application_token(agent_app_instance_id) + # instance_token = await self.get_agentic_instance_token(agent_app_instance_id) + + # token_endpoint = f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}/oauth2/v2.0/token" + + # parameters = { + # "client_id": agent_app_instance_id, + # "scope": " ".join(scopes), + # "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + # "client_assertion": agent_token, + # "username": upn, + # "user_federated_identity_credential": instance_token, + # "grant_type": "user_fic" + # } + + # async with aiohttp.ClientSession() as session: + # async with session.post( + # token_endpoint, + # data=parameters, + # headers={"Content-Type": "application/x-www-form-urlencoded"} + # ) as response: + + # if response.status >= 400: + # logger.error("Failed to acquire user federated identity token: %s", response.status) + # response.raise_for_status() + + # token_response = await response.json() + + # if token_response: + # return token_response.get("access_token") + + # return None From 755e97328186c1ba173512d232f32d66968bb068 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 23 Sep 2025 15:30:00 -0700 Subject: [PATCH 02/36] get_agentic_user_token implementation --- .../authentication/msal/agentic_msal_auth.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py index eabeabcd..df28af4c 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py @@ -100,3 +100,30 @@ async def get_agentic_instance_token(self, agent_app_instance_id: str) -> str: # return token_response.get("access_token") # return None + + async def get_agentic_user_token(self, agent_app_instance_id: str, upn: str, scopes: list[str]) -> Optional[str]: + + if not agent_app_instance_id or not upn: + raise ValueError("Agent application instance Id and user principal name must be provided.") + + agent_token = await self.get_agentic_application_token(agent_app_instance_id) + instance_token = await self.get_agentic_instance_token(agent_app_instance_id) + + authority = f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}" + + instance_app = ConfidentialClientApplication( + client_id=agent_app_instance_id, + authority=authority, + client_credential={"client_assertion": agent_token}, + ) + + auth_result_payload = instance_app.acquire_token_for_client( + scopes, + data={ + "username": upn, + "user_federated_identity_credential": instance_token, + "grant_type": "user_fic", + }, + ) + + return auth_result_payload.get("access_token") if auth_result_payload else None From c79d56beec8ce01ad5d30abe7fe3824338e62482 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 23 Sep 2025 15:30:41 -0700 Subject: [PATCH 03/36] get_agentic_user_token simplified implementation with ConfidentialClientApplication --- .../authentication/msal/agentic_msal_auth.py | 50 ++++--------------- 1 file changed, 9 insertions(+), 41 deletions(-) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py index df28af4c..3a4be270 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py @@ -63,53 +63,21 @@ async def get_agentic_instance_token(self, agent_app_instance_id: str) -> str: assert agent_instance_token return agent_instance_token["access_token"] - # async def get_agentic_user_token(self, agent_app_instance_id: str, upn: str, scopes: list[str]) -> Optional[str]: - - # if not agent_app_instance_id or not upn: - # raise ValueError("Agent application instance Id and user principal name must be provided.") - - # agent_token = await self.get_agentic_application_token(agent_app_instance_id) - # instance_token = await self.get_agentic_instance_token(agent_app_instance_id) - - # token_endpoint = f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}/oauth2/v2.0/token" - - # parameters = { - # "client_id": agent_app_instance_id, - # "scope": " ".join(scopes), - # "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - # "client_assertion": agent_token, - # "username": upn, - # "user_federated_identity_credential": instance_token, - # "grant_type": "user_fic" - # } - - # async with aiohttp.ClientSession() as session: - # async with session.post( - # token_endpoint, - # data=parameters, - # headers={"Content-Type": "application/x-www-form-urlencoded"} - # ) as response: - - # if response.status >= 400: - # logger.error("Failed to acquire user federated identity token: %s", response.status) - # response.raise_for_status() - - # token_response = await response.json() - - # if token_response: - # return token_response.get("access_token") - - # return None - - async def get_agentic_user_token(self, agent_app_instance_id: str, upn: str, scopes: list[str]) -> Optional[str]: + async def get_agentic_user_token( + self, agent_app_instance_id: str, upn: str, scopes: list[str] + ) -> Optional[str]: if not agent_app_instance_id or not upn: - raise ValueError("Agent application instance Id and user principal name must be provided.") + raise ValueError( + "Agent application instance Id and user principal name must be provided." + ) agent_token = await self.get_agentic_application_token(agent_app_instance_id) instance_token = await self.get_agentic_instance_token(agent_app_instance_id) - authority = f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}" + authority = ( + f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}" + ) instance_app = ConfidentialClientApplication( client_id=agent_app_instance_id, From c671841ee8f847b96b91b3bd2b032d46a09ea127 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 23 Sep 2025 15:48:22 -0700 Subject: [PATCH 04/36] Enhance AgenticMsalAuth: update get_agentic_instance_token to return tuple and add JWT decoding for blueprint ID --- .../authentication/msal/__init__.py | 2 ++ .../authentication/msal/agentic_msal_auth.py | 23 +++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/__init__.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/__init__.py index 8536f337..41ea3458 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/__init__.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/__init__.py @@ -1,7 +1,9 @@ from .msal_auth import MsalAuth from .msal_connection_manager import MsalConnectionManager +from .agentic_msal_auth import AgenticMsalAuth __all__ = [ "MsalAuth", "MsalConnectionManager", + "AgenticMsalAuth", ] diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py index 3a4be270..27234a62 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py @@ -1,9 +1,9 @@ from __future__ import annotations import logging +import jwt from typing import Optional -import aiohttp from msal import ConfidentialClientApplication from .msal_auth import MsalAuth @@ -37,7 +37,9 @@ async def get_agentic_application_token( return None - async def get_agentic_instance_token(self, agent_app_instance_id: str) -> str: + async def get_agentic_instance_token( + self, agent_app_instance_id: str + ) -> tuple[str, str]: if not agent_app_instance_id: raise ValueError("Agent application instance Id must be provided.") @@ -61,7 +63,17 @@ async def get_agentic_instance_token(self, agent_app_instance_id: str) -> str: ) assert agent_instance_token - return agent_instance_token["access_token"] + assert agent_token_result + + # future scenario where we don't know the blueprint id upfront + token = agent_instance_token["access_token"] + payload = jwt.decode(token, options={"verify_signature": False}) + agentic_blueprint_id = payload.get("xms_par_app_azp") + logger.debug("Agentic blueprint id: %s", agentic_blueprint_id) + + # "xms_par_app_azp": "84df77a3-1e3f-4372-a49f-c7e93c3db681", + + return agent_instance_token["access_token"], agent_token_result async def get_agentic_user_token( self, agent_app_instance_id: str, upn: str, scopes: list[str] @@ -72,8 +84,9 @@ async def get_agentic_user_token( "Agent application instance Id and user principal name must be provided." ) - agent_token = await self.get_agentic_application_token(agent_app_instance_id) - instance_token = await self.get_agentic_instance_token(agent_app_instance_id) + instance_token, agent_token = await self.get_agentic_instance_token( + agent_app_instance_id + ) authority = ( f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}" From b06af401e7466ab741bf65d9514125deba8a5f75 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Sep 2025 15:13:13 -0700 Subject: [PATCH 05/36] Supporting authorization variants --- .../microsoft_agents/activity/activity.py | 7 + .../activity/channel_account.py | 3 + .../microsoft_agents/activity/channels.py | 13 +- .../microsoft_agents/activity/role_types.py | 2 + .../authentication/msal/__init__.py | 2 - .../authentication/msal/agentic_msal_auth.py | 160 ++++++------- .../authentication/msal/msal_auth.py | 96 ++++++++ .../hosting/core/app/agent_application.py | 139 ++--------- .../hosting/core/app/auth/__init__.py | 14 ++ .../core/app/auth/agentic_authorization.py | 75 ++++++ .../core/app/{oauth => auth}/auth_handler.py | 2 + .../hosting/core/app/auth/authorization.py | 223 ++++++++++++++++++ .../core/app/auth/authorization_variant.py | 145 ++++++++++++ .../core/app/auth/user_authorization.py | 94 ++++++++ .../user_authorization_base.py} | 126 ++-------- .../hosting/core/app/oauth/__init__.py | 8 - .../access_token_provider_base.py | 18 +- .../hosting/core/channel_service_adapter.py | 2 +- .../hosting/core/turn_context.py | 14 +- 19 files changed, 830 insertions(+), 313 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{oauth => auth}/auth_handler.py (94%) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{oauth/authorization.py => auth/user_authorization_base.py} (76%) delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 89730568..e408a31b 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -20,6 +20,7 @@ from .text_highlight import TextHighlight from .semantic_action import SemanticAction from .agents_model import AgentsModel +from .role_types import RoleTypes from ._model_utils import pick_model, SkipNone from ._type_aliases import NonEmptyString @@ -648,3 +649,9 @@ def add_ai_metadata( self.entities = [] self.entities.append(ai_entity) + + def is_agentic(self) -> bool: + return self.recipient and self.recipient.role in [ + RoleTypes.agentic_identity, + RoleTypes.agentic_user, + ] \ No newline at end of file diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_account.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_account.py index 13b973d9..bf1db20c 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_account.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_account.py @@ -26,6 +26,9 @@ class ChannelAccount(AgentsModel): name: str = None aad_object_id: NonEmptyString = None role: NonEmptyString = None + agentic_user_id: NonEmptyString = None + agentic_app_id: NonEmptyString = None + tenant_id: NonEmptyString = None @property def properties(self) -> dict[str, Any]: diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py index dbb47e62..e92541b6 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py @@ -4,12 +4,23 @@ from enum import Enum from typing_extensions import Self - class Channels(str, Enum): """ Ids of channels supported by ABS. """ + """Agents channel.""" + agents = "agents" + agents_email_sub_channel = "email" + agents_excel_sub_channel = "excel" + agents_word_sub_channel = "word" + agents_power_point_sub_channel = "powerpoint" + + agents_email = "agents:email" + agents_excel = "agents:excel" + agents_word = "agents:word" + agents_power_point = "agents:powerpoint" + console = "console" """Console channel.""" diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/role_types.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/role_types.py index 8064c371..d3419967 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/role_types.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/role_types.py @@ -5,3 +5,5 @@ class RoleTypes(str, Enum): user = "user" agent = "bot" skill = "skill" + agentic_identity = "agenticAppInstance" + agentic_user = "agenticUser" \ No newline at end of file diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/__init__.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/__init__.py index 41ea3458..8536f337 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/__init__.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/__init__.py @@ -1,9 +1,7 @@ from .msal_auth import MsalAuth from .msal_connection_manager import MsalConnectionManager -from .agentic_msal_auth import AgenticMsalAuth __all__ = [ "MsalAuth", "MsalConnectionManager", - "AgenticMsalAuth", ] diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py index 27234a62..7efdc020 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py @@ -1,110 +1,110 @@ -from __future__ import annotations +# from __future__ import annotations -import logging -import jwt -from typing import Optional +# import logging +# import jwt +# from typing import Optional -from msal import ConfidentialClientApplication +# from msal import ConfidentialClientApplication -from .msal_auth import MsalAuth +# from .msal_auth import MsalAuth -logger = logging.getLogger(__name__) +# logger = logging.getLogger(__name__) -class AgenticMsalAuth(MsalAuth): +# class AgenticMsalAuth(MsalAuth): - # the call to MSAL is blocking, but in the future we want to create an asyncio task - # to avoid this - async def get_agentic_application_token( - self, agent_app_instance_id: str - ) -> Optional[str]: +# # the call to MSAL is blocking, but in the future we want to create an asyncio task +# # to avoid this +# async def get_agentic_application_token( +# self, agent_app_instance_id: str +# ) -> Optional[str]: - if not agent_app_instance_id: - raise ValueError("Agent application instance Id must be provided.") +# if not agent_app_instance_id: +# raise ValueError("Agent application instance Id must be provided.") - msal_auth_client = self._create_client_application() +# msal_auth_client = self._create_client_application() - if isinstance(msal_auth_client, ConfidentialClientApplication): +# if isinstance(msal_auth_client, ConfidentialClientApplication): - # https://github.dev/AzureAD/microsoft-authentication-library-for-dotnet - auth_result_payload = msal_auth_client.acquire_token_for_client( - ["api://AzureAdTokenExchange/.default"], - data={"fmi_path": agent_app_instance_id}, - ) +# # https://github.dev/AzureAD/microsoft-authentication-library-for-dotnet +# auth_result_payload = msal_auth_client.acquire_token_for_client( +# ["api://AzureAdTokenExchange/.default"], +# data={"fmi_path": agent_app_instance_id}, +# ) - if auth_result_payload: - return auth_result_payload.get("access_token") +# if auth_result_payload: +# return auth_result_payload.get("access_token") - return None +# return None - async def get_agentic_instance_token( - self, agent_app_instance_id: str - ) -> tuple[str, str]: +# async def get_agentic_instance_token( +# self, agent_app_instance_id: str +# ) -> tuple[str, str]: - if not agent_app_instance_id: - raise ValueError("Agent application instance Id must be provided.") +# if not agent_app_instance_id: +# raise ValueError("Agent application instance Id must be provided.") - agent_token_result = await self.get_agentic_application_token( - agent_app_instance_id - ) +# agent_token_result = await self.get_agentic_application_token( +# agent_app_instance_id +# ) - authority = ( - f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}" - ) +# authority = ( +# f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}" +# ) - instance_app = ConfidentialClientApplication( - client_id=agent_app_instance_id, - authority=authority, - client_credential={"client_assertion": agent_token_result}, - ) +# instance_app = ConfidentialClientApplication( +# client_id=agent_app_instance_id, +# authority=authority, +# client_credential={"client_assertion": agent_token_result}, +# ) - agent_instance_token = instance_app.acquire_token_for_client( - ["api://AzureAdTokenExchange/.default"] - ) +# agent_instance_token = instance_app.acquire_token_for_client( +# ["api://AzureAdTokenExchange/.default"] +# ) - assert agent_instance_token - assert agent_token_result +# assert agent_instance_token +# assert agent_token_result - # future scenario where we don't know the blueprint id upfront - token = agent_instance_token["access_token"] - payload = jwt.decode(token, options={"verify_signature": False}) - agentic_blueprint_id = payload.get("xms_par_app_azp") - logger.debug("Agentic blueprint id: %s", agentic_blueprint_id) +# # future scenario where we don't know the blueprint id upfront +# token = agent_instance_token["access_token"] +# payload = jwt.decode(token, options={"verify_signature": False}) +# agentic_blueprint_id = payload.get("xms_par_app_azp") +# logger.debug("Agentic blueprint id: %s", agentic_blueprint_id) - # "xms_par_app_azp": "84df77a3-1e3f-4372-a49f-c7e93c3db681", +# # "xms_par_app_azp": "84df77a3-1e3f-4372-a49f-c7e93c3db681", - return agent_instance_token["access_token"], agent_token_result +# return agent_instance_token["access_token"], agent_token_result - async def get_agentic_user_token( - self, agent_app_instance_id: str, upn: str, scopes: list[str] - ) -> Optional[str]: +# async def get_agentic_user_token( +# self, agent_app_instance_id: str, upn: str, scopes: list[str] +# ) -> Optional[str]: - if not agent_app_instance_id or not upn: - raise ValueError( - "Agent application instance Id and user principal name must be provided." - ) +# if not agent_app_instance_id or not upn: +# raise ValueError( +# "Agent application instance Id and user principal name must be provided." +# ) - instance_token, agent_token = await self.get_agentic_instance_token( - agent_app_instance_id - ) +# instance_token, agent_token = await self.get_agentic_instance_token( +# agent_app_instance_id +# ) - authority = ( - f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}" - ) +# authority = ( +# f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}" +# ) - instance_app = ConfidentialClientApplication( - client_id=agent_app_instance_id, - authority=authority, - client_credential={"client_assertion": agent_token}, - ) +# instance_app = ConfidentialClientApplication( +# client_id=agent_app_instance_id, +# authority=authority, +# client_credential={"client_assertion": agent_token}, +# ) - auth_result_payload = instance_app.acquire_token_for_client( - scopes, - data={ - "username": upn, - "user_federated_identity_credential": instance_token, - "grant_type": "user_fic", - }, - ) +# auth_result_payload = instance_app.acquire_token_for_client( +# scopes, +# data={ +# "username": upn, +# "user_federated_identity_credential": instance_token, +# "grant_type": "user_fic", +# }, +# ) - return auth_result_payload.get("access_token") if auth_result_payload else None +# return auth_result_payload.get("access_token") if auth_result_payload else None diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index eca444dd..ff5c5a17 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -186,3 +186,99 @@ def _resolve_scopes_list(self, instance_url: URI, scopes=None) -> list[str]: temp_list.append(scope_placeholder) logger.debug(f"Resolved scopes: {temp_list}") return temp_list + + # the call to MSAL is blocking, but in the future we want to create an asyncio task + # to avoid this + async def get_agentic_application_token( + self, agent_app_instance_id: str + ) -> Optional[str]: + + if not agent_app_instance_id: + raise ValueError("Agent application instance Id must be provided.") + + msal_auth_client = self._create_client_application() + + if isinstance(msal_auth_client, ConfidentialClientApplication): + + # https://github.dev/AzureAD/microsoft-authentication-library-for-dotnet + auth_result_payload = msal_auth_client.acquire_token_for_client( + ["api://AzureAdTokenExchange/.default"], + data={"fmi_path": agent_app_instance_id}, + ) + + if auth_result_payload: + return auth_result_payload.get("access_token") + + return None + + async def get_agentic_instance_token( + self, agent_app_instance_id: str + ) -> tuple[str, str]: + + if not agent_app_instance_id: + raise ValueError("Agent application instance Id must be provided.") + + agent_token_result = await self.get_agentic_application_token( + agent_app_instance_id + ) + + authority = ( + f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}" + ) + + instance_app = ConfidentialClientApplication( + client_id=agent_app_instance_id, + authority=authority, + client_credential={"client_assertion": agent_token_result}, + ) + + agent_instance_token = instance_app.acquire_token_for_client( + ["api://AzureAdTokenExchange/.default"] + ) + + assert agent_instance_token + assert agent_token_result + + # future scenario where we don't know the blueprint id upfront + token = agent_instance_token["access_token"] + payload = jwt.decode(token, options={"verify_signature": False}) + agentic_blueprint_id = payload.get("xms_par_app_azp") + logger.debug("Agentic blueprint id: %s", agentic_blueprint_id) + + # "xms_par_app_azp": "84df77a3-1e3f-4372-a49f-c7e93c3db681", + + return agent_instance_token["access_token"], agent_token_result + + async def get_agentic_user_token( + self, agent_app_instance_id: str, upn: str, scopes: list[str] + ) -> Optional[str]: + + if not agent_app_instance_id or not upn: + raise ValueError( + "Agent application instance Id and user principal name must be provided." + ) + + instance_token, agent_token = await self.get_agentic_instance_token( + agent_app_instance_id + ) + + authority = ( + f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}" + ) + + instance_app = ConfidentialClientApplication( + client_id=agent_app_instance_id, + authority=authority, + client_credential={"client_assertion": agent_token}, + ) + + auth_result_payload = instance_app.acquire_token_for_client( + scopes, + data={ + "username": upn, + "user_federated_identity_credential": instance_token, + "grant_type": "user_fic", + }, + ) + + return auth_result_payload.get("access_token") if auth_result_payload else None diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index 01e5bb7b..020f3c2e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -53,7 +53,11 @@ FlowState, FlowStateTag, ) -from .oauth import Authorization +from .auth import ( + Authorization, + UserAuthorization, + AgenticAuthorization, +) from .typing_indicator import TypingIndicator logger = logging.getLogger(__name__) @@ -208,6 +212,18 @@ def auth(self): ) return self._auth + + @property + def user_auth(self) -> UserAuthorization: + """The application's user authorization client.""" + assert self._auth + return cast(UserAuthorization, self._auth.resolve_auth_client(UserAuthorization.__name__)) + + @property + def agentic_auth(self) -> AgenticAuthorization: + """The application's agentic authorization client.""" + assert self._auth + return cast(AgenticAuthorization, self._auth.resolve_auth_client(AgenticAuthorization.__name__)) @property def options(self) -> ApplicationOptions: @@ -603,103 +619,6 @@ def turn_state_factory(self, func: Callable[[TurnContext], Awaitable[StateT]]): self._turn_state_factory = func return func - async def _handle_flow_response( - self, context: TurnContext, flow_response: FlowResponse - ) -> None: - """Handles CONTINUE and FAILURE flow responses, sending activities back.""" - flow_state: FlowState = flow_response.flow_state - - if flow_state.tag == FlowStateTag.BEGIN: - # Create the OAuth card - sign_in_resource = flow_response.sign_in_resource - o_card: Attachment = CardFactory.oauth_card( - OAuthCard( - text="Sign in", - connection_name=flow_state.connection, - buttons=[ - CardAction( - title="Sign in", - type=ActionTypes.signin, - value=sign_in_resource.sign_in_link, - channel_data=None, - ) - ], - token_exchange_resource=sign_in_resource.token_exchange_resource, - token_post_resource=sign_in_resource.token_post_resource, - ) - ) - # Send the card to the user - await context.send_activity(MessageFactory.attachment(o_card)) - elif flow_state.tag == FlowStateTag.FAILURE: - if flow_state.reached_max_attempts(): - await context.send_activity( - MessageFactory.text( - "Sign-in failed. Max retries reached. Please try again later." - ) - ) - elif flow_state.is_expired(): - await context.send_activity( - MessageFactory.text("Sign-in session expired. Please try again.") - ) - else: - logger.warning("Sign-in flow failed for unknown reasons.") - await context.send_activity("Sign-in failed. Please try again.") - - async def _on_turn_auth_intercept( - self, context: TurnContext, turn_state: TurnState - ) -> bool: - """Intercepts the turn to check for active authentication flows.""" - logger.debug( - "Checking for active sign-in flow for context: %s with activity type %s", - context.activity.id, - context.activity.type, - ) - prev_flow_state = await self._auth.get_active_flow_state(context) - if prev_flow_state: - logger.debug( - "Previous flow state: %s", - { - "user_id": prev_flow_state.user_id, - "connection": prev_flow_state.connection, - "channel_id": prev_flow_state.channel_id, - "auth_handler_id": prev_flow_state.auth_handler_id, - "tag": prev_flow_state.tag, - "expiration": prev_flow_state.expiration, - }, - ) - # proceed if there is an existing flow to continue - # new flows should be initiated in _on_activity - # this can be reorganized later... but it works for now - if ( - prev_flow_state - and ( - prev_flow_state.tag == FlowStateTag.NOT_STARTED - or prev_flow_state.is_active() - ) - and context.activity.type in [ActivityTypes.message, ActivityTypes.invoke] - ): - - logger.debug("Sign-in flow is active for context: %s", context.activity.id) - - flow_response: FlowResponse = await self._auth.begin_or_continue_flow( - context, turn_state, prev_flow_state.auth_handler_id - ) - - await self._handle_flow_response(context, flow_response) - - new_flow_state: FlowState = flow_response.flow_state - token_response: TokenResponse = flow_response.token_response - saved_activity: Activity = new_flow_state.continuation_activity.model_copy() - - if token_response: - new_context = copy(context) - new_context.activity = saved_activity - logger.info("Resending continuation activity %s", saved_activity.text) - await self.on_turn(new_context) - await turn_state.save(context) - return True # early return from _on_turn - return False # continue _on_turn - async def on_turn(self, context: TurnContext): logger.debug( f"AgentApplication.on_turn(): Processing turn for context: {context.activity.id}" @@ -716,7 +635,7 @@ async def _on_turn(self, context: TurnContext): logger.debug("Initializing turn state") turn_state = await self._initialize_state(context) - if self._auth and await self._on_turn_auth_intercept(context, turn_state): + if await self._auth.on_turn_auth_intercept(context, turn_state): return logger.debug("Running before turn middleware") @@ -834,26 +753,10 @@ async def _on_activity(self, context: TurnContext, state: StateT): if not route.auth_handlers: await route.handler(context, state) else: - sign_in_complete = False + sign_in_complete = True for auth_handler_id in route.auth_handlers: - logger.debug( - "Beginning or continuing flow for auth handler %s", - auth_handler_id, - ) - flow_response: FlowResponse = ( - await self._auth.begin_or_continue_flow( - context, state, auth_handler_id - ) - ) - await self._handle_flow_response(context, flow_response) - logger.debug( - "Flow response flow_state.tag: %s", - flow_response.flow_state.tag, - ) - sign_in_complete = ( - flow_response.flow_state.tag == FlowStateTag.COMPLETE - ) - if not sign_in_complete: + if not await self._auth.sign_in(context, state, auth_handler_id): + sign_in_complete = False break if sign_in_complete: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py new file mode 100644 index 00000000..cea5778b --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py @@ -0,0 +1,14 @@ +from .authorization import Authorization +from .auth_handler import AuthHandler, AuthorizationHandlers +from .agentic_authorization import AgenticAuthorization +from .user_authorization_base import UserAuthorization +from .authorization_variant import AuthorizationClient + +__all__ = [ + "Authorization", + "AuthHandler", + "AuthorizationHandlers", + "AgenticAuthorization", + "UserAuthorization", + "AuthorizationClient", +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py new file mode 100644 index 00000000..dd6889f5 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py @@ -0,0 +1,75 @@ +import logging + +from typing import Optional, Union, TypeVar + +from microsoft_agents.activity import ( + Activity, + TokenResponse +) + +from ...turn_context import TurnContext + +from .authorization_variant import AuthorizationVariant + +logger = logging.getLogger(__name__) + +StateT = TypeVar("StateT", bound=TurnState) + +class AgenticAuthorization(AuthorizationVariant[StateT]): + + def is_agentic_request(self, context_or_activity: Union[TurnContext, Activity]) -> bool: + if isinstance(context_or_activity, TurnContext): + activity = context_or_activity.activity + else: + activity = context_or_activity + + return activity.is_agentic() + + async def get_agent_instance_id(self, context: TurnContext) -> Optional[str]: + if not self.is_agentic_request(context): + return None + + return context.activity.recipient.agentic_app_id + + def get_agentic_user(self, context: TurnContext) -> Optional[str]: + if not self.is_agentic_request(context): + return None + + return context.activity.recipient.id + + async def get_agentic_instance_token(self, context: TurnContext) -> Optional[str]: + + if not self.is_agentic_request(context): + return None + + connection = self._connection_manager.get_token_provider(context.identity, "agentic") + return await connection.get_agentic_instance_token(self.get_agent_instance_id(context)) + + async def get_agentic_user_token(self, context: TurnContext, scopes: list[str]) -> Optional[str]: + + if not self.is_agentic_request(context) or not self.get_agentic_user(context): + return None + + connection = self._connection_manager.get_token_provider(context.identity, "agentic") + return await connection.get_agentic_user_token( + await self.get_agentic_instance_token(context), self.get_agentic_user(context), scopes + ) + + async def sign_in_user(self, context: TurnContext, exchange_connection: str, scopes: list[str]) -> TokenResponse: + return await self.get_refreshed_user_token(context, exchange_connection, scopes) + + async def get_refreshed_user_token(self, context: TurnContext, exchange_connection: str, scopes: list[str]) -> TokenResponse: + # not worrying about this for now... + # if not self._auth_settings.alternate_blueprint_connection_name: + # connection = self._connection_manager.get_connection(self._auth_settings.alternate_blueprint_connection_name) + # else: + connection = self._connection_manager.get_token_provider(context.identity, "agentic") + + token = await connection.get_agentic_user_token( + await self.get_agentic_instance_token(context), self.get_agentic_user(context), scopes + ) + + return TokenResponse(token=token) + + async def sign_out_user(self, context: TurnContext) -> None: + pass \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py similarity index 94% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py index bce68789..5df6c59b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py @@ -19,6 +19,7 @@ def __init__( text: str = None, abs_oauth_connection_name: str = None, obo_connection_name: str = None, + auth_type: str = None, **kwargs, ): """ @@ -39,6 +40,7 @@ def __init__( self.obo_connection_name = obo_connection_name or kwargs.get( "OBOCONNECTIONNAME" ) + self.auth_type = auth_type or kwargs.get("TYPE") logger.debug( f"AuthHandler initialized: name={self.name}, title={self.title}, text={self.text} abs_connection_name={self.abs_oauth_connection_name} obo_connection_name={self.obo_connection_name}" ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py new file mode 100644 index 00000000..d10ef29d --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py @@ -0,0 +1,223 @@ +import logging +from typing import TypeVar, Optional, Callable, Awaitable, Generic + +from microsoft_agents.activity import TokenResponse +from microsoft_agents.hosting.core import ( + TurnContext, + TurnState, + Connections +) + +from ...oauth import ( + FlowState, + FlowResponse, +) +from ...storage import Storage +from .auth_handler import AuthHandler +from .user_authorization_base import UserAuthorization +from .agentic_authorization import AgenticAuthorization +from .authorization_variant import AuthorizationClient + +AUTHORIZATION_TYPE_MAP: dict[str, type[AuthorizationClient]] = { + "userauthorization": UserAuthorization, + "agenticauthorization": AgenticAuthorization +} + +logger = logging.getLogger(__name__) +StateT = TypeVar("StateT", bound=TurnState) + +class Authorization(Generic[StateT]): + _authorization_clients: dict[str, AuthorizationClient[StateT]] + + def __init__( + self, + storage: Storage, + connection_manager: Connections, + auth_handlers: dict[str, AuthHandler] = None, + auto_signin: bool = None, + use_cache: bool = False, + **kwargs, + ): + """ + Creates a new instance of Authorization. + + Args: + storage: The storage system to use for state management. + auth_handlers: Configuration for OAuth providers. + + Raises: + ValueError: If storage is None or no auth handlers are provided. + """ + if not storage: + raise ValueError("Storage is required for Authorization") + + self._storage = storage + self._connection_manager = connection_manager + self._authorization_clients = {} + + auth_configuration: dict = kwargs.get("AGENTAPPLICATION", {}).get( + "USERAUTHORIZATION", {} + ) + + handlers_config: dict[str, dict] = auth_configuration.get("HANDLERS") + if not auth_handlers and handlers_config: + auth_handlers = { + handler_name: AuthHandler( + name=handler_name, **config.get("SETTINGS", {}) + ) + for handler_name, config in handlers_config.items() + } + + self._auth_handlers = auth_handlers or {} + self._sign_in_success_handler: Optional[ + Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]] + ] = None + self._sign_in_failure_handler: Optional[ + Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]] + ] = None + + self._init_auth_clients(self._auth_handlers) + + def _init_auth_clients(self, auth_handlers: dict[str, AuthHandler]): + auth_types = set(handler.auth_type for handler in auth_handlers.values()) + for auth_type in auth_types: + self._authorization_clients[auth_type] = AUTHORIZATION_TYPE_MAP[auth_type]( + storage=self._storage, + connection_manager=self._connection_manager, + auth_handler=self._auth_handlers.get(auth_type) + ) + + @property + def user_auth(self) -> UserAuthorization: + return self._resolve_auth_client(UserAuthorization.__name__) + + @property + def agentic_auth(self) -> AgenticAuthorization: + return self._resolve_auth_client(AgenticAuthorization.__name__) + + def _resolve_auth_client(self, auth_type_name: Optional[str] = None) -> AuthorizationClient: + if not auth_type_name: + return self.user_auth + + if auth_type_name not in self._authorization_clients: + raise ValueError(f"Auth type {auth_type_name} not recognized or not configured.") + + return self._authorization_clients[auth_type_name] + + async def sign_in(self, context: TurnContext, state: StateT, auth_handler_id: Optional[str] = None): + await self._resolve_auth_client(auth_handler_id).sign_in(context, state, auth_handler_id) + + async def on_turn_auth_intercept(self, context: TurnContext, state: StateT, continue_turn_callback: Callable[[TurnContext], Awaitable[None]]) -> bool: + """Intercepts the turn to check for active authentication flows. + + Returns true if the rest of the turn should be skipped because auth did not finish. + Returns false if the turn should continue processing as normal. + Calls continue_turn_callback if auth completes and a new turn should be started. <- TODO, seems a bit strange + """ + logger.debug( + "Checking for active sign-in flow for context: %s with activity type %s", + context.activity.id, + context.activity.type, + ) + prev_flow_state = await self._get_active_flow_state(context) + if prev_flow_state: + logger.debug( + "Previous flow state: %s", + { + "user_id": prev_flow_state.user_id, + "connection": prev_flow_state.connection, + "channel_id": prev_flow_state.channel_id, + "auth_handler_id": prev_flow_state.auth_handler_id, + "tag": prev_flow_state.tag, + "expiration": prev_flow_state.expiration, + }, + ) + # proceed if there is an existing flow to continue + # new flows should be initiated in _on_activity + # this can be reorganized later... but it works for now + if ( + prev_flow_state + and ( + prev_flow_state.tag == FlowStateTag.NOT_STARTED + or prev_flow_state.is_active() + ) + and context.activity.type in [ActivityTypes.message, ActivityTypes.invoke] + ): + + logger.debug("Sign-in flow is active for context: %s", context.activity.id) + + flow_response: FlowResponse = await self._auth.begin_or_continue_flow( + context, turn_state, prev_flow_state.auth_handler_id + ) + + await self._handle_flow_response(context, flow_response) + + new_flow_state: FlowState = flow_response.flow_state + token_response: TokenResponse = flow_response.token_response + saved_activity: Activity = new_flow_state.continuation_activity.model_copy() + + if token_response: + new_context = copy(context) + new_context.activity = saved_activity + logger.info("Resending continuation activity %s", saved_activity.text) + await self.on_turn(new_context) + await turn_state.save(context) + return True # early return from _on_turn + return False # continue _on_turn + + async def get_token( + self, context: TurnContext, auth_handler_id: str + ) -> TokenResponse: + """ + Gets the token for a specific auth handler. + + Args: + context: The context object for the current turn. + auth_handler_id: Optional ID of the auth handler to use, defaults to first handler. + + Returns: + The token response from the OAuth provider. + """ + return await self.resolve_auth_client(auth_handler_id).get_token(context, auth_handler_id) + + async def exchange_token( + self, + context: TurnContext, + scopes: list[str], + auth_handler_id: Optional[str] = None, + ) -> TokenResponse: + """ + Exchanges a token for another token with different scopes. + + Args: + context: The context object for the current turn. + scopes: The scopes to request for the new token. + auth_handler_id: Optional ID of the auth handler to use, defaults to first handler. + + Returns: + The token response from the OAuth provider. + """ + return await self.resolve_auth_client(auth_handler_id).exchange_token(context, scopes, auth_handler_id) + + def on_sign_in_success( + self, + handler: Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]], + ) -> None: + """ + Sets a handler to be called when sign-in is successfully completed. + + Args: + handler: The handler function to call on successful sign-in. + """ + self._sign_in_success_handler = handler + + def on_sign_in_failure( + self, + handler: Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]], + ) -> None: + """ + Sets a handler to be called when sign-in fails. + Args: + handler: The handler function to call on sign-in failure. + """ + self._sign_in_failure_handler = handler \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py new file mode 100644 index 00000000..ad8c1bb6 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py @@ -0,0 +1,145 @@ +import jwt +from abc import ABC +from typing import TypeVar, Optional, Generic +import logging + +from microsoft_agents.activity import ( + TokenResponse, +) + +from ...turn_context import TurnContext +from ...storage import Storage +from ...authorization import Connections, AccessTokenProviderBase +from ..state.turn_state import TurnState +from .auth_handler import AuthHandler + +logger = logging.getLogger(__name__) + +StateT = TypeVar("StateT", bound=TurnState) + +class AuthorizationVariant(ABC, Generic[StateT]): + + def __init__( + self, + storage: Storage, + connection_manager: Connections, + auth_handlers: dict[str, AuthHandler] = None, + auto_signin: bool = None, + use_cache: bool = False, + **kwargs, + ): + """ + Creates a new instance of Authorization. + + Args: + storage: The storage system to use for state management. + auth_handlers: Configuration for OAuth providers. + + Raises: + ValueError: If storage is None or no auth handlers are provided. + """ + if not storage: + raise ValueError("Storage is required for Authorization") + + self._storage = storage + self._connection_manager = connection_manager + + auth_configuration: dict = kwargs.get("AGENTAPPLICATION", {}).get( + "USERAUTHORIZATION", {} + ) + + handlers_config: dict[str, dict] = auth_configuration.get("HANDLERS", {}) + if not auth_handlers and handlers_config: + auth_handlers = { + handler_name: AuthHandler( + name=handler_name, **config.get("SETTINGS", {}) + ) + for handler_name, config in handlers_config.items() + } + + self._auth_handlers = auth_handlers or {} + + + async def get_token( + self, context: TurnContext, auth_handler_id: str + ) -> TokenResponse: + raise NotImplementedError() + + async def exchange_token( + self, + context: TurnContext, + scopes: list[str], + auth_handler_id: Optional[str] = None, + ) -> TokenResponse: + raise NotImplementedError() + + def _is_exchangeable(self, token: str) -> bool: + """ + Checks if a token is exchangeable (has api:// audience). + + Args: + token: The token to check. + + Returns: + True if the token is exchangeable, False otherwise. + """ + try: + # Decode without verification to check the audience + payload = jwt.decode(token, options={"verify_signature": False}) + aud = payload.get("aud") + return isinstance(aud, str) and aud.startswith("api://") + except Exception: + logger.error("Failed to decode token to check audience") + return False + + async def _handle_obo( + self, token: str, scopes: list[str], handler_id: str = None + ) -> TokenResponse: + """ + Handles On-Behalf-Of token exchange. + + Args: + context: The context object for the current turn. + token: The original token. + scopes: The scopes to request. + + Returns: + The new token response. + + """ + auth_handler = self.resolve_handler(handler_id) + token_provider: AccessTokenProviderBase = ( + self._connection_manager.get_connection(auth_handler.obo_connection_name) + ) + + logger.info("Attempting to exchange token on behalf of user") + new_token = await token_provider.aquire_token_on_behalf_of( + scopes=scopes, + user_assertion=token, + ) + return TokenResponse(token=new_token) + + def resolve_handler(self, auth_handler_id: Optional[str] = None) -> AuthHandler: + """Resolves the auth handler to use based on the provided ID. + + Args: + auth_handler_id: Optional ID of the auth handler to resolve, defaults to first handler. + + Returns: + The resolved auth handler. + """ + if auth_handler_id: + if auth_handler_id not in self._auth_handlers: + logger.error("Auth handler '%s' not found", auth_handler_id) + raise ValueError(f"Auth handler '{auth_handler_id}' not found") + return self._auth_handlers[auth_handler_id] + + # Return the first handler if no ID specified + return next(iter(self._auth_handlers.values())) + + async def sign_out( + self, + context: TurnContext, + auth_handler_id: Optional[str] = None, + ) -> None: + raise NotImplementedError() \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py new file mode 100644 index 00000000..12da9338 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py @@ -0,0 +1,94 @@ +from __future__ import annotations +import logging +from typing import Dict, Optional, Callable, Awaitable, AsyncIterator, TypeVar +from collections.abc import Iterable +from contextlib import asynccontextmanager + +from microsoft_agents.hosting.core.authorization import ( + Connections, + AccessTokenProviderBase, +) +from microsoft_agents.hosting.core.storage import Storage, MemoryStorage +from microsoft_agents.activity import ( + ActionTypes, + TokenResponse, + CardAction, + OAuthCard, + Attachment, + CardFactory, +) +from microsoft_agents.hosting.core.connector.client import UserTokenClient + +from ...turn_context import TurnContext +from ...oauth import OAuthFlow, FlowResponse, FlowState, FlowStateTag, FlowStorageClient +from ...message_factory import MessageFactory +from ..state.turn_state import TurnState +from .authorization_variant import AuthorizationClient +from .auth_handler import AuthHandler +from .user_authorization_base import UserAuthorizationBase + +logger = logging.getLogger(__name__) + +StateT = TypeVar("StateT", bound=TurnState) + +class UserAuthorization(UserAuthorizationBase[StateT]): + + async def _handle_flow_response( + self, context: TurnContext, flow_response: FlowResponse + ) -> None: + """Handles CONTINUE and FAILURE flow responses, sending activities back.""" + flow_state: FlowState = flow_response.flow_state + + if flow_state.tag == FlowStateTag.BEGIN: + # Create the OAuth card + sign_in_resource = flow_response.sign_in_resource + assert sign_in_resource + o_card: Attachment = CardFactory.oauth_card( + OAuthCard( + text="Sign in", + connection_name=flow_state.connection, + buttons=[ + CardAction( + title="Sign in", + type=ActionTypes.signin, + value=sign_in_resource.sign_in_link, + channel_data=None, + ) + ], + token_exchange_resource=sign_in_resource.token_exchange_resource, + token_post_resource=sign_in_resource.token_post_resource, + ) + ) + # Send the card to the user + await context.send_activity(MessageFactory.attachment(o_card)) + elif flow_state.tag == FlowStateTag.FAILURE: + if flow_state.reached_max_attempts(): + await context.send_activity( + MessageFactory.text( + "Sign-in failed. Max retries reached. Please try again later." + ) + ) + elif flow_state.is_expired(): + await context.send_activity( + MessageFactory.text("Sign-in session expired. Please try again.") + ) + else: + logger.warning("Sign-in flow failed for unknown reasons.") + await context.send_activity("Sign-in failed. Please try again.") + + async def sign_in(self, context: TurnContext, state: StateT, auth_handler_id: Optional[str] = None) -> bool: + logger.debug( + "Beginning or continuing flow for auth handler %s", + auth_handler_id, + ) + flow_response: FlowResponse = ( + await self.begin_or_continue_flow( + context, state, auth_handler_id + ) + ) + await self._handle_flow_response(context, flow_response) + logger.debug( + "Flow response flow_state.tag: %s", + flow_response.flow_state.tag, + ) + return flow_response.flow_state.tag == FlowStateTag.COMPLETE \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py similarity index 76% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py index 8ef635f0..04fede37 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py @@ -3,8 +3,8 @@ from __future__ import annotations import logging -import jwt -from typing import Dict, Optional, Callable, Awaitable, AsyncIterator +from abc import ABC +from typing import Dict, Optional, Callable, Awaitable, AsyncIterator, TypeVar from collections.abc import Iterable from contextlib import asynccontextmanager @@ -19,12 +19,14 @@ from ...turn_context import TurnContext from ...oauth import OAuthFlow, FlowResponse, FlowState, FlowStateTag, FlowStorageClient from ..state.turn_state import TurnState +from .authorization_variant import AuthorizationVariant from .auth_handler import AuthHandler logger = logging.getLogger(__name__) +StateT = TypeVar("StateT", bound=TurnState) -class Authorization: +class UserAuthorizationBase(AuthorizationVariant[StateT], ABC): """ Class responsible for managing authorization and OAuth flows. Handles multiple OAuth providers and manages the complete authentication lifecycle. @@ -59,7 +61,7 @@ def __init__( "USERAUTHORIZATION", {} ) - handlers_config: Dict[str, Dict] = auth_configuration.get("HANDLERS") + handlers_config: Dict[str, Dict] = auth_configuration.get("HANDLERS", {}) if not auth_handlers and handlers_config: auth_handlers = { handler_name: AuthHandler( @@ -69,12 +71,6 @@ def __init__( } self._auth_handlers = auth_handlers or {} - self._sign_in_success_handler: Optional[ - Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]] - ] = lambda *args: None - self._sign_in_failure_handler: Optional[ - Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]] - ] = lambda *args: None def _ids_from_context(self, context: TurnContext) -> tuple[str, str]: """Checks and returns IDs necessary to load a new or existing flow. @@ -89,7 +85,7 @@ def _ids_from_context(self, context: TurnContext) -> tuple[str, str]: raise ValueError("Channel ID and User ID are required") return context.activity.channel_id, context.activity.from_property.id - + async def _load_flow( self, context: TurnContext, auth_handler_id: str = "" ) -> tuple[OAuthFlow, FlowStorageClient]: @@ -136,7 +132,7 @@ async def _load_flow( flow = OAuthFlow(flow_state, user_token_client) return flow, flow_storage_client - + @asynccontextmanager async def open_flow( self, context: TurnContext, auth_handler_id: str = "" @@ -206,55 +202,6 @@ async def exchange_token( return TokenResponse() - def _is_exchangeable(self, token: str) -> bool: - """ - Checks if a token is exchangeable (has api:// audience). - - Args: - token: The token to check. - - Returns: - True if the token is exchangeable, False otherwise. - """ - try: - # Decode without verification to check the audience - payload = jwt.decode(token, options={"verify_signature": False}) - aud = payload.get("aud") - return isinstance(aud, str) and aud.startswith("api://") - except Exception: - logger.error("Failed to decode token to check audience") - return False - - async def _handle_obo( - self, token: str, scopes: list[str], handler_id: str = None - ) -> TokenResponse: - """ - Handles On-Behalf-Of token exchange. - - Args: - context: The context object for the current turn. - token: The original token. - scopes: The scopes to request. - - Returns: - The new token response. - - """ - auth_handler = self.resolve_handler(handler_id) - token_provider: AccessTokenProviderBase = ( - self._connection_manager.get_connection(auth_handler.obo_connection_name) - ) - - logger.info("Attempting to exchange token on behalf of user") - new_token = await token_provider.aquire_token_on_behalf_of( - scopes=scopes, - user_assertion=token, - ) - return TokenResponse( - token=new_token, - scopes=scopes, # Expiration can be set based on the token provider's response - ) - async def get_active_flow_state(self, context: TurnContext) -> Optional[FlowState]: """Gets the first active flow state for the current context.""" logger.debug("Getting active flow state") @@ -295,22 +242,22 @@ async def begin_or_continue_flow( flow_state: FlowState = flow_response.flow_state - if ( - flow_state.tag == FlowStateTag.COMPLETE - and prev_tag != FlowStateTag.COMPLETE - ): - logger.debug("Calling Authorization sign in success handler") - self._sign_in_success_handler( - context, turn_state, flow_state.auth_handler_id - ) - elif flow_state.tag == FlowStateTag.FAILURE: - logger.debug("Calling Authorization sign in failure handler") - self._sign_in_failure_handler( - context, - turn_state, - flow_state.auth_handler_id, - flow_response.flow_error_tag, - ) + # if ( + # flow_state.tag == FlowStateTag.COMPLETE + # and prev_tag != FlowStateTag.COMPLETE + # ): + # logger.debug("Calling Authorization sign in success handler") + # self._sign_in_success_handler( + # context, turn_state, flow_state.auth_handler_id + # ) + # elif flow_state.tag == FlowStateTag.FAILURE: + # logger.debug("Calling Authorization sign in failure handler") + # self._sign_in_failure_handler( + # context, + # turn_state, + # flow_state.auth_handler_id, + # flow_response.flow_error_tag, + # ) return flow_response @@ -372,27 +319,4 @@ async def sign_out( if auth_handler_id: await self._sign_out(context, [auth_handler_id]) else: - await self._sign_out(context, self._auth_handlers.keys()) - - def on_sign_in_success( - self, - handler: Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]], - ) -> None: - """ - Sets a handler to be called when sign-in is successfully completed. - - Args: - handler: The handler function to call on successful sign-in. - """ - self._sign_in_success_handler = handler - - def on_sign_in_failure( - self, - handler: Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]], - ) -> None: - """ - Sets a handler to be called when sign-in fails. - Args: - handler: The handler function to call on sign-in failure. - """ - self._sign_in_failure_handler = handler + await self._sign_out(context, self._auth_handlers.keys()) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py deleted file mode 100644 index 7c962a43..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .authorization import Authorization -from .auth_handler import AuthHandler, AuthorizationHandlers - -__all__ = [ - "Authorization", - "AuthHandler", - "AuthorizationHandlers", -] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py index 3c413e61..8d36d1c5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py @@ -1,4 +1,4 @@ -from typing import Protocol +from typing import Protocol, Optional from abc import abstractmethod @@ -28,3 +28,19 @@ async def aquire_token_on_behalf_of( :return: The access token as a string. """ raise NotImplementedError() + + async def get_agentic_application_token( + self, agent_app_instance_id: str + ) -> Optional[str]: + raise NotImplementedError() + + async def get_agentic_instance_token( + self, agent_app_instance_id: str + ) -> tuple[str, str]: + raise NotImplementedError() + + + async def get_agentic_user_token( + self, agent_app_instance_id: str, upn: str, scopes: list[str] + ) -> Optional[str]: + raise NotImplementedError() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py index 6c325c17..9123287b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py @@ -427,7 +427,7 @@ def _create_turn_context( user_token_client: UserTokenClientBase, callback: Callable[[TurnContext], Awaitable], ) -> TurnContext: - context = TurnContext(self, activity) + context = TurnContext(self, activity, claims_identity) context.turn_state[self.AGENT_IDENTITY_KEY] = claims_identity context.turn_state[self._AGENT_CONNECTOR_CLIENT_KEY] = connector_client diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index 70e022a4..216bc423 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +from typing import Optional from copy import copy, deepcopy from collections.abc import Callable @@ -17,13 +18,14 @@ ResourceResponse, DeliveryModes, ) +from .authorization import ClaimsIdentity class TurnContext(TurnContextProtocol): # Same constant as in the BF Adapter, duplicating here to avoid circular dependency _INVOKE_RESPONSE_KEY = "TurnContext.InvokeResponse" - def __init__(self, adapter_or_context, request: Activity = None): + def __init__(self, adapter_or_context, request: Activity = None, identity: ClaimsIdentity = None): """ Creates a new TurnContext instance. :param adapter_or_context: @@ -31,6 +33,7 @@ def __init__(self, adapter_or_context, request: Activity = None): """ if isinstance(adapter_or_context, TurnContext): adapter_or_context.copy_to(self) + self._identity = adapter_or_context.identity else: self.adapter = adapter_or_context self._activity = request @@ -46,6 +49,7 @@ def __init__(self, adapter_or_context, request: Activity = None): ["TurnContext", ConversationReference, Callable], None ] = [] self._responded: bool = False + self._identity = identity if self.adapter is None: raise TypeError("TurnContext must be instantiated with an adapter.") @@ -142,6 +146,10 @@ def streaming_response(self): # If the hosting library isn't available, return None self._streaming_response = None return self._streaming_response + + @property + def identity(self) -> Optional[ClaimsIdentity]: + return self._identity def get(self, key: str) -> object: if not key or not isinstance(key, str): @@ -419,3 +427,7 @@ def get_mentions(activity: Activity) -> list[Mention]: result.append(entity) return result + + @staticmethod + def is_agentic_request(context: TurnContext) -> bool: + return context.activity.is_agentic() \ No newline at end of file From 4ce735e85384c030e2b8dcbfd0e7bdc577daf098 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Sep 2025 21:55:09 -0700 Subject: [PATCH 06/36] Continued auth refactor --- .../authentication/msal/agentic_msal_auth.py | 110 --------- .../hosting/core/app/agent_application.py | 14 +- .../hosting/core/app/auth/__init__.py | 8 +- .../core/app/auth/agentic_authorization.py | 39 ++-- .../hosting/core/app/auth/auth_handler.py | 25 +-- .../hosting/core/app/auth/authorization.py | 210 ++++++++++++------ .../core/app/auth/authorization_variant.py | 85 +------ .../hosting/core/app/auth/sign_in_state.py | 23 ++ .../core/app/auth/user_authorization.py | 30 +-- .../core/app/auth/user_authorization_base.py | 206 ++--------------- tests/activity/test_activity.py | 2 + ...rization.py => test_user_authorization.py} | 92 ++++---- 12 files changed, 280 insertions(+), 564 deletions(-) delete mode 100644 libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py rename tests/hosting_core/app/{test_authorization.py => test_user_authorization.py} (84%) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py deleted file mode 100644 index 7efdc020..00000000 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/agentic_msal_auth.py +++ /dev/null @@ -1,110 +0,0 @@ -# from __future__ import annotations - -# import logging -# import jwt -# from typing import Optional - -# from msal import ConfidentialClientApplication - -# from .msal_auth import MsalAuth - -# logger = logging.getLogger(__name__) - - -# class AgenticMsalAuth(MsalAuth): - -# # the call to MSAL is blocking, but in the future we want to create an asyncio task -# # to avoid this -# async def get_agentic_application_token( -# self, agent_app_instance_id: str -# ) -> Optional[str]: - -# if not agent_app_instance_id: -# raise ValueError("Agent application instance Id must be provided.") - -# msal_auth_client = self._create_client_application() - -# if isinstance(msal_auth_client, ConfidentialClientApplication): - -# # https://github.dev/AzureAD/microsoft-authentication-library-for-dotnet -# auth_result_payload = msal_auth_client.acquire_token_for_client( -# ["api://AzureAdTokenExchange/.default"], -# data={"fmi_path": agent_app_instance_id}, -# ) - -# if auth_result_payload: -# return auth_result_payload.get("access_token") - -# return None - -# async def get_agentic_instance_token( -# self, agent_app_instance_id: str -# ) -> tuple[str, str]: - -# if not agent_app_instance_id: -# raise ValueError("Agent application instance Id must be provided.") - -# agent_token_result = await self.get_agentic_application_token( -# agent_app_instance_id -# ) - -# authority = ( -# f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}" -# ) - -# instance_app = ConfidentialClientApplication( -# client_id=agent_app_instance_id, -# authority=authority, -# client_credential={"client_assertion": agent_token_result}, -# ) - -# agent_instance_token = instance_app.acquire_token_for_client( -# ["api://AzureAdTokenExchange/.default"] -# ) - -# assert agent_instance_token -# assert agent_token_result - -# # future scenario where we don't know the blueprint id upfront -# token = agent_instance_token["access_token"] -# payload = jwt.decode(token, options={"verify_signature": False}) -# agentic_blueprint_id = payload.get("xms_par_app_azp") -# logger.debug("Agentic blueprint id: %s", agentic_blueprint_id) - -# # "xms_par_app_azp": "84df77a3-1e3f-4372-a49f-c7e93c3db681", - -# return agent_instance_token["access_token"], agent_token_result - -# async def get_agentic_user_token( -# self, agent_app_instance_id: str, upn: str, scopes: list[str] -# ) -> Optional[str]: - -# if not agent_app_instance_id or not upn: -# raise ValueError( -# "Agent application instance Id and user principal name must be provided." -# ) - -# instance_token, agent_token = await self.get_agentic_instance_token( -# agent_app_instance_id -# ) - -# authority = ( -# f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}" -# ) - -# instance_app = ConfidentialClientApplication( -# client_id=agent_app_instance_id, -# authority=authority, -# client_credential={"client_assertion": agent_token}, -# ) - -# auth_result_payload = instance_app.acquire_token_for_client( -# scopes, -# data={ -# "username": upn, -# "user_federated_identity_credential": instance_token, -# "grant_type": "user_fic", -# }, -# ) - -# return auth_result_payload.get("access_token") if auth_result_payload else None diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index 020f3c2e..a7ff7ed2 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -212,18 +212,6 @@ def auth(self): ) return self._auth - - @property - def user_auth(self) -> UserAuthorization: - """The application's user authorization client.""" - assert self._auth - return cast(UserAuthorization, self._auth.resolve_auth_client(UserAuthorization.__name__)) - - @property - def agentic_auth(self) -> AgenticAuthorization: - """The application's agentic authorization client.""" - assert self._auth - return cast(AgenticAuthorization, self._auth.resolve_auth_client(AgenticAuthorization.__name__)) @property def options(self) -> ApplicationOptions: @@ -755,7 +743,7 @@ async def _on_activity(self, context: TurnContext, state: StateT): else: sign_in_complete = True for auth_handler_id in route.auth_handlers: - if not await self._auth.sign_in(context, state, auth_handler_id): + if not await self._auth.start_or_continue_sign_in(context, state, auth_handler_id): sign_in_complete = False break diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py index cea5778b..66f2482c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py @@ -1,8 +1,9 @@ from .authorization import Authorization from .auth_handler import AuthHandler, AuthorizationHandlers from .agentic_authorization import AgenticAuthorization -from .user_authorization_base import UserAuthorization -from .authorization_variant import AuthorizationClient +from .user_authorization import UserAuthorization +from .authorization_variant import AuthorizationVariant +from .sign_in_state import SignInState __all__ = [ "Authorization", @@ -10,5 +11,6 @@ "AuthorizationHandlers", "AgenticAuthorization", "UserAuthorization", - "AuthorizationClient", + "AuthorizationVariant", + "SignInState" ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py index dd6889f5..48b0f6f2 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py @@ -13,9 +13,7 @@ logger = logging.getLogger(__name__) -StateT = TypeVar("StateT", bound=TurnState) - -class AgenticAuthorization(AuthorizationVariant[StateT]): +class AgenticAuthorization(AuthorizationVariant): def is_agentic_request(self, context_or_activity: Union[TurnContext, Activity]) -> bool: if isinstance(context_or_activity, TurnContext): @@ -25,7 +23,7 @@ def is_agentic_request(self, context_or_activity: Union[TurnContext, Activity]) return activity.is_agentic() - async def get_agent_instance_id(self, context: TurnContext) -> Optional[str]: + def get_agent_instance_id(self, context: TurnContext) -> Optional[str]: if not self.is_agentic_request(context): return None @@ -42,34 +40,31 @@ async def get_agentic_instance_token(self, context: TurnContext) -> Optional[str if not self.is_agentic_request(context): return None + assert context.identity connection = self._connection_manager.get_token_provider(context.identity, "agentic") - return await connection.get_agentic_instance_token(self.get_agent_instance_id(context)) + agent_instance_id = self.get_agent_instance_id(context) + assert agent_instance_id + instance_token, _ = await connection.get_agentic_instance_token(agent_instance_id) + return instance_token async def get_agentic_user_token(self, context: TurnContext, scopes: list[str]) -> Optional[str]: if not self.is_agentic_request(context) or not self.get_agentic_user(context): return None + assert context.identity connection = self._connection_manager.get_token_provider(context.identity, "agentic") + upn = self.get_agentic_user(context) + agentic_instance_id = self.get_agent_instance_id(context) + assert upn and agentic_instance_id return await connection.get_agentic_user_token( - await self.get_agentic_instance_token(context), self.get_agentic_user(context), scopes + agentic_instance_id, upn, scopes ) - async def sign_in_user(self, context: TurnContext, exchange_connection: str, scopes: list[str]) -> TokenResponse: - return await self.get_refreshed_user_token(context, exchange_connection, scopes) - - async def get_refreshed_user_token(self, context: TurnContext, exchange_connection: str, scopes: list[str]) -> TokenResponse: - # not worrying about this for now... - # if not self._auth_settings.alternate_blueprint_connection_name: - # connection = self._connection_manager.get_connection(self._auth_settings.alternate_blueprint_connection_name) - # else: - connection = self._connection_manager.get_token_provider(context.identity, "agentic") - - token = await connection.get_agentic_user_token( - await self.get_agentic_instance_token(context), self.get_agentic_user(context), scopes - ) - - return TokenResponse(token=token) + async def sign_in(self, context: TurnContext, scopes: Optional[list[str]] = None) -> Optional[str]: + scopes = scopes or [] + token = await self.get_agentic_user_token(context, scopes) + return token - async def sign_out_user(self, context: TurnContext) -> None: + async def sign_out(self, context: TurnContext) -> None: pass \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py index 5df6c59b..63019639 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py @@ -6,7 +6,6 @@ logger = logging.getLogger(__name__) - class AuthHandler: """ Interface defining an authorization handler for OAuth flows. @@ -14,12 +13,12 @@ class AuthHandler: def __init__( self, - name: str = None, - title: str = None, - text: str = None, - abs_oauth_connection_name: str = None, - obo_connection_name: str = None, - auth_type: str = None, + name: str = "", + title: str = "", + text: str = "", + abs_oauth_connection_name: str = "", + obo_connection_name: str = "", + auth_type: str = "", **kwargs, ): """ @@ -31,16 +30,16 @@ def __init__( title: Title for the OAuth card. text: Text for the OAuth button. """ - self.name = name or kwargs.get("NAME") - self.title = title or kwargs.get("TITLE") - self.text = text or kwargs.get("TEXT") + self.name = name or kwargs.get("NAME", "") + self.title = title or kwargs.get("TITLE", "") + self.text = text or kwargs.get("TEXT", "") self.abs_oauth_connection_name = abs_oauth_connection_name or kwargs.get( - "AZUREBOTOAUTHCONNECTIONNAME" + "AZUREBOTOAUTHCONNECTIONNAME", "" ) self.obo_connection_name = obo_connection_name or kwargs.get( - "OBOCONNECTIONNAME" + "OBOCONNECTIONNAME", "" ) - self.auth_type = auth_type or kwargs.get("TYPE") + self.auth_type = auth_type or kwargs.get("TYPE", "") logger.debug( f"AuthHandler initialized: name={self.name}, title={self.title}, text={self.text} abs_connection_name={self.abs_oauth_connection_name} obo_connection_name={self.obo_connection_name}" ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py index d10ef29d..99f3fe03 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py @@ -1,7 +1,11 @@ import logging -from typing import TypeVar, Optional, Callable, Awaitable, Generic +from typing import TypeVar, Optional, Callable, Awaitable, Generic, cast +import jwt -from microsoft_agents.activity import TokenResponse +from microsoft_agents.activity import ( + ActivityTypes, + TokenResponse +) from microsoft_agents.hosting.core import ( TurnContext, TurnState, @@ -14,11 +18,12 @@ ) from ...storage import Storage from .auth_handler import AuthHandler -from .user_authorization_base import UserAuthorization +from .user_authorization import UserAuthorization from .agentic_authorization import AgenticAuthorization -from .authorization_variant import AuthorizationClient +from .authorization_variant import AuthorizationVariant +from .sign_in_state import SignInState -AUTHORIZATION_TYPE_MAP: dict[str, type[AuthorizationClient]] = { +AUTHORIZATION_TYPE_MAP: dict[str, type[AuthorizationVariant]] = { "userauthorization": UserAuthorization, "agenticauthorization": AgenticAuthorization } @@ -27,7 +32,6 @@ StateT = TypeVar("StateT", bound=TurnState) class Authorization(Generic[StateT]): - _authorization_clients: dict[str, AuthorizationClient[StateT]] def __init__( self, @@ -76,36 +80,71 @@ def __init__( Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]] ] = None + self._authorization_variants = {} self._init_auth_clients(self._auth_handlers) def _init_auth_clients(self, auth_handlers: dict[str, AuthHandler]): auth_types = set(handler.auth_type for handler in auth_handlers.values()) for auth_type in auth_types: - self._authorization_clients[auth_type] = AUTHORIZATION_TYPE_MAP[auth_type]( + + associated_handlers = { + auth_handler.name: auth_handler + for auth_handler in self._auth_handlers.values() + if auth_handler.auth_type == auth_type + } + + self._authorization_variants[auth_type] = AUTHORIZATION_TYPE_MAP[auth_type]( storage=self._storage, connection_manager=self._connection_manager, - auth_handler=self._auth_handlers.get(auth_type) + auth_handlers=associated_handlers ) + def _sign_in_state_key(self, context: TurnContext) -> str: + return f"auth:SignInState:{context.activity.conversation.id}:{context.activity.from_property.id}" + + async def _load_sign_in_state(self, context: TurnContext) -> Optional[SignInState]: + key = self._sign_in_state_key(context) + return (await self._storage.read([key], target_cls=SignInState)).get(key) + + async def _save_sign_in_state(self, context: TurnContext, state: SignInState) -> None: + key = self._sign_in_state_key(context) + await self._storage.write({key: state}) + @property def user_auth(self) -> UserAuthorization: - return self._resolve_auth_client(UserAuthorization.__name__) + return cast(UserAuthorization, self._resolve_auth_variant(UserAuthorization.__name__)) @property def agentic_auth(self) -> AgenticAuthorization: - return self._resolve_auth_client(AgenticAuthorization.__name__) + return cast(AgenticAuthorization, self._resolve_auth_variant(AgenticAuthorization.__name__)) - def _resolve_auth_client(self, auth_type_name: Optional[str] = None) -> AuthorizationClient: - if not auth_type_name: - return self.user_auth + def _resolve_auth_variant(self, auth_variant: str) -> AuthorizationVariant: - if auth_type_name not in self._authorization_clients: - raise ValueError(f"Auth type {auth_type_name} not recognized or not configured.") - - return self._authorization_clients[auth_type_name] + if auth_variant not in self._authorization_clients: + raise ValueError(f"Auth variant {auth_variant} not recognized or not configured.") - async def sign_in(self, context: TurnContext, state: StateT, auth_handler_id: Optional[str] = None): - await self._resolve_auth_client(auth_handler_id).sign_in(context, state, auth_handler_id) + return self._authorization_variants[auth_variant] + + def resolve_handler(self, handler_id: str) -> AuthHandler: + if handler_id not in self._auth_handlers: + raise ValueError(f"Auth handler {handler_id} not recognized or not configured.") + return self._auth_handlers[handler_id] + + async def start_or_continue_sign_in(self, context: TurnContext, state: StateT, auth_handler_id: str) -> bool: + + sign_in_state = await self._load_sign_in_state(context) + auth_handler_id = sign_in_state.active_handler() if sign_in_state else "" + + if auth_handler_id: + token = await self._resolve_auth_variant(auth_handler_id).sign_in(context, auth_handler_id) + if token: + if not sign_in_state: + sign_in_state = SignInState() + sign_in_state.tokens[auth_handler_id] = token + await self._save_sign_in_state(context, sign_in_state) + else: + return False + return True async def on_turn_auth_intercept(self, context: TurnContext, state: StateT, continue_turn_callback: Callable[[TurnContext], Awaitable[None]]) -> bool: """Intercepts the turn to check for active authentication flows. @@ -114,56 +153,38 @@ async def on_turn_auth_intercept(self, context: TurnContext, state: StateT, cont Returns false if the turn should continue processing as normal. Calls continue_turn_callback if auth completes and a new turn should be started. <- TODO, seems a bit strange """ - logger.debug( - "Checking for active sign-in flow for context: %s with activity type %s", - context.activity.id, - context.activity.type, - ) - prev_flow_state = await self._get_active_flow_state(context) - if prev_flow_state: - logger.debug( - "Previous flow state: %s", - { - "user_id": prev_flow_state.user_id, - "connection": prev_flow_state.connection, - "channel_id": prev_flow_state.channel_id, - "auth_handler_id": prev_flow_state.auth_handler_id, - "tag": prev_flow_state.tag, - "expiration": prev_flow_state.expiration, - }, - ) - # proceed if there is an existing flow to continue - # new flows should be initiated in _on_activity - # this can be reorganized later... but it works for now - if ( - prev_flow_state - and ( - prev_flow_state.tag == FlowStateTag.NOT_STARTED - or prev_flow_state.is_active() - ) - and context.activity.type in [ActivityTypes.message, ActivityTypes.invoke] - ): - logger.debug("Sign-in flow is active for context: %s", context.activity.id) + # get active thing... - flow_response: FlowResponse = await self._auth.begin_or_continue_flow( - context, turn_state, prev_flow_state.auth_handler_id - ) + sign_in_state = await self._load_sign_in_state(context) + auth_handler_id = sign_in_state.active_handler() if sign_in_state else "" - await self._handle_flow_response(context, flow_response) + if auth_handler_id: + await self.start_or_continue_sign_in(context, state, auth_handler_id) + + + + logger.debug("Sign-in flow is active for context: %s", context.activity.id) - new_flow_state: FlowState = flow_response.flow_state - token_response: TokenResponse = flow_response.token_response - saved_activity: Activity = new_flow_state.continuation_activity.model_copy() + flow_response: FlowResponse = await self._auth.begin_or_continue_flow( + context, turn_state, prev_flow_state.auth_handler_id + ) - if token_response: - new_context = copy(context) - new_context.activity = saved_activity - logger.info("Resending continuation activity %s", saved_activity.text) - await self.on_turn(new_context) - await turn_state.save(context) - return True # early return from _on_turn - return False # continue _on_turn + await self._handle_flow_response(context, flow_response) + + new_flow_state: FlowState = flow_response.flow_state + token_response: TokenResponse = flow_response.token_response + saved_activity: Activity = new_flow_state.continuation_activity.model_copy() + + if token_response: + new_context = copy(context) + new_context.activity = saved_activity + logger.info("Resending continuation activity %s", saved_activity.text) + await self.on_turn(new_context) + await turn_state.save(context) + return True # early return from _on_turn + return False # continue _on_turn + return False async def get_token( self, context: TurnContext, auth_handler_id: str @@ -178,13 +199,17 @@ async def get_token( Returns: The token response from the OAuth provider. """ - return await self.resolve_auth_client(auth_handler_id).get_token(context, auth_handler_id) + sign_in_state = await self._load_sign_in_state(context) + if not sign_in_state: + raise Exception("No active sign-in state found for the user.") + token = sign_in_state.tokens.get(auth_handler_id) + return TokenResponse(token=token) if token else TokenResponse() async def exchange_token( self, context: TurnContext, scopes: list[str], - auth_handler_id: Optional[str] = None, + auth_handler_id: str, ) -> TokenResponse: """ Exchanges a token for another token with different scopes. @@ -197,7 +222,58 @@ async def exchange_token( Returns: The token response from the OAuth provider. """ - return await self.resolve_auth_client(auth_handler_id).exchange_token(context, scopes, auth_handler_id) + + token_response = await self.get_token(context, auth_handler_id) + + if token_response and self._is_exchangeable(token_response.token): + logger.debug("Token is exchangeable, performing OBO flow") + return await self._handle_obo(token_response.token, scopes, auth_handler_id) + + return TokenResponse() + + def _is_exchangeable(self, token: str) -> bool: + """ + Checks if a token is exchangeable (has api:// audience). + + Args: + token: The token to check. + + Returns: + True if the token is exchangeable, False otherwise. + """ + try: + # Decode without verification to check the audience + payload = jwt.decode(token, options={"verify_signature": False}) + aud = payload.get("aud") + return isinstance(aud, str) and aud.startswith("api://") + except Exception: + logger.error("Failed to decode token to check audience") + return False + + async def _handle_obo( + self, token: str, scopes: list[str], handler_id: str = None + ) -> TokenResponse: + """ + Handles On-Behalf-Of token exchange. + + Args: + context: The context object for the current turn. + token: The original token. + scopes: The scopes to request. + + Returns: + The new token response. + + """ + auth_handler = self.resolve_handler(handler_id) + token_provider = self._connection_manager.get_connection(auth_handler.obo_connection_name) + + logger.info("Attempting to exchange token on behalf of user") + new_token = await token_provider.aquire_token_on_behalf_of( + scopes=scopes, + user_assertion=token, + ) + return TokenResponse(token=new_token) def on_sign_in_success( self, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py index ad8c1bb6..9236e481 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py @@ -1,6 +1,5 @@ -import jwt from abc import ABC -from typing import TypeVar, Optional, Generic +from typing import Optional import logging from microsoft_agents.activity import ( @@ -9,15 +8,12 @@ from ...turn_context import TurnContext from ...storage import Storage -from ...authorization import Connections, AccessTokenProviderBase -from ..state.turn_state import TurnState +from ...authorization import Connections from .auth_handler import AuthHandler logger = logging.getLogger(__name__) -StateT = TypeVar("StateT", bound=TurnState) - -class AuthorizationVariant(ABC, Generic[StateT]): +class AuthorizationVariant(ABC): def __init__( self, @@ -58,84 +54,13 @@ def __init__( } self._auth_handlers = auth_handlers or {} - - async def get_token( - self, context: TurnContext, auth_handler_id: str - ) -> TokenResponse: - raise NotImplementedError() - - async def exchange_token( + async def sign_in( self, context: TurnContext, - scopes: list[str], - auth_handler_id: Optional[str] = None, + auth_handler_id: Optional[str] = None ) -> TokenResponse: raise NotImplementedError() - - def _is_exchangeable(self, token: str) -> bool: - """ - Checks if a token is exchangeable (has api:// audience). - - Args: - token: The token to check. - - Returns: - True if the token is exchangeable, False otherwise. - """ - try: - # Decode without verification to check the audience - payload = jwt.decode(token, options={"verify_signature": False}) - aud = payload.get("aud") - return isinstance(aud, str) and aud.startswith("api://") - except Exception: - logger.error("Failed to decode token to check audience") - return False - - async def _handle_obo( - self, token: str, scopes: list[str], handler_id: str = None - ) -> TokenResponse: - """ - Handles On-Behalf-Of token exchange. - - Args: - context: The context object for the current turn. - token: The original token. - scopes: The scopes to request. - - Returns: - The new token response. - - """ - auth_handler = self.resolve_handler(handler_id) - token_provider: AccessTokenProviderBase = ( - self._connection_manager.get_connection(auth_handler.obo_connection_name) - ) - - logger.info("Attempting to exchange token on behalf of user") - new_token = await token_provider.aquire_token_on_behalf_of( - scopes=scopes, - user_assertion=token, - ) - return TokenResponse(token=new_token) - - def resolve_handler(self, auth_handler_id: Optional[str] = None) -> AuthHandler: - """Resolves the auth handler to use based on the provided ID. - - Args: - auth_handler_id: Optional ID of the auth handler to resolve, defaults to first handler. - - Returns: - The resolved auth handler. - """ - if auth_handler_id: - if auth_handler_id not in self._auth_handlers: - logger.error("Auth handler '%s' not found", auth_handler_id) - raise ValueError(f"Auth handler '{auth_handler_id}' not found") - return self._auth_handlers[auth_handler_id] - - # Return the first handler if no ID specified - return next(iter(self._auth_handlers.values())) async def sign_out( self, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py new file mode 100644 index 00000000..ca7520e1 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Optional + +from ...storage._type_aliases import JSON +from ...storage import StoreItem + +class SignInState(StoreItem): + + def __init__(self, data: Optional[JSON] = None): + self.tokens = data or {} + + def store_item_to_json(self) -> JSON: + return self.tokens + + @staticmethod + def from_json_to_store_item(json_data: JSON) -> SignInState: + return SignInState(json_data) + + def active_handler(self) -> Optional[str]: + for handler_id, token in self.tokens.items(): + if not token: + return handler_id \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py index 12da9338..c081939e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py @@ -1,37 +1,23 @@ from __future__ import annotations import logging -from typing import Dict, Optional, Callable, Awaitable, AsyncIterator, TypeVar -from collections.abc import Iterable -from contextlib import asynccontextmanager +from typing import Optional -from microsoft_agents.hosting.core.authorization import ( - Connections, - AccessTokenProviderBase, -) -from microsoft_agents.hosting.core.storage import Storage, MemoryStorage from microsoft_agents.activity import ( ActionTypes, - TokenResponse, CardAction, OAuthCard, Attachment, - CardFactory, ) -from microsoft_agents.hosting.core.connector.client import UserTokenClient from ...turn_context import TurnContext -from ...oauth import OAuthFlow, FlowResponse, FlowState, FlowStateTag, FlowStorageClient +from ...oauth import FlowResponse, FlowState, FlowStateTag from ...message_factory import MessageFactory -from ..state.turn_state import TurnState -from .authorization_variant import AuthorizationClient -from .auth_handler import AuthHandler +from ...card_factory import CardFactory from .user_authorization_base import UserAuthorizationBase logger = logging.getLogger(__name__) -StateT = TypeVar("StateT", bound=TurnState) - -class UserAuthorization(UserAuthorizationBase[StateT]): +class UserAuthorization(UserAuthorizationBase): async def _handle_flow_response( self, context: TurnContext, flow_response: FlowResponse @@ -76,14 +62,14 @@ async def _handle_flow_response( logger.warning("Sign-in flow failed for unknown reasons.") await context.send_activity("Sign-in failed. Please try again.") - async def sign_in(self, context: TurnContext, state: StateT, auth_handler_id: Optional[str] = None) -> bool: + async def sign_in(self, context: TurnContext, auth_handler_id: str) -> Optional[str]: logger.debug( "Beginning or continuing flow for auth handler %s", auth_handler_id, ) - flow_response: FlowResponse = ( + flow_response = ( await self.begin_or_continue_flow( - context, state, auth_handler_id + context, auth_handler_id ) ) await self._handle_flow_response(context, flow_response) @@ -91,4 +77,4 @@ async def sign_in(self, context: TurnContext, state: StateT, auth_handler_id: Op "Flow response flow_state.tag: %s", flow_response.flow_state.tag, ) - return flow_response.flow_state.tag == FlowStateTag.COMPLETE \ No newline at end of file + return flow_response.token_response.token if flow_response.token_response else None \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py index 04fede37..4927686d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from abc import ABC +from re import U from typing import Dict, Optional, Callable, Awaitable, AsyncIterator, TypeVar from collections.abc import Iterable from contextlib import asynccontextmanager @@ -18,76 +19,19 @@ from ...turn_context import TurnContext from ...oauth import OAuthFlow, FlowResponse, FlowState, FlowStateTag, FlowStorageClient -from ..state.turn_state import TurnState from .authorization_variant import AuthorizationVariant from .auth_handler import AuthHandler logger = logging.getLogger(__name__) -StateT = TypeVar("StateT", bound=TurnState) - -class UserAuthorizationBase(AuthorizationVariant[StateT], ABC): +class UserAuthorizationBase(AuthorizationVariant, ABC): """ Class responsible for managing authorization and OAuth flows. Handles multiple OAuth providers and manages the complete authentication lifecycle. """ - - def __init__( - self, - storage: Storage, - connection_manager: Connections, - auth_handlers: dict[str, AuthHandler] = None, - auto_signin: bool = None, - use_cache: bool = False, - **kwargs, - ): - """ - Creates a new instance of Authorization. - - Args: - storage: The storage system to use for state management. - auth_handlers: Configuration for OAuth providers. - - Raises: - ValueError: If storage is None or no auth handlers are provided. - """ - if not storage: - raise ValueError("Storage is required for Authorization") - - self._storage = storage - self._connection_manager = connection_manager - - auth_configuration: Dict = kwargs.get("AGENTAPPLICATION", {}).get( - "USERAUTHORIZATION", {} - ) - - handlers_config: Dict[str, Dict] = auth_configuration.get("HANDLERS", {}) - if not auth_handlers and handlers_config: - auth_handlers = { - handler_name: AuthHandler( - name=handler_name, **config.get("SETTINGS", {}) - ) - for handler_name, config in handlers_config.items() - } - - self._auth_handlers = auth_handlers or {} - - def _ids_from_context(self, context: TurnContext) -> tuple[str, str]: - """Checks and returns IDs necessary to load a new or existing flow. - - Raises a ValueError if channel ID or user ID are missing. - """ - if ( - not context.activity.channel_id - or not context.activity.from_property - or not context.activity.from_property.id - ): - raise ValueError("Channel ID and User ID are required") - - return context.activity.channel_id, context.activity.from_property.id async def _load_flow( - self, context: TurnContext, auth_handler_id: str = "" + self, context: TurnContext, auth_handler_id: str ) -> tuple[OAuthFlow, FlowStorageClient]: """Loads the OAuth flow for a specific auth handler. @@ -105,10 +49,18 @@ async def _load_flow( ) # resolve handler id - auth_handler: AuthHandler = self.resolve_handler(auth_handler_id) + auth_handler: AuthHandler = self._auth_handlers[auth_handler_id] auth_handler_id = auth_handler.name - channel_id, user_id = self._ids_from_context(context) + if ( + not context.activity.channel_id + or not context.activity.from_property + or not context.activity.from_property.id + ): + raise ValueError("Channel ID and User ID are required") + + channel_id = context.activity.channel_id + user_id = context.activity.from_property.id ms_app_id = context.turn_state.get(context.adapter.AGENT_IDENTITY_KEY).claims[ "aud" @@ -132,153 +84,33 @@ async def _load_flow( flow = OAuthFlow(flow_state, user_token_client) return flow, flow_storage_client - - @asynccontextmanager - async def open_flow( - self, context: TurnContext, auth_handler_id: str = "" - ) -> AsyncIterator[OAuthFlow]: - """Loads an OAuth flow and saves changes the changes to storage if any are made. - - Args: - context: The context object for the current turn. - auth_handler_id: ID of the auth handler to use. - If none provided, uses the first handler. - - Yields: - OAuthFlow: - The OAuthFlow instance loaded from storage or newly created - if not yet present in storage. - """ - if not context: - logger.error("No context provided to open_flow") - raise ValueError("context is required") - - flow, flow_storage_client = await self._load_flow(context, auth_handler_id) - yield flow - logger.info("Saving OAuth flow state to storage") - await flow_storage_client.write(flow.flow_state) - - async def get_token( - self, context: TurnContext, auth_handler_id: str - ) -> TokenResponse: - """ - Gets the token for a specific auth handler. - - Args: - context: The context object for the current turn. - auth_handler_id: Optional ID of the auth handler to use, defaults to first handler. - - Returns: - The token response from the OAuth provider. - """ - logger.info("Getting token for auth handler: %s", auth_handler_id) - async with self.open_flow(context, auth_handler_id) as flow: - return await flow.get_user_token() - - async def exchange_token( - self, - context: TurnContext, - scopes: list[str], - auth_handler_id: Optional[str] = None, - ) -> TokenResponse: - """ - Exchanges a token for another token with different scopes. - - Args: - context: The context object for the current turn. - scopes: The scopes to request for the new token. - auth_handler_id: Optional ID of the auth handler to use, defaults to first handler. - - Returns: - The token response from the OAuth provider. - """ - logger.info("Exchanging token for scopes: %s", scopes) - async with self.open_flow(context, auth_handler_id) as flow: - token_response = await flow.get_user_token() - - if token_response and self._is_exchangeable(token_response.token): - logger.debug("Token is exchangeable, performing OBO flow") - return await self._handle_obo(token_response.token, scopes, auth_handler_id) - - return TokenResponse() - - async def get_active_flow_state(self, context: TurnContext) -> Optional[FlowState]: - """Gets the first active flow state for the current context.""" - logger.debug("Getting active flow state") - channel_id, user_id = self._ids_from_context(context) - flow_storage_client = FlowStorageClient(channel_id, user_id, self._storage) - for auth_handler_id in self._auth_handlers.keys(): - flow_state = await flow_storage_client.read(auth_handler_id) - if flow_state and flow_state.is_active(): - return flow_state - return None async def begin_or_continue_flow( self, context: TurnContext, - turn_state: TurnState, - auth_handler_id: str = "", + auth_handler_id: str ) -> FlowResponse: """Begins or continues an OAuth flow. Args: context: The context object for the current turn. - turn_state: The state object for the current turn. auth_handler_id: Optional ID of the auth handler to use, defaults to first handler. Returns: The token response from the OAuth provider. """ - if not auth_handler_id: - auth_handler_id = self.resolve_handler().name logger.debug("Beginning or continuing OAuth flow") - async with self.open_flow(context, auth_handler_id) as flow: - prev_tag = flow.flow_state.tag - flow_response: FlowResponse = await flow.begin_or_continue_flow( - context.activity - ) - flow_state: FlowState = flow_response.flow_state + flow, flow_storage_client = await self._load_flow(context, auth_handler_id) + flow_response: FlowResponse = await flow.begin_or_continue_flow(context.activity) - # if ( - # flow_state.tag == FlowStateTag.COMPLETE - # and prev_tag != FlowStateTag.COMPLETE - # ): - # logger.debug("Calling Authorization sign in success handler") - # self._sign_in_success_handler( - # context, turn_state, flow_state.auth_handler_id - # ) - # elif flow_state.tag == FlowStateTag.FAILURE: - # logger.debug("Calling Authorization sign in failure handler") - # self._sign_in_failure_handler( - # context, - # turn_state, - # flow_state.auth_handler_id, - # flow_response.flow_error_tag, - # ) + logger.info("Saving OAuth flow state to storage") + await flow_storage_client.write(flow_response.flow_state) return flow_response - def resolve_handler(self, auth_handler_id: Optional[str] = None) -> AuthHandler: - """Resolves the auth handler to use based on the provided ID. - - Args: - auth_handler_id: Optional ID of the auth handler to resolve, defaults to first handler. - - Returns: - The resolved auth handler. - """ - if auth_handler_id: - if auth_handler_id not in self._auth_handlers: - logger.error("Auth handler '%s' not found", auth_handler_id) - raise ValueError(f"Auth handler '{auth_handler_id}' not found") - return self._auth_handlers[auth_handler_id] - - # Return the first handler if no ID specified - return next(iter(self._auth_handlers.values())) - async def _sign_out( self, context: TurnContext, @@ -294,8 +126,6 @@ async def _sign_out( """ for auth_handler_id in auth_handler_ids: flow, flow_storage_client = await self._load_flow(context, auth_handler_id) - # ensure that the id is valid - self.resolve_handler(auth_handler_id) logger.info("Signing out from handler: %s", auth_handler_id) await flow.sign_out() await flow_storage_client.delete(auth_handler_id) diff --git a/tests/activity/test_activity.py b/tests/activity/test_activity.py index 179b40df..886dcabe 100644 --- a/tests/activity/test_activity.py +++ b/tests/activity/test_activity.py @@ -368,3 +368,5 @@ def test_get_mentions(self): Mention(text="Hello"), Entity(type="mention", text="Another mention"), ] + + # robrandao: TODO -> is_agentic \ No newline at end of file diff --git a/tests/hosting_core/app/test_authorization.py b/tests/hosting_core/app/test_user_authorization.py similarity index 84% rename from tests/hosting_core/app/test_authorization.py rename to tests/hosting_core/app/test_user_authorization.py index effacf87..eef78a0c 100644 --- a/tests/hosting_core/app/test_authorization.py +++ b/tests/hosting_core/app/test_user_authorization.py @@ -11,7 +11,7 @@ FlowState, FlowResponse, OAuthFlow, - Authorization, + UserAuthorization, MemoryStorage, ) @@ -89,8 +89,8 @@ def auth_handlers(self): return TEST_AUTH_DATA().auth_handlers @pytest.fixture - def authorization(self, connection_manager, storage, auth_handlers): - return Authorization(storage, connection_manager, auth_handlers) + def user_authorization(self, connection_manager, storage, auth_handlers): + return UserAuthorization(storage, connection_manager, auth_handlers) class TestAuthorization(TestEnv): @@ -113,13 +113,13 @@ def test_init_configuration_variants( } } } - auth_with_config_obj = Authorization( + auth_with_config_obj = UserAuthorization( storage, connection_manager, auth_handlers=None, AGENTAPPLICATION=AGENTAPPLICATION, ) - auth_with_handlers_list = Authorization( + auth_with_handlers_list = UserAuthorization( storage, connection_manager, auth_handlers=auth_handlers ) for auth_handler_name in auth_handlers.keys(): @@ -143,12 +143,12 @@ def test_init_configuration_variants( [["missing", "webchat", "Alice"], ["handler", "teams", "Bob"]], ) async def test_open_flow_value_error( - self, mocker, authorization, auth_handler_id, channel_id, user_id + self, mocker, user_authorization, auth_handler_id, channel_id, user_id ): """Test opening a flow with a missing auth handler.""" context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) with pytest.raises(ValueError): - async with authorization.open_flow(context, auth_handler_id): + async with user_authorization.open_flow(context, auth_handler_id): pass @pytest.mark.asyncio @@ -173,7 +173,7 @@ async def test_open_flow_readonly( """Test opening a flow and not modifying it.""" # setup context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) - auth = Authorization(storage, connection_manager, auth_handlers) + auth = UserAuthorization(storage, connection_manager, auth_handlers) flow_storage_client = FlowStorageClient(channel_id, user_id, storage) # test @@ -213,7 +213,7 @@ async def test_open_flow_success_modified_complete_flow( context.activity.type = ActivityTypes.message context.activity.text = "123456" - auth = Authorization(storage, connection_manager, auth_handlers) + auth = UserAuthorization(storage, connection_manager, auth_handlers) flow_storage_client = FlowStorageClient(channel_id, user_id, storage) # test @@ -247,7 +247,7 @@ async def test_open_flow_success_modified_failure( context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) context.activity.text = "invalid_magic_code" - auth = Authorization(storage, connection_manager, auth_handlers) + auth = UserAuthorization(storage, connection_manager, auth_handlers) flow_storage_client = FlowStorageClient(channel_id, user_id, storage) # test @@ -277,7 +277,7 @@ async def test_open_flow_success_modified_signout( context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) - auth = Authorization(storage, connection_manager, auth_handlers) + auth = UserAuthorization(storage, connection_manager, auth_handlers) flow_storage_client = FlowStorageClient(channel_id, user_id, storage) # test @@ -293,7 +293,7 @@ async def test_open_flow_success_modified_signout( assert flow_state_eq(actual_flow_state, expected_flow_state) @pytest.mark.asyncio - async def test_get_token_success(self, mocker, authorization): + async def test_get_token_success(self, mocker, user_authorization): user_token_client = self.UserTokenClient(mocker, get_token_return="token") context = self.TurnContext( mocker, @@ -301,13 +301,13 @@ async def test_get_token_success(self, mocker, authorization): user_id="__user_id", user_token_client=user_token_client, ) - assert await authorization.get_token(context, "slack") == TokenResponse( + assert await user_authorization.get_token(context, "slack") == TokenResponse( token="token" ) user_token_client.user_token.get_token.assert_called_once() @pytest.mark.asyncio - async def test_get_token_empty_response(self, mocker, authorization): + async def test_get_token_empty_response(self, mocker, user_authorization): user_token_client = self.UserTokenClient( mocker, get_token_return=TokenResponse() ) @@ -317,39 +317,39 @@ async def test_get_token_empty_response(self, mocker, authorization): user_id="__user_id", user_token_client=user_token_client, ) - assert await authorization.get_token(context, "graph") == TokenResponse() + assert await user_authorization.get_token(context, "graph") == TokenResponse() user_token_client.user_token.get_token.assert_called_once() @pytest.mark.asyncio async def test_get_token_error( self, turn_context, storage, connection_manager, auth_handlers ): - auth = Authorization(storage, connection_manager, auth_handlers) + auth = UserAuthorization(storage, connection_manager, auth_handlers) with pytest.raises(ValueError): await auth.get_token( turn_context, DEFAULTS.missing_abs_oauth_connection_name ) @pytest.mark.asyncio - async def test_exchange_token_no_token(self, mocker, turn_context, authorization): + async def test_exchange_token_no_token(self, mocker, turn_context, user_authorization): mock_class_OAuthFlow(mocker, get_user_token_return=TokenResponse()) - res = await authorization.exchange_token(turn_context, ["scope"], "github") + res = await user_authorization.exchange_token(turn_context, ["scope"], "github") assert res == TokenResponse() @pytest.mark.asyncio async def test_exchange_token_not_exchangeable( - self, mocker, turn_context, authorization + self, mocker, turn_context, user_authorization ): token = jwt.encode({"aud": "invalid://botframework.test.api"}, "") mock_class_OAuthFlow( mocker, get_user_token_return=TokenResponse(connection_name="github", token=token), ) - res = await authorization.exchange_token(turn_context, ["scope"], "github") + res = await user_authorization.exchange_token(turn_context, ["scope"], "github") assert res == TokenResponse() @pytest.mark.asyncio - async def test_exchange_token_valid_exchangeable(self, mocker, authorization): + async def test_exchange_token_valid_exchangeable(self, mocker, user_authorization): # setup token = jwt.encode({"aud": "api://botframework.test.api"}, "") mock_class_OAuthFlow( @@ -361,25 +361,25 @@ async def test_exchange_token_valid_exchangeable(self, mocker, authorization): ) turn_context = self.TurnContext(mocker, user_token_client=user_token_client) # test - res = await authorization.exchange_token(turn_context, ["scope"], "github") + res = await user_authorization.exchange_token(turn_context, ["scope"], "github") assert res == TokenResponse(token="github-obo-connection-obo-token") @pytest.mark.asyncio - async def test_get_active_flow_state(self, mocker, authorization): + async def test_get_active_flow_state(self, mocker, user_authorization): context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") - actual_flow_state = await authorization.get_active_flow_state(context) + actual_flow_state = await user_authorization.get_active_flow_state(context) assert actual_flow_state == STORAGE_DATA.dict["auth/webchat/Alice/github"] @pytest.mark.asyncio - async def test_get_active_flow_state_missing(self, mocker, authorization): + async def test_get_active_flow_state_missing(self, mocker, user_authorization): context = self.TurnContext( mocker, channel_id="__channel_id", user_id="__user_id" ) - res = await authorization.get_active_flow_state(context) + res = await user_authorization.get_active_flow_state(context) assert res is None @pytest.mark.asyncio - async def test_begin_or_continue_flow_success(self, mocker, authorization): + async def test_begin_or_continue_flow_success(self, mocker, user_authorization): # robrandao: TODO -> lower priority -> more testing here # setup mock_class_OAuthFlow( @@ -401,9 +401,9 @@ def on_sign_in_failure(context, turn_state, auth_handler_id, err): context.dummy_val = str(err) # test - authorization.on_sign_in_success(on_sign_in_success) - authorization.on_sign_in_failure(on_sign_in_failure) - flow_response = await authorization.begin_or_continue_flow( + user_authorization.on_sign_in_success(on_sign_in_success) + user_authorization.on_sign_in_failure(on_sign_in_failure) + flow_response = await user_authorization.begin_or_continue_flow( context, None, "github" ) assert context.dummy_val == "github" @@ -411,7 +411,7 @@ def on_sign_in_failure(context, turn_state, auth_handler_id, err): @pytest.mark.asyncio async def test_begin_or_continue_flow_already_completed( - self, mocker, authorization + self, mocker, user_authorization ): # robrandao: TODO -> lower priority -> more testing here # setup @@ -426,9 +426,9 @@ def on_sign_in_failure(context, turn_state, auth_handler_id, err): context.dummy_val = str(err) # test - authorization.on_sign_in_success(on_sign_in_success) - authorization.on_sign_in_failure(on_sign_in_failure) - flow_response = await authorization.begin_or_continue_flow( + user_authorization.on_sign_in_success(on_sign_in_success) + user_authorization.on_sign_in_failure(on_sign_in_failure) + flow_response = await user_authorization.begin_or_continue_flow( context, None, "graph" ) assert context.dummy_val == None @@ -436,7 +436,7 @@ def on_sign_in_failure(context, turn_state, auth_handler_id, err): assert flow_response.continuation_activity is None @pytest.mark.asyncio - async def test_begin_or_continue_flow_failure(self, mocker, authorization): + async def test_begin_or_continue_flow_failure(self, mocker, user_authorization): # robrandao: TODO -> lower priority -> more testing here # setup mock_class_OAuthFlow( @@ -459,9 +459,9 @@ def on_sign_in_failure(context, turn_state, auth_handler_id, err): context.dummy_val = str(err) # test - authorization.on_sign_in_success(on_sign_in_success) - authorization.on_sign_in_failure(on_sign_in_failure) - flow_response = await authorization.begin_or_continue_flow( + user_authorization.on_sign_in_success(on_sign_in_success) + user_authorization.on_sign_in_failure(on_sign_in_failure) + flow_response = await user_authorization.begin_or_continue_flow( context, None, "github" ) assert context.dummy_val == "FlowErrorTag.MAGIC_FORMAT" @@ -469,19 +469,19 @@ def on_sign_in_failure(context, turn_state, auth_handler_id, err): @pytest.mark.parametrize("auth_handler_id", ["graph", "github"]) def test_resolve_handler_specified( - self, authorization, auth_handlers, auth_handler_id + self, user_authorization, auth_handlers, auth_handler_id ): assert ( - authorization.resolve_handler(auth_handler_id) + user_authorization.resolve_handler(auth_handler_id) == auth_handlers[auth_handler_id] ) - def test_resolve_handler_error(self, authorization): + def test_resolve_handler_error(self, user_authorization): with pytest.raises(ValueError): - authorization.resolve_handler("missing-handler") + user_authorization.resolve_handler("missing-handler") - def test_resolve_handler_first(self, authorization, auth_handlers): - assert authorization.resolve_handler() == next(iter(auth_handlers.values())) + def test_resolve_handler_first(self, user_authorization, auth_handlers): + assert user_authorization.resolve_handler() == next(iter(auth_handlers.values())) @pytest.mark.asyncio async def test_sign_out_individual( @@ -495,7 +495,7 @@ async def test_sign_out_individual( mock_class_OAuthFlow(mocker) storage_client = FlowStorageClient("teams", "Alice", storage) context = self.TurnContext(mocker, channel_id="teams", user_id="Alice") - auth = Authorization(storage, connection_manager, auth_handlers) + auth = UserAuthorization(storage, connection_manager, auth_handlers) # test await auth.sign_out(context, "graph") @@ -519,7 +519,7 @@ async def test_sign_out_all( mock_class_OAuthFlow(mocker) context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") storage_client = FlowStorageClient("webchat", "Alice", storage) - auth = Authorization(storage, connection_manager, auth_handlers) + auth = UserAuthorization(storage, connection_manager, auth_handlers) # test await auth.sign_out(context) From ac48fbc7a96d0cd3de8eaf85d24b22a953c94822 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Sep 2025 22:51:47 -0700 Subject: [PATCH 07/36] Addressing continuation activity --- .../core/app/auth/agentic_authorization.py | 6 +- .../hosting/core/app/auth/authorization.py | 85 ++++++++++--------- .../core/app/auth/authorization_variant.py | 4 +- .../hosting/core/app/auth/sign_in_response.py | 9 ++ .../hosting/core/app/auth/sign_in_state.py | 17 ++-- .../core/app/auth/user_authorization.py | 11 ++- .../core/app/auth/user_authorization_base.py | 6 ++ 7 files changed, 86 insertions(+), 52 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py index 48b0f6f2..dd26c18c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py @@ -6,10 +6,12 @@ Activity, TokenResponse ) +from microsoft_agents.hosting.core.app.auth.sign_in_response import SignInResponse from ...turn_context import TurnContext from .authorization_variant import AuthorizationVariant +from .sign_in_response import SignInResponse logger = logging.getLogger(__name__) @@ -61,10 +63,10 @@ async def get_agentic_user_token(self, context: TurnContext, scopes: list[str]) agentic_instance_id, upn, scopes ) - async def sign_in(self, context: TurnContext, scopes: Optional[list[str]] = None) -> Optional[str]: + async def sign_in(self, context: TurnContext, scopes: Optional[list[str]] = None) -> SignInResponse: scopes = scopes or [] token = await self.get_agentic_user_token(context, scopes) - return token + return SignInResponse(token=token, tag=FlowStateTag.COMPLETED) if token else SignInResponse() async def sign_out(self, context: TurnContext) -> None: pass \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py index 99f3fe03..b13f4b87 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py @@ -1,27 +1,22 @@ import logging from typing import TypeVar, Optional, Callable, Awaitable, Generic, cast import jwt +from copy import copy -from microsoft_agents.activity import ( - ActivityTypes, - TokenResponse -) +from microsoft_agents.activity import TokenResponse from microsoft_agents.hosting.core import ( TurnContext, TurnState, Connections ) - -from ...oauth import ( - FlowState, - FlowResponse, -) from ...storage import Storage +from ...oauth import FlowStateTag from .auth_handler import AuthHandler from .user_authorization import UserAuthorization from .agentic_authorization import AgenticAuthorization from .authorization_variant import AuthorizationVariant from .sign_in_state import SignInState +from .sign_in_response import SignInResponse AUTHORIZATION_TYPE_MAP: dict[str, type[AuthorizationVariant]] = { "userauthorization": UserAuthorization, @@ -130,21 +125,38 @@ def resolve_handler(self, handler_id: str) -> AuthHandler: raise ValueError(f"Auth handler {handler_id} not recognized or not configured.") return self._auth_handlers[handler_id] - async def start_or_continue_sign_in(self, context: TurnContext, state: StateT, auth_handler_id: str) -> bool: + async def _start_or_continue_sign_in(self, context: TurnContext, state: StateT, auth_handler_id: str) -> SignInResponse: sign_in_state = await self._load_sign_in_state(context) - auth_handler_id = sign_in_state.active_handler() if sign_in_state else "" + if not sign_in_state: + sign_in_state = SignInState({auth_handler_id: ""}) + if sign_in_state.tokens.get(auth_handler_id): + return SignInResponse(tag=FlowStateTag.COMPLETE, token=sign_in_state.tokens[auth_handler_id]) + + sign_in_response = SignInResponse(tag=FlowStateTag.NOT_STARTED) if auth_handler_id: - token = await self._resolve_auth_variant(auth_handler_id).sign_in(context, auth_handler_id) - if token: - if not sign_in_state: - sign_in_state = SignInState() + sign_in_response = await self._resolve_auth_variant(auth_handler_id).sign_in(context, auth_handler_id) + + if sign_in_response.tag == FlowStateTag.COMPLETE: + if self._sign_in_success_handler: + await self._sign_in_success_handler(context, state, auth_handler_id) + token = sign_in_response.token sign_in_state.tokens[auth_handler_id] = token await self._save_sign_in_state(context, sign_in_state) - else: - return False - return True + + elif sign_in_response.tag == FlowStateTag.FAILURE: + if self._sign_in_failure_handler: + await self._sign_in_failure_handler(context, state, auth_handler_id) + + elif sign_in_response.tag in [FlowStateTag.BEGIN, FlowStateTag.CONTINUE]: + sign_in_state.continuation_activity = context.activity + + return sign_in_response + + async def start_or_continue_sign_in(self, context: TurnContext, state: StateT, auth_handler_id: str) -> bool: + sign_in_response = await self._start_or_continue_sign_in(context, state, auth_handler_id) + return sign_in_response.tag in [FlowStateTag.NOT_STARTED, FlowStateTag.COMPLETE] async def on_turn_auth_intercept(self, context: TurnContext, state: StateT, continue_turn_callback: Callable[[TurnContext], Awaitable[None]]) -> bool: """Intercepts the turn to check for active authentication flows. @@ -157,33 +169,22 @@ async def on_turn_auth_intercept(self, context: TurnContext, state: StateT, cont # get active thing... sign_in_state = await self._load_sign_in_state(context) - auth_handler_id = sign_in_state.active_handler() if sign_in_state else "" - - if auth_handler_id: - await self.start_or_continue_sign_in(context, state, auth_handler_id) - - - logger.debug("Sign-in flow is active for context: %s", context.activity.id) - - flow_response: FlowResponse = await self._auth.begin_or_continue_flow( - context, turn_state, prev_flow_state.auth_handler_id - ) - - await self._handle_flow_response(context, flow_response) - - new_flow_state: FlowState = flow_response.flow_state - token_response: TokenResponse = flow_response.token_response - saved_activity: Activity = new_flow_state.continuation_activity.model_copy() + if sign_in_state: + auth_handler_id = sign_in_state.active_handler() + if auth_handler_id: + assert sign_in_state.continuation_activity is not None + continuation_activity = sign_in_state.continuation_activity.model_copy() + sign_in_response = await self._start_or_continue_sign_in(context, state, auth_handler_id) + + if sign_in_response.tag == FlowStateTag.COMPLETE: - if token_response: new_context = copy(context) - new_context.activity = saved_activity - logger.info("Resending continuation activity %s", saved_activity.text) - await self.on_turn(new_context) - await turn_state.save(context) - return True # early return from _on_turn - return False # continue _on_turn + new_context.activity = continuation_activity + logger.info("Resending continuation activity %s", continuation_activity.text) + await continue_turn_callback(new_context) + await state.save(context) + return True # continue _on_turn return False async def get_token( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py index 9236e481..4a22527a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py @@ -7,9 +7,11 @@ ) from ...turn_context import TurnContext +from ...oauth import FlowStateTag from ...storage import Storage from ...authorization import Connections from .auth_handler import AuthHandler +from .sign_in_response import SignInResponse logger = logging.getLogger(__name__) @@ -59,7 +61,7 @@ async def sign_in( self, context: TurnContext, auth_handler_id: Optional[str] = None - ) -> TokenResponse: + ) -> SignInResponse: raise NotImplementedError() async def sign_out( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py new file mode 100644 index 00000000..53bb955e --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py @@ -0,0 +1,9 @@ +from typing import Optional +from dataclasses import dataclass + +from ...oauth import FlowStateTag + +@dataclass +class SignInResponse: + token: Optional[str] = None + tag: FlowStateTag = FlowStateTag.FAILURE \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py index ca7520e1..9eda3176 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py @@ -2,22 +2,29 @@ from typing import Optional +from microsoft_agents.activity import Activity + from ...storage._type_aliases import JSON from ...storage import StoreItem class SignInState(StoreItem): - def __init__(self, data: Optional[JSON] = None): + def __init__(self, data: Optional[JSON] = None, continuation_activity: Optional[Activity] = None) -> None: self.tokens = data or {} + self.continuation_activity = continuation_activity def store_item_to_json(self) -> JSON: - return self.tokens + return { + "tokens": self.tokens, + "continuation_activity": self.continuation_activity, + } @staticmethod def from_json_to_store_item(json_data: JSON) -> SignInState: - return SignInState(json_data) + return SignInState(json_data["tokens"], json_data.get("continuation_activity")) - def active_handler(self) -> Optional[str]: + def active_handler(self) -> "": for handler_id, token in self.tokens.items(): if not token: - return handler_id \ No newline at end of file + return handler_id + return "" \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py index c081939e..46f4228b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py @@ -14,6 +14,7 @@ from ...message_factory import MessageFactory from ...card_factory import CardFactory from .user_authorization_base import UserAuthorizationBase +from .sign_in_response import SignInResponse logger = logging.getLogger(__name__) @@ -62,7 +63,7 @@ async def _handle_flow_response( logger.warning("Sign-in flow failed for unknown reasons.") await context.send_activity("Sign-in failed. Please try again.") - async def sign_in(self, context: TurnContext, auth_handler_id: str) -> Optional[str]: + async def sign_in(self, context: TurnContext, auth_handler_id: str) -> SignInResponse: logger.debug( "Beginning or continuing flow for auth handler %s", auth_handler_id, @@ -77,4 +78,10 @@ async def sign_in(self, context: TurnContext, auth_handler_id: str) -> Optional[ "Flow response flow_state.tag: %s", flow_response.flow_state.tag, ) - return flow_response.token_response.token if flow_response.token_response else None \ No newline at end of file + + sign_in_response = SignInResponse( + token=flow_response.token_response.token if flow_response.token_response else None, + tag=flow_response.flow_state.tag + ) + + return sign_in_response \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py index 4927686d..dd029381 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py @@ -104,10 +104,16 @@ async def begin_or_continue_flow( logger.debug("Beginning or continuing OAuth flow") flow, flow_storage_client = await self._load_flow(context, auth_handler_id) + prev_tag = flow.flow_state.tag flow_response: FlowResponse = await flow.begin_or_continue_flow(context.activity) logger.info("Saving OAuth flow state to storage") await flow_storage_client.write(flow_response.flow_state) + + if prev_tag != flow_response.flow_state.tag and flow_response.flow_state.tag == FlowStateTag.COMPLETED: + # Clear the flow state on completion + flow_response.continuation_activity = + await flow_storage_client.delete(auth_handler_id) return flow_response From a078bd68bfa8d7837e075c5bb539390d90facec5 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 25 Sep 2025 09:04:57 -0700 Subject: [PATCH 08/36] Adding authorization tests --- .../microsoft_agents/hosting/core/__init__.py | 6 +- .../hosting/core/app/__init__.py | 15 +- .../hosting/core/app/agent_application.py | 32 +- .../hosting/core/app/app_options.py | 2 +- .../hosting/core/app/auth/__init__.py | 4 +- .../core/app/auth/agentic_authorization.py | 2 +- .../hosting/core/app/auth/authorization.py | 113 ++-- .../hosting/core/app/auth/sign_in_response.py | 4 +- .../core/app/auth/user_authorization.py | 2 +- .../core/app/auth/user_authorization_base.py | 7 +- tests/_common/data/__init__.py | 6 + .../_common/data/test_agentic_auth_config.py | 39 ++ tests/_common/data/test_auth_config.py | 29 + tests/_common/data/test_defaults.py | 17 +- tests/_common/testing_objects/__init__.py | 6 + .../_common/testing_objects/mocks/__init__.py | 9 + .../mocks/mock_authorization.py | 19 + tests/activity/test_activity.py | 14 +- tests/hosting_core/app/auth/__init__.py | 0 tests/hosting_core/app/auth/_common.py | 45 ++ tests/hosting_core/app/auth/_env.py | 18 + .../app/auth/test_agentic_authorization.py | 0 .../app/auth/test_auth_handler.py | 22 + .../app/auth/test_authorization.py | 195 +++++++ .../app/auth/test_authorization_variant.py | 0 .../app/auth/test_sign_in_state.py | 57 ++ .../app/auth/test_user_authorization.py | 540 ++++++++++++++++++ .../app/test_user_authorization.py | 540 ------------------ 28 files changed, 1112 insertions(+), 631 deletions(-) create mode 100644 tests/_common/data/test_agentic_auth_config.py create mode 100644 tests/_common/data/test_auth_config.py create mode 100644 tests/_common/testing_objects/mocks/mock_authorization.py create mode 100644 tests/hosting_core/app/auth/__init__.py create mode 100644 tests/hosting_core/app/auth/_common.py create mode 100644 tests/hosting_core/app/auth/_env.py create mode 100644 tests/hosting_core/app/auth/test_agentic_authorization.py create mode 100644 tests/hosting_core/app/auth/test_auth_handler.py create mode 100644 tests/hosting_core/app/auth/test_authorization.py create mode 100644 tests/hosting_core/app/auth/test_authorization_variant.py create mode 100644 tests/hosting_core/app/auth/test_sign_in_state.py create mode 100644 tests/hosting_core/app/auth/test_user_authorization.py delete mode 100644 tests/hosting_core/app/test_user_authorization.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py index f5d07cef..4235d43a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py @@ -20,10 +20,14 @@ from .app.typing_indicator import TypingIndicator # App Auth -from .app.oauth import ( +from .app.auth import ( Authorization, AuthorizationHandlers, AuthHandler, + UserAuthorization, + AgenticAuthorization, + SignInState, + SignInResponse, ) # App State diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py index 4089c3fb..e2767221 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py @@ -14,10 +14,14 @@ from .typing_indicator import TypingIndicator # Auth -from .oauth import ( +from .auth import ( Authorization, AuthHandler, AuthorizationHandlers, + UserAuthorization, + AgenticAuthorization, + SignInResponse, + SignInState, ) # App State @@ -27,15 +31,11 @@ from .state.turn_state import TurnState __all__ = [ - "ActivityType", "AgentApplication", "ApplicationError", "ApplicationOptions", - "ConversationUpdateType", "InputFile", "InputFileDownloader", - "MessageReactionType", - "MessageUpdateType", "Query", "Route", "RouteHandler", @@ -50,4 +50,9 @@ "Authorization", "AuthHandler", "AuthorizationHandlers", + "AuthorizationVariant", + "UserAuthorization", + "AgenticAuthorization", + "SignInState", + "SignInResponse", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index a7ff7ed2..36e017fe 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -23,24 +23,18 @@ cast, ) -from microsoft_agents.hosting.core.authorization import Connections - -from microsoft_agents.hosting.core import Agent, TurnContext from microsoft_agents.activity import ( Activity, ActivityTypes, - ActionTypes, ConversationUpdateTypes, MessageReactionTypes, MessageUpdateTypes, InvokeResponse, - TokenResponse, - OAuthCard, - Attachment, - CardAction, ) -from .. import CardFactory, MessageFactory +from ..turn_context import TurnContext +from ..agent import Agent +from ..authorization import Connections from .app_error import ApplicationError from .app_options import ApplicationOptions @@ -48,16 +42,7 @@ from .route import Route, RouteHandler from .state import TurnState from ..channel_service_adapter import ChannelServiceAdapter -from ..oauth import ( - FlowResponse, - FlowState, - FlowStateTag, -) -from .auth import ( - Authorization, - UserAuthorization, - AgenticAuthorization, -) +from .auth import Authorization from .typing_indicator import TypingIndicator logger = logging.getLogger(__name__) @@ -623,7 +608,14 @@ async def _on_turn(self, context: TurnContext): logger.debug("Initializing turn state") turn_state = await self._initialize_state(context) - if await self._auth.on_turn_auth_intercept(context, turn_state): + auth_intercepts, continuation_activity = await self._auth.on_turn_auth_intercept(context, turn_state) + if auth_intercepts: + if continuation_activity: + new_context = copy(context) + new_context.activity = continuation_activity + logger.info("Resending continuation activity %s", continuation_activity.text) + await self.on_turn(new_context) + await turn_state.save(context) return logger.debug("Running before turn middleware") diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py index 21312c76..ed5defa7 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py @@ -9,7 +9,7 @@ from logging import Logger from typing import Callable, List, Optional -from microsoft_agents.hosting.core.app.oauth import AuthHandler +from microsoft_agents.hosting.core.app.auth import AuthHandler from microsoft_agents.hosting.core.storage import Storage # from .auth import AuthOptions diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py index 66f2482c..3e7018f4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py @@ -4,6 +4,7 @@ from .user_authorization import UserAuthorization from .authorization_variant import AuthorizationVariant from .sign_in_state import SignInState +from .sign_in_response import SignInResponse __all__ = [ "Authorization", @@ -12,5 +13,6 @@ "AgenticAuthorization", "UserAuthorization", "AuthorizationVariant", - "SignInState" + "SignInState", + "SignInResponse", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py index dd26c18c..7ba6093f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py @@ -66,7 +66,7 @@ async def get_agentic_user_token(self, context: TurnContext, scopes: list[str]) async def sign_in(self, context: TurnContext, scopes: Optional[list[str]] = None) -> SignInResponse: scopes = scopes or [] token = await self.get_agentic_user_token(context, scopes) - return SignInResponse(token=token, tag=FlowStateTag.COMPLETED) if token else SignInResponse() + return SignInResponse(token_response=TokenResponse(token=token), tag=FlowStateTag.COMPLETED) if token else SignInResponse() async def sign_out(self, context: TurnContext) -> None: pass \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py index b13f4b87..ee69cd8d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py @@ -1,16 +1,16 @@ import logging from typing import TypeVar, Optional, Callable, Awaitable, Generic, cast import jwt -from copy import copy - -from microsoft_agents.activity import TokenResponse -from microsoft_agents.hosting.core import ( - TurnContext, - TurnState, - Connections -) + +from microsoft_agents.activity import Activity, TokenResponse + +from tests.hosting_core.app import auth + +from ...turn_context import TurnContext from ...storage import Storage +from ...authorization import Connections from ...oauth import FlowStateTag +from ..state import TurnState from .auth_handler import AuthHandler from .user_authorization import UserAuthorization from .agentic_authorization import AgenticAuthorization @@ -19,8 +19,8 @@ from .sign_in_response import SignInResponse AUTHORIZATION_TYPE_MAP: dict[str, type[AuthorizationVariant]] = { - "userauthorization": UserAuthorization, - "agenticauthorization": AgenticAuthorization + UserAuthorization.__name__.lower(): UserAuthorization, + AgenticAuthorization.__name__.lower(): AgenticAuthorization } logger = logging.getLogger(__name__) @@ -52,7 +52,6 @@ def __init__( self._storage = storage self._connection_manager = connection_manager - self._authorization_clients = {} auth_configuration: dict = kwargs.get("AGENTAPPLICATION", {}).get( "USERAUTHORIZATION", {} @@ -76,16 +75,17 @@ def __init__( ] = None self._authorization_variants = {} - self._init_auth_clients(self._auth_handlers) + self._init_auth_variants(self._auth_handlers) - def _init_auth_clients(self, auth_handlers: dict[str, AuthHandler]): + def _init_auth_variants(self, auth_handlers: dict[str, AuthHandler]): auth_types = set(handler.auth_type for handler in auth_handlers.values()) for auth_type in auth_types: + auth_type = auth_type.lower() associated_handlers = { auth_handler.name: auth_handler for auth_handler in self._auth_handlers.values() - if auth_handler.auth_type == auth_type + if auth_handler.auth_type.lower() == auth_type } self._authorization_variants[auth_type] = AUTHORIZATION_TYPE_MAP[auth_type]( @@ -94,17 +94,21 @@ def _init_auth_clients(self, auth_handlers: dict[str, AuthHandler]): auth_handlers=associated_handlers ) - def _sign_in_state_key(self, context: TurnContext) -> str: + def sign_in_state_key(self, context: TurnContext) -> str: return f"auth:SignInState:{context.activity.conversation.id}:{context.activity.from_property.id}" async def _load_sign_in_state(self, context: TurnContext) -> Optional[SignInState]: - key = self._sign_in_state_key(context) + key = self.sign_in_state_key(context) return (await self._storage.read([key], target_cls=SignInState)).get(key) async def _save_sign_in_state(self, context: TurnContext, state: SignInState) -> None: - key = self._sign_in_state_key(context) + key = self.sign_in_state_key(context) await self._storage.write({key: state}) + async def _delete_sign_in_state(self, context: TurnContext) -> None: + key = self.sign_in_state_key(context) + await self._storage.delete([key]) + @property def user_auth(self) -> UserAuthorization: return cast(UserAuthorization, self._resolve_auth_variant(UserAuthorization.__name__)) @@ -115,7 +119,8 @@ def agentic_auth(self) -> AgenticAuthorization: def _resolve_auth_variant(self, auth_variant: str) -> AuthorizationVariant: - if auth_variant not in self._authorization_clients: + auth_variant = auth_variant.lower() + if auth_variant not in self._authorization_variants: raise ValueError(f"Auth variant {auth_variant} not recognized or not configured.") return self._authorization_variants[auth_variant] @@ -134,31 +139,39 @@ async def _start_or_continue_sign_in(self, context: TurnContext, state: StateT, if sign_in_state.tokens.get(auth_handler_id): return SignInResponse(tag=FlowStateTag.COMPLETE, token=sign_in_state.tokens[auth_handler_id]) - sign_in_response = SignInResponse(tag=FlowStateTag.NOT_STARTED) - if auth_handler_id: - sign_in_response = await self._resolve_auth_variant(auth_handler_id).sign_in(context, auth_handler_id) + sign_in_response = await self._resolve_auth_variant(auth_handler_id).sign_in(context, auth_handler_id) - if sign_in_response.tag == FlowStateTag.COMPLETE: - if self._sign_in_success_handler: - await self._sign_in_success_handler(context, state, auth_handler_id) - token = sign_in_response.token - sign_in_state.tokens[auth_handler_id] = token - await self._save_sign_in_state(context, sign_in_state) - - elif sign_in_response.tag == FlowStateTag.FAILURE: - if self._sign_in_failure_handler: - await self._sign_in_failure_handler(context, state, auth_handler_id) - - elif sign_in_response.tag in [FlowStateTag.BEGIN, FlowStateTag.CONTINUE]: - sign_in_state.continuation_activity = context.activity - - return sign_in_response + if sign_in_response.tag == FlowStateTag.COMPLETE: + if self._sign_in_success_handler: + await self._sign_in_success_handler(context, state, auth_handler_id) + token = sign_in_response.token + sign_in_state.tokens[auth_handler_id] = token + await self._save_sign_in_state(context, sign_in_state) + + elif sign_in_response.tag == FlowStateTag.FAILURE: + if self._sign_in_failure_handler: + await self._sign_in_failure_handler(context, state, auth_handler_id) + + elif sign_in_response.tag in [FlowStateTag.BEGIN, FlowStateTag.CONTINUE]: + sign_in_state.continuation_activity = context.activity async def start_or_continue_sign_in(self, context: TurnContext, state: StateT, auth_handler_id: str) -> bool: sign_in_response = await self._start_or_continue_sign_in(context, state, auth_handler_id) return sign_in_response.tag in [FlowStateTag.NOT_STARTED, FlowStateTag.COMPLETE] + + async def sign_out(self, context: TurnContext, state: StateT, auth_handler_id=None) -> None: + sign_in_state = await self._load_sign_in_state(context) + if sign_in_state: + if not auth_handler_id: + for handler_id in sign_in_state.tokens.keys(): + await self._resolve_auth_variant(handler_id).sign_out(context, handler_id) + await self._delete_sign_in_state(context) + else: + await self._resolve_auth_variant(auth_handler_id).sign_out(context, auth_handler_id) + del sign_in_state.tokens[auth_handler_id] + await self._save_sign_in_state(context, sign_in_state) - async def on_turn_auth_intercept(self, context: TurnContext, state: StateT, continue_turn_callback: Callable[[TurnContext], Awaitable[None]]) -> bool: + async def on_turn_auth_intercept(self, context: TurnContext, state: StateT) -> tuple[bool, Optional[Activity]]: """Intercepts the turn to check for active authentication flows. Returns true if the rest of the turn should be skipped because auth did not finish. @@ -174,18 +187,12 @@ async def on_turn_auth_intercept(self, context: TurnContext, state: StateT, cont auth_handler_id = sign_in_state.active_handler() if auth_handler_id: assert sign_in_state.continuation_activity is not None - continuation_activity = sign_in_state.continuation_activity.model_copy() + continuation_activity = None sign_in_response = await self._start_or_continue_sign_in(context, state, auth_handler_id) - if sign_in_response.tag == FlowStateTag.COMPLETE: - - new_context = copy(context) - new_context.activity = continuation_activity - logger.info("Resending continuation activity %s", continuation_activity.text) - await continue_turn_callback(new_context) - await state.save(context) - return True # continue _on_turn - return False + continuation_activity = sign_in_state.continuation_activity.model_copy() + return True, continuation_activity # continue _on_turn + return False, None async def get_token( self, context: TurnContext, auth_handler_id: str @@ -201,10 +208,10 @@ async def get_token( The token response from the OAuth provider. """ sign_in_state = await self._load_sign_in_state(context) - if not sign_in_state: - raise Exception("No active sign-in state found for the user.") - token = sign_in_state.tokens.get(auth_handler_id) - return TokenResponse(token=token) if token else TokenResponse() + if not sign_in_state or not sign_in_state.tokens.get(auth_handler_id): + return TokenResponse() + token = sign_in_state.tokens[auth_handler_id] + return TokenResponse(token=token) async def exchange_token( self, @@ -229,8 +236,8 @@ async def exchange_token( if token_response and self._is_exchangeable(token_response.token): logger.debug("Token is exchangeable, performing OBO flow") return await self._handle_obo(token_response.token, scopes, auth_handler_id) - - return TokenResponse() + + return token_response def _is_exchangeable(self, token: str) -> bool: """ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py index 53bb955e..f381dd00 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py @@ -1,9 +1,11 @@ from typing import Optional from dataclasses import dataclass +from microsoft_agents.activity import TokenResponse + from ...oauth import FlowStateTag @dataclass class SignInResponse: - token: Optional[str] = None + token_response: TokenResponse = TokenResponse() tag: FlowStateTag = FlowStateTag.FAILURE \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py index 46f4228b..80d00418 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py @@ -80,7 +80,7 @@ async def sign_in(self, context: TurnContext, auth_handler_id: str) -> SignInRes ) sign_in_response = SignInResponse( - token=flow_response.token_response.token if flow_response.token_response else None, + token_response=flow_response.token_response, tag=flow_response.flow_state.tag ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py index dd029381..c188378c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py @@ -110,10 +110,9 @@ async def begin_or_continue_flow( logger.info("Saving OAuth flow state to storage") await flow_storage_client.write(flow_response.flow_state) - if prev_tag != flow_response.flow_state.tag and flow_response.flow_state.tag == FlowStateTag.COMPLETED: - # Clear the flow state on completion - flow_response.continuation_activity = - await flow_storage_client.delete(auth_handler_id) + # if prev_tag != flow_response.flow_state.tag and flow_response.flow_state.tag == FlowStateTag.COMPLETE: + # # Clear the flow state on completion + # await flow_storage_client.delete(auth_handler_id) return flow_response diff --git a/tests/_common/data/__init__.py b/tests/_common/data/__init__.py index 11754a85..6695407c 100644 --- a/tests/_common/data/__init__.py +++ b/tests/_common/data/__init__.py @@ -5,6 +5,8 @@ ) from .test_storage_data import TEST_STORAGE_DATA from .test_flow_data import TEST_FLOW_DATA +from .test_auth_config import TEST_ENV_DICT, TEST_ENV +from .test_agentic_auth_config import TEST_AGENTIC_ENV_DICT, TEST_AGENTIC_ENV __all__ = [ "TEST_DEFAULTS", @@ -12,4 +14,8 @@ "TEST_STORAGE_DATA", "TEST_FLOW_DATA", "create_test_auth_handler", + "TEST_ENV_DICT", + "TEST_ENV", + "TEST_AGENTIC_ENV_DICT", + "TEST_AGENTIC_ENV", ] diff --git a/tests/_common/data/test_agentic_auth_config.py b/tests/_common/data/test_agentic_auth_config.py new file mode 100644 index 00000000..fb473f17 --- /dev/null +++ b/tests/_common/data/test_agentic_auth_config.py @@ -0,0 +1,39 @@ +from microsoft_agents.activity import load_configuration_from_env + +from .test_defaults import TEST_DEFAULTS + +DEFAULTS = TEST_DEFAULTS() + +_TEST_AGENTIC_ENV_RAW = """ +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME={abs_oauth_connection_name} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__OBOCONNECTIONNAME={obo_connection_name} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__TITLE={auth_handler_title} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__TEXT={auth_handler_text} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__TYPE=UserAuthorization +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME={agentic_abs_oauth_connection_name} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__OBOCONNECTIONNAME={agentic_obo_connection_name} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__TITLE={agentic_auth_handler_title} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__TEXT={agentic_auth_handler_text} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__TYPE=AgenticAuthorization +""".format( + abs_oauth_connection_name=DEFAULTS.abs_oauth_connection_name, + obo_connection_name=DEFAULTS.obo_connection_name, + auth_handler_id=DEFAULTS.auth_handler_id, + auth_handler_title=DEFAULTS.auth_handler_title, + auth_handler_text=DEFAULTS.auth_handler_text, + agentic_abs_oauth_connection_name=DEFAULTS.agentic_abs_oauth_connection_name, + agentic_obo_connection_name=DEFAULTS.agentic_obo_connection_name, + agentic_auth_handler_id=DEFAULTS.agentic_auth_handler_id, + agentic_auth_handler_title=DEFAULTS.agentic_auth_handler_title, + agentic_auth_handler_text=DEFAULTS.agentic_auth_handler_text) + +def TEST_AGENTIC_ENV(): + lines = _TEST_AGENTIC_ENV_RAW.strip().split("\n") + env = {} + for line in lines: + key, value = line.split("=", 1) + env[key.strip()] = value.strip() + return env + +def TEST_AGENTIC_ENV_DICT(): + return load_configuration_from_env(TEST_AGENTIC_ENV()) \ No newline at end of file diff --git a/tests/_common/data/test_auth_config.py b/tests/_common/data/test_auth_config.py new file mode 100644 index 00000000..a513874b --- /dev/null +++ b/tests/_common/data/test_auth_config.py @@ -0,0 +1,29 @@ +from microsoft_agents.activity import load_configuration_from_env + +from .test_defaults import TEST_DEFAULTS + +DEFAULTS = TEST_DEFAULTS() + +_TEST_ENV_RAW = """ +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME={abs_oauth_connection_name} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__OBOCONNECTIONNAME={obo_connection_name} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__TITLE={auth_handler_title} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__TEXT={auth_handler_text} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__TYPE=UserAuthorization +""".format( + abs_oauth_connection_name=DEFAULTS.abs_oauth_connection_name, + obo_connection_name=DEFAULTS.obo_connection_name, + auth_handler_id=DEFAULTS.auth_handler_id, + auth_handler_title=DEFAULTS.auth_handler_title, + auth_handler_text=DEFAULTS.auth_handler_text) + +def TEST_ENV(): + lines = _TEST_ENV_RAW.strip().split("\n") + env = {} + for line in lines: + key, value = line.split("=", 1) + env[key.strip()] = value.strip() + return env + +def TEST_ENV_DICT(): + return load_configuration_from_env(TEST_ENV()) \ No newline at end of file diff --git a/tests/_common/data/test_defaults.py b/tests/_common/data/test_defaults.py index 7422d253..231868cb 100644 --- a/tests/_common/data/test_defaults.py +++ b/tests/_common/data/test_defaults.py @@ -14,7 +14,20 @@ def __init__(self): self.user_id = "__user_id" self.bot_url = "https://botframework.com" self.ms_app_id = "__ms_app_id" - self.abs_oauth_connection_name = "__connection_name" - self.missing_abs_oauth_connection_name = "__missing_connection_name" + + self.abs_oauth_connection_name = "connection_name" + self.obo_connection_name = "SERVICE_CONNECTION" + self.auth_handler_id = "auth_handler_id" + self.auth_handler_title = "auth_handler_title" + self.auth_handler_text = "auth_handler_text" + + self.agentic_abs_oauth_connection_name = "agentic_connection_name" + self.agentic_obo_connection_name = "SERVICE_CONNECTION" + self.agentic_auth_handler_id = "agentic_auth_handler_id" + self.agentic_auth_handler_title = "agentic_auth_handler_title" + self.agentic_auth_handler_text = "agentic_auth_handler_text" + + + self.missing_abs_oauth_connection_name = "missing_connection_name" self.auth_handlers = [AuthHandler()] diff --git a/tests/_common/testing_objects/__init__.py b/tests/_common/testing_objects/__init__.py index 7e36b7e2..8b4aead4 100644 --- a/tests/_common/testing_objects/__init__.py +++ b/tests/_common/testing_objects/__init__.py @@ -6,6 +6,9 @@ mock_class_OAuthFlow, mock_UserTokenClient, mock_class_UserTokenClient, + mock_class_UserAuthorization, + mock_class_AgenticAuthorization, + mock_class_Authorization ) from .testing_authorization import TestingAuthorization @@ -26,4 +29,7 @@ "TestingTokenProvider", "TestingUserTokenClient", "TestingAdapter", + "mock_class_UserAuthorization", + "mock_class_AgenticAuthorization", + "mock_class_Authorization", ] diff --git a/tests/_common/testing_objects/mocks/__init__.py b/tests/_common/testing_objects/mocks/__init__.py index 786a79c8..0b6379c8 100644 --- a/tests/_common/testing_objects/mocks/__init__.py +++ b/tests/_common/testing_objects/mocks/__init__.py @@ -1,10 +1,19 @@ from .mock_msal_auth import MockMsalAuth from .mock_oauth_flow import mock_OAuthFlow, mock_class_OAuthFlow from .mock_user_token_client import mock_UserTokenClient, mock_class_UserTokenClient +from .mock_authorization import ( + mock_class_UserAuthorization, + mock_class_AgenticAuthorization, + mock_class_Authorization +) __all__ = [ "MockMsalAuth", "mock_OAuthFlow", "mock_class_OAuthFlow", "mock_UserTokenClient", + "mock_class_UserTokenClient", + "mock_class_UserAuthorization", + "mock_class_AgenticAuthorization", + "mock_class_Authorization" ] diff --git a/tests/_common/testing_objects/mocks/mock_authorization.py b/tests/_common/testing_objects/mocks/mock_authorization.py new file mode 100644 index 00000000..d9268c84 --- /dev/null +++ b/tests/_common/testing_objects/mocks/mock_authorization.py @@ -0,0 +1,19 @@ +from microsoft_agents.hosting.core import ( + Authorization, + UserAuthorization, + AgenticAuthorization +) +from microsoft_agents.hosting.core.app.auth import SignInResponse + +def mock_class_UserAuthorization(mocker, sign_in_return=None): + if sign_in_return is None: + sign_in_return = SignInResponse() + mocker.patch(UserAuthorization, sign_in=mocker.AsyncMock(return_value=sign_in_return)) + +def mock_class_AgenticAuthorization(mocker, sign_in_return=None): + if sign_in_return is None: + sign_in_return = SignInResponse() + mocker.patch(AgenticAuthorization, sign_in=mocker.AsyncMock(return_value=sign_in_return)) + +def mock_class_Authorization(mocker, start_or_continue_sign_in_return=False): + mocker.patch(Authorization, start_or_continue_sign_in=mocker.AsyncMock(return_value=start_or_continue_sign_in_return)) \ No newline at end of file diff --git a/tests/activity/test_activity.py b/tests/activity/test_activity.py index 886dcabe..fe98a6dc 100644 --- a/tests/activity/test_activity.py +++ b/tests/activity/test_activity.py @@ -16,6 +16,7 @@ AIEntity, Place, Thing, + RoleTypes, ) from tests.activity._common.my_channel_data import MyChannelData @@ -369,4 +370,15 @@ def test_get_mentions(self): Entity(type="mention", text="Another mention"), ] - # robrandao: TODO -> is_agentic \ No newline at end of file + @pytest.mark.parametrize("role, expected", [ + [RoleTypes.user, False], + [RoleTypes.agent, False], + [RoleTypes.skill, False], + [RoleTypes.agentic_user, True], + [RoleTypes.agentic_identity, True] + ]) + def test_is_agentic(self, role, expected): + activity = Activity(type="message", + recipient=ChannelAccount(id="bot", name="bot", role=role) + ) + assert activity.is_agentic() == expected \ No newline at end of file diff --git a/tests/hosting_core/app/auth/__init__.py b/tests/hosting_core/app/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/app/auth/_common.py b/tests/hosting_core/app/auth/_common.py new file mode 100644 index 00000000..67939bc3 --- /dev/null +++ b/tests/hosting_core/app/auth/_common.py @@ -0,0 +1,45 @@ +from microsoft_agents.activity import ( + Activity, + ActivityTypes +) + +from microsoft_agents.hosting.core import ( + TurnContext +) + +from tests._common.data import TEST_DEFAULTS +from tests._common.testing_objects import mock_UserTokenClient + +DEFAULTS = TEST_DEFAULTS() + +def testing_Activity(): + return Activity( + type=ActivityTypes.message, + channel_id=DEFAULTS.channel_id, + from_property={"id": DEFAULTS.user_id}, + text="Hello, World!", + ) + +def testing_TurnContext( + mocker, + channel_id=DEFAULTS.channel_id, + user_id=DEFAULTS.user_id, + user_token_client=None, +): + if not user_token_client: + user_token_client = mock_UserTokenClient(mocker) + + turn_context = mocker.Mock() + turn_context.activity.channel_id = channel_id + turn_context.activity.from_property.id = user_id + turn_context.activity.type = ActivityTypes.message + turn_context.adapter.USER_TOKEN_CLIENT_KEY = "__user_token_client" + turn_context.adapter.AGENT_IDENTITY_KEY = "__agent_identity_key" + agent_identity = mocker.Mock() + agent_identity.claims = {"aud": DEFAULTS.ms_app_id} + turn_context.turn_state = { + "__user_token_client": user_token_client, + "__agent_identity_key": agent_identity, + } + return turn_context + \ No newline at end of file diff --git a/tests/hosting_core/app/auth/_env.py b/tests/hosting_core/app/auth/_env.py new file mode 100644 index 00000000..e6c1056e --- /dev/null +++ b/tests/hosting_core/app/auth/_env.py @@ -0,0 +1,18 @@ +from tests._common.data import TEST_DEFAULTS + +DEFAULTS = TEST_DEFAULTS() + +def ENV_CONFIG(): + return { + "AGENTAPPLICATION": { + "USERAUTHORIZATION": { + "HANDLERS": { + DEFAULTS.connection_name: { + "SETTINGS": { + AZUREBOTOAUTHCONNECTIONNAME + } + } + } + } + } + } \ No newline at end of file diff --git a/tests/hosting_core/app/auth/test_agentic_authorization.py b/tests/hosting_core/app/auth/test_agentic_authorization.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/app/auth/test_auth_handler.py b/tests/hosting_core/app/auth/test_auth_handler.py new file mode 100644 index 00000000..4aebdea0 --- /dev/null +++ b/tests/hosting_core/app/auth/test_auth_handler.py @@ -0,0 +1,22 @@ +import pytest + +from microsoft_agents.hosting.core import AuthHandler + +from tests._common.data import TEST_DEFAULTS, TEST_ENV_DICT + +DEFAULTS = TEST_DEFAULTS() +ENV_DICT = TEST_ENV_DICT() + +class TestAuthHandler: + + @pytest.fixture + def auth_setting(self): + return ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][DEFAULTS.auth_handler_id]["SETTINGS"] + + def test_init(self, auth_setting): + auth_handler = AuthHandler(DEFAULTS.auth_handler_id, **auth_setting) + assert auth_handler.name == DEFAULTS.auth_handler_id + assert auth_handler.title == DEFAULTS.auth_handler_title + assert auth_handler.text == DEFAULTS.auth_handler_text + assert auth_handler.obo_connection_name == DEFAULTS.obo_connection_name + assert auth_handler.abs_oauth_connection_name == DEFAULTS.abs_oauth_connection_name \ No newline at end of file diff --git a/tests/hosting_core/app/auth/test_authorization.py b/tests/hosting_core/app/auth/test_authorization.py new file mode 100644 index 00000000..755325db --- /dev/null +++ b/tests/hosting_core/app/auth/test_authorization.py @@ -0,0 +1,195 @@ +import pytest +from datetime import datetime +import jwt + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + TokenResponse +) + +from microsoft_agents.hosting.core import ( + FlowStorageClient, + FlowErrorTag, + FlowStateTag, + FlowState, + FlowResponse, + OAuthFlow, + Authorization, + UserAuthorization, + MemoryStorage, + AuthHandler, + FlowStateTag, +) +from microsoft_agents.hosting.core.app.auth import SignInState + +from tests._common.storage.utils import StorageBaseline + +# test constants +from tests._common.data import ( + TEST_FLOW_DATA, + TEST_AUTH_DATA, + TEST_STORAGE_DATA, + TEST_DEFAULTS, + TEST_ENV_DICT, + TEST_AGENTIC_ENV_DICT, + create_test_auth_handler, +) +from tests._common.fixtures import FlowStateFixtures +from tests._common.testing_objects import ( + TestingConnectionManager as MockConnectionManager, + mock_class_OAuthFlow, + mock_UserTokenClient, + mock_class_UserAuthorization, + mock_class_AgenticAuthorization, + mock_class_Authorization +) +from tests.hosting_core._common import flow_state_eq + +from ._common import testing_TurnContext, testing_Activity + +DEFAULTS = TEST_DEFAULTS() +FLOW_DATA = TEST_FLOW_DATA() +STORAGE_DATA = TEST_STORAGE_DATA() +ENV_DICT = TEST_ENV_DICT() +AGENTIC_ENV_DICT = TEST_AGENTIC_ENV_DICT() + +def get_sign_in_state(auth: Authorization, storage: Storage, context: TurnContext) -> Optional[SignInState]: + key = auth.get_sign_in_state_key(context) + return storage.read([key], target_cls=SignInState).get(key) + +def set_sign_in_state(auth: Authorization, storage: Storage, context: TurnContext, state: SignInState): + key = auth.get_sign_in_state_key(context) + storage.write({key: state}) + + +class TestEnv(FlowStateFixtures): + def setup_method(self): + self.TurnContext = testing_TurnContext + self.UserTokenClient = mock_UserTokenClient + self.ConnectionManager = lambda mocker: MockConnectionManager() + + @pytest.fixture + def context(self, mocker): + return self.TurnContext(mocker) + + @pytest.fixture + def activity(self): + return testing_Activity() + + @pytest.fixture + def baseline_storage(self): + return StorageBaseline(TEST_STORAGE_DATA().dict) + + @pytest.fixture + def storage(self): + return MemoryStorage(STORAGE_DATA.get_init_data()) + + @pytest.fixture + def connection_manager(self, mocker): + return self.ConnectionManager(mocker) + + @pytest.fixture + def auth_handlers(self): + return TEST_AUTH_DATA().auth_handlers + + @pytest.fixture + def authorization(self, connection_manager, storage): + return Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) + + @pytest.fixture(params=[ENV_DICT, AGENTIC_ENV_DICT]) + def env_dict(self, request): + return request.param + +class TestAuthorizationSetup(TestEnv): + + def test_init_user_auth(self, connection_manager, storage, env_dict): + auth = Authorization(storage, connection_manager, **env_dict) + assert auth.user_auth is not None + + def test_init_agentic_auth_not_configured(self, connection_manager, storage): + auth = Authorization(storage, connection_manager, **ENV_DICT) + with pytest.raises(ValueError): + agentic_auth = auth.agentic_auth + + def test_init_agentic_auth(self, connection_manager, storage): + auth = Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) + assert auth.agentic_auth is not None + + @pytest.mark.parametrize("auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id]) + def test_resolve_handler(self, connection_manager, storage, auth_handler_id): + auth = Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) + handler_config = AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][auth_handler_id] + auth.resolve_handler(auth_handler_id) == AuthHandler(auth_handler_id, **handler_config) + + def test_sign_in_state_key(self, mocker, connection_manager, storage): + auth = Authorization(storage, connection_manager, **ENV_DICT) + context = self.TurnContext(mocker) + key = auth.sign_in_state_key(context) + assert key == f"auth:SignInState:{DEFAULTS.channel_id}:{DEFAULTS.user_id}" + +class TestAuthorizationUsage(TestEnv): + + @pytest.mark.asyncio + async def test_get_token(self, mocker, storage, authorization): + context = self.TurnContext(mocker) + token_response = await authorization.get_token(context, DEFAULTS.auth_handler_id) + assert not token_response + + + @pytest.mark.asyncio + async def test_get_token_with_sign_in_state_empty(self, mocker, storage, authorization, context): + # setup + key = authorization.get_sign_in_state_key(context) + storage.write({key: SignInState( + tokens={DEFAULTS.auth_handler_id: "", DEFAULTS.agentic_auth_handler_id: ""} + )}) + + # test + token_response = await authorization.get_token(context, DEFAULTS.auth_handler_id) + assert not token_response + + @pytest.mark.asyncio + async def test_get_token_with_sign_in_state_empty_alt(self, mocker, storage, authorization, context): + # setup + key = authorization.get_sign_in_state_key(context) + storage.write({key: SignInState( + tokens={DEFAULTS.auth_handler_id: "token", DEFAULTS.agentic_auth_handler_id: ""} + )}) + + # test + token_response = await authorization.get_token(context, DEFAULTS.agentic_auth_handler_id) + assert not token_response + + @pytest.mark.asyncio + async def test_get_token_with_sign_in_state_valid(self, mocker, storage, authorization): + # setup + context = self.TurnContext(mocker) + key = authorization.get_sign_in_state_key(context) + storage.write({key: SignInState( + tokens={DEFAULTS.auth_handler_id: "valid_token"} + )}) + + # test + token_response = await authorization.get_token(context, DEFAULTS.auth_handler_id) + assert token_response.token == "valid_token" + + def test_start_or_continue_sign_in_cached(self, storage, authorization, context, activity): + # setup + initial_state = SignInState( + tokens={DEFAULTS.auth_handler_id: "valid_token"}, continuation_activity=activity + ) + set_sign_in_state(authorization, storage, context, initial_state) + assert await authorization.start_or_continue_sign_in(context, None, DEFAULTS.auth_handler_id) + assert get_sign_in_state(authorization, storage, context) == initial_state + + def test_start_or_continue_sign_in_no_state_to_complete(self, mocker, storage, authorization, context): + mock_class_UserAuthorization(mocker, sign_in_return=SignInResponse( + token_response=TokenResponse(token=DEFAULTS.token), + tag=FlowStateTag.COMPLETE + )) + await authorization.start_or_continue_sign_in(context, None, DEFAULTS.auth_handler_id) + + + assert not await authorization.start_or_continue_sign_in(context, None, DEFAULTS.auth_handler_id) + assert get_sign_in_state(authorization, storage, context) is None \ No newline at end of file diff --git a/tests/hosting_core/app/auth/test_authorization_variant.py b/tests/hosting_core/app/auth/test_authorization_variant.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/app/auth/test_sign_in_state.py b/tests/hosting_core/app/auth/test_sign_in_state.py new file mode 100644 index 00000000..a95fa440 --- /dev/null +++ b/tests/hosting_core/app/auth/test_sign_in_state.py @@ -0,0 +1,57 @@ +import pytest + +from microsoft_agents.hosting.core.app.auth import SignInState + +from ._common import testing_Activity, testing_TurnContext + +class TestSignInState: + + def test_init(self): + state = SignInState() + assert state.tokens == {} + assert state.continuation_activity is None + + def test_init_with_values(self): + activity = testing_Activity() + state = SignInState({ + "handler": "some_token" + }, activity) + assert state.tokens == {"handler": "some_token"} + assert state.continuation_activity == activity + + def test_from_json_to_store_item(self): + tokens = { + "some_handler": "some_token", + "other_handler": "other_token" + } + activity = testing_Activity() + data = { + "tokens": tokens, + "continuation_activity": activity + } + state = SignInState.from_json_to_store_item(data) + assert state.tokens == tokens + assert state.continuation_activity == activity + + def test_store_item_to_json(self): + tokens = { + "some_handler": "some_token", + "other_handler": "other_token" + } + activity = testing_Activity() + state = SignInState(tokens, activity) + json_data = state.store_item_to_json() + assert json_data["tokens"] == tokens + assert json_data["continuation_activity"] == activity + + @pytest.mark.parametrize("tokens, active_handler", [ + [{}, ""], + [{"some_handler": ""}, "some_handler"], + [{"some_handler": "some_token"}, ""], + [{"some_handler": "some_value", "other_handler": ""}, "other_handler"], + [{"some_handler": "some_value", "other_handler": "other_value"}, ""], + [{"some_handler": "some_value", "another_handler": "", "wow": "wow"}, "another_handler"], + ]) + def test_active_handler(self, tokens, active_handler): + state = SignInState(tokens) + assert state.active_handler() == active_handler \ No newline at end of file diff --git a/tests/hosting_core/app/auth/test_user_authorization.py b/tests/hosting_core/app/auth/test_user_authorization.py new file mode 100644 index 00000000..9ef98be7 --- /dev/null +++ b/tests/hosting_core/app/auth/test_user_authorization.py @@ -0,0 +1,540 @@ +# import pytest +# from datetime import datetime +# import jwt + +# from microsoft_agents.activity import ActivityTypes, TokenResponse + +# from microsoft_agents.hosting.core import ( +# FlowStorageClient, +# FlowErrorTag, +# FlowStateTag, +# FlowState, +# FlowResponse, +# OAuthFlow, +# UserAuthorization, +# MemoryStorage, +# ) + +# from tests._common.storage.utils import StorageBaseline + +# # test constants +# from tests._common.data import ( +# TEST_FLOW_DATA, +# TEST_AUTH_DATA, +# TEST_STORAGE_DATA, +# TEST_DEFAULTS, +# create_test_auth_handler, +# ) +# from tests._common.fixtures import FlowStateFixtures +# from tests._common.testing_objects import ( +# TestingConnectionManager as MockConnectionManager, +# mock_class_OAuthFlow, +# mock_UserTokenClient, +# ) +# from tests.hosting_core._common import flow_state_eq + +# DEFAULTS = TEST_DEFAULTS() +# FLOW_DATA = TEST_FLOW_DATA() +# STORAGE_DATA = TEST_STORAGE_DATA() + + +# def testing_TurnContext( +# mocker, +# channel_id=DEFAULTS.channel_id, +# user_id=DEFAULTS.user_id, +# user_token_client=None, +# ): +# if not user_token_client: +# user_token_client = mock_UserTokenClient(mocker) + +# turn_context = mocker.Mock() +# turn_context.activity.channel_id = channel_id +# turn_context.activity.from_property.id = user_id +# turn_context.activity.type = ActivityTypes.message +# turn_context.adapter.USER_TOKEN_CLIENT_KEY = "__user_token_client" +# turn_context.adapter.AGENT_IDENTITY_KEY = "__agent_identity_key" +# agent_identity = mocker.Mock() +# agent_identity.claims = {"aud": DEFAULTS.ms_app_id} +# turn_context.turn_state = { +# "__user_token_client": user_token_client, +# "__agent_identity_key": agent_identity, +# } +# return turn_context + + +# class TestEnv(FlowStateFixtures): +# def setup_method(self): +# self.TurnContext = testing_TurnContext +# self.UserTokenClient = mock_UserTokenClient +# self.ConnectionManager = lambda mocker: MockConnectionManager() + +# @pytest.fixture +# def turn_context(self, mocker): +# return self.TurnContext(mocker) + +# @pytest.fixture +# def baseline_storage(self): +# return StorageBaseline(TEST_STORAGE_DATA().dict) + +# @pytest.fixture +# def storage(self): +# return MemoryStorage(STORAGE_DATA.get_init_data()) + +# @pytest.fixture +# def connection_manager(self, mocker): +# return self.ConnectionManager(mocker) + +# @pytest.fixture +# def auth_handlers(self): +# return TEST_AUTH_DATA().auth_handlers + +# @pytest.fixture +# def user_authorization(self, connection_manager, storage, auth_handlers): +# return UserAuthorization(storage, connection_manager, auth_handlers) + + +# class TestAuthorization(TestEnv): +# def test_init_configuration_variants( +# self, storage, connection_manager, auth_handlers +# ): +# """Test initialization of authorization with different configuration variants.""" +# AGENTAPPLICATION = { +# "USERAUTHORIZATION": { +# "HANDLERS": { +# handler_name: { +# "SETTINGS": { +# "title": handler.title, +# "text": handler.text, +# "abs_oauth_connection_name": handler.abs_oauth_connection_name, +# "obo_connection_name": handler.obo_connection_name, +# } +# } +# for handler_name, handler in auth_handlers.items() +# } +# } +# } +# auth_with_config_obj = UserAuthorization( +# storage, +# connection_manager, +# auth_handlers=None, +# AGENTAPPLICATION=AGENTAPPLICATION, +# ) +# auth_with_handlers_list = UserAuthorization( +# storage, connection_manager, auth_handlers=auth_handlers +# ) +# for auth_handler_name in auth_handlers.keys(): +# auth_handler_a = auth_with_config_obj.resolve_handler(auth_handler_name) +# auth_handler_b = auth_with_handlers_list.resolve_handler(auth_handler_name) + +# assert auth_handler_a.name == auth_handler_b.name +# assert auth_handler_a.title == auth_handler_b.title +# assert auth_handler_a.text == auth_handler_b.text +# assert ( +# auth_handler_a.abs_oauth_connection_name +# == auth_handler_b.abs_oauth_connection_name +# ) +# assert ( +# auth_handler_a.obo_connection_name == auth_handler_b.obo_connection_name +# ) + +# @pytest.mark.asyncio +# @pytest.mark.parametrize( +# "auth_handler_id, channel_id, user_id", +# [["missing", "webchat", "Alice"], ["handler", "teams", "Bob"]], +# ) +# async def test_open_flow_value_error( +# self, mocker, user_authorization, auth_handler_id, channel_id, user_id +# ): +# """Test opening a flow with a missing auth handler.""" +# context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) +# with pytest.raises(ValueError): +# async with user_authorization.open_flow(context, auth_handler_id): +# pass + +# @pytest.mark.asyncio +# @pytest.mark.parametrize( +# "auth_handler_id, channel_id, user_id", +# [ +# ["", "webchat", "Alice"], +# ["graph", "teams", "Bob"], +# ["slack", "webchat", "Chuck"], +# ], +# ) +# async def test_open_flow_readonly( +# self, +# mocker, +# storage, +# connection_manager, +# auth_handlers, +# auth_handler_id, +# channel_id, +# user_id, +# ): +# """Test opening a flow and not modifying it.""" +# # setup +# context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) +# auth = UserAuthorization(storage, connection_manager, auth_handlers) +# flow_storage_client = FlowStorageClient(channel_id, user_id, storage) + +# # test +# async with auth.open_flow(context, auth_handler_id) as flow: +# expected_flow_state = flow.flow_state + +# # verify +# actual_flow_state = await flow_storage_client.read( +# auth.resolve_handler(auth_handler_id).name +# ) +# assert actual_flow_state == expected_flow_state + +# @pytest.mark.asyncio +# async def test_open_flow_success_modified_complete_flow( +# self, +# mocker, +# storage, +# connection_manager, +# auth_handlers, +# ): +# # mock +# channel_id = "teams" +# user_id = "Alice" +# auth_handler_id = "graph" + +# user_token_client = self.UserTokenClient( +# mocker, get_token_return=DEFAULTS.token +# ) +# context = self.TurnContext( +# mocker, +# channel_id=channel_id, +# user_id=user_id, +# user_token_client=user_token_client, +# ) + +# # setup +# context.activity.type = ActivityTypes.message +# context.activity.text = "123456" + +# auth = UserAuthorization(storage, connection_manager, auth_handlers) +# flow_storage_client = FlowStorageClient(channel_id, user_id, storage) + +# # test +# async with auth.open_flow(context, auth_handler_id) as flow: +# expected_flow_state = flow.flow_state +# expected_flow_state.tag = FlowStateTag.COMPLETE +# expected_flow_state.user_token = DEFAULTS.token + +# flow_response = await flow.begin_or_continue_flow(context.activity) +# res_flow_state = flow_response.flow_state + +# # verify +# actual_flow_state = await flow_storage_client.read(auth_handler_id) +# expected_flow_state.expiration = actual_flow_state.expiration +# assert flow_state_eq(actual_flow_state, expected_flow_state) +# assert flow_state_eq(res_flow_state, expected_flow_state) + +# @pytest.mark.asyncio +# async def test_open_flow_success_modified_failure( +# self, +# mocker, +# storage, +# connection_manager, +# auth_handlers, +# ): +# # setup +# channel_id = "teams" +# user_id = "Bob" +# auth_handler_id = "slack" + +# context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) +# context.activity.text = "invalid_magic_code" + +# auth = UserAuthorization(storage, connection_manager, auth_handlers) +# flow_storage_client = FlowStorageClient(channel_id, user_id, storage) + +# # test +# async with auth.open_flow(context, auth_handler_id) as flow: +# expected_flow_state = flow.flow_state +# expected_flow_state.tag = FlowStateTag.FAILURE +# expected_flow_state.attempts_remaining = 0 + +# flow_response = await flow.begin_or_continue_flow(context.activity) +# res_flow_state = flow_response.flow_state + +# # verify +# actual_flow_state = await flow_storage_client.read(auth_handler_id) + +# assert flow_response.flow_error_tag == FlowErrorTag.MAGIC_FORMAT +# assert flow_state_eq(res_flow_state, expected_flow_state) +# assert flow_state_eq(actual_flow_state, expected_flow_state) + +# @pytest.mark.asyncio +# async def test_open_flow_success_modified_signout( +# self, mocker, storage, connection_manager, auth_handlers +# ): +# # setup +# channel_id = "webchat" +# user_id = "Alice" +# auth_handler_id = "graph" + +# context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) + +# auth = UserAuthorization(storage, connection_manager, auth_handlers) +# flow_storage_client = FlowStorageClient(channel_id, user_id, storage) + +# # test +# async with auth.open_flow(context, auth_handler_id) as flow: +# expected_flow_state = flow.flow_state +# expected_flow_state.tag = FlowStateTag.NOT_STARTED +# expected_flow_state.user_token = "" + +# await flow.sign_out() + +# # verify +# actual_flow_state = await flow_storage_client.read(auth_handler_id) +# assert flow_state_eq(actual_flow_state, expected_flow_state) + +# @pytest.mark.asyncio +# async def test_get_token_success(self, mocker, user_authorization): +# user_token_client = self.UserTokenClient(mocker, get_token_return="token") +# context = self.TurnContext( +# mocker, +# channel_id="__channel_id", +# user_id="__user_id", +# user_token_client=user_token_client, +# ) +# assert await user_authorization.get_token(context, "slack") == TokenResponse( +# token="token" +# ) +# user_token_client.user_token.get_token.assert_called_once() + +# @pytest.mark.asyncio +# async def test_get_token_empty_response(self, mocker, user_authorization): +# user_token_client = self.UserTokenClient( +# mocker, get_token_return=TokenResponse() +# ) +# context = self.TurnContext( +# mocker, +# channel_id="__channel_id", +# user_id="__user_id", +# user_token_client=user_token_client, +# ) +# assert await user_authorization.get_token(context, "graph") == TokenResponse() +# user_token_client.user_token.get_token.assert_called_once() + +# @pytest.mark.asyncio +# async def test_get_token_error( +# self, turn_context, storage, connection_manager, auth_handlers +# ): +# auth = UserAuthorization(storage, connection_manager, auth_handlers) +# with pytest.raises(ValueError): +# await auth.get_token( +# turn_context, DEFAULTS.missing_abs_oauth_connection_name +# ) + +# @pytest.mark.asyncio +# async def test_exchange_token_no_token(self, mocker, turn_context, user_authorization): +# mock_class_OAuthFlow(mocker, get_user_token_return=TokenResponse()) +# res = await user_authorization.exchange_token(turn_context, ["scope"], "github") +# assert res == TokenResponse() + +# @pytest.mark.asyncio +# async def test_exchange_token_not_exchangeable( +# self, mocker, turn_context, user_authorization +# ): +# token = jwt.encode({"aud": "invalid://botframework.test.api"}, "") +# mock_class_OAuthFlow( +# mocker, +# get_user_token_return=TokenResponse(connection_name="github", token=token), +# ) +# res = await user_authorization.exchange_token(turn_context, ["scope"], "github") +# assert res == TokenResponse() + +# @pytest.mark.asyncio +# async def test_exchange_token_valid_exchangeable(self, mocker, user_authorization): +# # setup +# token = jwt.encode({"aud": "api://botframework.test.api"}, "") +# mock_class_OAuthFlow( +# mocker, +# get_user_token_return=TokenResponse(connection_name="github", token=token), +# ) +# user_token_client = self.UserTokenClient( +# mocker, get_token_return="github-obo-connection-obo-token" +# ) +# turn_context = self.TurnContext(mocker, user_token_client=user_token_client) +# # test +# res = await user_authorization.exchange_token(turn_context, ["scope"], "github") +# assert res == TokenResponse(token="github-obo-connection-obo-token") + +# @pytest.mark.asyncio +# async def test_get_active_flow_state(self, mocker, user_authorization): +# context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") +# actual_flow_state = await user_authorization.get_active_flow_state(context) +# assert actual_flow_state == STORAGE_DATA.dict["auth/webchat/Alice/github"] + +# @pytest.mark.asyncio +# async def test_get_active_flow_state_missing(self, mocker, user_authorization): +# context = self.TurnContext( +# mocker, channel_id="__channel_id", user_id="__user_id" +# ) +# res = await user_authorization.get_active_flow_state(context) +# assert res is None + +# @pytest.mark.asyncio +# async def test_begin_or_continue_flow_success(self, mocker, user_authorization): +# # robrandao: TODO -> lower priority -> more testing here +# # setup +# mock_class_OAuthFlow( +# mocker, +# begin_or_continue_flow_return=FlowResponse( +# token_response=TokenResponse(token="token"), +# flow_state=FlowState( +# tag=FlowStateTag.COMPLETE, auth_handler_id="github" +# ), +# ), +# ) +# context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") +# context.dummy_val = None + +# def on_sign_in_success(context, turn_state, auth_handler_id): +# context.dummy_val = auth_handler_id + +# def on_sign_in_failure(context, turn_state, auth_handler_id, err): +# context.dummy_val = str(err) + +# # test +# user_authorization.on_sign_in_success(on_sign_in_success) +# user_authorization.on_sign_in_failure(on_sign_in_failure) +# flow_response = await user_authorization.begin_or_continue_flow( +# context, None, "github" +# ) +# assert context.dummy_val == "github" +# assert flow_response.token_response == TokenResponse(token="token") + +# @pytest.mark.asyncio +# async def test_begin_or_continue_flow_already_completed( +# self, mocker, user_authorization +# ): +# # robrandao: TODO -> lower priority -> more testing here +# # setup +# context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") + +# context.dummy_val = None + +# def on_sign_in_success(context, turn_state, auth_handler_id): +# context.dummy_val = auth_handler_id + +# def on_sign_in_failure(context, turn_state, auth_handler_id, err): +# context.dummy_val = str(err) + +# # test +# user_authorization.on_sign_in_success(on_sign_in_success) +# user_authorization.on_sign_in_failure(on_sign_in_failure) +# flow_response = await user_authorization.begin_or_continue_flow( +# context, None, "graph" +# ) +# assert context.dummy_val == None +# assert flow_response.token_response == TokenResponse(token="test_token") +# assert flow_response.continuation_activity is None + +# @pytest.mark.asyncio +# async def test_begin_or_continue_flow_failure(self, mocker, user_authorization): +# # robrandao: TODO -> lower priority -> more testing here +# # setup +# mock_class_OAuthFlow( +# mocker, +# begin_or_continue_flow_return=FlowResponse( +# token_response=TokenResponse(token="token"), +# flow_state=FlowState( +# tag=FlowStateTag.FAILURE, auth_handler_id="github" +# ), +# flow_error_tag=FlowErrorTag.MAGIC_FORMAT, +# ), +# ) +# context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") +# context.dummy_val = None + +# def on_sign_in_success(context, turn_state, auth_handler_id): +# context.dummy_val = auth_handler_id + +# def on_sign_in_failure(context, turn_state, auth_handler_id, err): +# context.dummy_val = str(err) + +# # test +# user_authorization.on_sign_in_success(on_sign_in_success) +# user_authorization.on_sign_in_failure(on_sign_in_failure) +# flow_response = await user_authorization.begin_or_continue_flow( +# context, None, "github" +# ) +# assert context.dummy_val == "FlowErrorTag.MAGIC_FORMAT" +# assert flow_response.token_response == TokenResponse(token="token") + +# @pytest.mark.parametrize("auth_handler_id", ["graph", "github"]) +# def test_resolve_handler_specified( +# self, user_authorization, auth_handlers, auth_handler_id +# ): +# assert ( +# user_authorization.resolve_handler(auth_handler_id) +# == auth_handlers[auth_handler_id] +# ) + +# def test_resolve_handler_error(self, user_authorization): +# with pytest.raises(ValueError): +# user_authorization.resolve_handler("missing-handler") + +# def test_resolve_handler_first(self, user_authorization, auth_handlers): +# assert user_authorization.resolve_handler() == next(iter(auth_handlers.values())) + +# @pytest.mark.asyncio +# async def test_sign_out_individual( +# self, +# mocker, +# storage, +# connection_manager, +# auth_handlers, +# ): +# # setup +# mock_class_OAuthFlow(mocker) +# storage_client = FlowStorageClient("teams", "Alice", storage) +# context = self.TurnContext(mocker, channel_id="teams", user_id="Alice") +# auth = UserAuthorization(storage, connection_manager, auth_handlers) + +# # test +# await auth.sign_out(context, "graph") + +# # verify +# assert ( +# await storage.read([storage_client.key("graph")], target_cls=FlowState) +# == {} +# ) +# OAuthFlow.sign_out.assert_called_once() + +# @pytest.mark.asyncio +# async def test_sign_out_all( +# self, +# mocker, +# storage, +# connection_manager, +# auth_handlers, +# ): +# # setup +# mock_class_OAuthFlow(mocker) +# context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") +# storage_client = FlowStorageClient("webchat", "Alice", storage) +# auth = UserAuthorization(storage, connection_manager, auth_handlers) + +# # test +# await auth.sign_out(context) + +# # verify +# assert ( +# await storage.read([storage_client.key("graph")], target_cls=FlowState) +# == {} +# ) +# assert ( +# await storage.read([storage_client.key("github")], target_cls=FlowState) +# == {} +# ) +# assert ( +# await storage.read([storage_client.key("slack")], target_cls=FlowState) +# == {} +# ) +# OAuthFlow.sign_out.assert_called() # ignore red squiggly -> mocked diff --git a/tests/hosting_core/app/test_user_authorization.py b/tests/hosting_core/app/test_user_authorization.py deleted file mode 100644 index eef78a0c..00000000 --- a/tests/hosting_core/app/test_user_authorization.py +++ /dev/null @@ -1,540 +0,0 @@ -import pytest -from datetime import datetime -import jwt - -from microsoft_agents.activity import ActivityTypes, TokenResponse - -from microsoft_agents.hosting.core import ( - FlowStorageClient, - FlowErrorTag, - FlowStateTag, - FlowState, - FlowResponse, - OAuthFlow, - UserAuthorization, - MemoryStorage, -) - -from tests._common.storage.utils import StorageBaseline - -# test constants -from tests._common.data import ( - TEST_FLOW_DATA, - TEST_AUTH_DATA, - TEST_STORAGE_DATA, - TEST_DEFAULTS, - create_test_auth_handler, -) -from tests._common.fixtures import FlowStateFixtures -from tests._common.testing_objects import ( - TestingConnectionManager as MockConnectionManager, - mock_class_OAuthFlow, - mock_UserTokenClient, -) -from tests.hosting_core._common import flow_state_eq - -DEFAULTS = TEST_DEFAULTS() -FLOW_DATA = TEST_FLOW_DATA() -STORAGE_DATA = TEST_STORAGE_DATA() - - -def testing_TurnContext( - mocker, - channel_id=DEFAULTS.channel_id, - user_id=DEFAULTS.user_id, - user_token_client=None, -): - if not user_token_client: - user_token_client = mock_UserTokenClient(mocker) - - turn_context = mocker.Mock() - turn_context.activity.channel_id = channel_id - turn_context.activity.from_property.id = user_id - turn_context.activity.type = ActivityTypes.message - turn_context.adapter.USER_TOKEN_CLIENT_KEY = "__user_token_client" - turn_context.adapter.AGENT_IDENTITY_KEY = "__agent_identity_key" - agent_identity = mocker.Mock() - agent_identity.claims = {"aud": DEFAULTS.ms_app_id} - turn_context.turn_state = { - "__user_token_client": user_token_client, - "__agent_identity_key": agent_identity, - } - return turn_context - - -class TestEnv(FlowStateFixtures): - def setup_method(self): - self.TurnContext = testing_TurnContext - self.UserTokenClient = mock_UserTokenClient - self.ConnectionManager = lambda mocker: MockConnectionManager() - - @pytest.fixture - def turn_context(self, mocker): - return self.TurnContext(mocker) - - @pytest.fixture - def baseline_storage(self): - return StorageBaseline(TEST_STORAGE_DATA().dict) - - @pytest.fixture - def storage(self): - return MemoryStorage(STORAGE_DATA.get_init_data()) - - @pytest.fixture - def connection_manager(self, mocker): - return self.ConnectionManager(mocker) - - @pytest.fixture - def auth_handlers(self): - return TEST_AUTH_DATA().auth_handlers - - @pytest.fixture - def user_authorization(self, connection_manager, storage, auth_handlers): - return UserAuthorization(storage, connection_manager, auth_handlers) - - -class TestAuthorization(TestEnv): - def test_init_configuration_variants( - self, storage, connection_manager, auth_handlers - ): - """Test initialization of authorization with different configuration variants.""" - AGENTAPPLICATION = { - "USERAUTHORIZATION": { - "HANDLERS": { - handler_name: { - "SETTINGS": { - "title": handler.title, - "text": handler.text, - "abs_oauth_connection_name": handler.abs_oauth_connection_name, - "obo_connection_name": handler.obo_connection_name, - } - } - for handler_name, handler in auth_handlers.items() - } - } - } - auth_with_config_obj = UserAuthorization( - storage, - connection_manager, - auth_handlers=None, - AGENTAPPLICATION=AGENTAPPLICATION, - ) - auth_with_handlers_list = UserAuthorization( - storage, connection_manager, auth_handlers=auth_handlers - ) - for auth_handler_name in auth_handlers.keys(): - auth_handler_a = auth_with_config_obj.resolve_handler(auth_handler_name) - auth_handler_b = auth_with_handlers_list.resolve_handler(auth_handler_name) - - assert auth_handler_a.name == auth_handler_b.name - assert auth_handler_a.title == auth_handler_b.title - assert auth_handler_a.text == auth_handler_b.text - assert ( - auth_handler_a.abs_oauth_connection_name - == auth_handler_b.abs_oauth_connection_name - ) - assert ( - auth_handler_a.obo_connection_name == auth_handler_b.obo_connection_name - ) - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "auth_handler_id, channel_id, user_id", - [["missing", "webchat", "Alice"], ["handler", "teams", "Bob"]], - ) - async def test_open_flow_value_error( - self, mocker, user_authorization, auth_handler_id, channel_id, user_id - ): - """Test opening a flow with a missing auth handler.""" - context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) - with pytest.raises(ValueError): - async with user_authorization.open_flow(context, auth_handler_id): - pass - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "auth_handler_id, channel_id, user_id", - [ - ["", "webchat", "Alice"], - ["graph", "teams", "Bob"], - ["slack", "webchat", "Chuck"], - ], - ) - async def test_open_flow_readonly( - self, - mocker, - storage, - connection_manager, - auth_handlers, - auth_handler_id, - channel_id, - user_id, - ): - """Test opening a flow and not modifying it.""" - # setup - context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) - auth = UserAuthorization(storage, connection_manager, auth_handlers) - flow_storage_client = FlowStorageClient(channel_id, user_id, storage) - - # test - async with auth.open_flow(context, auth_handler_id) as flow: - expected_flow_state = flow.flow_state - - # verify - actual_flow_state = await flow_storage_client.read( - auth.resolve_handler(auth_handler_id).name - ) - assert actual_flow_state == expected_flow_state - - @pytest.mark.asyncio - async def test_open_flow_success_modified_complete_flow( - self, - mocker, - storage, - connection_manager, - auth_handlers, - ): - # mock - channel_id = "teams" - user_id = "Alice" - auth_handler_id = "graph" - - user_token_client = self.UserTokenClient( - mocker, get_token_return=DEFAULTS.token - ) - context = self.TurnContext( - mocker, - channel_id=channel_id, - user_id=user_id, - user_token_client=user_token_client, - ) - - # setup - context.activity.type = ActivityTypes.message - context.activity.text = "123456" - - auth = UserAuthorization(storage, connection_manager, auth_handlers) - flow_storage_client = FlowStorageClient(channel_id, user_id, storage) - - # test - async with auth.open_flow(context, auth_handler_id) as flow: - expected_flow_state = flow.flow_state - expected_flow_state.tag = FlowStateTag.COMPLETE - expected_flow_state.user_token = DEFAULTS.token - - flow_response = await flow.begin_or_continue_flow(context.activity) - res_flow_state = flow_response.flow_state - - # verify - actual_flow_state = await flow_storage_client.read(auth_handler_id) - expected_flow_state.expiration = actual_flow_state.expiration - assert flow_state_eq(actual_flow_state, expected_flow_state) - assert flow_state_eq(res_flow_state, expected_flow_state) - - @pytest.mark.asyncio - async def test_open_flow_success_modified_failure( - self, - mocker, - storage, - connection_manager, - auth_handlers, - ): - # setup - channel_id = "teams" - user_id = "Bob" - auth_handler_id = "slack" - - context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) - context.activity.text = "invalid_magic_code" - - auth = UserAuthorization(storage, connection_manager, auth_handlers) - flow_storage_client = FlowStorageClient(channel_id, user_id, storage) - - # test - async with auth.open_flow(context, auth_handler_id) as flow: - expected_flow_state = flow.flow_state - expected_flow_state.tag = FlowStateTag.FAILURE - expected_flow_state.attempts_remaining = 0 - - flow_response = await flow.begin_or_continue_flow(context.activity) - res_flow_state = flow_response.flow_state - - # verify - actual_flow_state = await flow_storage_client.read(auth_handler_id) - - assert flow_response.flow_error_tag == FlowErrorTag.MAGIC_FORMAT - assert flow_state_eq(res_flow_state, expected_flow_state) - assert flow_state_eq(actual_flow_state, expected_flow_state) - - @pytest.mark.asyncio - async def test_open_flow_success_modified_signout( - self, mocker, storage, connection_manager, auth_handlers - ): - # setup - channel_id = "webchat" - user_id = "Alice" - auth_handler_id = "graph" - - context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) - - auth = UserAuthorization(storage, connection_manager, auth_handlers) - flow_storage_client = FlowStorageClient(channel_id, user_id, storage) - - # test - async with auth.open_flow(context, auth_handler_id) as flow: - expected_flow_state = flow.flow_state - expected_flow_state.tag = FlowStateTag.NOT_STARTED - expected_flow_state.user_token = "" - - await flow.sign_out() - - # verify - actual_flow_state = await flow_storage_client.read(auth_handler_id) - assert flow_state_eq(actual_flow_state, expected_flow_state) - - @pytest.mark.asyncio - async def test_get_token_success(self, mocker, user_authorization): - user_token_client = self.UserTokenClient(mocker, get_token_return="token") - context = self.TurnContext( - mocker, - channel_id="__channel_id", - user_id="__user_id", - user_token_client=user_token_client, - ) - assert await user_authorization.get_token(context, "slack") == TokenResponse( - token="token" - ) - user_token_client.user_token.get_token.assert_called_once() - - @pytest.mark.asyncio - async def test_get_token_empty_response(self, mocker, user_authorization): - user_token_client = self.UserTokenClient( - mocker, get_token_return=TokenResponse() - ) - context = self.TurnContext( - mocker, - channel_id="__channel_id", - user_id="__user_id", - user_token_client=user_token_client, - ) - assert await user_authorization.get_token(context, "graph") == TokenResponse() - user_token_client.user_token.get_token.assert_called_once() - - @pytest.mark.asyncio - async def test_get_token_error( - self, turn_context, storage, connection_manager, auth_handlers - ): - auth = UserAuthorization(storage, connection_manager, auth_handlers) - with pytest.raises(ValueError): - await auth.get_token( - turn_context, DEFAULTS.missing_abs_oauth_connection_name - ) - - @pytest.mark.asyncio - async def test_exchange_token_no_token(self, mocker, turn_context, user_authorization): - mock_class_OAuthFlow(mocker, get_user_token_return=TokenResponse()) - res = await user_authorization.exchange_token(turn_context, ["scope"], "github") - assert res == TokenResponse() - - @pytest.mark.asyncio - async def test_exchange_token_not_exchangeable( - self, mocker, turn_context, user_authorization - ): - token = jwt.encode({"aud": "invalid://botframework.test.api"}, "") - mock_class_OAuthFlow( - mocker, - get_user_token_return=TokenResponse(connection_name="github", token=token), - ) - res = await user_authorization.exchange_token(turn_context, ["scope"], "github") - assert res == TokenResponse() - - @pytest.mark.asyncio - async def test_exchange_token_valid_exchangeable(self, mocker, user_authorization): - # setup - token = jwt.encode({"aud": "api://botframework.test.api"}, "") - mock_class_OAuthFlow( - mocker, - get_user_token_return=TokenResponse(connection_name="github", token=token), - ) - user_token_client = self.UserTokenClient( - mocker, get_token_return="github-obo-connection-obo-token" - ) - turn_context = self.TurnContext(mocker, user_token_client=user_token_client) - # test - res = await user_authorization.exchange_token(turn_context, ["scope"], "github") - assert res == TokenResponse(token="github-obo-connection-obo-token") - - @pytest.mark.asyncio - async def test_get_active_flow_state(self, mocker, user_authorization): - context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") - actual_flow_state = await user_authorization.get_active_flow_state(context) - assert actual_flow_state == STORAGE_DATA.dict["auth/webchat/Alice/github"] - - @pytest.mark.asyncio - async def test_get_active_flow_state_missing(self, mocker, user_authorization): - context = self.TurnContext( - mocker, channel_id="__channel_id", user_id="__user_id" - ) - res = await user_authorization.get_active_flow_state(context) - assert res is None - - @pytest.mark.asyncio - async def test_begin_or_continue_flow_success(self, mocker, user_authorization): - # robrandao: TODO -> lower priority -> more testing here - # setup - mock_class_OAuthFlow( - mocker, - begin_or_continue_flow_return=FlowResponse( - token_response=TokenResponse(token="token"), - flow_state=FlowState( - tag=FlowStateTag.COMPLETE, auth_handler_id="github" - ), - ), - ) - context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") - context.dummy_val = None - - def on_sign_in_success(context, turn_state, auth_handler_id): - context.dummy_val = auth_handler_id - - def on_sign_in_failure(context, turn_state, auth_handler_id, err): - context.dummy_val = str(err) - - # test - user_authorization.on_sign_in_success(on_sign_in_success) - user_authorization.on_sign_in_failure(on_sign_in_failure) - flow_response = await user_authorization.begin_or_continue_flow( - context, None, "github" - ) - assert context.dummy_val == "github" - assert flow_response.token_response == TokenResponse(token="token") - - @pytest.mark.asyncio - async def test_begin_or_continue_flow_already_completed( - self, mocker, user_authorization - ): - # robrandao: TODO -> lower priority -> more testing here - # setup - context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") - - context.dummy_val = None - - def on_sign_in_success(context, turn_state, auth_handler_id): - context.dummy_val = auth_handler_id - - def on_sign_in_failure(context, turn_state, auth_handler_id, err): - context.dummy_val = str(err) - - # test - user_authorization.on_sign_in_success(on_sign_in_success) - user_authorization.on_sign_in_failure(on_sign_in_failure) - flow_response = await user_authorization.begin_or_continue_flow( - context, None, "graph" - ) - assert context.dummy_val == None - assert flow_response.token_response == TokenResponse(token="test_token") - assert flow_response.continuation_activity is None - - @pytest.mark.asyncio - async def test_begin_or_continue_flow_failure(self, mocker, user_authorization): - # robrandao: TODO -> lower priority -> more testing here - # setup - mock_class_OAuthFlow( - mocker, - begin_or_continue_flow_return=FlowResponse( - token_response=TokenResponse(token="token"), - flow_state=FlowState( - tag=FlowStateTag.FAILURE, auth_handler_id="github" - ), - flow_error_tag=FlowErrorTag.MAGIC_FORMAT, - ), - ) - context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") - context.dummy_val = None - - def on_sign_in_success(context, turn_state, auth_handler_id): - context.dummy_val = auth_handler_id - - def on_sign_in_failure(context, turn_state, auth_handler_id, err): - context.dummy_val = str(err) - - # test - user_authorization.on_sign_in_success(on_sign_in_success) - user_authorization.on_sign_in_failure(on_sign_in_failure) - flow_response = await user_authorization.begin_or_continue_flow( - context, None, "github" - ) - assert context.dummy_val == "FlowErrorTag.MAGIC_FORMAT" - assert flow_response.token_response == TokenResponse(token="token") - - @pytest.mark.parametrize("auth_handler_id", ["graph", "github"]) - def test_resolve_handler_specified( - self, user_authorization, auth_handlers, auth_handler_id - ): - assert ( - user_authorization.resolve_handler(auth_handler_id) - == auth_handlers[auth_handler_id] - ) - - def test_resolve_handler_error(self, user_authorization): - with pytest.raises(ValueError): - user_authorization.resolve_handler("missing-handler") - - def test_resolve_handler_first(self, user_authorization, auth_handlers): - assert user_authorization.resolve_handler() == next(iter(auth_handlers.values())) - - @pytest.mark.asyncio - async def test_sign_out_individual( - self, - mocker, - storage, - connection_manager, - auth_handlers, - ): - # setup - mock_class_OAuthFlow(mocker) - storage_client = FlowStorageClient("teams", "Alice", storage) - context = self.TurnContext(mocker, channel_id="teams", user_id="Alice") - auth = UserAuthorization(storage, connection_manager, auth_handlers) - - # test - await auth.sign_out(context, "graph") - - # verify - assert ( - await storage.read([storage_client.key("graph")], target_cls=FlowState) - == {} - ) - OAuthFlow.sign_out.assert_called_once() - - @pytest.mark.asyncio - async def test_sign_out_all( - self, - mocker, - storage, - connection_manager, - auth_handlers, - ): - # setup - mock_class_OAuthFlow(mocker) - context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") - storage_client = FlowStorageClient("webchat", "Alice", storage) - auth = UserAuthorization(storage, connection_manager, auth_handlers) - - # test - await auth.sign_out(context) - - # verify - assert ( - await storage.read([storage_client.key("graph")], target_cls=FlowState) - == {} - ) - assert ( - await storage.read([storage_client.key("github")], target_cls=FlowState) - == {} - ) - assert ( - await storage.read([storage_client.key("slack")], target_cls=FlowState) - == {} - ) - OAuthFlow.sign_out.assert_called() # ignore red squiggly -> mocked From 860faded72acc6704e7ad459a187ef68a9a04f29 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 25 Sep 2025 11:50:21 -0700 Subject: [PATCH 09/36] Passing Authorization tests --- .../microsoft_agents/hosting/core/__init__.py | 5 + .../hosting/core/app/agent_application.py | 2 +- .../core/app/auth/agentic_authorization.py | 2 +- .../hosting/core/app/auth/authorization.py | 40 +-- .../hosting/core/app/auth/sign_in_response.py | 13 +- .../hosting/core/app/auth/sign_in_state.py | 4 +- .../mocks/mock_authorization.py | 8 +- .../app/auth/test_authorization.py | 252 ++++++++++++++++-- .../app/auth/test_sign_in_response.py | 9 + 9 files changed, 285 insertions(+), 50 deletions(-) create mode 100644 tests/hosting_core/app/auth/test_sign_in_response.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py index 4235d43a..0db68b84 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py @@ -172,4 +172,9 @@ "FlowResponse", "FlowStorageClient", "OAuthFlow", + "UserAuthorization", + "AgenticAuthorization", + "Authorization", + "SignInState", + "SignInResponse", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index 36e017fe..a6663e80 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -735,7 +735,7 @@ async def _on_activity(self, context: TurnContext, state: StateT): else: sign_in_complete = True for auth_handler_id in route.auth_handlers: - if not await self._auth.start_or_continue_sign_in(context, state, auth_handler_id): + if not (await self._auth.start_or_continue_sign_in(context, state, auth_handler_id)).sign_in_complete(): sign_in_complete = False break diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py index 7ba6093f..c2ed7710 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py @@ -63,7 +63,7 @@ async def get_agentic_user_token(self, context: TurnContext, scopes: list[str]) agentic_instance_id, upn, scopes ) - async def sign_in(self, context: TurnContext, scopes: Optional[list[str]] = None) -> SignInResponse: + async def sign_in(self, context: TurnContext, connection_name: str, scopes: Optional[list[str]] = None) -> SignInResponse: scopes = scopes or [] token = await self.get_agentic_user_token(context, scopes) return SignInResponse(token_response=TokenResponse(token=token), tag=FlowStateTag.COMPLETED) if token else SignInResponse() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py index ee69cd8d..97001b82 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py @@ -95,7 +95,7 @@ def _init_auth_variants(self, auth_handlers: dict[str, AuthHandler]): ) def sign_in_state_key(self, context: TurnContext) -> str: - return f"auth:SignInState:{context.activity.conversation.id}:{context.activity.from_property.id}" + return f"auth:SignInState:{context.activity.channel_id}:{context.activity.from_property.id}" async def _load_sign_in_state(self, context: TurnContext) -> Optional[SignInState]: key = self.sign_in_state_key(context) @@ -130,21 +130,23 @@ def resolve_handler(self, handler_id: str) -> AuthHandler: raise ValueError(f"Auth handler {handler_id} not recognized or not configured.") return self._auth_handlers[handler_id] - async def _start_or_continue_sign_in(self, context: TurnContext, state: StateT, auth_handler_id: str) -> SignInResponse: + async def start_or_continue_sign_in(self, context: TurnContext, state: StateT, auth_handler_id: str) -> SignInResponse: sign_in_state = await self._load_sign_in_state(context) if not sign_in_state: sign_in_state = SignInState({auth_handler_id: ""}) if sign_in_state.tokens.get(auth_handler_id): - return SignInResponse(tag=FlowStateTag.COMPLETE, token=sign_in_state.tokens[auth_handler_id]) + return SignInResponse(tag=FlowStateTag.COMPLETE, token_response=TokenResponse(token=sign_in_state.tokens[auth_handler_id])) - sign_in_response = await self._resolve_auth_variant(auth_handler_id).sign_in(context, auth_handler_id) + handler = self.resolve_handler(auth_handler_id) + variant = self._resolve_auth_variant(handler.auth_type) + sign_in_response = await variant.sign_in(context, auth_handler_id) if sign_in_response.tag == FlowStateTag.COMPLETE: if self._sign_in_success_handler: await self._sign_in_success_handler(context, state, auth_handler_id) - token = sign_in_response.token + token = sign_in_response.token_response.token sign_in_state.tokens[auth_handler_id] = token await self._save_sign_in_state(context, sign_in_state) @@ -154,20 +156,24 @@ async def _start_or_continue_sign_in(self, context: TurnContext, state: StateT, elif sign_in_response.tag in [FlowStateTag.BEGIN, FlowStateTag.CONTINUE]: sign_in_state.continuation_activity = context.activity - - async def start_or_continue_sign_in(self, context: TurnContext, state: StateT, auth_handler_id: str) -> bool: - sign_in_response = await self._start_or_continue_sign_in(context, state, auth_handler_id) - return sign_in_response.tag in [FlowStateTag.NOT_STARTED, FlowStateTag.COMPLETE] + await self._save_sign_in_state(context, sign_in_state) + + return sign_in_response async def sign_out(self, context: TurnContext, state: StateT, auth_handler_id=None) -> None: sign_in_state = await self._load_sign_in_state(context) if sign_in_state: if not auth_handler_id: for handler_id in sign_in_state.tokens.keys(): - await self._resolve_auth_variant(handler_id).sign_out(context, handler_id) + if handler_id in sign_in_state.tokens: + handler = self.resolve_handler(handler_id) + variant = self._resolve_auth_variant(handler.auth_type) + await variant.sign_out(context, handler_id) await self._delete_sign_in_state(context) - else: - await self._resolve_auth_variant(auth_handler_id).sign_out(context, auth_handler_id) + elif auth_handler_id in sign_in_state.tokens: + handler = self.resolve_handler(auth_handler_id) + variant = self._resolve_auth_variant(handler.auth_type) + await variant.sign_out(context, auth_handler_id) del sign_in_state.tokens[auth_handler_id] await self._save_sign_in_state(context, sign_in_state) @@ -180,18 +186,18 @@ async def on_turn_auth_intercept(self, context: TurnContext, state: StateT) -> t """ # get active thing... - + sign_in_state = await self._load_sign_in_state(context) if sign_in_state: auth_handler_id = sign_in_state.active_handler() if auth_handler_id: - assert sign_in_state.continuation_activity is not None - continuation_activity = None - sign_in_response = await self._start_or_continue_sign_in(context, state, auth_handler_id) + sign_in_response = await self.start_or_continue_sign_in(context, state, auth_handler_id) if sign_in_response.tag == FlowStateTag.COMPLETE: + assert sign_in_state.continuation_activity is not None continuation_activity = sign_in_state.continuation_activity.model_copy() - return True, continuation_activity # continue _on_turn + return True, continuation_activity + return True, None return False, None async def get_token( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py index f381dd00..7af87260 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py @@ -1,11 +1,16 @@ from typing import Optional -from dataclasses import dataclass from microsoft_agents.activity import TokenResponse from ...oauth import FlowStateTag -@dataclass class SignInResponse: - token_response: TokenResponse = TokenResponse() - tag: FlowStateTag = FlowStateTag.FAILURE \ No newline at end of file + token_response: TokenResponse + tag: FlowStateTag + + def __init__(self, token_response: Optional[TokenResponse] = None, tag: FlowStateTag = FlowStateTag.FAILURE) -> None: + self.token_response = token_response or TokenResponse() + self.tag = tag + + def sign_in_complete(self) -> bool: + return self.tag in [FlowStateTag.COMPLETE, FlowStateTag.NOT_STARTED] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py index 9eda3176..a1c404d4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py @@ -9,8 +9,8 @@ class SignInState(StoreItem): - def __init__(self, data: Optional[JSON] = None, continuation_activity: Optional[Activity] = None) -> None: - self.tokens = data or {} + def __init__(self, tokens: Optional[JSON] = None, continuation_activity: Optional[Activity] = None) -> None: + self.tokens = tokens or {} self.continuation_activity = continuation_activity def store_item_to_json(self) -> JSON: diff --git a/tests/_common/testing_objects/mocks/mock_authorization.py b/tests/_common/testing_objects/mocks/mock_authorization.py index d9268c84..61cbdddc 100644 --- a/tests/_common/testing_objects/mocks/mock_authorization.py +++ b/tests/_common/testing_objects/mocks/mock_authorization.py @@ -8,12 +8,14 @@ def mock_class_UserAuthorization(mocker, sign_in_return=None): if sign_in_return is None: sign_in_return = SignInResponse() - mocker.patch(UserAuthorization, sign_in=mocker.AsyncMock(return_value=sign_in_return)) + mocker.patch.object(UserAuthorization, "sign_in", return_value=sign_in_return) + mocker.patch.object(UserAuthorization, "sign_out") def mock_class_AgenticAuthorization(mocker, sign_in_return=None): if sign_in_return is None: sign_in_return = SignInResponse() - mocker.patch(AgenticAuthorization, sign_in=mocker.AsyncMock(return_value=sign_in_return)) + mocker.patch.object(AgenticAuthorization, "sign_in", return_value=sign_in_return) + mocker.patch.object(AgenticAuthorization, "sign_out") def mock_class_Authorization(mocker, start_or_continue_sign_in_return=False): - mocker.patch(Authorization, start_or_continue_sign_in=mocker.AsyncMock(return_value=start_or_continue_sign_in_return)) \ No newline at end of file + mocker.patch.object(Authorization, "start_or_continue_sign_in", return_value=start_or_continue_sign_in_return) \ No newline at end of file diff --git a/tests/hosting_core/app/auth/test_authorization.py b/tests/hosting_core/app/auth/test_authorization.py index 755325db..51eba142 100644 --- a/tests/hosting_core/app/auth/test_authorization.py +++ b/tests/hosting_core/app/auth/test_authorization.py @@ -2,6 +2,8 @@ from datetime import datetime import jwt +from typing import Optional + from microsoft_agents.activity import ( Activity, ActivityTypes, @@ -17,11 +19,14 @@ OAuthFlow, Authorization, UserAuthorization, + Storage, + TurnContext, MemoryStorage, AuthHandler, FlowStateTag, + SignInState, + SignInResponse, ) -from microsoft_agents.hosting.core.app.auth import SignInState from tests._common.storage.utils import StorageBaseline @@ -54,14 +59,30 @@ ENV_DICT = TEST_ENV_DICT() AGENTIC_ENV_DICT = TEST_AGENTIC_ENV_DICT() -def get_sign_in_state(auth: Authorization, storage: Storage, context: TurnContext) -> Optional[SignInState]: - key = auth.get_sign_in_state_key(context) - return storage.read([key], target_cls=SignInState).get(key) +async def get_sign_in_state(auth: Authorization, storage: Storage, context: TurnContext) -> Optional[SignInState]: + key = auth.sign_in_state_key(context) + return (await storage.read([key], target_cls=SignInState)).get(key) + +async def set_sign_in_state(auth: Authorization, storage: Storage, context: TurnContext, state: SignInState): + key = auth.sign_in_state_key(context) + await storage.write({key: state}) -def set_sign_in_state(auth: Authorization, storage: Storage, context: TurnContext, state: SignInState): - key = auth.get_sign_in_state_key(context) - storage.write({key: state}) +def mock_variants(mocker, sign_in_return=None): + mock_class_UserAuthorization(mocker, sign_in_return=sign_in_return) + mock_class_AgenticAuthorization(mocker, sign_in_return=sign_in_return) +def sign_in_state_eq(a: Optional[SignInState], b: Optional[SignInState]) -> bool: + if a is None and b is None: + return True + if a is None or b is None: + return False + return a.tokens == b.tokens and a.continuation_activity == b.continuation_activity + +def copy_sign_in_state(state: SignInState) -> SignInState: + return SignInState( + tokens=state.tokens.copy(), + continuation_activity=state.continuation_activity.model_copy() if state.continuation_activity else None + ) class TestEnv(FlowStateFixtures): def setup_method(self): @@ -101,6 +122,10 @@ def authorization(self, connection_manager, storage): def env_dict(self, request): return request.param + @pytest.fixture(params=[DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id]) + def auth_handler_id(self, request): + return request.param + class TestAuthorizationSetup(TestEnv): def test_init_user_auth(self, connection_manager, storage, env_dict): @@ -140,8 +165,8 @@ async def test_get_token(self, mocker, storage, authorization): @pytest.mark.asyncio async def test_get_token_with_sign_in_state_empty(self, mocker, storage, authorization, context): # setup - key = authorization.get_sign_in_state_key(context) - storage.write({key: SignInState( + key = authorization.sign_in_state_key(context) + await storage.write({key: SignInState( tokens={DEFAULTS.auth_handler_id: "", DEFAULTS.agentic_auth_handler_id: ""} )}) @@ -152,8 +177,8 @@ async def test_get_token_with_sign_in_state_empty(self, mocker, storage, authori @pytest.mark.asyncio async def test_get_token_with_sign_in_state_empty_alt(self, mocker, storage, authorization, context): # setup - key = authorization.get_sign_in_state_key(context) - storage.write({key: SignInState( + key = authorization.sign_in_state_key(context) + await storage.write({key: SignInState( tokens={DEFAULTS.auth_handler_id: "token", DEFAULTS.agentic_auth_handler_id: ""} )}) @@ -165,8 +190,8 @@ async def test_get_token_with_sign_in_state_empty_alt(self, mocker, storage, aut async def test_get_token_with_sign_in_state_valid(self, mocker, storage, authorization): # setup context = self.TurnContext(mocker) - key = authorization.get_sign_in_state_key(context) - storage.write({key: SignInState( + key = authorization.sign_in_state_key(context) + await storage.write({key: SignInState( tokens={DEFAULTS.auth_handler_id: "valid_token"} )}) @@ -174,22 +199,205 @@ async def test_get_token_with_sign_in_state_valid(self, mocker, storage, authori token_response = await authorization.get_token(context, DEFAULTS.auth_handler_id) assert token_response.token == "valid_token" - def test_start_or_continue_sign_in_cached(self, storage, authorization, context, activity): + @pytest.mark.asyncio + async def test_start_or_continue_sign_in_cached(self, storage, authorization, context, activity): # setup initial_state = SignInState( tokens={DEFAULTS.auth_handler_id: "valid_token"}, continuation_activity=activity ) - set_sign_in_state(authorization, storage, context, initial_state) - assert await authorization.start_or_continue_sign_in(context, None, DEFAULTS.auth_handler_id) - assert get_sign_in_state(authorization, storage, context) == initial_state + await set_sign_in_state(authorization, storage, context, initial_state) + sign_in_response = await authorization.start_or_continue_sign_in(context, None, DEFAULTS.auth_handler_id) + assert sign_in_response.tag == FlowStateTag.COMPLETE + assert sign_in_response.token_response.token == "valid_token" - def test_start_or_continue_sign_in_no_state_to_complete(self, mocker, storage, authorization, context): - mock_class_UserAuthorization(mocker, sign_in_return=SignInResponse( + assert sign_in_state_eq(await get_sign_in_state(authorization, storage, context), initial_state) + + @pytest.mark.asyncio + @pytest.mark.parametrize("auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id]) + async def test_start_or_continue_sign_in_no_initial_state_to_complete(self, mocker, storage, authorization, context, auth_handler_id): + mock_variants(mocker, sign_in_return=SignInResponse( token_response=TokenResponse(token=DEFAULTS.token), tag=FlowStateTag.COMPLETE )) - await authorization.start_or_continue_sign_in(context, None, DEFAULTS.auth_handler_id) + sign_in_response = await authorization.start_or_continue_sign_in(context, None, auth_handler_id) + assert sign_in_response.tag == FlowStateTag.COMPLETE + assert sign_in_response.token_response.token == DEFAULTS.token + + final_state = await get_sign_in_state(authorization, storage, context) + assert final_state.tokens[auth_handler_id] == DEFAULTS.token + assert final_state.continuation_activity is None + + @pytest.mark.asyncio + @pytest.mark.parametrize("auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id]) + async def test_start_or_continue_sign_in_to_complete_with_prev_state(self, mocker, storage, authorization, context, auth_handler_id): + # setup + initial_state = SignInState( + tokens={"my_handler": "old_token"}, continuation_activity=Activity(type=ActivityTypes.message, text="old activity") + ) + await set_sign_in_state(authorization, storage, context, initial_state) + mock_variants(mocker, sign_in_return=SignInResponse( + token_response=TokenResponse(token=DEFAULTS.token), + tag=FlowStateTag.COMPLETE + )) + + # test + sign_in_response = await authorization.start_or_continue_sign_in(context, None, auth_handler_id) + assert sign_in_response.tag == FlowStateTag.COMPLETE + assert sign_in_response.token_response.token == DEFAULTS.token + + # verify + final_state = await get_sign_in_state(authorization, storage, context) + assert final_state.tokens[auth_handler_id] == DEFAULTS.token + assert final_state.tokens["my_handler"] == "old_token" + assert final_state.continuation_activity == initial_state.continuation_activity + + @pytest.mark.asyncio + @pytest.mark.parametrize("auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id]) + async def test_start_or_continue_sign_in_to_failure_with_prev_state(self, mocker, storage, authorization, context, auth_handler_id): + # setup + initial_state = SignInState( + tokens={"my_handler": "old_token"}, continuation_activity=Activity(type=ActivityTypes.message, text="old activity") + ) + await set_sign_in_state(authorization, storage, context, initial_state) + mock_variants(mocker, sign_in_return=SignInResponse( + token_response=TokenResponse(), + tag=FlowStateTag.FAILURE + )) + + # test + sign_in_response = await authorization.start_or_continue_sign_in(context, None, auth_handler_id) + assert sign_in_response.tag == FlowStateTag.FAILURE + assert not sign_in_response.token_response + + # verify + final_state = await get_sign_in_state(authorization, storage, context) + assert not final_state.tokens.get(auth_handler_id) + assert final_state.tokens["my_handler"] == "old_token" + assert final_state.continuation_activity == initial_state.continuation_activity + + @pytest.mark.asyncio + @pytest.mark.parametrize("auth_handler_id, tag", [ + (DEFAULTS.auth_handler_id, FlowStateTag.BEGIN), + (DEFAULTS.agentic_auth_handler_id, FlowStateTag.BEGIN), + (DEFAULTS.auth_handler_id, FlowStateTag.CONTINUE), + (DEFAULTS.agentic_auth_handler_id, FlowStateTag.CONTINUE) + ]) + async def test_start_or_continue_sign_in_to_pending_with_prev_state(self, mocker, storage, authorization, context, auth_handler_id, tag): + # setup + initial_state = SignInState( + tokens={"my_handler": "old_token"}, continuation_activity=Activity(type=ActivityTypes.message, text="old activity") + ) + await set_sign_in_state(authorization, storage, context, initial_state) + mock_variants(mocker, sign_in_return=SignInResponse( + token_response=TokenResponse(), + tag=tag + )) + + # test + sign_in_response = await authorization.start_or_continue_sign_in(context, None, auth_handler_id) + assert sign_in_response.tag == tag + assert not sign_in_response.token_response + + # verify + final_state = await get_sign_in_state(authorization, storage, context) + assert not final_state.tokens.get(auth_handler_id) + assert final_state.tokens["my_handler"] == "old_token" + assert final_state.continuation_activity == context.activity + + @pytest.mark.asyncio + @pytest.mark.parametrize("auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id]) + async def test_sign_out_not_signed_in_single_handler(self, mocker, storage, authorization, context, activity, auth_handler_id): + mock_variants(mocker) + initial_state = SignInState(tokens={DEFAULTS.auth_handler_id: "", "my_handler": "old_token"}, continuation_activity=activity) + await set_sign_in_state(authorization, storage, context, copy_sign_in_state(initial_state)) + await authorization.sign_out(context, None, auth_handler_id) + final_state = await get_sign_in_state(authorization, storage, context) + if auth_handler_id in initial_state.tokens: + del initial_state.tokens[auth_handler_id] + assert sign_in_state_eq(final_state, initial_state) + + @pytest.mark.asyncio + @pytest.mark.parametrize("auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id]) + async def test_sign_out_signed_in_in_single_handler(self, mocker, storage, authorization, context, activity, auth_handler_id): + mock_variants(mocker) + initial_state = SignInState(tokens={DEFAULTS.auth_handler_id: "token", DEFAULTS.agentic_auth_handler_id: "another_token", "my_handler": "old_token"}, continuation_activity=activity) + await set_sign_in_state(authorization, storage, context, copy_sign_in_state(initial_state)) + await authorization.sign_out(context, None, auth_handler_id) + final_state = await get_sign_in_state(authorization, storage, context) + del initial_state.tokens[auth_handler_id] + assert sign_in_state_eq(final_state, initial_state) + + @pytest.mark.asyncio + async def test_sign_out_not_signed_in_all_handlers(self, mocker, storage, authorization, context, activity): + mock_variants(mocker) + initial_state = SignInState(tokens={DEFAULTS.auth_handler_id: ""}, continuation_activity=activity) + await set_sign_in_state(authorization, storage, context, initial_state) + await authorization.sign_out(context, None) + final_state = await get_sign_in_state(authorization, storage, context) + assert final_state is None + + @pytest.mark.asyncio + async def test_sign_out_signed_in_in_all_handlers(self, mocker, storage, authorization, context, activity): + mock_variants(mocker) + initial_state = SignInState(tokens={DEFAULTS.auth_handler_id: "token", DEFAULTS.agentic_auth_handler_id: "another_token"}, continuation_activity=activity) + await set_sign_in_state(authorization, storage, context, initial_state) + await authorization.sign_out(context, None) + final_state = await get_sign_in_state(authorization, storage, context) + assert final_state is None + + @pytest.mark.asyncio + @pytest.mark.parametrize("sign_in_state", [ + SignInState(), + SignInState(tokens={DEFAULTS.auth_handler_id: "token"}, continuation_activity=Activity(type=ActivityTypes.message, text="activity")), + SignInState(tokens={DEFAULTS.auth_handler_id: "token", DEFAULTS.agentic_auth_handler_id: "another_token"}, continuation_activity=Activity(type=ActivityTypes.message, text="activity")), + SignInState(tokens={DEFAULTS.auth_handler_id: "token", "my_handler": "old_token"}, continuation_activity=Activity(type=ActivityTypes.message, text="activity")), + ]) + async def test_on_turn_auth_intercept_no_intercept(self, storage, authorization, context, sign_in_state): + await set_sign_in_state(authorization, storage, context, copy_sign_in_state(sign_in_state)) + + intercepts, continuation_activity = await authorization.on_turn_auth_intercept(context, None) + + assert not continuation_activity + assert not intercepts + + final_state = await get_sign_in_state(authorization, storage, context) + + assert sign_in_state_eq(final_state, sign_in_state) + + @pytest.mark.asyncio + @pytest.mark.parametrize("sign_in_response", [ + SignInResponse(tag=FlowStateTag.BEGIN), + SignInResponse(tag=FlowStateTag.CONTINUE), + SignInResponse(tag=FlowStateTag.FAILURE) + ]) + async def test_on_turn_auth_intercept_with_intercept_incomplete(self, mocker, storage, authorization, context, sign_in_response, auth_handler_id): + mock_class_Authorization(mocker, start_or_continue_sign_in_return=sign_in_response) + + initial_state = SignInState(tokens={"some_handler": "old_token", auth_handler_id: ""}, continuation_activity=Activity(type=ActivityTypes.message, text="old activity")) + await set_sign_in_state(authorization, storage, context, copy_sign_in_state(initial_state)) + + intercepts, continuation_activity = await authorization.on_turn_auth_intercept(context, auth_handler_id) + + assert not continuation_activity + assert intercepts + + final_state = await get_sign_in_state(authorization, storage, context) + assert sign_in_state_eq(final_state, initial_state) + + @pytest.mark.asyncio + async def test_on_turn_auth_intercept_with_intercept_complete(self, mocker, storage, authorization, context, auth_handler_id): + mock_class_Authorization(mocker, start_or_continue_sign_in_return=SignInResponse(tag=FlowStateTag.COMPLETE)) + + old_activity = Activity(type=ActivityTypes.message, text="old activity") + initial_state = SignInState(tokens={"some_handler": "old_token", auth_handler_id: ""}, continuation_activity=old_activity) + await set_sign_in_state(authorization, storage, context, copy_sign_in_state(initial_state)) + + intercepts, continuation_activity = await authorization.on_turn_auth_intercept(context, auth_handler_id) + assert continuation_activity == old_activity + assert intercepts - assert not await authorization.start_or_continue_sign_in(context, None, DEFAULTS.auth_handler_id) - assert get_sign_in_state(authorization, storage, context) is None \ No newline at end of file + # start_or_continue_sign_in is the only method that modifies the state, + # so since it is mocked, the state should not be changed + final_state = await get_sign_in_state(authorization, storage, context) + assert sign_in_state_eq(final_state, initial_state) \ No newline at end of file diff --git a/tests/hosting_core/app/auth/test_sign_in_response.py b/tests/hosting_core/app/auth/test_sign_in_response.py new file mode 100644 index 00000000..08f58a76 --- /dev/null +++ b/tests/hosting_core/app/auth/test_sign_in_response.py @@ -0,0 +1,9 @@ +from microsoft_agents.hosting.core import SignInResponse, FlowStateTag + +def test_sign_in_response_sign_in_complete(): + assert SignInResponse(tag=FlowStateTag.BEGIN).sign_in_complete() == False + assert SignInResponse(tag=FlowStateTag.CONTINUE).sign_in_complete() == False + assert SignInResponse(tag=FlowStateTag.FAILURE).sign_in_complete() == False + assert SignInResponse().sign_in_complete() == False + assert SignInResponse(tag=FlowStateTag.NOT_STARTED).sign_in_complete() == True + assert SignInResponse(tag=FlowStateTag.COMPLETE).sign_in_complete() == True \ No newline at end of file From 56d46b8032102340b512e767b4eebb8c7a8132eb Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 25 Sep 2025 13:56:21 -0700 Subject: [PATCH 10/36] Basic AgenticAuthorization tests --- .../core/app/auth/agentic_authorization.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py index c2ed7710..3fc60d9b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py @@ -1,12 +1,11 @@ import logging -from typing import Optional, Union, TypeVar +from typing import Optional, Union from microsoft_agents.activity import ( Activity, TokenResponse ) -from microsoft_agents.hosting.core.app.auth.sign_in_response import SignInResponse from ...turn_context import TurnContext @@ -17,7 +16,8 @@ class AgenticAuthorization(AuthorizationVariant): - def is_agentic_request(self, context_or_activity: Union[TurnContext, Activity]) -> bool: + @staticmethod + def is_agentic_request(context_or_activity: Union[TurnContext, Activity]) -> bool: if isinstance(context_or_activity, TurnContext): activity = context_or_activity.activity else: @@ -25,14 +25,16 @@ def is_agentic_request(self, context_or_activity: Union[TurnContext, Activity]) return activity.is_agentic() - def get_agent_instance_id(self, context: TurnContext) -> Optional[str]: - if not self.is_agentic_request(context): + @staticmethod + def get_agent_instance_id(context: TurnContext) -> Optional[str]: + if not AgenticAuthorization.is_agentic_request(context): return None return context.activity.recipient.agentic_app_id - def get_agentic_user(self, context: TurnContext) -> Optional[str]: - if not self.is_agentic_request(context): + @staticmethod + def get_agentic_user(context: TurnContext) -> Optional[str]: + if not AgenticAuthorization.is_agentic_request(context): return None return context.activity.recipient.id From e514ea8b2446fb0cfc2f1eef9aa73d0d56e696b4 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 25 Sep 2025 14:41:45 -0700 Subject: [PATCH 11/36] Added AgenticAuthorization tests --- .../msal/msal_connection_manager.py | 3 +- tests/_common/data/test_defaults.py | 3 + tests/_common/testing_objects/__init__.py | 5 +- .../_common/testing_objects/mocks/__init__.py | 5 +- .../testing_objects/mocks/mock_msal_auth.py | 16 +- tests/authentication_msal/test_msal_auth.py | 42 ++++ tests/hosting_core/app/auth/_common.py | 40 +++- .../app/auth/test_agentic_authorization.py | 182 ++++++++++++++++++ 8 files changed, 286 insertions(+), 10 deletions(-) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py index 597f0b1c..b6f66f48 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py @@ -62,7 +62,8 @@ def get_token_provider( """ if not self._connections_map: return self.get_default_connection() - + + return self.get_default_connection() # TODO: Implement logic to select the appropriate connection based on the connection map def get_default_connection_configuration(self) -> AgentAuthConfiguration: diff --git a/tests/_common/data/test_defaults.py b/tests/_common/data/test_defaults.py index 231868cb..3f63e637 100644 --- a/tests/_common/data/test_defaults.py +++ b/tests/_common/data/test_defaults.py @@ -27,6 +27,9 @@ def __init__(self): self.agentic_auth_handler_title = "agentic_auth_handler_title" self.agentic_auth_handler_text = "agentic_auth_handler_text" + self.agentic_instance_id = "agentic_instance_id" + self.agentic_user_id = "agentic_user_id" + self.missing_abs_oauth_connection_name = "missing_connection_name" diff --git a/tests/_common/testing_objects/__init__.py b/tests/_common/testing_objects/__init__.py index 8b4aead4..ce5c3619 100644 --- a/tests/_common/testing_objects/__init__.py +++ b/tests/_common/testing_objects/__init__.py @@ -1,3 +1,4 @@ +from tests._common.testing_objects.mocks.mock_msal_auth import agentic_mock_class_MsalAuth from .adapters import TestingAdapter from .mocks import ( @@ -8,7 +9,8 @@ mock_class_UserTokenClient, mock_class_UserAuthorization, mock_class_AgenticAuthorization, - mock_class_Authorization + mock_class_Authorization, + agentic_mock_class_MsalAuth ) from .testing_authorization import TestingAuthorization @@ -32,4 +34,5 @@ "mock_class_UserAuthorization", "mock_class_AgenticAuthorization", "mock_class_Authorization", + "agentic_mock_class_MsalAuth" ] diff --git a/tests/_common/testing_objects/mocks/__init__.py b/tests/_common/testing_objects/mocks/__init__.py index 0b6379c8..fd894d7f 100644 --- a/tests/_common/testing_objects/mocks/__init__.py +++ b/tests/_common/testing_objects/mocks/__init__.py @@ -1,4 +1,4 @@ -from .mock_msal_auth import MockMsalAuth +from .mock_msal_auth import MockMsalAuth, agentic_mock_class_MsalAuth from .mock_oauth_flow import mock_OAuthFlow, mock_class_OAuthFlow from .mock_user_token_client import mock_UserTokenClient, mock_class_UserTokenClient from .mock_authorization import ( @@ -15,5 +15,6 @@ "mock_class_UserTokenClient", "mock_class_UserAuthorization", "mock_class_AgenticAuthorization", - "mock_class_Authorization" + "mock_class_Authorization", + "agentic_mock_class_MsalAuth" ] diff --git a/tests/_common/testing_objects/mocks/mock_msal_auth.py b/tests/_common/testing_objects/mocks/mock_msal_auth.py index 44a94025..c85b88ab 100644 --- a/tests/_common/testing_objects/mocks/mock_msal_auth.py +++ b/tests/_common/testing_objects/mocks/mock_msal_auth.py @@ -1,18 +1,18 @@ from microsoft_agents.authentication.msal import MsalAuth from microsoft_agents.hosting.core.authorization import AgentAuthConfiguration - +# used by MsalAuth tests class MockMsalAuth(MsalAuth): """ Mock object for MsalAuth """ - def __init__(self, mocker, client_type): + def __init__(self, mocker, client_type, acquire_token_for_client_return={"access_token": "token"}): super().__init__(AgentAuthConfiguration()) mock_client = mocker.Mock(spec=client_type) mock_client.acquire_token_for_client = mocker.Mock( - return_value={"access_token": "token"} + return_value=acquire_token_for_client_return ) mock_client.acquire_token_on_behalf_of = mocker.Mock( return_value={"access_token": "token"} @@ -20,3 +20,13 @@ def __init__(self, mocker, client_type): self.mock_client = mock_client self._create_client_application = mocker.Mock(return_value=self.mock_client) + +def agentic_mock_class_MsalAuth( + mocker, + get_agentic_application_token_return=None, + get_agentic_instance_token_return=None, + get_agentic_user_token_return=None, +): + mocker.patch.object(MsalAuth, "get_agentic_application_token", return_value=get_agentic_application_token_return) + mocker.patch.object(MsalAuth, "get_agentic_instance_token", return_value=get_agentic_instance_token_return) + mocker.patch.object(MsalAuth, "get_agentic_user_token", return_value=get_agentic_user_token_return) \ No newline at end of file diff --git a/tests/authentication_msal/test_msal_auth.py b/tests/authentication_msal/test_msal_auth.py index 21576a81..45f44b42 100644 --- a/tests/authentication_msal/test_msal_auth.py +++ b/tests/authentication_msal/test_msal_auth.py @@ -1,6 +1,7 @@ import pytest from msal import ManagedIdentityClient, ConfidentialClientApplication +from microsoft_agents.authentication.msal import MsalAuth from microsoft_agents.hosting.core import Connections from tests._common.testing_objects import MockMsalAuth @@ -63,3 +64,44 @@ async def test_aquire_token_on_behalf_of_confidential(self, mocker): mock_auth.mock_client.acquire_token_on_behalf_of.assert_called_with( scopes=["test-scope"], user_assertion="test-assertion" ) + +# class TestMsalAuthAgentic: + +# @pytest.mark.asyncio +# async def test_get_agentic_user_token_data_flow(self, mocker): +# agent_app_instance_id = "test-agent-app-id" +# app_token = "app-token" +# instance_token = "instance-token" +# agent_user_token = "agent-token" +# upn = "test-upn" +# scopes = ["user.read"] + +# mocker.patch.object(MsalAuth, "get_agentic_instance_token", return_value=[instance_token, app_token]) + +# mock_auth = MockMsalAuth(mocker, ConfidentialClientApplication) +# mocker.patch.object(ConfidentialClientApplication, "__new__", return_value=mocker.Mock(spec=ConfidentialClientApplication)) + +# result = await mock_auth.get_agentic_user_token(agent_app_instance_id, upn, scopes) +# mock_auth.get_agentic_instance_token.assert_called_once_with(agent_app_instance_id) + +# assert result == agent_user_token + +# @pytest.mark.asyncio +# async def test_get_agentic_user_token_failure(self, mocker): +# agent_app_instance_id = "test-agent-app-id" +# app_token = "app-token" +# instance_token = "instance-token" +# agent_user_token = "agent-token" +# upn = "test-upn" +# scopes = ["user.read"] + +# mocker.patch.object(MsalAuth, "get_agentic_instance_token", return_value=[instance_token, app_token]) + +# mock_auth = MockMsalAuth(mocker, ConfidentialClientApplication, acquire_token_for_client_return=None) +# mocker.patch.object(ConfidentialClientApplication, "__new__", return_value=mocker.Mock(spec=ConfidentialClientApplication)) + +# result = await mock_auth.get_agentic_user_token(agent_app_instance_id, upn, scopes) + +# mock_auth.get_agentic_instance_token.assert_called_once_with(agent_app_instance_id) + +# assert result is None \ No newline at end of file diff --git a/tests/hosting_core/app/auth/_common.py b/tests/hosting_core/app/auth/_common.py index 67939bc3..f98ad464 100644 --- a/tests/hosting_core/app/auth/_common.py +++ b/tests/hosting_core/app/auth/_common.py @@ -25,14 +25,18 @@ def testing_TurnContext( channel_id=DEFAULTS.channel_id, user_id=DEFAULTS.user_id, user_token_client=None, + activity=None ): if not user_token_client: user_token_client = mock_UserTokenClient(mocker) turn_context = mocker.Mock() - turn_context.activity.channel_id = channel_id - turn_context.activity.from_property.id = user_id - turn_context.activity.type = ActivityTypes.message + if not activity: + turn_context.activity.channel_id = channel_id + turn_context.activity.from_property.id = user_id + turn_context.activity.type = ActivityTypes.message + else: + turn_context.activity = activity turn_context.adapter.USER_TOKEN_CLIENT_KEY = "__user_token_client" turn_context.adapter.AGENT_IDENTITY_KEY = "__agent_identity_key" agent_identity = mocker.Mock() @@ -42,4 +46,34 @@ def testing_TurnContext( "__agent_identity_key": agent_identity, } return turn_context + +def testing_TurnContext_magic( + mocker, + channel_id=DEFAULTS.channel_id, + user_id=DEFAULTS.user_id, + user_token_client=None, + activity=None +): + if not user_token_client: + user_token_client = mock_UserTokenClient(mocker) + + turn_context = mocker.MagicMock(spec=TurnContext) + turn_context.adapter = mocker.Mock() + if not activity: + turn_context.activity = mocker.Mock() + turn_context.activity.channel_id = channel_id + turn_context.activity.from_property.id = user_id + turn_context.activity.type = ActivityTypes.message + else: + turn_context.activity = activity + turn_context.adapter.USER_TOKEN_CLIENT_KEY = "__user_token_client" + turn_context.adapter.AGENT_IDENTITY_KEY = "__agent_identity_key" + agent_identity = mocker.Mock() + agent_identity.claims = {"aud": DEFAULTS.ms_app_id} + turn_context.turn_state = mocker.Mock() + turn_context.turn_state = { + "__user_token_client": user_token_client, + "__agent_identity_key": agent_identity, + } + return turn_context \ No newline at end of file diff --git a/tests/hosting_core/app/auth/test_agentic_authorization.py b/tests/hosting_core/app/auth/test_agentic_authorization.py index e69de29b..259d7400 100644 --- a/tests/hosting_core/app/auth/test_agentic_authorization.py +++ b/tests/hosting_core/app/auth/test_agentic_authorization.py @@ -0,0 +1,182 @@ +import pytest + +from microsoft_agents.activity import ( + Activity, + ChannelAccount, + RoleTypes +) + +from microsoft_agents.authentication.msal import MsalAuth, MsalConnectionManager + +from microsoft_agents.hosting.core import ( + AgenticAuthorization, + SignInResponse, + MemoryStorage +) + +from tests._common.data import ( + TEST_FLOW_DATA, + TEST_AUTH_DATA, + TEST_STORAGE_DATA, + TEST_DEFAULTS, + TEST_ENV_DICT, + TEST_AGENTIC_ENV_DICT, + create_test_auth_handler, +) + +from tests._common.testing_objects import ( + TestingConnectionManager, + TestingTokenProvider, + agentic_mock_class_MsalAuth, + TestingConnectionManager as MockConnectionManager, +) + +from ._common import ( + testing_TurnContext_magic, +) + +DEFAULTS = TEST_DEFAULTS() +AGENTIC_ENV_DICT = TEST_AGENTIC_ENV_DICT() + +class TestUtils: + + def setup_method(self): + self.TurnContext = testing_TurnContext_magic + + @pytest.fixture + def storage(self): + return MemoryStorage() + + @pytest.fixture + def connection_manager(self, mocker): + return MockConnectionManager() + + @pytest.fixture + def agentic_auth(self, mocker, storage, connection_manager): + return AgenticAuthorization( + storage, + connection_manager, + **AGENTIC_ENV_DICT + ) + + @pytest.fixture(params=[RoleTypes.user, RoleTypes.skill, RoleTypes.agent]) + def non_agentic_role(self, request): + return request.param + + @pytest.fixture(params=[RoleTypes.agentic_user, RoleTypes.agentic_identity]) + def agentic_role(self, request): + return request.param + +class TestAgenticAuthorization(TestUtils): + + @pytest.mark.parametrize("activity", [ + Activity( + type="message", + recipient=ChannelAccount( + id="bot_id", + agentic_app_id=DEFAULTS.agentic_instance_id, + role=RoleTypes.agent, + ) + ), + Activity( + type="message", + recipient=ChannelAccount( + id=DEFAULTS.agentic_user_id, + agentic_app_id=DEFAULTS.agentic_instance_id, + role=RoleTypes.agentic_user, + ) + ), + Activity( + type="message", + recipient=ChannelAccount( + id=DEFAULTS.agentic_user_id, + ) + ), + Activity( + type="message", + recipient=ChannelAccount(id="some_id") + ) + ]) + def test_is_agentic_request(self, mocker, activity): + assert activity.is_agentic() == AgenticAuthorization.is_agentic_request(activity) + context = self.TurnContext(mocker, activity=activity) + assert activity.is_agentic() == AgenticAuthorization.is_agentic_request(context) + + def test_get_agent_instance_id_is_agentic(self, mocker, agentic_role): + activity = Activity(type="message", recipient=ChannelAccount(id="some_id", agentic_app_id=DEFAULTS.agentic_instance_id, role=agentic_role)) + context = self.TurnContext(mocker, activity=activity) + assert AgenticAuthorization.get_agent_instance_id(context) == DEFAULTS.agentic_instance_id + + def test_get_agent_instance_id_not_agentic(self, mocker, non_agentic_role): + activity = Activity(type="message", recipient=ChannelAccount(id="some_id", agentic_app_id=DEFAULTS.agentic_instance_id, role=non_agentic_role)) + context = self.TurnContext(mocker, activity=activity) + assert AgenticAuthorization.get_agent_instance_id(context) is None + + def test_get_agentic_user_is_agentic(self, mocker, agentic_role): + activity = Activity(type="message", recipient=ChannelAccount(id=DEFAULTS.agentic_user_id, agentic_app_id=DEFAULTS.agentic_instance_id, role=agentic_role)) + context = self.TurnContext(mocker, activity=activity) + assert AgenticAuthorization.get_agentic_user(context) == DEFAULTS.agentic_user_id + + def test_get_agentic_user_not_agentic(self, mocker, non_agentic_role): + activity = Activity(type="message", recipient=ChannelAccount(id=DEFAULTS.agentic_user_id, agentic_app_id=DEFAULTS.agentic_instance_id, role=non_agentic_role)) + context = self.TurnContext(mocker, activity=activity) + assert AgenticAuthorization.get_agentic_user(context) is None + + @pytest.mark.asyncio + async def test_get_agentic_instance_token_not_agentic(self, mocker, non_agentic_role, agentic_auth): + activity = Activity(type="message", recipient=ChannelAccount(id=DEFAULTS.agentic_user_id, agentic_app_id=DEFAULTS.agentic_instance_id, role=non_agentic_role)) + context = self.TurnContext(mocker, activity=activity) + assert await agentic_auth.get_agentic_instance_token(context) is None + + @pytest.mark.asyncio + async def test_get_agentic_user_token_not_agentic(self, mocker, non_agentic_role, agentic_auth): + activity = Activity(type="message", recipient=ChannelAccount(id=DEFAULTS.agentic_user_id, agentic_app_id=DEFAULTS.agentic_instance_id, role=non_agentic_role)) + context = self.TurnContext(mocker, activity=activity) + assert await agentic_auth.get_agentic_user_token(context, ["user.Read"]) is None + + @pytest.mark.asyncio + async def test_get_agentic_user_token_agentic_no_user_id(self, mocker, agentic_role, agentic_auth): + activity = Activity(type="message", recipient=ChannelAccount(agentic_app_id=DEFAULTS.agentic_instance_id, role=agentic_role)) + context = self.TurnContext(mocker, activity=activity) + assert await agentic_auth.get_agentic_user_token(context, ["user.Read"]) is None + + @pytest.mark.asyncio + async def test_get_agentic_instance_token_is_agentic(self, mocker, agentic_role, agentic_auth): + mock_provider = mocker.Mock(spec=MsalAuth) + mock_provider.get_agentic_instance_token = mocker.AsyncMock(return_value=[DEFAULTS.token, "bot_id"]) + + connection_manager = mocker.Mock(spec=MsalConnectionManager) + connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) + + agentic_auth = AgenticAuthorization( + MemoryStorage(), + connection_manager, + **AGENTIC_ENV_DICT + ) + + activity = Activity(type="message", recipient=ChannelAccount(id="some_id", agentic_app_id=DEFAULTS.agentic_instance_id, role=agentic_role)) + context = self.TurnContext(mocker, activity=activity) + + token = await agentic_auth.get_agentic_instance_token(context) + assert token == DEFAULTS.token + + @pytest.mark.asyncio + async def test_get_agentic_user_token_is_agentic(self, mocker, agentic_role, agentic_auth): + mock_provider = mocker.Mock(spec=MsalAuth) + mock_provider.get_agentic_user_token = mocker.AsyncMock(return_value=DEFAULTS.token) + + connection_manager = mocker.Mock(spec=MsalConnectionManager) + connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) + + agentic_auth = AgenticAuthorization( + MemoryStorage(), + connection_manager, + **AGENTIC_ENV_DICT + ) + + activity = Activity(type="message", recipient=ChannelAccount(id="some_id", agentic_app_id=DEFAULTS.agentic_instance_id, role=agentic_role)) + context = self.TurnContext(mocker, activity=activity) + + token = await agentic_auth.get_agentic_user_token(context, ["user.Read"]) + assert token == DEFAULTS.token + From 83c6688c1d67473d39fffb4b7d7de1d9db44afde Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 25 Sep 2025 15:10:45 -0700 Subject: [PATCH 12/36] Finalized fundamental unit tests for agentic auth scenarios --- .../microsoft_agents/activity/activity.py | 2 +- .../microsoft_agents/activity/channels.py | 1 + .../microsoft_agents/activity/role_types.py | 2 +- .../msal/msal_connection_manager.py | 3 +- .../hosting/core/app/agent_application.py | 19 +- .../core/app/auth/agentic_authorization.py | 63 +- .../hosting/core/app/auth/auth_handler.py | 1 + .../hosting/core/app/auth/authorization.py | 88 +- .../core/app/auth/authorization_variant.py | 10 +- .../hosting/core/app/auth/sign_in_response.py | 9 +- .../hosting/core/app/auth/sign_in_state.py | 14 +- .../core/app/auth/user_authorization.py | 16 +- .../core/app/auth/user_authorization_base.py | 15 +- .../access_token_provider_base.py | 3 +- .../hosting/core/turn_context.py | 13 +- .../_common/data/test_agentic_auth_config.py | 7 +- tests/_common/data/test_auth_config.py | 7 +- tests/_common/data/test_defaults.py | 1 - tests/_common/testing_objects/__init__.py | 8 +- .../_common/testing_objects/mocks/__init__.py | 4 +- .../mocks/mock_authorization.py | 11 +- .../testing_objects/mocks/mock_msal_auth.py | 25 +- tests/activity/test_activity.py | 23 +- tests/authentication_msal/test_msal_auth.py | 3 +- tests/hosting_core/app/auth/_common.py | 19 +- tests/hosting_core/app/auth/_env.py | 7 +- .../app/auth/test_agentic_authorization.py | 220 +++-- .../app/auth/test_auth_handler.py | 10 +- .../app/auth/test_authorization.py | 411 ++++++--- .../app/auth/test_authorization_variant.py | 0 .../app/auth/test_sign_in_response.py | 3 +- .../app/auth/test_sign_in_state.py | 45 +- .../app/auth/test_user_authorization.py | 803 ++++++------------ 33 files changed, 977 insertions(+), 889 deletions(-) delete mode 100644 tests/hosting_core/app/auth/test_authorization_variant.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index e408a31b..fa31470b 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -654,4 +654,4 @@ def is_agentic(self) -> bool: return self.recipient and self.recipient.role in [ RoleTypes.agentic_identity, RoleTypes.agentic_user, - ] \ No newline at end of file + ] diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py index e92541b6..d8184b80 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py @@ -4,6 +4,7 @@ from enum import Enum from typing_extensions import Self + class Channels(str, Enum): """ Ids of channels supported by ABS. diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/role_types.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/role_types.py index d3419967..1008cb8a 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/role_types.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/role_types.py @@ -6,4 +6,4 @@ class RoleTypes(str, Enum): agent = "bot" skill = "skill" agentic_identity = "agenticAppInstance" - agentic_user = "agenticUser" \ No newline at end of file + agentic_user = "agenticUser" diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py index b6f66f48..3abf4543 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py @@ -10,7 +10,6 @@ class MsalConnectionManager(Connections): - def __init__( self, connections_configurations: Dict[str, AgentAuthConfiguration] = None, @@ -62,7 +61,7 @@ def get_token_provider( """ if not self._connections_map: return self.get_default_connection() - + return self.get_default_connection() # TODO: Implement logic to select the appropriate connection based on the connection map diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index a6663e80..a6a01a9c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -443,7 +443,9 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: return __call - def handoff(self, *, auth_handlers: Optional[List[str]] = None) -> Callable[ + def handoff( + self, *, auth_handlers: Optional[List[str]] = None + ) -> Callable[ [Callable[[TurnContext, StateT, str], Awaitable[None]]], Callable[[TurnContext, StateT, str], Awaitable[None]], ]: @@ -608,12 +610,17 @@ async def _on_turn(self, context: TurnContext): logger.debug("Initializing turn state") turn_state = await self._initialize_state(context) - auth_intercepts, continuation_activity = await self._auth.on_turn_auth_intercept(context, turn_state) + ( + auth_intercepts, + continuation_activity, + ) = await self._auth.on_turn_auth_intercept(context, turn_state) if auth_intercepts: if continuation_activity: new_context = copy(context) new_context.activity = continuation_activity - logger.info("Resending continuation activity %s", continuation_activity.text) + logger.info( + "Resending continuation activity %s", continuation_activity.text + ) await self.on_turn(new_context) await turn_state.save(context) return @@ -735,7 +742,11 @@ async def _on_activity(self, context: TurnContext, state: StateT): else: sign_in_complete = True for auth_handler_id in route.auth_handlers: - if not (await self._auth.start_or_continue_sign_in(context, state, auth_handler_id)).sign_in_complete(): + if not ( + await self._auth.start_or_continue_sign_in( + context, state, auth_handler_id + ) + ).sign_in_complete(): sign_in_complete = False break diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py index 3fc60d9b..c7003a99 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py @@ -2,20 +2,18 @@ from typing import Optional, Union -from microsoft_agents.activity import ( - Activity, - TokenResponse -) +from microsoft_agents.activity import Activity, TokenResponse from ...turn_context import TurnContext +from ...oauth import FlowStateTag from .authorization_variant import AuthorizationVariant from .sign_in_response import SignInResponse logger = logging.getLogger(__name__) -class AgenticAuthorization(AuthorizationVariant): +class AgenticAuthorization(AuthorizationVariant): @staticmethod def is_agentic_request(context_or_activity: Union[TurnContext, Activity]) -> bool: if isinstance(context_or_activity, TurnContext): @@ -24,51 +22,68 @@ def is_agentic_request(context_or_activity: Union[TurnContext, Activity]) -> boo activity = context_or_activity return activity.is_agentic() - + @staticmethod def get_agent_instance_id(context: TurnContext) -> Optional[str]: if not AgenticAuthorization.is_agentic_request(context): return None - + return context.activity.recipient.agentic_app_id - + @staticmethod def get_agentic_user(context: TurnContext) -> Optional[str]: if not AgenticAuthorization.is_agentic_request(context): return None - + return context.activity.recipient.id - + async def get_agentic_instance_token(self, context: TurnContext) -> Optional[str]: if not self.is_agentic_request(context): return None - + assert context.identity - connection = self._connection_manager.get_token_provider(context.identity, "agentic") + connection = self._connection_manager.get_token_provider( + context.identity, "agentic" + ) agent_instance_id = self.get_agent_instance_id(context) assert agent_instance_id - instance_token, _ = await connection.get_agentic_instance_token(agent_instance_id) + instance_token, _ = await connection.get_agentic_instance_token( + agent_instance_id + ) return instance_token - async def get_agentic_user_token(self, context: TurnContext, scopes: list[str]) -> Optional[str]: - + async def get_agentic_user_token( + self, context: TurnContext, scopes: list[str] + ) -> Optional[str]: + if not self.is_agentic_request(context) or not self.get_agentic_user(context): return None - + assert context.identity - connection = self._connection_manager.get_token_provider(context.identity, "agentic") + connection = self._connection_manager.get_token_provider( + context.identity, "agentic" + ) upn = self.get_agentic_user(context) agentic_instance_id = self.get_agent_instance_id(context) assert upn and agentic_instance_id - return await connection.get_agentic_user_token( - agentic_instance_id, upn, scopes - ) - - async def sign_in(self, context: TurnContext, connection_name: str, scopes: Optional[list[str]] = None) -> SignInResponse: + return await connection.get_agentic_user_token(agentic_instance_id, upn, scopes) + + async def sign_in( + self, + context: TurnContext, + connection_name: str, + scopes: Optional[list[str]] = None, + ) -> SignInResponse: scopes = scopes or [] token = await self.get_agentic_user_token(context, scopes) - return SignInResponse(token_response=TokenResponse(token=token), tag=FlowStateTag.COMPLETED) if token else SignInResponse() + return ( + SignInResponse( + token_response=TokenResponse(token=token), tag=FlowStateTag.COMPLETE + ) + if token + else SignInResponse() + ) async def sign_out(self, context: TurnContext) -> None: - pass \ No newline at end of file + pass diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py index 63019639..a2ec9361 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py @@ -6,6 +6,7 @@ logger = logging.getLogger(__name__) + class AuthHandler: """ Interface defining an authorization handler for OAuth flows. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py index 97001b82..14350197 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py @@ -20,14 +20,14 @@ AUTHORIZATION_TYPE_MAP: dict[str, type[AuthorizationVariant]] = { UserAuthorization.__name__.lower(): UserAuthorization, - AgenticAuthorization.__name__.lower(): AgenticAuthorization + AgenticAuthorization.__name__.lower(): AgenticAuthorization, } logger = logging.getLogger(__name__) StateT = TypeVar("StateT", bound=TurnState) -class Authorization(Generic[StateT]): +class Authorization(Generic[StateT]): def __init__( self, storage: Storage, @@ -83,7 +83,7 @@ def _init_auth_variants(self, auth_handlers: dict[str, AuthHandler]): auth_type = auth_type.lower() associated_handlers = { - auth_handler.name: auth_handler + auth_handler.name: auth_handler for auth_handler in self._auth_handlers.values() if auth_handler.auth_type.lower() == auth_type } @@ -91,7 +91,7 @@ def _init_auth_variants(self, auth_handlers: dict[str, AuthHandler]): self._authorization_variants[auth_type] = AUTHORIZATION_TYPE_MAP[auth_type]( storage=self._storage, connection_manager=self._connection_manager, - auth_handlers=associated_handlers + auth_handlers=associated_handlers, ) def sign_in_state_key(self, context: TurnContext) -> str: @@ -100,8 +100,10 @@ def sign_in_state_key(self, context: TurnContext) -> str: async def _load_sign_in_state(self, context: TurnContext) -> Optional[SignInState]: key = self.sign_in_state_key(context) return (await self._storage.read([key], target_cls=SignInState)).get(key) - - async def _save_sign_in_state(self, context: TurnContext, state: SignInState) -> None: + + async def _save_sign_in_state( + self, context: TurnContext, state: SignInState + ) -> None: key = self.sign_in_state_key(context) await self._storage.write({key: state}) @@ -111,33 +113,49 @@ async def _delete_sign_in_state(self, context: TurnContext) -> None: @property def user_auth(self) -> UserAuthorization: - return cast(UserAuthorization, self._resolve_auth_variant(UserAuthorization.__name__)) - + return cast( + UserAuthorization, self._resolve_auth_variant(UserAuthorization.__name__) + ) + @property def agentic_auth(self) -> AgenticAuthorization: - return cast(AgenticAuthorization, self._resolve_auth_variant(AgenticAuthorization.__name__)) + return cast( + AgenticAuthorization, + self._resolve_auth_variant(AgenticAuthorization.__name__), + ) def _resolve_auth_variant(self, auth_variant: str) -> AuthorizationVariant: - + auth_variant = auth_variant.lower() if auth_variant not in self._authorization_variants: - raise ValueError(f"Auth variant {auth_variant} not recognized or not configured.") + raise ValueError( + f"Auth variant {auth_variant} not recognized or not configured." + ) return self._authorization_variants[auth_variant] - + def resolve_handler(self, handler_id: str) -> AuthHandler: if handler_id not in self._auth_handlers: - raise ValueError(f"Auth handler {handler_id} not recognized or not configured.") + raise ValueError( + f"Auth handler {handler_id} not recognized or not configured." + ) return self._auth_handlers[handler_id] - async def start_or_continue_sign_in(self, context: TurnContext, state: StateT, auth_handler_id: str) -> SignInResponse: + async def start_or_continue_sign_in( + self, context: TurnContext, state: StateT, auth_handler_id: str + ) -> SignInResponse: sign_in_state = await self._load_sign_in_state(context) if not sign_in_state: sign_in_state = SignInState({auth_handler_id: ""}) if sign_in_state.tokens.get(auth_handler_id): - return SignInResponse(tag=FlowStateTag.COMPLETE, token_response=TokenResponse(token=sign_in_state.tokens[auth_handler_id])) + return SignInResponse( + tag=FlowStateTag.COMPLETE, + token_response=TokenResponse( + token=sign_in_state.tokens[auth_handler_id] + ), + ) handler = self.resolve_handler(auth_handler_id) variant = self._resolve_auth_variant(handler.auth_type) @@ -149,18 +167,20 @@ async def start_or_continue_sign_in(self, context: TurnContext, state: StateT, a token = sign_in_response.token_response.token sign_in_state.tokens[auth_handler_id] = token await self._save_sign_in_state(context, sign_in_state) - + elif sign_in_response.tag == FlowStateTag.FAILURE: if self._sign_in_failure_handler: await self._sign_in_failure_handler(context, state, auth_handler_id) - + elif sign_in_response.tag in [FlowStateTag.BEGIN, FlowStateTag.CONTINUE]: sign_in_state.continuation_activity = context.activity await self._save_sign_in_state(context, sign_in_state) - + return sign_in_response - - async def sign_out(self, context: TurnContext, state: StateT, auth_handler_id=None) -> None: + + async def sign_out( + self, context: TurnContext, state: StateT, auth_handler_id=None + ) -> None: sign_in_state = await self._load_sign_in_state(context) if sign_in_state: if not auth_handler_id: @@ -177,25 +197,31 @@ async def sign_out(self, context: TurnContext, state: StateT, auth_handler_id=No del sign_in_state.tokens[auth_handler_id] await self._save_sign_in_state(context, sign_in_state) - async def on_turn_auth_intercept(self, context: TurnContext, state: StateT) -> tuple[bool, Optional[Activity]]: + async def on_turn_auth_intercept( + self, context: TurnContext, state: StateT + ) -> tuple[bool, Optional[Activity]]: """Intercepts the turn to check for active authentication flows. - + Returns true if the rest of the turn should be skipped because auth did not finish. Returns false if the turn should continue processing as normal. Calls continue_turn_callback if auth completes and a new turn should be started. <- TODO, seems a bit strange """ # get active thing... - + sign_in_state = await self._load_sign_in_state(context) - + if sign_in_state: auth_handler_id = sign_in_state.active_handler() if auth_handler_id: - sign_in_response = await self.start_or_continue_sign_in(context, state, auth_handler_id) + sign_in_response = await self.start_or_continue_sign_in( + context, state, auth_handler_id + ) if sign_in_response.tag == FlowStateTag.COMPLETE: assert sign_in_state.continuation_activity is not None - continuation_activity = sign_in_state.continuation_activity.model_copy() + continuation_activity = ( + sign_in_state.continuation_activity.model_copy() + ) return True, continuation_activity return True, None return False, None @@ -242,9 +268,9 @@ async def exchange_token( if token_response and self._is_exchangeable(token_response.token): logger.debug("Token is exchangeable, performing OBO flow") return await self._handle_obo(token_response.token, scopes, auth_handler_id) - + return token_response - + def _is_exchangeable(self, token: str) -> bool: """ Checks if a token is exchangeable (has api:// audience). @@ -280,7 +306,9 @@ async def _handle_obo( """ auth_handler = self.resolve_handler(handler_id) - token_provider = self._connection_manager.get_connection(auth_handler.obo_connection_name) + token_provider = self._connection_manager.get_connection( + auth_handler.obo_connection_name + ) logger.info("Attempting to exchange token on behalf of user") new_token = await token_provider.aquire_token_on_behalf_of( @@ -310,4 +338,4 @@ def on_sign_in_failure( Args: handler: The handler function to call on sign-in failure. """ - self._sign_in_failure_handler = handler \ No newline at end of file + self._sign_in_failure_handler = handler diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py index 4a22527a..e6566e83 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py @@ -15,8 +15,8 @@ logger = logging.getLogger(__name__) -class AuthorizationVariant(ABC): +class AuthorizationVariant(ABC): def __init__( self, storage: Storage, @@ -56,11 +56,9 @@ def __init__( } self._auth_handlers = auth_handlers or {} - + async def sign_in( - self, - context: TurnContext, - auth_handler_id: Optional[str] = None + self, context: TurnContext, auth_handler_id: Optional[str] = None ) -> SignInResponse: raise NotImplementedError() @@ -69,4 +67,4 @@ async def sign_out( context: TurnContext, auth_handler_id: Optional[str] = None, ) -> None: - raise NotImplementedError() \ No newline at end of file + raise NotImplementedError() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py index 7af87260..5cc4f426 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py @@ -4,13 +4,18 @@ from ...oauth import FlowStateTag + class SignInResponse: token_response: TokenResponse tag: FlowStateTag - def __init__(self, token_response: Optional[TokenResponse] = None, tag: FlowStateTag = FlowStateTag.FAILURE) -> None: + def __init__( + self, + token_response: Optional[TokenResponse] = None, + tag: FlowStateTag = FlowStateTag.FAILURE, + ) -> None: self.token_response = token_response or TokenResponse() self.tag = tag def sign_in_complete(self) -> bool: - return self.tag in [FlowStateTag.COMPLETE, FlowStateTag.NOT_STARTED] \ No newline at end of file + return self.tag in [FlowStateTag.COMPLETE, FlowStateTag.NOT_STARTED] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py index a1c404d4..a381ce3c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py @@ -7,9 +7,13 @@ from ...storage._type_aliases import JSON from ...storage import StoreItem -class SignInState(StoreItem): - def __init__(self, tokens: Optional[JSON] = None, continuation_activity: Optional[Activity] = None) -> None: +class SignInState(StoreItem): + def __init__( + self, + tokens: Optional[JSON] = None, + continuation_activity: Optional[Activity] = None, + ) -> None: self.tokens = tokens or {} self.continuation_activity = continuation_activity @@ -18,13 +22,13 @@ def store_item_to_json(self) -> JSON: "tokens": self.tokens, "continuation_activity": self.continuation_activity, } - + @staticmethod def from_json_to_store_item(json_data: JSON) -> SignInState: return SignInState(json_data["tokens"], json_data.get("continuation_activity")) - + def active_handler(self) -> "": for handler_id, token in self.tokens.items(): if not token: return handler_id - return "" \ No newline at end of file + return "" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py index 80d00418..9a122d4b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py @@ -18,8 +18,8 @@ logger = logging.getLogger(__name__) -class UserAuthorization(UserAuthorizationBase): +class UserAuthorization(UserAuthorizationBase): async def _handle_flow_response( self, context: TurnContext, flow_response: FlowResponse ) -> None: @@ -63,16 +63,14 @@ async def _handle_flow_response( logger.warning("Sign-in flow failed for unknown reasons.") await context.send_activity("Sign-in failed. Please try again.") - async def sign_in(self, context: TurnContext, auth_handler_id: str) -> SignInResponse: + async def sign_in( + self, context: TurnContext, auth_handler_id: str + ) -> SignInResponse: logger.debug( "Beginning or continuing flow for auth handler %s", auth_handler_id, ) - flow_response = ( - await self.begin_or_continue_flow( - context, auth_handler_id - ) - ) + flow_response = await self.begin_or_continue_flow(context, auth_handler_id) await self._handle_flow_response(context, flow_response) logger.debug( "Flow response flow_state.tag: %s", @@ -81,7 +79,7 @@ async def sign_in(self, context: TurnContext, auth_handler_id: str) -> SignInRes sign_in_response = SignInResponse( token_response=flow_response.token_response, - tag=flow_response.flow_state.tag + tag=flow_response.flow_state.tag, ) - return sign_in_response \ No newline at end of file + return sign_in_response diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py index c188378c..31113af5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py @@ -24,12 +24,13 @@ logger = logging.getLogger(__name__) + class UserAuthorizationBase(AuthorizationVariant, ABC): """ Class responsible for managing authorization and OAuth flows. Handles multiple OAuth providers and manages the complete authentication lifecycle. """ - + async def _load_flow( self, context: TurnContext, auth_handler_id: str ) -> tuple[OAuthFlow, FlowStorageClient]: @@ -86,9 +87,7 @@ async def _load_flow( return flow, flow_storage_client async def begin_or_continue_flow( - self, - context: TurnContext, - auth_handler_id: str + self, context: TurnContext, auth_handler_id: str ) -> FlowResponse: """Begins or continues an OAuth flow. @@ -105,11 +104,13 @@ async def begin_or_continue_flow( flow, flow_storage_client = await self._load_flow(context, auth_handler_id) prev_tag = flow.flow_state.tag - flow_response: FlowResponse = await flow.begin_or_continue_flow(context.activity) + flow_response: FlowResponse = await flow.begin_or_continue_flow( + context.activity + ) logger.info("Saving OAuth flow state to storage") await flow_storage_client.write(flow_response.flow_state) - + # if prev_tag != flow_response.flow_state.tag and flow_response.flow_state.tag == FlowStateTag.COMPLETE: # # Clear the flow state on completion # await flow_storage_client.delete(auth_handler_id) @@ -154,4 +155,4 @@ async def sign_out( if auth_handler_id: await self._sign_out(context, [auth_handler_id]) else: - await self._sign_out(context, self._auth_handlers.keys()) \ No newline at end of file + await self._sign_out(context, self._auth_handlers.keys()) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py index 8d36d1c5..37f0e236 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py @@ -28,7 +28,7 @@ async def aquire_token_on_behalf_of( :return: The access token as a string. """ raise NotImplementedError() - + async def get_agentic_application_token( self, agent_app_instance_id: str ) -> Optional[str]: @@ -38,7 +38,6 @@ async def get_agentic_instance_token( self, agent_app_instance_id: str ) -> tuple[str, str]: raise NotImplementedError() - async def get_agentic_user_token( self, agent_app_instance_id: str, upn: str, scopes: list[str] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index 216bc423..e89a36b5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -25,7 +25,12 @@ class TurnContext(TurnContextProtocol): # Same constant as in the BF Adapter, duplicating here to avoid circular dependency _INVOKE_RESPONSE_KEY = "TurnContext.InvokeResponse" - def __init__(self, adapter_or_context, request: Activity = None, identity: ClaimsIdentity = None): + def __init__( + self, + adapter_or_context, + request: Activity = None, + identity: ClaimsIdentity = None, + ): """ Creates a new TurnContext instance. :param adapter_or_context: @@ -146,7 +151,7 @@ def streaming_response(self): # If the hosting library isn't available, return None self._streaming_response = None return self._streaming_response - + @property def identity(self) -> Optional[ClaimsIdentity]: return self._identity @@ -427,7 +432,7 @@ def get_mentions(activity: Activity) -> list[Mention]: result.append(entity) return result - + @staticmethod def is_agentic_request(context: TurnContext) -> bool: - return context.activity.is_agentic() \ No newline at end of file + return context.activity.is_agentic() diff --git a/tests/_common/data/test_agentic_auth_config.py b/tests/_common/data/test_agentic_auth_config.py index fb473f17..22af23d6 100644 --- a/tests/_common/data/test_agentic_auth_config.py +++ b/tests/_common/data/test_agentic_auth_config.py @@ -25,7 +25,9 @@ agentic_obo_connection_name=DEFAULTS.agentic_obo_connection_name, agentic_auth_handler_id=DEFAULTS.agentic_auth_handler_id, agentic_auth_handler_title=DEFAULTS.agentic_auth_handler_title, - agentic_auth_handler_text=DEFAULTS.agentic_auth_handler_text) + agentic_auth_handler_text=DEFAULTS.agentic_auth_handler_text, +) + def TEST_AGENTIC_ENV(): lines = _TEST_AGENTIC_ENV_RAW.strip().split("\n") @@ -35,5 +37,6 @@ def TEST_AGENTIC_ENV(): env[key.strip()] = value.strip() return env + def TEST_AGENTIC_ENV_DICT(): - return load_configuration_from_env(TEST_AGENTIC_ENV()) \ No newline at end of file + return load_configuration_from_env(TEST_AGENTIC_ENV()) diff --git a/tests/_common/data/test_auth_config.py b/tests/_common/data/test_auth_config.py index a513874b..3d1dcbee 100644 --- a/tests/_common/data/test_auth_config.py +++ b/tests/_common/data/test_auth_config.py @@ -15,7 +15,9 @@ obo_connection_name=DEFAULTS.obo_connection_name, auth_handler_id=DEFAULTS.auth_handler_id, auth_handler_title=DEFAULTS.auth_handler_title, - auth_handler_text=DEFAULTS.auth_handler_text) + auth_handler_text=DEFAULTS.auth_handler_text, +) + def TEST_ENV(): lines = _TEST_ENV_RAW.strip().split("\n") @@ -25,5 +27,6 @@ def TEST_ENV(): env[key.strip()] = value.strip() return env + def TEST_ENV_DICT(): - return load_configuration_from_env(TEST_ENV()) \ No newline at end of file + return load_configuration_from_env(TEST_ENV()) diff --git a/tests/_common/data/test_defaults.py b/tests/_common/data/test_defaults.py index 3f63e637..9f7d67c5 100644 --- a/tests/_common/data/test_defaults.py +++ b/tests/_common/data/test_defaults.py @@ -30,7 +30,6 @@ def __init__(self): self.agentic_instance_id = "agentic_instance_id" self.agentic_user_id = "agentic_user_id" - self.missing_abs_oauth_connection_name = "missing_connection_name" self.auth_handlers = [AuthHandler()] diff --git a/tests/_common/testing_objects/__init__.py b/tests/_common/testing_objects/__init__.py index ce5c3619..92ab0041 100644 --- a/tests/_common/testing_objects/__init__.py +++ b/tests/_common/testing_objects/__init__.py @@ -1,4 +1,6 @@ -from tests._common.testing_objects.mocks.mock_msal_auth import agentic_mock_class_MsalAuth +from tests._common.testing_objects.mocks.mock_msal_auth import ( + agentic_mock_class_MsalAuth, +) from .adapters import TestingAdapter from .mocks import ( @@ -10,7 +12,7 @@ mock_class_UserAuthorization, mock_class_AgenticAuthorization, mock_class_Authorization, - agentic_mock_class_MsalAuth + agentic_mock_class_MsalAuth, ) from .testing_authorization import TestingAuthorization @@ -34,5 +36,5 @@ "mock_class_UserAuthorization", "mock_class_AgenticAuthorization", "mock_class_Authorization", - "agentic_mock_class_MsalAuth" + "agentic_mock_class_MsalAuth", ] diff --git a/tests/_common/testing_objects/mocks/__init__.py b/tests/_common/testing_objects/mocks/__init__.py index fd894d7f..780b218c 100644 --- a/tests/_common/testing_objects/mocks/__init__.py +++ b/tests/_common/testing_objects/mocks/__init__.py @@ -4,7 +4,7 @@ from .mock_authorization import ( mock_class_UserAuthorization, mock_class_AgenticAuthorization, - mock_class_Authorization + mock_class_Authorization, ) __all__ = [ @@ -16,5 +16,5 @@ "mock_class_UserAuthorization", "mock_class_AgenticAuthorization", "mock_class_Authorization", - "agentic_mock_class_MsalAuth" + "agentic_mock_class_MsalAuth", ] diff --git a/tests/_common/testing_objects/mocks/mock_authorization.py b/tests/_common/testing_objects/mocks/mock_authorization.py index 61cbdddc..4caa4fde 100644 --- a/tests/_common/testing_objects/mocks/mock_authorization.py +++ b/tests/_common/testing_objects/mocks/mock_authorization.py @@ -1,21 +1,28 @@ from microsoft_agents.hosting.core import ( Authorization, UserAuthorization, - AgenticAuthorization + AgenticAuthorization, ) from microsoft_agents.hosting.core.app.auth import SignInResponse + def mock_class_UserAuthorization(mocker, sign_in_return=None): if sign_in_return is None: sign_in_return = SignInResponse() mocker.patch.object(UserAuthorization, "sign_in", return_value=sign_in_return) mocker.patch.object(UserAuthorization, "sign_out") + def mock_class_AgenticAuthorization(mocker, sign_in_return=None): if sign_in_return is None: sign_in_return = SignInResponse() mocker.patch.object(AgenticAuthorization, "sign_in", return_value=sign_in_return) mocker.patch.object(AgenticAuthorization, "sign_out") + def mock_class_Authorization(mocker, start_or_continue_sign_in_return=False): - mocker.patch.object(Authorization, "start_or_continue_sign_in", return_value=start_or_continue_sign_in_return) \ No newline at end of file + mocker.patch.object( + Authorization, + "start_or_continue_sign_in", + return_value=start_or_continue_sign_in_return, + ) diff --git a/tests/_common/testing_objects/mocks/mock_msal_auth.py b/tests/_common/testing_objects/mocks/mock_msal_auth.py index c85b88ab..f9a046b7 100644 --- a/tests/_common/testing_objects/mocks/mock_msal_auth.py +++ b/tests/_common/testing_objects/mocks/mock_msal_auth.py @@ -1,13 +1,19 @@ from microsoft_agents.authentication.msal import MsalAuth from microsoft_agents.hosting.core.authorization import AgentAuthConfiguration + # used by MsalAuth tests class MockMsalAuth(MsalAuth): """ Mock object for MsalAuth """ - def __init__(self, mocker, client_type, acquire_token_for_client_return={"access_token": "token"}): + def __init__( + self, + mocker, + client_type, + acquire_token_for_client_return={"access_token": "token"}, + ): super().__init__(AgentAuthConfiguration()) mock_client = mocker.Mock(spec=client_type) @@ -21,12 +27,23 @@ def __init__(self, mocker, client_type, acquire_token_for_client_return={"access self._create_client_application = mocker.Mock(return_value=self.mock_client) + def agentic_mock_class_MsalAuth( mocker, get_agentic_application_token_return=None, get_agentic_instance_token_return=None, get_agentic_user_token_return=None, ): - mocker.patch.object(MsalAuth, "get_agentic_application_token", return_value=get_agentic_application_token_return) - mocker.patch.object(MsalAuth, "get_agentic_instance_token", return_value=get_agentic_instance_token_return) - mocker.patch.object(MsalAuth, "get_agentic_user_token", return_value=get_agentic_user_token_return) \ No newline at end of file + mocker.patch.object( + MsalAuth, + "get_agentic_application_token", + return_value=get_agentic_application_token_return, + ) + mocker.patch.object( + MsalAuth, + "get_agentic_instance_token", + return_value=get_agentic_instance_token_return, + ) + mocker.patch.object( + MsalAuth, "get_agentic_user_token", return_value=get_agentic_user_token_return + ) diff --git a/tests/activity/test_activity.py b/tests/activity/test_activity.py index fe98a6dc..d30c40c7 100644 --- a/tests/activity/test_activity.py +++ b/tests/activity/test_activity.py @@ -370,15 +370,18 @@ def test_get_mentions(self): Entity(type="mention", text="Another mention"), ] - @pytest.mark.parametrize("role, expected", [ - [RoleTypes.user, False], - [RoleTypes.agent, False], - [RoleTypes.skill, False], - [RoleTypes.agentic_user, True], - [RoleTypes.agentic_identity, True] - ]) + @pytest.mark.parametrize( + "role, expected", + [ + [RoleTypes.user, False], + [RoleTypes.agent, False], + [RoleTypes.skill, False], + [RoleTypes.agentic_user, True], + [RoleTypes.agentic_identity, True], + ], + ) def test_is_agentic(self, role, expected): - activity = Activity(type="message", - recipient=ChannelAccount(id="bot", name="bot", role=role) + activity = Activity( + type="message", recipient=ChannelAccount(id="bot", name="bot", role=role) ) - assert activity.is_agentic() == expected \ No newline at end of file + assert activity.is_agentic() == expected diff --git a/tests/authentication_msal/test_msal_auth.py b/tests/authentication_msal/test_msal_auth.py index 45f44b42..0da1909b 100644 --- a/tests/authentication_msal/test_msal_auth.py +++ b/tests/authentication_msal/test_msal_auth.py @@ -65,6 +65,7 @@ async def test_aquire_token_on_behalf_of_confidential(self, mocker): scopes=["test-scope"], user_assertion="test-assertion" ) + # class TestMsalAuthAgentic: # @pytest.mark.asyncio @@ -104,4 +105,4 @@ async def test_aquire_token_on_behalf_of_confidential(self, mocker): # mock_auth.get_agentic_instance_token.assert_called_once_with(agent_app_instance_id) -# assert result is None \ No newline at end of file +# assert result is None diff --git a/tests/hosting_core/app/auth/_common.py b/tests/hosting_core/app/auth/_common.py index f98ad464..81247cb8 100644 --- a/tests/hosting_core/app/auth/_common.py +++ b/tests/hosting_core/app/auth/_common.py @@ -1,17 +1,13 @@ -from microsoft_agents.activity import ( - Activity, - ActivityTypes -) +from microsoft_agents.activity import Activity, ActivityTypes -from microsoft_agents.hosting.core import ( - TurnContext -) +from microsoft_agents.hosting.core import TurnContext from tests._common.data import TEST_DEFAULTS from tests._common.testing_objects import mock_UserTokenClient DEFAULTS = TEST_DEFAULTS() + def testing_Activity(): return Activity( type=ActivityTypes.message, @@ -20,12 +16,13 @@ def testing_Activity(): text="Hello, World!", ) + def testing_TurnContext( mocker, channel_id=DEFAULTS.channel_id, user_id=DEFAULTS.user_id, user_token_client=None, - activity=None + activity=None, ): if not user_token_client: user_token_client = mock_UserTokenClient(mocker) @@ -46,13 +43,14 @@ def testing_TurnContext( "__agent_identity_key": agent_identity, } return turn_context - + + def testing_TurnContext_magic( mocker, channel_id=DEFAULTS.channel_id, user_id=DEFAULTS.user_id, user_token_client=None, - activity=None + activity=None, ): if not user_token_client: user_token_client = mock_UserTokenClient(mocker) @@ -76,4 +74,3 @@ def testing_TurnContext_magic( "__agent_identity_key": agent_identity, } return turn_context - \ No newline at end of file diff --git a/tests/hosting_core/app/auth/_env.py b/tests/hosting_core/app/auth/_env.py index e6c1056e..160373d3 100644 --- a/tests/hosting_core/app/auth/_env.py +++ b/tests/hosting_core/app/auth/_env.py @@ -2,17 +2,16 @@ DEFAULTS = TEST_DEFAULTS() + def ENV_CONFIG(): return { "AGENTAPPLICATION": { "USERAUTHORIZATION": { "HANDLERS": { DEFAULTS.connection_name: { - "SETTINGS": { - AZUREBOTOAUTHCONNECTIONNAME - } + "SETTINGS": {AZUREBOTOAUTHCONNECTIONNAME} } } } } - } \ No newline at end of file + } diff --git a/tests/hosting_core/app/auth/test_agentic_authorization.py b/tests/hosting_core/app/auth/test_agentic_authorization.py index 259d7400..dd6e9b56 100644 --- a/tests/hosting_core/app/auth/test_agentic_authorization.py +++ b/tests/hosting_core/app/auth/test_agentic_authorization.py @@ -1,17 +1,14 @@ import pytest -from microsoft_agents.activity import ( - Activity, - ChannelAccount, - RoleTypes -) +from microsoft_agents.activity import Activity, ChannelAccount, RoleTypes from microsoft_agents.authentication.msal import MsalAuth, MsalConnectionManager from microsoft_agents.hosting.core import ( AgenticAuthorization, SignInResponse, - MemoryStorage + MemoryStorage, + FlowStateTag, ) from tests._common.data import ( @@ -38,8 +35,8 @@ DEFAULTS = TEST_DEFAULTS() AGENTIC_ENV_DICT = TEST_AGENTIC_ENV_DICT() -class TestUtils: +class TestUtils: def setup_method(self): self.TurnContext = testing_TurnContext_magic @@ -53,11 +50,7 @@ def connection_manager(self, mocker): @pytest.fixture def agentic_auth(self, mocker, storage, connection_manager): - return AgenticAuthorization( - storage, - connection_manager, - **AGENTIC_ENV_DICT - ) + return AgenticAuthorization(storage, connection_manager, **AGENTIC_ENV_DICT) @pytest.fixture(params=[RoleTypes.user, RoleTypes.skill, RoleTypes.agent]) def non_agentic_role(self, request): @@ -67,116 +60,211 @@ def non_agentic_role(self, request): def agentic_role(self, request): return request.param -class TestAgenticAuthorization(TestUtils): - @pytest.mark.parametrize("activity", [ - Activity( - type="message", - recipient=ChannelAccount( - id="bot_id", - agentic_app_id=DEFAULTS.agentic_instance_id, - role=RoleTypes.agent, - ) - ), - Activity( - type="message", - recipient=ChannelAccount( - id=DEFAULTS.agentic_user_id, - agentic_app_id=DEFAULTS.agentic_instance_id, - role=RoleTypes.agentic_user, - ) - ), - Activity( - type="message", - recipient=ChannelAccount( - id=DEFAULTS.agentic_user_id, - ) - ), - Activity( - type="message", - recipient=ChannelAccount(id="some_id") - ) - ]) +class TestAgenticAuthorization(TestUtils): + @pytest.mark.parametrize( + "activity", + [ + Activity( + type="message", + recipient=ChannelAccount( + id="bot_id", + agentic_app_id=DEFAULTS.agentic_instance_id, + role=RoleTypes.agent, + ), + ), + Activity( + type="message", + recipient=ChannelAccount( + id=DEFAULTS.agentic_user_id, + agentic_app_id=DEFAULTS.agentic_instance_id, + role=RoleTypes.agentic_user, + ), + ), + Activity( + type="message", + recipient=ChannelAccount( + id=DEFAULTS.agentic_user_id, + ), + ), + Activity(type="message", recipient=ChannelAccount(id="some_id")), + ], + ) def test_is_agentic_request(self, mocker, activity): - assert activity.is_agentic() == AgenticAuthorization.is_agentic_request(activity) + assert activity.is_agentic() == AgenticAuthorization.is_agentic_request( + activity + ) context = self.TurnContext(mocker, activity=activity) assert activity.is_agentic() == AgenticAuthorization.is_agentic_request(context) def test_get_agent_instance_id_is_agentic(self, mocker, agentic_role): - activity = Activity(type="message", recipient=ChannelAccount(id="some_id", agentic_app_id=DEFAULTS.agentic_instance_id, role=agentic_role)) + activity = Activity( + type="message", + recipient=ChannelAccount( + id="some_id", + agentic_app_id=DEFAULTS.agentic_instance_id, + role=agentic_role, + ), + ) context = self.TurnContext(mocker, activity=activity) - assert AgenticAuthorization.get_agent_instance_id(context) == DEFAULTS.agentic_instance_id + assert ( + AgenticAuthorization.get_agent_instance_id(context) + == DEFAULTS.agentic_instance_id + ) def test_get_agent_instance_id_not_agentic(self, mocker, non_agentic_role): - activity = Activity(type="message", recipient=ChannelAccount(id="some_id", agentic_app_id=DEFAULTS.agentic_instance_id, role=non_agentic_role)) + activity = Activity( + type="message", + recipient=ChannelAccount( + id="some_id", + agentic_app_id=DEFAULTS.agentic_instance_id, + role=non_agentic_role, + ), + ) context = self.TurnContext(mocker, activity=activity) assert AgenticAuthorization.get_agent_instance_id(context) is None def test_get_agentic_user_is_agentic(self, mocker, agentic_role): - activity = Activity(type="message", recipient=ChannelAccount(id=DEFAULTS.agentic_user_id, agentic_app_id=DEFAULTS.agentic_instance_id, role=agentic_role)) + activity = Activity( + type="message", + recipient=ChannelAccount( + id=DEFAULTS.agentic_user_id, + agentic_app_id=DEFAULTS.agentic_instance_id, + role=agentic_role, + ), + ) context = self.TurnContext(mocker, activity=activity) - assert AgenticAuthorization.get_agentic_user(context) == DEFAULTS.agentic_user_id + assert ( + AgenticAuthorization.get_agentic_user(context) == DEFAULTS.agentic_user_id + ) def test_get_agentic_user_not_agentic(self, mocker, non_agentic_role): - activity = Activity(type="message", recipient=ChannelAccount(id=DEFAULTS.agentic_user_id, agentic_app_id=DEFAULTS.agentic_instance_id, role=non_agentic_role)) + activity = Activity( + type="message", + recipient=ChannelAccount( + id=DEFAULTS.agentic_user_id, + agentic_app_id=DEFAULTS.agentic_instance_id, + role=non_agentic_role, + ), + ) context = self.TurnContext(mocker, activity=activity) assert AgenticAuthorization.get_agentic_user(context) is None @pytest.mark.asyncio - async def test_get_agentic_instance_token_not_agentic(self, mocker, non_agentic_role, agentic_auth): - activity = Activity(type="message", recipient=ChannelAccount(id=DEFAULTS.agentic_user_id, agentic_app_id=DEFAULTS.agentic_instance_id, role=non_agentic_role)) + async def test_get_agentic_instance_token_not_agentic( + self, mocker, non_agentic_role, agentic_auth + ): + activity = Activity( + type="message", + recipient=ChannelAccount( + id=DEFAULTS.agentic_user_id, + agentic_app_id=DEFAULTS.agentic_instance_id, + role=non_agentic_role, + ), + ) context = self.TurnContext(mocker, activity=activity) assert await agentic_auth.get_agentic_instance_token(context) is None @pytest.mark.asyncio - async def test_get_agentic_user_token_not_agentic(self, mocker, non_agentic_role, agentic_auth): - activity = Activity(type="message", recipient=ChannelAccount(id=DEFAULTS.agentic_user_id, agentic_app_id=DEFAULTS.agentic_instance_id, role=non_agentic_role)) + async def test_get_agentic_user_token_not_agentic( + self, mocker, non_agentic_role, agentic_auth + ): + activity = Activity( + type="message", + recipient=ChannelAccount( + id=DEFAULTS.agentic_user_id, + agentic_app_id=DEFAULTS.agentic_instance_id, + role=non_agentic_role, + ), + ) context = self.TurnContext(mocker, activity=activity) assert await agentic_auth.get_agentic_user_token(context, ["user.Read"]) is None @pytest.mark.asyncio - async def test_get_agentic_user_token_agentic_no_user_id(self, mocker, agentic_role, agentic_auth): - activity = Activity(type="message", recipient=ChannelAccount(agentic_app_id=DEFAULTS.agentic_instance_id, role=agentic_role)) + async def test_get_agentic_user_token_agentic_no_user_id( + self, mocker, agentic_role, agentic_auth + ): + activity = Activity( + type="message", + recipient=ChannelAccount( + agentic_app_id=DEFAULTS.agentic_instance_id, role=agentic_role + ), + ) context = self.TurnContext(mocker, activity=activity) assert await agentic_auth.get_agentic_user_token(context, ["user.Read"]) is None @pytest.mark.asyncio - async def test_get_agentic_instance_token_is_agentic(self, mocker, agentic_role, agentic_auth): + async def test_get_agentic_instance_token_is_agentic( + self, mocker, agentic_role, agentic_auth + ): mock_provider = mocker.Mock(spec=MsalAuth) - mock_provider.get_agentic_instance_token = mocker.AsyncMock(return_value=[DEFAULTS.token, "bot_id"]) + mock_provider.get_agentic_instance_token = mocker.AsyncMock( + return_value=[DEFAULTS.token, "bot_id"] + ) connection_manager = mocker.Mock(spec=MsalConnectionManager) connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) agentic_auth = AgenticAuthorization( - MemoryStorage(), - connection_manager, - **AGENTIC_ENV_DICT + MemoryStorage(), connection_manager, **AGENTIC_ENV_DICT ) - activity = Activity(type="message", recipient=ChannelAccount(id="some_id", agentic_app_id=DEFAULTS.agentic_instance_id, role=agentic_role)) + activity = Activity( + type="message", + recipient=ChannelAccount( + id="some_id", + agentic_app_id=DEFAULTS.agentic_instance_id, + role=agentic_role, + ), + ) context = self.TurnContext(mocker, activity=activity) token = await agentic_auth.get_agentic_instance_token(context) assert token == DEFAULTS.token @pytest.mark.asyncio - async def test_get_agentic_user_token_is_agentic(self, mocker, agentic_role, agentic_auth): + async def test_get_agentic_user_token_is_agentic( + self, mocker, agentic_role, agentic_auth + ): mock_provider = mocker.Mock(spec=MsalAuth) - mock_provider.get_agentic_user_token = mocker.AsyncMock(return_value=DEFAULTS.token) + mock_provider.get_agentic_user_token = mocker.AsyncMock( + return_value=DEFAULTS.token + ) connection_manager = mocker.Mock(spec=MsalConnectionManager) connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) agentic_auth = AgenticAuthorization( - MemoryStorage(), - connection_manager, - **AGENTIC_ENV_DICT + MemoryStorage(), connection_manager, **AGENTIC_ENV_DICT ) - activity = Activity(type="message", recipient=ChannelAccount(id="some_id", agentic_app_id=DEFAULTS.agentic_instance_id, role=agentic_role)) + activity = Activity( + type="message", + recipient=ChannelAccount( + id="some_id", + agentic_app_id=DEFAULTS.agentic_instance_id, + role=agentic_role, + ), + ) context = self.TurnContext(mocker, activity=activity) token = await agentic_auth.get_agentic_user_token(context, ["user.Read"]) assert token == DEFAULTS.token + @pytest.mark.asyncio + async def test_sign_in_success(self, mocker, agentic_auth): + mocker.patch.object( + AgenticAuthorization, "get_agentic_user_token", return_value=DEFAULTS.token + ) + res = await agentic_auth.sign_in(None, ["user.Read"]) + assert res.token_response.token == DEFAULTS.token + assert res.tag == FlowStateTag.COMPLETE + + @pytest.mark.asyncio + async def test_sign_in_failure(self, mocker, agentic_auth): + mocker.patch.object( + AgenticAuthorization, "get_agentic_user_token", return_value=None + ) + res = await agentic_auth.sign_in(None, ["user.Read"]) + assert not res.token_response + assert res.tag == FlowStateTag.FAILURE diff --git a/tests/hosting_core/app/auth/test_auth_handler.py b/tests/hosting_core/app/auth/test_auth_handler.py index 4aebdea0..9d426e23 100644 --- a/tests/hosting_core/app/auth/test_auth_handler.py +++ b/tests/hosting_core/app/auth/test_auth_handler.py @@ -7,11 +7,13 @@ DEFAULTS = TEST_DEFAULTS() ENV_DICT = TEST_ENV_DICT() + class TestAuthHandler: - @pytest.fixture def auth_setting(self): - return ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][DEFAULTS.auth_handler_id]["SETTINGS"] + return ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][ + DEFAULTS.auth_handler_id + ]["SETTINGS"] def test_init(self, auth_setting): auth_handler = AuthHandler(DEFAULTS.auth_handler_id, **auth_setting) @@ -19,4 +21,6 @@ def test_init(self, auth_setting): assert auth_handler.title == DEFAULTS.auth_handler_title assert auth_handler.text == DEFAULTS.auth_handler_text assert auth_handler.obo_connection_name == DEFAULTS.obo_connection_name - assert auth_handler.abs_oauth_connection_name == DEFAULTS.abs_oauth_connection_name \ No newline at end of file + assert ( + auth_handler.abs_oauth_connection_name == DEFAULTS.abs_oauth_connection_name + ) diff --git a/tests/hosting_core/app/auth/test_authorization.py b/tests/hosting_core/app/auth/test_authorization.py index 51eba142..0433aaa6 100644 --- a/tests/hosting_core/app/auth/test_authorization.py +++ b/tests/hosting_core/app/auth/test_authorization.py @@ -4,11 +4,7 @@ from typing import Optional -from microsoft_agents.activity import ( - Activity, - ActivityTypes, - TokenResponse -) +from microsoft_agents.activity import Activity, ActivityTypes, TokenResponse from microsoft_agents.hosting.core import ( FlowStorageClient, @@ -47,7 +43,7 @@ mock_UserTokenClient, mock_class_UserAuthorization, mock_class_AgenticAuthorization, - mock_class_Authorization + mock_class_Authorization, ) from tests.hosting_core._common import flow_state_eq @@ -59,18 +55,26 @@ ENV_DICT = TEST_ENV_DICT() AGENTIC_ENV_DICT = TEST_AGENTIC_ENV_DICT() -async def get_sign_in_state(auth: Authorization, storage: Storage, context: TurnContext) -> Optional[SignInState]: + +async def get_sign_in_state( + auth: Authorization, storage: Storage, context: TurnContext +) -> Optional[SignInState]: key = auth.sign_in_state_key(context) return (await storage.read([key], target_cls=SignInState)).get(key) -async def set_sign_in_state(auth: Authorization, storage: Storage, context: TurnContext, state: SignInState): + +async def set_sign_in_state( + auth: Authorization, storage: Storage, context: TurnContext, state: SignInState +): key = auth.sign_in_state_key(context) await storage.write({key: state}) + def mock_variants(mocker, sign_in_return=None): mock_class_UserAuthorization(mocker, sign_in_return=sign_in_return) mock_class_AgenticAuthorization(mocker, sign_in_return=sign_in_return) + def sign_in_state_eq(a: Optional[SignInState], b: Optional[SignInState]) -> bool: if a is None and b is None: return True @@ -78,12 +82,18 @@ def sign_in_state_eq(a: Optional[SignInState], b: Optional[SignInState]) -> bool return False return a.tokens == b.tokens and a.continuation_activity == b.continuation_activity + def copy_sign_in_state(state: SignInState) -> SignInState: return SignInState( tokens=state.tokens.copy(), - continuation_activity=state.continuation_activity.model_copy() if state.continuation_activity else None + continuation_activity=( + state.continuation_activity.model_copy() + if state.continuation_activity + else None + ), ) + class TestEnv(FlowStateFixtures): def setup_method(self): self.TurnContext = testing_TurnContext @@ -126,8 +136,8 @@ def env_dict(self, request): def auth_handler_id(self, request): return request.param -class TestAuthorizationSetup(TestEnv): +class TestAuthorizationSetup(TestEnv): def test_init_user_auth(self, connection_manager, storage, env_dict): auth = Authorization(storage, connection_manager, **env_dict) assert auth.user_auth is not None @@ -141,110 +151,171 @@ def test_init_agentic_auth(self, connection_manager, storage): auth = Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) assert auth.agentic_auth is not None - @pytest.mark.parametrize("auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id]) + @pytest.mark.parametrize( + "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] + ) def test_resolve_handler(self, connection_manager, storage, auth_handler_id): auth = Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) - handler_config = AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][auth_handler_id] - auth.resolve_handler(auth_handler_id) == AuthHandler(auth_handler_id, **handler_config) + handler_config = AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"][ + "HANDLERS" + ][auth_handler_id] + auth.resolve_handler(auth_handler_id) == AuthHandler( + auth_handler_id, **handler_config + ) def test_sign_in_state_key(self, mocker, connection_manager, storage): auth = Authorization(storage, connection_manager, **ENV_DICT) context = self.TurnContext(mocker) key = auth.sign_in_state_key(context) assert key == f"auth:SignInState:{DEFAULTS.channel_id}:{DEFAULTS.user_id}" - -class TestAuthorizationUsage(TestEnv): + +class TestAuthorizationUsage(TestEnv): @pytest.mark.asyncio async def test_get_token(self, mocker, storage, authorization): context = self.TurnContext(mocker) - token_response = await authorization.get_token(context, DEFAULTS.auth_handler_id) + token_response = await authorization.get_token( + context, DEFAULTS.auth_handler_id + ) assert not token_response - @pytest.mark.asyncio - async def test_get_token_with_sign_in_state_empty(self, mocker, storage, authorization, context): + async def test_get_token_with_sign_in_state_empty( + self, mocker, storage, authorization, context + ): # setup key = authorization.sign_in_state_key(context) - await storage.write({key: SignInState( - tokens={DEFAULTS.auth_handler_id: "", DEFAULTS.agentic_auth_handler_id: ""} - )}) + await storage.write( + { + key: SignInState( + tokens={ + DEFAULTS.auth_handler_id: "", + DEFAULTS.agentic_auth_handler_id: "", + } + ) + } + ) # test - token_response = await authorization.get_token(context, DEFAULTS.auth_handler_id) + token_response = await authorization.get_token( + context, DEFAULTS.auth_handler_id + ) assert not token_response @pytest.mark.asyncio - async def test_get_token_with_sign_in_state_empty_alt(self, mocker, storage, authorization, context): + async def test_get_token_with_sign_in_state_empty_alt( + self, mocker, storage, authorization, context + ): # setup key = authorization.sign_in_state_key(context) - await storage.write({key: SignInState( - tokens={DEFAULTS.auth_handler_id: "token", DEFAULTS.agentic_auth_handler_id: ""} - )}) + await storage.write( + { + key: SignInState( + tokens={ + DEFAULTS.auth_handler_id: "token", + DEFAULTS.agentic_auth_handler_id: "", + } + ) + } + ) # test - token_response = await authorization.get_token(context, DEFAULTS.agentic_auth_handler_id) + token_response = await authorization.get_token( + context, DEFAULTS.agentic_auth_handler_id + ) assert not token_response @pytest.mark.asyncio - async def test_get_token_with_sign_in_state_valid(self, mocker, storage, authorization): + async def test_get_token_with_sign_in_state_valid( + self, mocker, storage, authorization + ): # setup context = self.TurnContext(mocker) key = authorization.sign_in_state_key(context) - await storage.write({key: SignInState( - tokens={DEFAULTS.auth_handler_id: "valid_token"} - )}) + await storage.write( + {key: SignInState(tokens={DEFAULTS.auth_handler_id: "valid_token"})} + ) # test - token_response = await authorization.get_token(context, DEFAULTS.auth_handler_id) + token_response = await authorization.get_token( + context, DEFAULTS.auth_handler_id + ) assert token_response.token == "valid_token" @pytest.mark.asyncio - async def test_start_or_continue_sign_in_cached(self, storage, authorization, context, activity): + async def test_start_or_continue_sign_in_cached( + self, storage, authorization, context, activity + ): # setup initial_state = SignInState( - tokens={DEFAULTS.auth_handler_id: "valid_token"}, continuation_activity=activity + tokens={DEFAULTS.auth_handler_id: "valid_token"}, + continuation_activity=activity, ) await set_sign_in_state(authorization, storage, context, initial_state) - sign_in_response = await authorization.start_or_continue_sign_in(context, None, DEFAULTS.auth_handler_id) + sign_in_response = await authorization.start_or_continue_sign_in( + context, None, DEFAULTS.auth_handler_id + ) assert sign_in_response.tag == FlowStateTag.COMPLETE assert sign_in_response.token_response.token == "valid_token" - assert sign_in_state_eq(await get_sign_in_state(authorization, storage, context), initial_state) + assert sign_in_state_eq( + await get_sign_in_state(authorization, storage, context), initial_state + ) @pytest.mark.asyncio - @pytest.mark.parametrize("auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id]) - async def test_start_or_continue_sign_in_no_initial_state_to_complete(self, mocker, storage, authorization, context, auth_handler_id): - mock_variants(mocker, sign_in_return=SignInResponse( - token_response=TokenResponse(token=DEFAULTS.token), - tag=FlowStateTag.COMPLETE - )) - sign_in_response = await authorization.start_or_continue_sign_in(context, None, auth_handler_id) + @pytest.mark.parametrize( + "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] + ) + async def test_start_or_continue_sign_in_no_initial_state_to_complete( + self, mocker, storage, authorization, context, auth_handler_id + ): + mock_variants( + mocker, + sign_in_return=SignInResponse( + token_response=TokenResponse(token=DEFAULTS.token), + tag=FlowStateTag.COMPLETE, + ), + ) + sign_in_response = await authorization.start_or_continue_sign_in( + context, None, auth_handler_id + ) assert sign_in_response.tag == FlowStateTag.COMPLETE assert sign_in_response.token_response.token == DEFAULTS.token - + final_state = await get_sign_in_state(authorization, storage, context) assert final_state.tokens[auth_handler_id] == DEFAULTS.token assert final_state.continuation_activity is None @pytest.mark.asyncio - @pytest.mark.parametrize("auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id]) - async def test_start_or_continue_sign_in_to_complete_with_prev_state(self, mocker, storage, authorization, context, auth_handler_id): + @pytest.mark.parametrize( + "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] + ) + async def test_start_or_continue_sign_in_to_complete_with_prev_state( + self, mocker, storage, authorization, context, auth_handler_id + ): # setup initial_state = SignInState( - tokens={"my_handler": "old_token"}, continuation_activity=Activity(type=ActivityTypes.message, text="old activity") + tokens={"my_handler": "old_token"}, + continuation_activity=Activity( + type=ActivityTypes.message, text="old activity" + ), ) await set_sign_in_state(authorization, storage, context, initial_state) - mock_variants(mocker, sign_in_return=SignInResponse( - token_response=TokenResponse(token=DEFAULTS.token), - tag=FlowStateTag.COMPLETE - )) + mock_variants( + mocker, + sign_in_return=SignInResponse( + token_response=TokenResponse(token=DEFAULTS.token), + tag=FlowStateTag.COMPLETE, + ), + ) # test - sign_in_response = await authorization.start_or_continue_sign_in(context, None, auth_handler_id) + sign_in_response = await authorization.start_or_continue_sign_in( + context, None, auth_handler_id + ) assert sign_in_response.tag == FlowStateTag.COMPLETE assert sign_in_response.token_response.token == DEFAULTS.token - + # verify final_state = await get_sign_in_state(authorization, storage, context) assert final_state.tokens[auth_handler_id] == DEFAULTS.token @@ -252,23 +323,34 @@ async def test_start_or_continue_sign_in_to_complete_with_prev_state(self, mocke assert final_state.continuation_activity == initial_state.continuation_activity @pytest.mark.asyncio - @pytest.mark.parametrize("auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id]) - async def test_start_or_continue_sign_in_to_failure_with_prev_state(self, mocker, storage, authorization, context, auth_handler_id): + @pytest.mark.parametrize( + "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] + ) + async def test_start_or_continue_sign_in_to_failure_with_prev_state( + self, mocker, storage, authorization, context, auth_handler_id + ): # setup initial_state = SignInState( - tokens={"my_handler": "old_token"}, continuation_activity=Activity(type=ActivityTypes.message, text="old activity") + tokens={"my_handler": "old_token"}, + continuation_activity=Activity( + type=ActivityTypes.message, text="old activity" + ), ) await set_sign_in_state(authorization, storage, context, initial_state) - mock_variants(mocker, sign_in_return=SignInResponse( - token_response=TokenResponse(), - tag=FlowStateTag.FAILURE - )) + mock_variants( + mocker, + sign_in_return=SignInResponse( + token_response=TokenResponse(), tag=FlowStateTag.FAILURE + ), + ) # test - sign_in_response = await authorization.start_or_continue_sign_in(context, None, auth_handler_id) + sign_in_response = await authorization.start_or_continue_sign_in( + context, None, auth_handler_id + ) assert sign_in_response.tag == FlowStateTag.FAILURE assert not sign_in_response.token_response - + # verify final_state = await get_sign_in_state(authorization, storage, context) assert not final_state.tokens.get(auth_handler_id) @@ -276,28 +358,38 @@ async def test_start_or_continue_sign_in_to_failure_with_prev_state(self, mocker assert final_state.continuation_activity == initial_state.continuation_activity @pytest.mark.asyncio - @pytest.mark.parametrize("auth_handler_id, tag", [ - (DEFAULTS.auth_handler_id, FlowStateTag.BEGIN), - (DEFAULTS.agentic_auth_handler_id, FlowStateTag.BEGIN), - (DEFAULTS.auth_handler_id, FlowStateTag.CONTINUE), - (DEFAULTS.agentic_auth_handler_id, FlowStateTag.CONTINUE) - ]) - async def test_start_or_continue_sign_in_to_pending_with_prev_state(self, mocker, storage, authorization, context, auth_handler_id, tag): + @pytest.mark.parametrize( + "auth_handler_id, tag", + [ + (DEFAULTS.auth_handler_id, FlowStateTag.BEGIN), + (DEFAULTS.agentic_auth_handler_id, FlowStateTag.BEGIN), + (DEFAULTS.auth_handler_id, FlowStateTag.CONTINUE), + (DEFAULTS.agentic_auth_handler_id, FlowStateTag.CONTINUE), + ], + ) + async def test_start_or_continue_sign_in_to_pending_with_prev_state( + self, mocker, storage, authorization, context, auth_handler_id, tag + ): # setup initial_state = SignInState( - tokens={"my_handler": "old_token"}, continuation_activity=Activity(type=ActivityTypes.message, text="old activity") + tokens={"my_handler": "old_token"}, + continuation_activity=Activity( + type=ActivityTypes.message, text="old activity" + ), ) await set_sign_in_state(authorization, storage, context, initial_state) - mock_variants(mocker, sign_in_return=SignInResponse( - token_response=TokenResponse(), - tag=tag - )) + mock_variants( + mocker, + sign_in_return=SignInResponse(token_response=TokenResponse(), tag=tag), + ) # test - sign_in_response = await authorization.start_or_continue_sign_in(context, None, auth_handler_id) + sign_in_response = await authorization.start_or_continue_sign_in( + context, None, auth_handler_id + ) assert sign_in_response.tag == tag assert not sign_in_response.token_response - + # verify final_state = await get_sign_in_state(authorization, storage, context) assert not final_state.tokens.get(auth_handler_id) @@ -305,11 +397,20 @@ async def test_start_or_continue_sign_in_to_pending_with_prev_state(self, mocker assert final_state.continuation_activity == context.activity @pytest.mark.asyncio - @pytest.mark.parametrize("auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id]) - async def test_sign_out_not_signed_in_single_handler(self, mocker, storage, authorization, context, activity, auth_handler_id): + @pytest.mark.parametrize( + "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] + ) + async def test_sign_out_not_signed_in_single_handler( + self, mocker, storage, authorization, context, activity, auth_handler_id + ): mock_variants(mocker) - initial_state = SignInState(tokens={DEFAULTS.auth_handler_id: "", "my_handler": "old_token"}, continuation_activity=activity) - await set_sign_in_state(authorization, storage, context, copy_sign_in_state(initial_state)) + initial_state = SignInState( + tokens={DEFAULTS.auth_handler_id: "", "my_handler": "old_token"}, + continuation_activity=activity, + ) + await set_sign_in_state( + authorization, storage, context, copy_sign_in_state(initial_state) + ) await authorization.sign_out(context, None, auth_handler_id) final_state = await get_sign_in_state(authorization, storage, context) if auth_handler_id in initial_state.tokens: @@ -317,45 +418,97 @@ async def test_sign_out_not_signed_in_single_handler(self, mocker, storage, auth assert sign_in_state_eq(final_state, initial_state) @pytest.mark.asyncio - @pytest.mark.parametrize("auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id]) - async def test_sign_out_signed_in_in_single_handler(self, mocker, storage, authorization, context, activity, auth_handler_id): + @pytest.mark.parametrize( + "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] + ) + async def test_sign_out_signed_in_in_single_handler( + self, mocker, storage, authorization, context, activity, auth_handler_id + ): mock_variants(mocker) - initial_state = SignInState(tokens={DEFAULTS.auth_handler_id: "token", DEFAULTS.agentic_auth_handler_id: "another_token", "my_handler": "old_token"}, continuation_activity=activity) - await set_sign_in_state(authorization, storage, context, copy_sign_in_state(initial_state)) + initial_state = SignInState( + tokens={ + DEFAULTS.auth_handler_id: "token", + DEFAULTS.agentic_auth_handler_id: "another_token", + "my_handler": "old_token", + }, + continuation_activity=activity, + ) + await set_sign_in_state( + authorization, storage, context, copy_sign_in_state(initial_state) + ) await authorization.sign_out(context, None, auth_handler_id) final_state = await get_sign_in_state(authorization, storage, context) del initial_state.tokens[auth_handler_id] assert sign_in_state_eq(final_state, initial_state) @pytest.mark.asyncio - async def test_sign_out_not_signed_in_all_handlers(self, mocker, storage, authorization, context, activity): + async def test_sign_out_not_signed_in_all_handlers( + self, mocker, storage, authorization, context, activity + ): mock_variants(mocker) - initial_state = SignInState(tokens={DEFAULTS.auth_handler_id: ""}, continuation_activity=activity) + initial_state = SignInState( + tokens={DEFAULTS.auth_handler_id: ""}, continuation_activity=activity + ) await set_sign_in_state(authorization, storage, context, initial_state) await authorization.sign_out(context, None) final_state = await get_sign_in_state(authorization, storage, context) assert final_state is None @pytest.mark.asyncio - async def test_sign_out_signed_in_in_all_handlers(self, mocker, storage, authorization, context, activity): + async def test_sign_out_signed_in_in_all_handlers( + self, mocker, storage, authorization, context, activity + ): mock_variants(mocker) - initial_state = SignInState(tokens={DEFAULTS.auth_handler_id: "token", DEFAULTS.agentic_auth_handler_id: "another_token"}, continuation_activity=activity) + initial_state = SignInState( + tokens={ + DEFAULTS.auth_handler_id: "token", + DEFAULTS.agentic_auth_handler_id: "another_token", + }, + continuation_activity=activity, + ) await set_sign_in_state(authorization, storage, context, initial_state) await authorization.sign_out(context, None) final_state = await get_sign_in_state(authorization, storage, context) assert final_state is None - + @pytest.mark.asyncio - @pytest.mark.parametrize("sign_in_state", [ - SignInState(), - SignInState(tokens={DEFAULTS.auth_handler_id: "token"}, continuation_activity=Activity(type=ActivityTypes.message, text="activity")), - SignInState(tokens={DEFAULTS.auth_handler_id: "token", DEFAULTS.agentic_auth_handler_id: "another_token"}, continuation_activity=Activity(type=ActivityTypes.message, text="activity")), - SignInState(tokens={DEFAULTS.auth_handler_id: "token", "my_handler": "old_token"}, continuation_activity=Activity(type=ActivityTypes.message, text="activity")), - ]) - async def test_on_turn_auth_intercept_no_intercept(self, storage, authorization, context, sign_in_state): - await set_sign_in_state(authorization, storage, context, copy_sign_in_state(sign_in_state)) - - intercepts, continuation_activity = await authorization.on_turn_auth_intercept(context, None) + @pytest.mark.parametrize( + "sign_in_state", + [ + SignInState(), + SignInState( + tokens={DEFAULTS.auth_handler_id: "token"}, + continuation_activity=Activity( + type=ActivityTypes.message, text="activity" + ), + ), + SignInState( + tokens={ + DEFAULTS.auth_handler_id: "token", + DEFAULTS.agentic_auth_handler_id: "another_token", + }, + continuation_activity=Activity( + type=ActivityTypes.message, text="activity" + ), + ), + SignInState( + tokens={DEFAULTS.auth_handler_id: "token", "my_handler": "old_token"}, + continuation_activity=Activity( + type=ActivityTypes.message, text="activity" + ), + ), + ], + ) + async def test_on_turn_auth_intercept_no_intercept( + self, storage, authorization, context, sign_in_state + ): + await set_sign_in_state( + authorization, storage, context, copy_sign_in_state(sign_in_state) + ) + + intercepts, continuation_activity = await authorization.on_turn_auth_intercept( + context, None + ) assert not continuation_activity assert not intercepts @@ -365,18 +518,34 @@ async def test_on_turn_auth_intercept_no_intercept(self, storage, authorization, assert sign_in_state_eq(final_state, sign_in_state) @pytest.mark.asyncio - @pytest.mark.parametrize("sign_in_response", [ - SignInResponse(tag=FlowStateTag.BEGIN), - SignInResponse(tag=FlowStateTag.CONTINUE), - SignInResponse(tag=FlowStateTag.FAILURE) - ]) - async def test_on_turn_auth_intercept_with_intercept_incomplete(self, mocker, storage, authorization, context, sign_in_response, auth_handler_id): - mock_class_Authorization(mocker, start_or_continue_sign_in_return=sign_in_response) + @pytest.mark.parametrize( + "sign_in_response", + [ + SignInResponse(tag=FlowStateTag.BEGIN), + SignInResponse(tag=FlowStateTag.CONTINUE), + SignInResponse(tag=FlowStateTag.FAILURE), + ], + ) + async def test_on_turn_auth_intercept_with_intercept_incomplete( + self, mocker, storage, authorization, context, sign_in_response, auth_handler_id + ): + mock_class_Authorization( + mocker, start_or_continue_sign_in_return=sign_in_response + ) - initial_state = SignInState(tokens={"some_handler": "old_token", auth_handler_id: ""}, continuation_activity=Activity(type=ActivityTypes.message, text="old activity")) - await set_sign_in_state(authorization, storage, context, copy_sign_in_state(initial_state)) + initial_state = SignInState( + tokens={"some_handler": "old_token", auth_handler_id: ""}, + continuation_activity=Activity( + type=ActivityTypes.message, text="old activity" + ), + ) + await set_sign_in_state( + authorization, storage, context, copy_sign_in_state(initial_state) + ) - intercepts, continuation_activity = await authorization.on_turn_auth_intercept(context, auth_handler_id) + intercepts, continuation_activity = await authorization.on_turn_auth_intercept( + context, auth_handler_id + ) assert not continuation_activity assert intercepts @@ -385,14 +554,26 @@ async def test_on_turn_auth_intercept_with_intercept_incomplete(self, mocker, st assert sign_in_state_eq(final_state, initial_state) @pytest.mark.asyncio - async def test_on_turn_auth_intercept_with_intercept_complete(self, mocker, storage, authorization, context, auth_handler_id): - mock_class_Authorization(mocker, start_or_continue_sign_in_return=SignInResponse(tag=FlowStateTag.COMPLETE)) + async def test_on_turn_auth_intercept_with_intercept_complete( + self, mocker, storage, authorization, context, auth_handler_id + ): + mock_class_Authorization( + mocker, + start_or_continue_sign_in_return=SignInResponse(tag=FlowStateTag.COMPLETE), + ) old_activity = Activity(type=ActivityTypes.message, text="old activity") - initial_state = SignInState(tokens={"some_handler": "old_token", auth_handler_id: ""}, continuation_activity=old_activity) - await set_sign_in_state(authorization, storage, context, copy_sign_in_state(initial_state)) + initial_state = SignInState( + tokens={"some_handler": "old_token", auth_handler_id: ""}, + continuation_activity=old_activity, + ) + await set_sign_in_state( + authorization, storage, context, copy_sign_in_state(initial_state) + ) - intercepts, continuation_activity = await authorization.on_turn_auth_intercept(context, auth_handler_id) + intercepts, continuation_activity = await authorization.on_turn_auth_intercept( + context, auth_handler_id + ) assert continuation_activity == old_activity assert intercepts @@ -400,4 +581,4 @@ async def test_on_turn_auth_intercept_with_intercept_complete(self, mocker, stor # start_or_continue_sign_in is the only method that modifies the state, # so since it is mocked, the state should not be changed final_state = await get_sign_in_state(authorization, storage, context) - assert sign_in_state_eq(final_state, initial_state) \ No newline at end of file + assert sign_in_state_eq(final_state, initial_state) diff --git a/tests/hosting_core/app/auth/test_authorization_variant.py b/tests/hosting_core/app/auth/test_authorization_variant.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/hosting_core/app/auth/test_sign_in_response.py b/tests/hosting_core/app/auth/test_sign_in_response.py index 08f58a76..99d7a894 100644 --- a/tests/hosting_core/app/auth/test_sign_in_response.py +++ b/tests/hosting_core/app/auth/test_sign_in_response.py @@ -1,9 +1,10 @@ from microsoft_agents.hosting.core import SignInResponse, FlowStateTag + def test_sign_in_response_sign_in_complete(): assert SignInResponse(tag=FlowStateTag.BEGIN).sign_in_complete() == False assert SignInResponse(tag=FlowStateTag.CONTINUE).sign_in_complete() == False assert SignInResponse(tag=FlowStateTag.FAILURE).sign_in_complete() == False assert SignInResponse().sign_in_complete() == False assert SignInResponse(tag=FlowStateTag.NOT_STARTED).sign_in_complete() == True - assert SignInResponse(tag=FlowStateTag.COMPLETE).sign_in_complete() == True \ No newline at end of file + assert SignInResponse(tag=FlowStateTag.COMPLETE).sign_in_complete() == True diff --git a/tests/hosting_core/app/auth/test_sign_in_state.py b/tests/hosting_core/app/auth/test_sign_in_state.py index a95fa440..2621cf31 100644 --- a/tests/hosting_core/app/auth/test_sign_in_state.py +++ b/tests/hosting_core/app/auth/test_sign_in_state.py @@ -4,8 +4,8 @@ from ._common import testing_Activity, testing_TurnContext -class TestSignInState: +class TestSignInState: def test_init(self): state = SignInState() assert state.tokens == {} @@ -13,45 +13,40 @@ def test_init(self): def test_init_with_values(self): activity = testing_Activity() - state = SignInState({ - "handler": "some_token" - }, activity) + state = SignInState({"handler": "some_token"}, activity) assert state.tokens == {"handler": "some_token"} assert state.continuation_activity == activity def test_from_json_to_store_item(self): - tokens = { - "some_handler": "some_token", - "other_handler": "other_token" - } + tokens = {"some_handler": "some_token", "other_handler": "other_token"} activity = testing_Activity() - data = { - "tokens": tokens, - "continuation_activity": activity - } + data = {"tokens": tokens, "continuation_activity": activity} state = SignInState.from_json_to_store_item(data) assert state.tokens == tokens assert state.continuation_activity == activity def test_store_item_to_json(self): - tokens = { - "some_handler": "some_token", - "other_handler": "other_token" - } + tokens = {"some_handler": "some_token", "other_handler": "other_token"} activity = testing_Activity() state = SignInState(tokens, activity) json_data = state.store_item_to_json() assert json_data["tokens"] == tokens assert json_data["continuation_activity"] == activity - @pytest.mark.parametrize("tokens, active_handler", [ - [{}, ""], - [{"some_handler": ""}, "some_handler"], - [{"some_handler": "some_token"}, ""], - [{"some_handler": "some_value", "other_handler": ""}, "other_handler"], - [{"some_handler": "some_value", "other_handler": "other_value"}, ""], - [{"some_handler": "some_value", "another_handler": "", "wow": "wow"}, "another_handler"], - ]) + @pytest.mark.parametrize( + "tokens, active_handler", + [ + [{}, ""], + [{"some_handler": ""}, "some_handler"], + [{"some_handler": "some_token"}, ""], + [{"some_handler": "some_value", "other_handler": ""}, "other_handler"], + [{"some_handler": "some_value", "other_handler": "other_value"}, ""], + [ + {"some_handler": "some_value", "another_handler": "", "wow": "wow"}, + "another_handler", + ], + ], + ) def test_active_handler(self, tokens, active_handler): state = SignInState(tokens) - assert state.active_handler() == active_handler \ No newline at end of file + assert state.active_handler() == active_handler diff --git a/tests/hosting_core/app/auth/test_user_authorization.py b/tests/hosting_core/app/auth/test_user_authorization.py index 9ef98be7..2c461a6a 100644 --- a/tests/hosting_core/app/auth/test_user_authorization.py +++ b/tests/hosting_core/app/auth/test_user_authorization.py @@ -1,540 +1,263 @@ -# import pytest -# from datetime import datetime -# import jwt - -# from microsoft_agents.activity import ActivityTypes, TokenResponse - -# from microsoft_agents.hosting.core import ( -# FlowStorageClient, -# FlowErrorTag, -# FlowStateTag, -# FlowState, -# FlowResponse, -# OAuthFlow, -# UserAuthorization, -# MemoryStorage, -# ) - -# from tests._common.storage.utils import StorageBaseline - -# # test constants -# from tests._common.data import ( -# TEST_FLOW_DATA, -# TEST_AUTH_DATA, -# TEST_STORAGE_DATA, -# TEST_DEFAULTS, -# create_test_auth_handler, -# ) -# from tests._common.fixtures import FlowStateFixtures -# from tests._common.testing_objects import ( -# TestingConnectionManager as MockConnectionManager, -# mock_class_OAuthFlow, -# mock_UserTokenClient, -# ) -# from tests.hosting_core._common import flow_state_eq - -# DEFAULTS = TEST_DEFAULTS() -# FLOW_DATA = TEST_FLOW_DATA() -# STORAGE_DATA = TEST_STORAGE_DATA() - - -# def testing_TurnContext( -# mocker, -# channel_id=DEFAULTS.channel_id, -# user_id=DEFAULTS.user_id, -# user_token_client=None, -# ): -# if not user_token_client: -# user_token_client = mock_UserTokenClient(mocker) - -# turn_context = mocker.Mock() -# turn_context.activity.channel_id = channel_id -# turn_context.activity.from_property.id = user_id -# turn_context.activity.type = ActivityTypes.message -# turn_context.adapter.USER_TOKEN_CLIENT_KEY = "__user_token_client" -# turn_context.adapter.AGENT_IDENTITY_KEY = "__agent_identity_key" -# agent_identity = mocker.Mock() -# agent_identity.claims = {"aud": DEFAULTS.ms_app_id} -# turn_context.turn_state = { -# "__user_token_client": user_token_client, -# "__agent_identity_key": agent_identity, -# } -# return turn_context - - -# class TestEnv(FlowStateFixtures): -# def setup_method(self): -# self.TurnContext = testing_TurnContext -# self.UserTokenClient = mock_UserTokenClient -# self.ConnectionManager = lambda mocker: MockConnectionManager() - -# @pytest.fixture -# def turn_context(self, mocker): -# return self.TurnContext(mocker) - -# @pytest.fixture -# def baseline_storage(self): -# return StorageBaseline(TEST_STORAGE_DATA().dict) - -# @pytest.fixture -# def storage(self): -# return MemoryStorage(STORAGE_DATA.get_init_data()) - -# @pytest.fixture -# def connection_manager(self, mocker): -# return self.ConnectionManager(mocker) - -# @pytest.fixture -# def auth_handlers(self): -# return TEST_AUTH_DATA().auth_handlers - -# @pytest.fixture -# def user_authorization(self, connection_manager, storage, auth_handlers): -# return UserAuthorization(storage, connection_manager, auth_handlers) - - -# class TestAuthorization(TestEnv): -# def test_init_configuration_variants( -# self, storage, connection_manager, auth_handlers -# ): -# """Test initialization of authorization with different configuration variants.""" -# AGENTAPPLICATION = { -# "USERAUTHORIZATION": { -# "HANDLERS": { -# handler_name: { -# "SETTINGS": { -# "title": handler.title, -# "text": handler.text, -# "abs_oauth_connection_name": handler.abs_oauth_connection_name, -# "obo_connection_name": handler.obo_connection_name, -# } -# } -# for handler_name, handler in auth_handlers.items() -# } -# } -# } -# auth_with_config_obj = UserAuthorization( -# storage, -# connection_manager, -# auth_handlers=None, -# AGENTAPPLICATION=AGENTAPPLICATION, -# ) -# auth_with_handlers_list = UserAuthorization( -# storage, connection_manager, auth_handlers=auth_handlers -# ) -# for auth_handler_name in auth_handlers.keys(): -# auth_handler_a = auth_with_config_obj.resolve_handler(auth_handler_name) -# auth_handler_b = auth_with_handlers_list.resolve_handler(auth_handler_name) - -# assert auth_handler_a.name == auth_handler_b.name -# assert auth_handler_a.title == auth_handler_b.title -# assert auth_handler_a.text == auth_handler_b.text -# assert ( -# auth_handler_a.abs_oauth_connection_name -# == auth_handler_b.abs_oauth_connection_name -# ) -# assert ( -# auth_handler_a.obo_connection_name == auth_handler_b.obo_connection_name -# ) - -# @pytest.mark.asyncio -# @pytest.mark.parametrize( -# "auth_handler_id, channel_id, user_id", -# [["missing", "webchat", "Alice"], ["handler", "teams", "Bob"]], -# ) -# async def test_open_flow_value_error( -# self, mocker, user_authorization, auth_handler_id, channel_id, user_id -# ): -# """Test opening a flow with a missing auth handler.""" -# context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) -# with pytest.raises(ValueError): -# async with user_authorization.open_flow(context, auth_handler_id): -# pass - -# @pytest.mark.asyncio -# @pytest.mark.parametrize( -# "auth_handler_id, channel_id, user_id", -# [ -# ["", "webchat", "Alice"], -# ["graph", "teams", "Bob"], -# ["slack", "webchat", "Chuck"], -# ], -# ) -# async def test_open_flow_readonly( -# self, -# mocker, -# storage, -# connection_manager, -# auth_handlers, -# auth_handler_id, -# channel_id, -# user_id, -# ): -# """Test opening a flow and not modifying it.""" -# # setup -# context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) -# auth = UserAuthorization(storage, connection_manager, auth_handlers) -# flow_storage_client = FlowStorageClient(channel_id, user_id, storage) - -# # test -# async with auth.open_flow(context, auth_handler_id) as flow: -# expected_flow_state = flow.flow_state - -# # verify -# actual_flow_state = await flow_storage_client.read( -# auth.resolve_handler(auth_handler_id).name -# ) -# assert actual_flow_state == expected_flow_state - -# @pytest.mark.asyncio -# async def test_open_flow_success_modified_complete_flow( -# self, -# mocker, -# storage, -# connection_manager, -# auth_handlers, -# ): -# # mock -# channel_id = "teams" -# user_id = "Alice" -# auth_handler_id = "graph" - -# user_token_client = self.UserTokenClient( -# mocker, get_token_return=DEFAULTS.token -# ) -# context = self.TurnContext( -# mocker, -# channel_id=channel_id, -# user_id=user_id, -# user_token_client=user_token_client, -# ) - -# # setup -# context.activity.type = ActivityTypes.message -# context.activity.text = "123456" - -# auth = UserAuthorization(storage, connection_manager, auth_handlers) -# flow_storage_client = FlowStorageClient(channel_id, user_id, storage) - -# # test -# async with auth.open_flow(context, auth_handler_id) as flow: -# expected_flow_state = flow.flow_state -# expected_flow_state.tag = FlowStateTag.COMPLETE -# expected_flow_state.user_token = DEFAULTS.token - -# flow_response = await flow.begin_or_continue_flow(context.activity) -# res_flow_state = flow_response.flow_state - -# # verify -# actual_flow_state = await flow_storage_client.read(auth_handler_id) -# expected_flow_state.expiration = actual_flow_state.expiration -# assert flow_state_eq(actual_flow_state, expected_flow_state) -# assert flow_state_eq(res_flow_state, expected_flow_state) - -# @pytest.mark.asyncio -# async def test_open_flow_success_modified_failure( -# self, -# mocker, -# storage, -# connection_manager, -# auth_handlers, -# ): -# # setup -# channel_id = "teams" -# user_id = "Bob" -# auth_handler_id = "slack" - -# context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) -# context.activity.text = "invalid_magic_code" - -# auth = UserAuthorization(storage, connection_manager, auth_handlers) -# flow_storage_client = FlowStorageClient(channel_id, user_id, storage) - -# # test -# async with auth.open_flow(context, auth_handler_id) as flow: -# expected_flow_state = flow.flow_state -# expected_flow_state.tag = FlowStateTag.FAILURE -# expected_flow_state.attempts_remaining = 0 - -# flow_response = await flow.begin_or_continue_flow(context.activity) -# res_flow_state = flow_response.flow_state - -# # verify -# actual_flow_state = await flow_storage_client.read(auth_handler_id) - -# assert flow_response.flow_error_tag == FlowErrorTag.MAGIC_FORMAT -# assert flow_state_eq(res_flow_state, expected_flow_state) -# assert flow_state_eq(actual_flow_state, expected_flow_state) - -# @pytest.mark.asyncio -# async def test_open_flow_success_modified_signout( -# self, mocker, storage, connection_manager, auth_handlers -# ): -# # setup -# channel_id = "webchat" -# user_id = "Alice" -# auth_handler_id = "graph" - -# context = self.TurnContext(mocker, channel_id=channel_id, user_id=user_id) - -# auth = UserAuthorization(storage, connection_manager, auth_handlers) -# flow_storage_client = FlowStorageClient(channel_id, user_id, storage) - -# # test -# async with auth.open_flow(context, auth_handler_id) as flow: -# expected_flow_state = flow.flow_state -# expected_flow_state.tag = FlowStateTag.NOT_STARTED -# expected_flow_state.user_token = "" - -# await flow.sign_out() - -# # verify -# actual_flow_state = await flow_storage_client.read(auth_handler_id) -# assert flow_state_eq(actual_flow_state, expected_flow_state) - -# @pytest.mark.asyncio -# async def test_get_token_success(self, mocker, user_authorization): -# user_token_client = self.UserTokenClient(mocker, get_token_return="token") -# context = self.TurnContext( -# mocker, -# channel_id="__channel_id", -# user_id="__user_id", -# user_token_client=user_token_client, -# ) -# assert await user_authorization.get_token(context, "slack") == TokenResponse( -# token="token" -# ) -# user_token_client.user_token.get_token.assert_called_once() - -# @pytest.mark.asyncio -# async def test_get_token_empty_response(self, mocker, user_authorization): -# user_token_client = self.UserTokenClient( -# mocker, get_token_return=TokenResponse() -# ) -# context = self.TurnContext( -# mocker, -# channel_id="__channel_id", -# user_id="__user_id", -# user_token_client=user_token_client, -# ) -# assert await user_authorization.get_token(context, "graph") == TokenResponse() -# user_token_client.user_token.get_token.assert_called_once() - -# @pytest.mark.asyncio -# async def test_get_token_error( -# self, turn_context, storage, connection_manager, auth_handlers -# ): -# auth = UserAuthorization(storage, connection_manager, auth_handlers) -# with pytest.raises(ValueError): -# await auth.get_token( -# turn_context, DEFAULTS.missing_abs_oauth_connection_name -# ) - -# @pytest.mark.asyncio -# async def test_exchange_token_no_token(self, mocker, turn_context, user_authorization): -# mock_class_OAuthFlow(mocker, get_user_token_return=TokenResponse()) -# res = await user_authorization.exchange_token(turn_context, ["scope"], "github") -# assert res == TokenResponse() - -# @pytest.mark.asyncio -# async def test_exchange_token_not_exchangeable( -# self, mocker, turn_context, user_authorization -# ): -# token = jwt.encode({"aud": "invalid://botframework.test.api"}, "") -# mock_class_OAuthFlow( -# mocker, -# get_user_token_return=TokenResponse(connection_name="github", token=token), -# ) -# res = await user_authorization.exchange_token(turn_context, ["scope"], "github") -# assert res == TokenResponse() - -# @pytest.mark.asyncio -# async def test_exchange_token_valid_exchangeable(self, mocker, user_authorization): -# # setup -# token = jwt.encode({"aud": "api://botframework.test.api"}, "") -# mock_class_OAuthFlow( -# mocker, -# get_user_token_return=TokenResponse(connection_name="github", token=token), -# ) -# user_token_client = self.UserTokenClient( -# mocker, get_token_return="github-obo-connection-obo-token" -# ) -# turn_context = self.TurnContext(mocker, user_token_client=user_token_client) -# # test -# res = await user_authorization.exchange_token(turn_context, ["scope"], "github") -# assert res == TokenResponse(token="github-obo-connection-obo-token") - -# @pytest.mark.asyncio -# async def test_get_active_flow_state(self, mocker, user_authorization): -# context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") -# actual_flow_state = await user_authorization.get_active_flow_state(context) -# assert actual_flow_state == STORAGE_DATA.dict["auth/webchat/Alice/github"] - -# @pytest.mark.asyncio -# async def test_get_active_flow_state_missing(self, mocker, user_authorization): -# context = self.TurnContext( -# mocker, channel_id="__channel_id", user_id="__user_id" -# ) -# res = await user_authorization.get_active_flow_state(context) -# assert res is None - -# @pytest.mark.asyncio -# async def test_begin_or_continue_flow_success(self, mocker, user_authorization): -# # robrandao: TODO -> lower priority -> more testing here -# # setup -# mock_class_OAuthFlow( -# mocker, -# begin_or_continue_flow_return=FlowResponse( -# token_response=TokenResponse(token="token"), -# flow_state=FlowState( -# tag=FlowStateTag.COMPLETE, auth_handler_id="github" -# ), -# ), -# ) -# context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") -# context.dummy_val = None - -# def on_sign_in_success(context, turn_state, auth_handler_id): -# context.dummy_val = auth_handler_id - -# def on_sign_in_failure(context, turn_state, auth_handler_id, err): -# context.dummy_val = str(err) - -# # test -# user_authorization.on_sign_in_success(on_sign_in_success) -# user_authorization.on_sign_in_failure(on_sign_in_failure) -# flow_response = await user_authorization.begin_or_continue_flow( -# context, None, "github" -# ) -# assert context.dummy_val == "github" -# assert flow_response.token_response == TokenResponse(token="token") - -# @pytest.mark.asyncio -# async def test_begin_or_continue_flow_already_completed( -# self, mocker, user_authorization -# ): -# # robrandao: TODO -> lower priority -> more testing here -# # setup -# context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") - -# context.dummy_val = None - -# def on_sign_in_success(context, turn_state, auth_handler_id): -# context.dummy_val = auth_handler_id - -# def on_sign_in_failure(context, turn_state, auth_handler_id, err): -# context.dummy_val = str(err) - -# # test -# user_authorization.on_sign_in_success(on_sign_in_success) -# user_authorization.on_sign_in_failure(on_sign_in_failure) -# flow_response = await user_authorization.begin_or_continue_flow( -# context, None, "graph" -# ) -# assert context.dummy_val == None -# assert flow_response.token_response == TokenResponse(token="test_token") -# assert flow_response.continuation_activity is None - -# @pytest.mark.asyncio -# async def test_begin_or_continue_flow_failure(self, mocker, user_authorization): -# # robrandao: TODO -> lower priority -> more testing here -# # setup -# mock_class_OAuthFlow( -# mocker, -# begin_or_continue_flow_return=FlowResponse( -# token_response=TokenResponse(token="token"), -# flow_state=FlowState( -# tag=FlowStateTag.FAILURE, auth_handler_id="github" -# ), -# flow_error_tag=FlowErrorTag.MAGIC_FORMAT, -# ), -# ) -# context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") -# context.dummy_val = None - -# def on_sign_in_success(context, turn_state, auth_handler_id): -# context.dummy_val = auth_handler_id - -# def on_sign_in_failure(context, turn_state, auth_handler_id, err): -# context.dummy_val = str(err) - -# # test -# user_authorization.on_sign_in_success(on_sign_in_success) -# user_authorization.on_sign_in_failure(on_sign_in_failure) -# flow_response = await user_authorization.begin_or_continue_flow( -# context, None, "github" -# ) -# assert context.dummy_val == "FlowErrorTag.MAGIC_FORMAT" -# assert flow_response.token_response == TokenResponse(token="token") - -# @pytest.mark.parametrize("auth_handler_id", ["graph", "github"]) -# def test_resolve_handler_specified( -# self, user_authorization, auth_handlers, auth_handler_id -# ): -# assert ( -# user_authorization.resolve_handler(auth_handler_id) -# == auth_handlers[auth_handler_id] -# ) - -# def test_resolve_handler_error(self, user_authorization): -# with pytest.raises(ValueError): -# user_authorization.resolve_handler("missing-handler") - -# def test_resolve_handler_first(self, user_authorization, auth_handlers): -# assert user_authorization.resolve_handler() == next(iter(auth_handlers.values())) - -# @pytest.mark.asyncio -# async def test_sign_out_individual( -# self, -# mocker, -# storage, -# connection_manager, -# auth_handlers, -# ): -# # setup -# mock_class_OAuthFlow(mocker) -# storage_client = FlowStorageClient("teams", "Alice", storage) -# context = self.TurnContext(mocker, channel_id="teams", user_id="Alice") -# auth = UserAuthorization(storage, connection_manager, auth_handlers) - -# # test -# await auth.sign_out(context, "graph") - -# # verify -# assert ( -# await storage.read([storage_client.key("graph")], target_cls=FlowState) -# == {} -# ) -# OAuthFlow.sign_out.assert_called_once() - -# @pytest.mark.asyncio -# async def test_sign_out_all( -# self, -# mocker, -# storage, -# connection_manager, -# auth_handlers, -# ): -# # setup -# mock_class_OAuthFlow(mocker) -# context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") -# storage_client = FlowStorageClient("webchat", "Alice", storage) -# auth = UserAuthorization(storage, connection_manager, auth_handlers) - -# # test -# await auth.sign_out(context) - -# # verify -# assert ( -# await storage.read([storage_client.key("graph")], target_cls=FlowState) -# == {} -# ) -# assert ( -# await storage.read([storage_client.key("github")], target_cls=FlowState) -# == {} -# ) -# assert ( -# await storage.read([storage_client.key("slack")], target_cls=FlowState) -# == {} -# ) -# OAuthFlow.sign_out.assert_called() # ignore red squiggly -> mocked +import pytest +from datetime import datetime +import jwt + +from microsoft_agents.activity import Activity, ActivityTypes, TokenResponse + +from microsoft_agents.hosting.core import ( + FlowStorageClient, + FlowErrorTag, + FlowStateTag, + FlowState, + FlowResponse, + OAuthFlow, + UserAuthorization, + MemoryStorage, +) + +from tests._common.storage.utils import StorageBaseline + +# test constants +from tests._common.data import ( + TEST_FLOW_DATA, + TEST_AUTH_DATA, + TEST_STORAGE_DATA, + TEST_DEFAULTS, + TEST_ENV_DICT, + create_test_auth_handler, +) +from tests._common.fixtures import FlowStateFixtures +from tests._common.testing_objects import ( + TestingConnectionManager as MockConnectionManager, + mock_class_OAuthFlow, + mock_UserTokenClient, +) +from tests.hosting_core._common import flow_state_eq + +DEFAULTS = TEST_DEFAULTS() +FLOW_DATA = TEST_FLOW_DATA() +ENV_DICT = TEST_ENV_DICT() +STORAGE_DATA = TEST_STORAGE_DATA() + + +class MyUserAuthorization(UserAuthorization): + def _handle_flow_response(self, *args, **kwargs): + pass + + +def testing_TurnContext( + mocker, + channel_id=DEFAULTS.channel_id, + user_id=DEFAULTS.user_id, + user_token_client=None, +): + if not user_token_client: + user_token_client = mock_UserTokenClient(mocker) + + turn_context = mocker.Mock() + turn_context.activity.channel_id = channel_id + turn_context.activity.from_property.id = user_id + turn_context.activity.type = ActivityTypes.message + turn_context.adapter.USER_TOKEN_CLIENT_KEY = "__user_token_client" + turn_context.adapter.AGENT_IDENTITY_KEY = "__agent_identity_key" + agent_identity = mocker.Mock() + agent_identity.claims = {"aud": DEFAULTS.ms_app_id} + turn_context.turn_state = { + "__user_token_client": user_token_client, + "__agent_identity_key": agent_identity, + } + return turn_context + + +class TestEnv(FlowStateFixtures): + def setup_method(self): + self.TurnContext = testing_TurnContext + self.UserTokenClient = mock_UserTokenClient + self.ConnectionManager = lambda mocker: MockConnectionManager() + + @pytest.fixture + def turn_context(self, mocker): + return self.TurnContext(mocker) + + @pytest.fixture + def baseline_storage(self): + return StorageBaseline(TEST_STORAGE_DATA().dict) + + @pytest.fixture + def storage(self): + return MemoryStorage(STORAGE_DATA.get_init_data()) + + @pytest.fixture + def connection_manager(self, mocker): + return self.ConnectionManager(mocker) + + @pytest.fixture + def auth_handlers(self): + return TEST_AUTH_DATA().auth_handlers + + @pytest.fixture + def user_authorization(self, connection_manager, storage, auth_handlers): + return UserAuthorization( + storage, connection_manager, auth_handlers=auth_handlers + ) + + +class TestUserAuthorization(TestEnv): + + # TODO -> test init + + @pytest.mark.asyncio + async def test_begin_or_continue_flow_success(self, mocker, user_authorization): + # robrandao: TODO -> lower priority -> more testing here + # setup + mock_class_OAuthFlow( + mocker, + begin_or_continue_flow_return=FlowResponse( + token_response=TokenResponse(token="token"), + flow_state=FlowState( + tag=FlowStateTag.COMPLETE, auth_handler_id="github" + ), + ), + ) + context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") + context.dummy_val = None + + flow_response = await user_authorization.begin_or_continue_flow( + context, "github" + ) + assert flow_response.token_response == TokenResponse(token="token") + + @pytest.mark.asyncio + async def test_begin_or_continue_flow_already_completed( + self, mocker, user_authorization + ): + # robrandao: TODO -> lower priority -> more testing here + # setup + context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") + # test + flow_response = await user_authorization.begin_or_continue_flow( + context, "graph" + ) + assert flow_response.token_response == TokenResponse(token="test_token") + assert flow_response.continuation_activity is None + + @pytest.mark.asyncio + async def test_begin_or_continue_flow_failure(self, mocker, user_authorization): + # robrandao: TODO -> lower priority -> more testing here + # setup + mock_class_OAuthFlow( + mocker, + begin_or_continue_flow_return=FlowResponse( + token_response=TokenResponse(token="token"), + flow_state=FlowState( + tag=FlowStateTag.FAILURE, auth_handler_id="github" + ), + flow_error_tag=FlowErrorTag.MAGIC_FORMAT, + ), + ) + context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") + # test + flow_response = await user_authorization.begin_or_continue_flow( + context, "github" + ) + assert flow_response.token_response == TokenResponse(token="token") + + @pytest.mark.asyncio + async def test_sign_out_individual( + self, + mocker, + storage, + connection_manager, + auth_handlers, + ): + # setup + mock_class_OAuthFlow(mocker) + storage_client = FlowStorageClient("teams", "Alice", storage) + context = self.TurnContext(mocker, channel_id="teams", user_id="Alice") + auth = UserAuthorization(storage, connection_manager, auth_handlers) + + # test + await auth.sign_out(context, "graph") + + # verify + assert ( + await storage.read([storage_client.key("graph")], target_cls=FlowState) + == {} + ) + OAuthFlow.sign_out.assert_called_once() + + @pytest.mark.asyncio + async def test_sign_out_all( + self, + mocker, + storage, + connection_manager, + auth_handlers, + ): + # setup + mock_class_OAuthFlow(mocker) + context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") + storage_client = FlowStorageClient("webchat", "Alice", storage) + auth = UserAuthorization(storage, connection_manager, auth_handlers) + + # test + await auth.sign_out(context) + + # verify + assert ( + await storage.read([storage_client.key("graph")], target_cls=FlowState) + == {} + ) + assert ( + await storage.read([storage_client.key("github")], target_cls=FlowState) + == {} + ) + assert ( + await storage.read([storage_client.key("slack")], target_cls=FlowState) + == {} + ) + OAuthFlow.sign_out.assert_called() # ignore red squiggly -> mocked + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "flow_response", + [ + FlowResponse( + token_response=TokenResponse(token="token"), + flow_state=FlowState( + tag=FlowStateTag.COMPLETE, auth_handler_id="github" + ), + ), + FlowResponse( + token_response=TokenResponse(), + flow_state=FlowState( + tag=FlowStateTag.CONTINUE, auth_handler_id="github" + ), + continuation_activity=Activity( + type=ActivityTypes.message, text="Please sign in" + ), + ), + FlowResponse( + token_response=TokenResponse(token="wow"), + flow_state=FlowState( + tag=FlowStateTag.FAILURE, auth_handler_id="github" + ), + flow_error_tag=FlowErrorTag.MAGIC_FORMAT, + continuation_activity=Activity( + type=ActivityTypes.message, text="There was an error" + ), + ), + ], + ) + async def test_sign_in_success( + self, mocker, user_authorization, turn_context, flow_response + ): + mocker.patch.object( + user_authorization, "_handle_flow_response", return_value=None + ) + user_authorization.begin_or_continue_flow = mocker.AsyncMock( + return_value=flow_response + ) + res = await user_authorization.sign_in(turn_context, "github") + assert res.token_response == flow_response.token_response + assert res.tag == flow_response.flow_state.tag From 265643528cc3da295f8724c3fdfc5742da8ac777 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 26 Sep 2025 11:07:48 -0700 Subject: [PATCH 13/36] Formatting --- .../authentication/msal/msal_auth.py | 28 ++- .../hosting/core/app/agent_application.py | 4 +- .../core/app/auth/agentic_authorization.py | 77 +++++--- .../hosting/core/app/auth/auth_handler.py | 19 +- .../hosting/core/app/auth/authorization.py | 183 +++++++++++++----- .../core/app/auth/authorization_variant.py | 37 ++-- .../hosting/core/app/auth/sign_in_response.py | 3 + .../hosting/core/app/auth/sign_in_state.py | 9 +- .../core/app/auth/user_authorization.py | 18 +- .../core/app/auth/user_authorization_base.py | 65 +++---- 10 files changed, 315 insertions(+), 128 deletions(-) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index ff5c5a17..4fa13966 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import jwt from typing import Optional from urllib.parse import urlparse, ParseResult as URI from msal import ( @@ -192,6 +193,13 @@ def _resolve_scopes_list(self, instance_url: URI, scopes=None) -> list[str]: async def get_agentic_application_token( self, agent_app_instance_id: str ) -> Optional[str]: + """Gets the agentic application token for the given agent application instance ID. + + :param agent_app_instance_id: The agent application instance ID. + :type agent_app_instance_id: str + :return: The agentic application token, or None if not found. + :rtype: Optional[str] + """ if not agent_app_instance_id: raise ValueError("Agent application instance Id must be provided.") @@ -214,6 +222,13 @@ async def get_agentic_application_token( async def get_agentic_instance_token( self, agent_app_instance_id: str ) -> tuple[str, str]: + """Gets the agentic instance token for the given agent application instance ID. + + :param agent_app_instance_id: The agent application instance ID. + :type agent_app_instance_id: str + :return: A tuple containing the agentic instance token and the agent application token. + :rtype: tuple[str, str] + """ if not agent_app_instance_id: raise ValueError("Agent application instance Id must be provided.") @@ -245,13 +260,22 @@ async def get_agentic_instance_token( agentic_blueprint_id = payload.get("xms_par_app_azp") logger.debug("Agentic blueprint id: %s", agentic_blueprint_id) - # "xms_par_app_azp": "84df77a3-1e3f-4372-a49f-c7e93c3db681", - return agent_instance_token["access_token"], agent_token_result async def get_agentic_user_token( self, agent_app_instance_id: str, upn: str, scopes: list[str] ) -> Optional[str]: + """Gets the agentic user token for the given agent application instance ID and user principal name and the scopes. + + :param agent_app_instance_id: The agent application instance ID. + :type agent_app_instance_id: str + :param upn: The user principal name. + :type upn: str + :param scopes: The scopes to request for the token. + :type scopes: list[str] + :return: The agentic user token, or None if not found. + :rtype: Optional[str] + """ if not agent_app_instance_id or not upn: raise ValueError( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index a6a01a9c..6605fb6d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -443,9 +443,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: return __call - def handoff( - self, *, auth_handlers: Optional[List[str]] = None - ) -> Callable[ + def handoff(self, *, auth_handlers: Optional[List[str]] = None) -> Callable[ [Callable[[TurnContext, StateT, str], Awaitable[None]]], Callable[[TurnContext, StateT, str], Awaitable[None]], ]: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py index c7003a99..818c72e3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py @@ -14,30 +14,16 @@ class AgenticAuthorization(AuthorizationVariant): - @staticmethod - def is_agentic_request(context_or_activity: Union[TurnContext, Activity]) -> bool: - if isinstance(context_or_activity, TurnContext): - activity = context_or_activity.activity - else: - activity = context_or_activity - - return activity.is_agentic() - - @staticmethod - def get_agent_instance_id(context: TurnContext) -> Optional[str]: - if not AgenticAuthorization.is_agentic_request(context): - return None - - return context.activity.recipient.agentic_app_id - - @staticmethod - def get_agentic_user(context: TurnContext) -> Optional[str]: - if not AgenticAuthorization.is_agentic_request(context): - return None - - return context.activity.recipient.id + """Class responsible for managing agentic authorization""" async def get_agentic_instance_token(self, context: TurnContext) -> Optional[str]: + """Gets the agentic instance token for the current agent instance. + + :param context: The context object for the current turn. + :type context: TurnContext + :return: The agentic instance token, or None if not an agentic request. + :rtype: Optional[str] + """ if not self.is_agentic_request(context): return None @@ -56,6 +42,15 @@ async def get_agentic_instance_token(self, context: TurnContext) -> Optional[str async def get_agentic_user_token( self, context: TurnContext, scopes: list[str] ) -> Optional[str]: + """Gets the agentic user token for the current agent instance and user. + + :param context: The context object for the current turn. + :type context: TurnContext + :param scopes: The scopes to request for the token. + :type scopes: list[str] + :return: The agentic user token, or None if not an agentic request or no user. + :rtype: Optional[str] + """ if not self.is_agentic_request(context) or not self.get_agentic_user(context): return None @@ -75,6 +70,17 @@ async def sign_in( connection_name: str, scopes: Optional[list[str]] = None, ) -> SignInResponse: + """Retrieves the agentic user token if available. + + :param context: The context object for the current turn. + :type context: TurnContext + :param connection_name: The name of the connection to use for sign-in. + :type connection_name: str + :param scopes: The scopes to request for the token. + :type scopes: Optional[list[str]] + :return: A SignInResponse containing the token response and flow state tag. + :rtype: SignInResponse + """ scopes = scopes or [] token = await self.get_agentic_user_token(context, scopes) return ( @@ -86,4 +92,31 @@ async def sign_in( ) async def sign_out(self, context: TurnContext) -> None: + """Signs out the agentic user by clearing any stored tokens.""" pass + + @staticmethod + def is_agentic_request(context_or_activity: Union[TurnContext, Activity]) -> bool: + """Determines if the request is from an agentic source.""" + if isinstance(context_or_activity, TurnContext): + activity = context_or_activity.activity + else: + activity = context_or_activity + + return activity.is_agentic() + + @staticmethod + def get_agent_instance_id(context: TurnContext) -> Optional[str]: + """Gets the agent instance ID from the context if it's an agentic request.""" + if not AgenticAuthorization.is_agentic_request(context): + return None + + return context.activity.recipient.agentic_app_id + + @staticmethod + def get_agentic_user(context: TurnContext) -> Optional[str]: + """Gets the agentic user (UPN) from the context if it's an agentic request.""" + if not AgenticAuthorization.is_agentic_request(context): + return None + + return context.activity.recipient.id diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py index a2ec9361..008cf1b7 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py @@ -25,11 +25,20 @@ def __init__( """ Initializes a new instance of AuthHandler. - Args: - name: The name of the OAuth connection. - auto: Whether to automatically start the OAuth flow. - title: Title for the OAuth card. - text: Text for the OAuth button. + :param name: The name of the handler. This is how it is accessed programatically + in this library. + :type name: str + :param title: Title for the OAuth card. + :type title: str + :param text: Text for the OAuth button. + :type text: str + :param abs_oauth_connection_name: The name of the Azure Bot Service OAuth connection. + :type abs_oauth_connection_name: str + :param obo_connection_name: The name of the On-Behalf-Of connection. + :type obo_connection_name: str + :param auth_type: The authorization variant used. This is likely to change in the future + to accept a class that implements AuthorizationVariant. + :type auth_type: str """ self.name = name or kwargs.get("NAME", "") self.title = title or kwargs.get("TITLE", "") diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py index 14350197..1dc5acb5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py @@ -4,8 +4,6 @@ from microsoft_agents.activity import Activity, TokenResponse -from tests.hosting_core.app import auth - from ...turn_context import TurnContext from ...storage import Storage from ...authorization import Connections @@ -40,12 +38,16 @@ def __init__( """ Creates a new instance of Authorization. - Args: - storage: The storage system to use for state management. - auth_handlers: Configuration for OAuth providers. + Handlers defined in the configuration (passed in via kwargs) will be used + only if auth_handlers is empty or None. - Raises: - ValueError: If storage is None or no auth handlers are provided. + :param storage: The storage system to use for state management. + :type storage: Storage + :param connection_manager: The connection manager for OAuth providers. + :type connection_manager: Connections + :param auth_handlers: Configuration for OAuth providers. + :type auth_handlers: dict[str, AuthHandler], optional + :raises ValueError: When storage is None or no auth handlers provided. """ if not storage: raise ValueError("Storage is required for Authorization") @@ -78,10 +80,19 @@ def __init__( self._init_auth_variants(self._auth_handlers) def _init_auth_variants(self, auth_handlers: dict[str, AuthHandler]): + """Initialize authorization variants based on the provided auth handlers. + + This method maps the auth types to their corresponding authorization variants, and + it initializes an instance of each variant that is referenced. + + :param auth_handlers: A dictionary of auth handler configurations. + :type auth_handlers: dict[str, AuthHandler] + """ auth_types = set(handler.auth_type for handler in auth_handlers.values()) for auth_type in auth_types: auth_type = auth_type.lower() + # get handlers that match this variant type associated_handlers = { auth_handler.name: auth_handler for auth_handler in self._auth_handlers.values() @@ -95,36 +106,60 @@ def _init_auth_variants(self, auth_handlers: dict[str, AuthHandler]): ) def sign_in_state_key(self, context: TurnContext) -> str: + """Generate a unique storage key for the sign-in state based on the context. + + This is the key used to store and retrieve the sign-in state from storage, and + can be used to inspect or manipulate the state directly if needed. + + :param context: The turn context for the current turn of conversation. + :type context: TurnContext + :return: A unique (across other values of channel_id and user_id) key for the sign-in state. + :rtype: str + """ return f"auth:SignInState:{context.activity.channel_id}:{context.activity.from_property.id}" async def _load_sign_in_state(self, context: TurnContext) -> Optional[SignInState]: + """Load the sign-in state from storage for the given context.""" key = self.sign_in_state_key(context) return (await self._storage.read([key], target_cls=SignInState)).get(key) async def _save_sign_in_state( self, context: TurnContext, state: SignInState ) -> None: + """Save the sign-in state to storage for the given context.""" key = self.sign_in_state_key(context) await self._storage.write({key: state}) async def _delete_sign_in_state(self, context: TurnContext) -> None: + """Delete the sign-in state from storage for the given context.""" key = self.sign_in_state_key(context) await self._storage.delete([key]) @property def user_auth(self) -> UserAuthorization: + """Get the user authorization variant. Raises if not configured.""" return cast( UserAuthorization, self._resolve_auth_variant(UserAuthorization.__name__) ) @property def agentic_auth(self) -> AgenticAuthorization: + """Get the agentic authorization variant. Raises if not configured.""" return cast( AgenticAuthorization, self._resolve_auth_variant(AgenticAuthorization.__name__), ) def _resolve_auth_variant(self, auth_variant: str) -> AuthorizationVariant: + """Resolve the authorization variant by its type name. + + :param auth_variant: The type name of the authorization variant to resolve. + Should corresponde to the __name__ of the class, e.g. "UserAuthorization". + :type auth_variant: str + :return: The corresponding AuthorizationVariant instance. + :rtype: AuthorizationVariant + :raises ValueError: If the auth variant is not recognized or not configured. + """ auth_variant = auth_variant.lower() if auth_variant not in self._authorization_variants: @@ -135,6 +170,14 @@ def _resolve_auth_variant(self, auth_variant: str) -> AuthorizationVariant: return self._authorization_variants[auth_variant] def resolve_handler(self, handler_id: str) -> AuthHandler: + """Resolve the auth handler by its ID. + + :param handler_id: The ID of the auth handler to resolve. + :type handler_id: str + :return: The corresponding AuthHandler instance. + :rtype: AuthHandler + :raises ValueError: If the handler ID is not recognized or not configured. + """ if handler_id not in self._auth_handlers: raise ValueError( f"Auth handler {handler_id} not recognized or not configured." @@ -144,12 +187,29 @@ def resolve_handler(self, handler_id: str) -> AuthHandler: async def start_or_continue_sign_in( self, context: TurnContext, state: StateT, auth_handler_id: str ) -> SignInResponse: + """Start or continue the sign-in process for the user with the given auth handler. + + SignInResponse output is based on the result of the variant used by the handler. + Storage is updated as needed with SignInState data for caching purposes. + + :param context: The turn context for the current turn of conversation. + :type context: TurnContext + :param state: The turn state for the current turn of conversation. + :type state: StateT + :param auth_handler_id: The ID of the auth handler to use for sign-in. If None, the first handler will be used. + :type auth_handler_id: str + :return: A SignInResponse indicating the result of the sign-in attempt. + :rtype: SignInResponse + """ + # check cached sign in state sign_in_state = await self._load_sign_in_state(context) if not sign_in_state: + # no existing sign-in state, create a new one sign_in_state = SignInState({auth_handler_id: ""}) if sign_in_state.tokens.get(auth_handler_id): + # already signed in with this handler, got it from cached SignInState return SignInResponse( tag=FlowStateTag.COMPLETE, token_response=TokenResponse( @@ -159,6 +219,8 @@ async def start_or_continue_sign_in( handler = self.resolve_handler(auth_handler_id) variant = self._resolve_auth_variant(handler.auth_type) + + # attempt sign-in continuation (or beginning) sign_in_response = await variant.sign_in(context, auth_handler_id) if sign_in_response.tag == FlowStateTag.COMPLETE: @@ -173,27 +235,44 @@ async def start_or_continue_sign_in( await self._sign_in_failure_handler(context, state, auth_handler_id) elif sign_in_response.tag in [FlowStateTag.BEGIN, FlowStateTag.CONTINUE]: + # store continuation activity and wait for next turn sign_in_state.continuation_activity = context.activity await self._save_sign_in_state(context, sign_in_state) return sign_in_response + async def _sign_out(self, context: TurnContext, auth_handler_id) -> None: + """Helper to sign out from a specific handler.""" + handler = self.resolve_handler(auth_handler_id) + variant = self._resolve_auth_variant(handler.auth_type) + await variant.sign_out(context, auth_handler_id) + async def sign_out( self, context: TurnContext, state: StateT, auth_handler_id=None ) -> None: + """Attempts to sign out the user from the specified auth handler or all handlers if none specified. + + :param context: The turn context for the current turn of conversation. + :type context: TurnContext + :param state: The turn state for the current turn of conversation. + :type state: StateT + :param auth_handler_id: The ID of the auth handler to sign out from. If None, sign out from all handlers. + :type auth_handler_id: Optional[str] + :return: None + """ sign_in_state = await self._load_sign_in_state(context) if sign_in_state: + if not auth_handler_id: + # sign out from all handlers for handler_id in sign_in_state.tokens.keys(): if handler_id in sign_in_state.tokens: - handler = self.resolve_handler(handler_id) - variant = self._resolve_auth_variant(handler.auth_type) - await variant.sign_out(context, handler_id) + await self._sign_out(context, handler_id) await self._delete_sign_in_state(context) + elif auth_handler_id in sign_in_state.tokens: - handler = self.resolve_handler(auth_handler_id) - variant = self._resolve_auth_variant(handler.auth_type) - await variant.sign_out(context, auth_handler_id) + # sign out from specific handler + await self._sign_out(context, auth_handler_id) del sign_in_state.tokens[auth_handler_id] await self._save_sign_in_state(context, sign_in_state) @@ -204,11 +283,16 @@ async def on_turn_auth_intercept( Returns true if the rest of the turn should be skipped because auth did not finish. Returns false if the turn should continue processing as normal. - Calls continue_turn_callback if auth completes and a new turn should be started. <- TODO, seems a bit strange + If auth completes and a new turn should be started, returns the continuation activity + from the cached SignInState. + + :param context: The context object for the current turn. + :type context: TurnContext + :param state: The turn state for the current turn. + :type state: StateT + :return: A tuple indicating whether the turn should be skipped and the continuation activity if applicable. + :rtype: tuple[bool, Optional[Activity]] """ - - # get active thing... - sign_in_state = await self._load_sign_in_state(context) if sign_in_state: @@ -222,22 +306,26 @@ async def on_turn_auth_intercept( continuation_activity = ( sign_in_state.continuation_activity.model_copy() ) + # flow complete, start new turn with continuation activity return True, continuation_activity + # auth flow still in progress, the turn should be skipped return True, None + # no active auth flow, continue processing return False, None async def get_token( self, context: TurnContext, auth_handler_id: str ) -> TokenResponse: - """ - Gets the token for a specific auth handler. + """Gets the token for a specific auth handler. - Args: - context: The context object for the current turn. - auth_handler_id: Optional ID of the auth handler to use, defaults to first handler. + The token is taken from cache, so this does not initiate nor continue a sign-in flow. - Returns: - The token response from the OAuth provider. + :param context: The context object for the current turn. + :type context: TurnContext + :param auth_handler_id: The ID of the auth handler to get the token for. + :type auth_handler_id: str + :return: The token response from the OAuth provider. + :rtype: TokenResponse """ sign_in_state = await self._load_sign_in_state(context) if not sign_in_state or not sign_in_state.tokens.get(auth_handler_id): @@ -254,13 +342,15 @@ async def exchange_token( """ Exchanges a token for another token with different scopes. - Args: - context: The context object for the current turn. - scopes: The scopes to request for the new token. - auth_handler_id: Optional ID of the auth handler to use, defaults to first handler. - - Returns: - The token response from the OAuth provider. + :param context: The context object for the current turn. + :type context: TurnContext + :param scopes: The scopes to request for the new token. + :type scopes: list[str] + :param auth_handler_id: Optional ID of the auth handler to use, defaults to first + :type auth_handler_id: str + :return: The token response from the OAuth provider from the exchange. + If the cached token is not exchangeable, returns the cached token. + :rtype: TokenResponse """ token_response = await self.get_token(context, auth_handler_id) @@ -275,11 +365,9 @@ def _is_exchangeable(self, token: str) -> bool: """ Checks if a token is exchangeable (has api:// audience). - Args: - token: The token to check. - - Returns: - True if the token is exchangeable, False otherwise. + :param token: The token to check. + :type token: str + :return: True if the token is exchangeable, False otherwise. """ try: # Decode without verification to check the audience @@ -296,14 +384,14 @@ async def _handle_obo( """ Handles On-Behalf-Of token exchange. - Args: - context: The context object for the current turn. - token: The original token. - scopes: The scopes to request. - - Returns: - The new token response. - + :param token: The original token. + :type token: str + :param scopes: The scopes to request. + :type scopes: list[str] + :param handler_id: The ID of the auth handler to use, defaults to first + :type handler_id: str, optional + :return: The new token response. + :rtype: TokenResponse """ auth_handler = self.resolve_handler(handler_id) token_provider = self._connection_manager.get_connection( @@ -324,8 +412,7 @@ def on_sign_in_success( """ Sets a handler to be called when sign-in is successfully completed. - Args: - handler: The handler function to call on successful sign-in. + :param handler: The handler function to call on successful sign-in. """ self._sign_in_success_handler = handler @@ -335,7 +422,7 @@ def on_sign_in_failure( ) -> None: """ Sets a handler to be called when sign-in fails. - Args: - handler: The handler function to call on sign-in failure. + + :param handler: The handler function to call on sign-in failure. """ self._sign_in_failure_handler = handler diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py index e6566e83..fa0bf15f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py @@ -2,10 +2,6 @@ from typing import Optional import logging -from microsoft_agents.activity import ( - TokenResponse, -) - from ...turn_context import TurnContext from ...oauth import FlowStateTag from ...storage import Storage @@ -17,6 +13,8 @@ class AuthorizationVariant(ABC): + """Base class for different authorization strategies.""" + def __init__( self, storage: Storage, @@ -25,16 +23,17 @@ def __init__( auto_signin: bool = None, use_cache: bool = False, **kwargs, - ): + ) -> None: """ Creates a new instance of Authorization. - Args: - storage: The storage system to use for state management. - auth_handlers: Configuration for OAuth providers. - - Raises: - ValueError: If storage is None or no auth handlers are provided. + :param storage: The storage system to use for state management. + :type storage: Storage + :param connection_manager: The connection manager for OAuth providers. + :type connection_manager: Connections + :param auth_handlers: Configuration for OAuth providers. + :type auth_handlers: dict[str, AuthHandler], optional + :raises ValueError: When storage is None or no auth handlers provided. """ if not storage: raise ValueError("Storage is required for Authorization") @@ -60,6 +59,15 @@ def __init__( async def sign_in( self, context: TurnContext, auth_handler_id: Optional[str] = None ) -> SignInResponse: + """Initiate or continue the sign-in process for the user with the given auth handler. + + :param context: The turn context for the current turn of conversation. + :type context: TurnContext + :param auth_handler_id: The ID of the auth handler to use for sign-in. If None, the first handler will be used. + :type auth_handler_id: Optional[str] + :return: A SignInResponse indicating the result of the sign-in attempt. + :rtype: SignInResponse + """ raise NotImplementedError() async def sign_out( @@ -67,4 +75,11 @@ async def sign_out( context: TurnContext, auth_handler_id: Optional[str] = None, ) -> None: + """Attempts to sign out the user from the specified auth handler or all handlers if none specified. + + :param context: The turn context for the current turn of conversation. + :type context: TurnContext + :param auth_handler_id: The ID of the auth handler to sign out from. If None, sign out from all handlers. + :type auth_handler_id: Optional[str] + """ raise NotImplementedError() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py index 5cc4f426..25f2bc4d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py @@ -6,6 +6,8 @@ class SignInResponse: + """Response for a sign-in attempt, including the token response and flow state tag.""" + token_response: TokenResponse tag: FlowStateTag @@ -18,4 +20,5 @@ def __init__( self.tag = tag def sign_in_complete(self) -> bool: + """Return True if the sign-in flow is complete (either successful or no attempt needed).""" return self.tag in [FlowStateTag.COMPLETE, FlowStateTag.NOT_STARTED] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py index a381ce3c..8d4fa439 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py @@ -9,6 +9,12 @@ class SignInState(StoreItem): + """Store item for sign-in state, including tokens and continuation activity. + + Used to cache tokens and keep track of activities during single and + multi-turn sign-in flows. + """ + def __init__( self, tokens: Optional[JSON] = None, @@ -27,7 +33,8 @@ def store_item_to_json(self) -> JSON: def from_json_to_store_item(json_data: JSON) -> SignInState: return SignInState(json_data["tokens"], json_data.get("continuation_activity")) - def active_handler(self) -> "": + def active_handler(self) -> str: + """Return the handler ID that is missing a token, according to the state.""" for handler_id, token in self.tokens.items(): if not token: return handler_id diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py index 9a122d4b..c5422610 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py @@ -1,6 +1,5 @@ from __future__ import annotations import logging -from typing import Optional from microsoft_agents.activity import ( ActionTypes, @@ -20,6 +19,11 @@ class UserAuthorization(UserAuthorizationBase): + """Class responsible for managing user authorization and OAuth flows. + + Handles the sending and receiving of OAuth cards, and manages the complete user OAuth lifecycle. + """ + async def _handle_flow_response( self, context: TurnContext, flow_response: FlowResponse ) -> None: @@ -66,6 +70,18 @@ async def _handle_flow_response( async def sign_in( self, context: TurnContext, auth_handler_id: str ) -> SignInResponse: + """Begins or continues an OAuth flow. + + Handles the flow response, sending the OAuth card to the context. + + :param context: The context object for the current turn. + :type context: TurnContext + :param auth_handler_id: The ID of the auth handler to use. + :type auth_handler_id: str + :return: The SignInResponse containing the token response and flow state tag. + :rtype: SignInResponse + """ + logger.debug( "Beginning or continuing flow for auth handler %s", auth_handler_id, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py index 31113af5..93e239b0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py @@ -4,21 +4,13 @@ from __future__ import annotations import logging from abc import ABC -from re import U -from typing import Dict, Optional, Callable, Awaitable, AsyncIterator, TypeVar +from typing import Optional from collections.abc import Iterable -from contextlib import asynccontextmanager - -from microsoft_agents.hosting.core.authorization import ( - Connections, - AccessTokenProviderBase, -) -from microsoft_agents.hosting.core.storage import Storage, MemoryStorage -from microsoft_agents.activity import TokenResponse + from microsoft_agents.hosting.core.connector.client import UserTokenClient from ...turn_context import TurnContext -from ...oauth import OAuthFlow, FlowResponse, FlowState, FlowStateTag, FlowStorageClient +from ...oauth import OAuthFlow, FlowResponse, FlowState, FlowStorageClient from .authorization_variant import AuthorizationVariant from .auth_handler import AuthHandler @@ -36,14 +28,16 @@ async def _load_flow( ) -> tuple[OAuthFlow, FlowStorageClient]: """Loads the OAuth flow for a specific auth handler. - Args: - context: The context object for the current turn. - auth_handler_id: The ID of the auth handler to use. + A new flow is created in Storage if none exists for the channel, user, and handler + combination. - Returns: - The OAuthFlow returned corresponds to the flow associated with the - chosen handler, and the channel and user info found in the context. - The FlowStorageClient corresponds to the same channel and user info. + :param context: The context object for the current turn. + :type context: TurnContext + :param auth_handler_id: The ID of the auth handler to use. + :type auth_handler_id: str + :return: A tuple containing the OAuthFlow and FlowStorageClient created from the + context and the specified auth handler. + :rtype: tuple[OAuthFlow, FlowStorageClient] """ user_token_client: UserTokenClient = context.turn_state.get( context.adapter.USER_TOKEN_CLIENT_KEY @@ -91,19 +85,20 @@ async def begin_or_continue_flow( ) -> FlowResponse: """Begins or continues an OAuth flow. - Args: - context: The context object for the current turn. - auth_handler_id: Optional ID of the auth handler to use, defaults to first handler. - - Returns: - The token response from the OAuth provider. + Delegates to the OAuthFlow to handle the activity and manage the flow state. + :param context: The context object for the current turn. + :type context: TurnContext + :param auth_handler_id: The ID of the auth handler to use. + :type auth_handler_id: str + :return: The FlowResponse from the OAuth flow. + :rtype: FlowResponse """ logger.debug("Beginning or continuing OAuth flow") flow, flow_storage_client = await self._load_flow(context, auth_handler_id) - prev_tag = flow.flow_state.tag + # prev_tag = flow.flow_state.tag flow_response: FlowResponse = await flow.begin_or_continue_flow( context.activity ) @@ -111,6 +106,7 @@ async def begin_or_continue_flow( logger.info("Saving OAuth flow state to storage") await flow_storage_client.write(flow_response.flow_state) + # optimization for the future. Would like to double check this logic. # if prev_tag != flow_response.flow_state.tag and flow_response.flow_state.tag == FlowStateTag.COMPLETE: # # Clear the flow state on completion # await flow_storage_client.delete(auth_handler_id) @@ -124,11 +120,13 @@ async def _sign_out( ) -> None: """Signs out from the specified auth handlers. - Args: - context: The context object for the current turn. - auth_handler_ids: Iterable of auth handler IDs to sign out from. + Deletes the associated flows from storage. - Deletes the associated flow states from storage. + :param context: The context object for the current turn. + :type context: TurnContext + :param auth_handler_ids: Iterable of auth handler IDs to sign out from. + :type auth_handler_ids: Iterable[str] + :return: None """ for auth_handler_id in auth_handler_ids: flow, flow_storage_client = await self._load_flow(context, auth_handler_id) @@ -145,12 +143,9 @@ async def sign_out( Signs out the current user. This method clears the user's token and resets the OAuth state. - Args: - context: The context object for the current turn. - auth_handler_id: Optional ID of the auth handler to use for sign out. If None, - signs out from all the handlers. - - Deletes the associated flow state(s) from storage. + :param context: The context object for the current turn. + :param auth_handler_id: Optional ID of the auth handler to use for sign out. If None, + signs out from all the handlers. """ if auth_handler_id: await self._sign_out(context, [auth_handler_id]) From ef0143814ef826ddf972d025eaf0803965b449c8 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 26 Sep 2025 11:28:27 -0700 Subject: [PATCH 14/36] Formatting --- .../microsoft_agents/hosting/core/app/agent_application.py | 5 +++-- .../microsoft_agents/hosting/core/app/auth/auth_handler.py | 3 --- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index 6605fb6d..b5e373e1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -38,7 +38,6 @@ from .app_error import ApplicationError from .app_options import ApplicationOptions -# from .auth import AuthManager, OAuth, OAuthOptions from .route import Route, RouteHandler from .state import TurnState from ..channel_service_adapter import ChannelServiceAdapter @@ -443,7 +442,9 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: return __call - def handoff(self, *, auth_handlers: Optional[List[str]] = None) -> Callable[ + def handoff( + self, *, auth_handlers: Optional[List[str]] = None + ) -> Callable[ [Callable[[TurnContext, StateT, str], Awaitable[None]]], Callable[[TurnContext, StateT, str], Awaitable[None]], ]: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py index 008cf1b7..36e2cbdf 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py @@ -50,9 +50,6 @@ def __init__( "OBOCONNECTIONNAME", "" ) self.auth_type = auth_type or kwargs.get("TYPE", "") - logger.debug( - f"AuthHandler initialized: name={self.name}, title={self.title}, text={self.text} abs_connection_name={self.abs_oauth_connection_name} obo_connection_name={self.obo_connection_name}" - ) # # Type alias for authorization handlers dictionary From 185acfcfd1f17cc382ebeacdd875cb32a99c1d40 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 26 Sep 2025 14:20:33 -0700 Subject: [PATCH 15/36] Address review comments and more breaking changes --- .../microsoft_agents/hosting/core/__init__.py | 2 +- .../hosting/core/app/__init__.py | 2 +- .../hosting/core/app/agent_application.py | 8 ++--- .../hosting/core/app/app_options.py | 2 +- .../core/app/{auth => oauth}/__init__.py | 0 .../{auth => oauth}/agentic_authorization.py | 24 ++++---------- .../core/app/{auth => oauth}/auth_handler.py | 3 ++ .../core/app/{auth => oauth}/authorization.py | 33 ++++++++----------- .../{auth => oauth}/authorization_variant.py | 2 +- .../app/{auth => oauth}/sign_in_response.py | 0 .../core/app/{auth => oauth}/sign_in_state.py | 0 .../app/{auth => oauth}/user_authorization.py | 2 +- .../user_authorization_base.py | 0 .../hosting/core/turn_context.py | 6 +--- .../mocks/mock_authorization.py | 2 +- .../app/auth/test_sign_in_state.py | 2 +- 16 files changed, 34 insertions(+), 54 deletions(-) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{auth => oauth}/__init__.py (100%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{auth => oauth}/agentic_authorization.py (83%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{auth => oauth}/auth_handler.py (92%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{auth => oauth}/authorization.py (95%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{auth => oauth}/authorization_variant.py (97%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{auth => oauth}/sign_in_response.py (100%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{auth => oauth}/sign_in_state.py (100%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{auth => oauth}/user_authorization.py (97%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{auth => oauth}/user_authorization_base.py (100%) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py index 0db68b84..6cf78066 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py @@ -20,7 +20,7 @@ from .app.typing_indicator import TypingIndicator # App Auth -from .app.auth import ( +from .app.oauth import ( Authorization, AuthorizationHandlers, AuthHandler, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py index e2767221..9e4f871e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py @@ -14,7 +14,7 @@ from .typing_indicator import TypingIndicator # Auth -from .auth import ( +from .oauth import ( Authorization, AuthHandler, AuthorizationHandlers, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index b5e373e1..48cdff63 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -41,7 +41,7 @@ from .route import Route, RouteHandler from .state import TurnState from ..channel_service_adapter import ChannelServiceAdapter -from .auth import Authorization +from .oauth import Authorization from .typing_indicator import TypingIndicator logger = logging.getLogger(__name__) @@ -179,7 +179,7 @@ def adapter(self) -> ChannelServiceAdapter: return self._adapter @property - def auth(self): + def auth(self) -> Authorization: """ The application's authentication manager """ @@ -442,9 +442,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: return __call - def handoff( - self, *, auth_handlers: Optional[List[str]] = None - ) -> Callable[ + def handoff(self, *, auth_handlers: Optional[List[str]] = None) -> Callable[ [Callable[[TurnContext, StateT, str], Awaitable[None]]], Callable[[TurnContext, StateT, str], Awaitable[None]], ]: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py index ed5defa7..21312c76 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py @@ -9,7 +9,7 @@ from logging import Logger from typing import Callable, List, Optional -from microsoft_agents.hosting.core.app.auth import AuthHandler +from microsoft_agents.hosting.core.app.oauth import AuthHandler from microsoft_agents.hosting.core.storage import Storage # from .auth import AuthOptions diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/agentic_authorization.py similarity index 83% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/agentic_authorization.py index 818c72e3..c32dc441 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/agentic_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/agentic_authorization.py @@ -1,8 +1,8 @@ import logging -from typing import Optional, Union +from typing import Optional -from microsoft_agents.activity import Activity, TokenResponse +from microsoft_agents.activity import TokenResponse from ...turn_context import TurnContext from ...oauth import FlowStateTag @@ -25,7 +25,7 @@ async def get_agentic_instance_token(self, context: TurnContext) -> Optional[str :rtype: Optional[str] """ - if not self.is_agentic_request(context): + if not context.activity.is_agentic(): return None assert context.identity @@ -52,7 +52,7 @@ async def get_agentic_user_token( :rtype: Optional[str] """ - if not self.is_agentic_request(context) or not self.get_agentic_user(context): + if not context.activity.is_agentic() or not self.get_agentic_user(context): return None assert context.identity @@ -95,28 +95,16 @@ async def sign_out(self, context: TurnContext) -> None: """Signs out the agentic user by clearing any stored tokens.""" pass - @staticmethod - def is_agentic_request(context_or_activity: Union[TurnContext, Activity]) -> bool: - """Determines if the request is from an agentic source.""" - if isinstance(context_or_activity, TurnContext): - activity = context_or_activity.activity - else: - activity = context_or_activity - - return activity.is_agentic() - @staticmethod def get_agent_instance_id(context: TurnContext) -> Optional[str]: """Gets the agent instance ID from the context if it's an agentic request.""" - if not AgenticAuthorization.is_agentic_request(context): + if not context.activity.is_agentic() or not context.activity.recipient: return None - return context.activity.recipient.agentic_app_id @staticmethod def get_agentic_user(context: TurnContext) -> Optional[str]: """Gets the agentic user (UPN) from the context if it's an agentic request.""" - if not AgenticAuthorization.is_agentic_request(context): + if not context.activity.is_agentic() or not context.activity.recipient: return None - return context.activity.recipient.id diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py similarity index 92% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py index 36e2cbdf..40c33658 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py @@ -20,6 +20,7 @@ def __init__( abs_oauth_connection_name: str = "", obo_connection_name: str = "", auth_type: str = "", + scopes: list[str] = None **kwargs, ): """ @@ -50,6 +51,8 @@ def __init__( "OBOCONNECTIONNAME", "" ) self.auth_type = auth_type or kwargs.get("TYPE", "") + self.auth_type = self.auth_type.lower() + self.scopes = list(scopes) or kwargs.get("SCOPES", []) # # Type alias for authorization handlers dictionary diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py similarity index 95% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index 1dc5acb5..c399b3d6 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -22,10 +22,8 @@ } logger = logging.getLogger(__name__) -StateT = TypeVar("StateT", bound=TurnState) - -class Authorization(Generic[StateT]): +class Authorization: def __init__( self, storage: Storage, @@ -77,9 +75,9 @@ def __init__( ] = None self._authorization_variants = {} - self._init_auth_variants(self._auth_handlers) + self._init_auth_variants() - def _init_auth_variants(self, auth_handlers: dict[str, AuthHandler]): + def _init_auth_variants(self) -> None: """Initialize authorization variants based on the provided auth handlers. This method maps the auth types to their corresponding authorization variants, and @@ -90,13 +88,11 @@ def _init_auth_variants(self, auth_handlers: dict[str, AuthHandler]): """ auth_types = set(handler.auth_type for handler in auth_handlers.values()) for auth_type in auth_types: - auth_type = auth_type.lower() - # get handlers that match this variant type associated_handlers = { auth_handler.name: auth_handler for auth_handler in self._auth_handlers.values() - if auth_handler.auth_type.lower() == auth_type + if auth_handler.auth_type == auth_type } self._authorization_variants[auth_type] = AUTHORIZATION_TYPE_MAP[auth_type]( @@ -105,7 +101,8 @@ def _init_auth_variants(self, auth_handlers: dict[str, AuthHandler]): auth_handlers=associated_handlers, ) - def sign_in_state_key(self, context: TurnContext) -> str: + @staticmethod + def sign_in_state_key(context: TurnContext) -> str: """Generate a unique storage key for the sign-in state based on the context. This is the key used to store and retrieve the sign-in state from storage, and @@ -160,8 +157,6 @@ def _resolve_auth_variant(self, auth_variant: str) -> AuthorizationVariant: :rtype: AuthorizationVariant :raises ValueError: If the auth variant is not recognized or not configured. """ - - auth_variant = auth_variant.lower() if auth_variant not in self._authorization_variants: raise ValueError( f"Auth variant {auth_variant} not recognized or not configured." @@ -185,7 +180,7 @@ def resolve_handler(self, handler_id: str) -> AuthHandler: return self._auth_handlers[handler_id] async def start_or_continue_sign_in( - self, context: TurnContext, state: StateT, auth_handler_id: str + self, context: TurnContext, state: TurnState, auth_handler_id: str ) -> SignInResponse: """Start or continue the sign-in process for the user with the given auth handler. @@ -195,7 +190,7 @@ async def start_or_continue_sign_in( :param context: The turn context for the current turn of conversation. :type context: TurnContext :param state: The turn state for the current turn of conversation. - :type state: StateT + :type state: TurnState :param auth_handler_id: The ID of the auth handler to use for sign-in. If None, the first handler will be used. :type auth_handler_id: str :return: A SignInResponse indicating the result of the sign-in attempt. @@ -221,7 +216,7 @@ async def start_or_continue_sign_in( variant = self._resolve_auth_variant(handler.auth_type) # attempt sign-in continuation (or beginning) - sign_in_response = await variant.sign_in(context, auth_handler_id) + sign_in_response = await variant.sign_in(context, auth_handler_id, handler.scopes) if sign_in_response.tag == FlowStateTag.COMPLETE: if self._sign_in_success_handler: @@ -248,14 +243,14 @@ async def _sign_out(self, context: TurnContext, auth_handler_id) -> None: await variant.sign_out(context, auth_handler_id) async def sign_out( - self, context: TurnContext, state: StateT, auth_handler_id=None + self, context: TurnContext, state: TurnState, auth_handler_id=None ) -> None: """Attempts to sign out the user from the specified auth handler or all handlers if none specified. :param context: The turn context for the current turn of conversation. :type context: TurnContext :param state: The turn state for the current turn of conversation. - :type state: StateT + :type state: TurnState :param auth_handler_id: The ID of the auth handler to sign out from. If None, sign out from all handlers. :type auth_handler_id: Optional[str] :return: None @@ -277,7 +272,7 @@ async def sign_out( await self._save_sign_in_state(context, sign_in_state) async def on_turn_auth_intercept( - self, context: TurnContext, state: StateT + self, context: TurnContext, state: TurnState ) -> tuple[bool, Optional[Activity]]: """Intercepts the turn to check for active authentication flows. @@ -289,7 +284,7 @@ async def on_turn_auth_intercept( :param context: The context object for the current turn. :type context: TurnContext :param state: The turn state for the current turn. - :type state: StateT + :type state: TurnState :return: A tuple indicating whether the turn should be skipped and the continuation activity if applicable. :rtype: tuple[bool, Optional[Activity]] """ @@ -425,4 +420,4 @@ def on_sign_in_failure( :param handler: The handler function to call on sign-in failure. """ - self._sign_in_failure_handler = handler + self._sign_in_failure_handler = handler \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization_variant.py similarity index 97% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization_variant.py index fa0bf15f..e188516d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization_variant.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization_variant.py @@ -57,7 +57,7 @@ def __init__( self._auth_handlers = auth_handlers or {} async def sign_in( - self, context: TurnContext, auth_handler_id: Optional[str] = None + self, context: TurnContext, auth_handler_id: str, scopes: Optional[list[str]] = None ) -> SignInResponse: """Initiate or continue the sign-in process for the user with the given auth handler. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/sign_in_response.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/sign_in_response.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/sign_in_state.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/sign_in_state.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/user_authorization.py similarity index 97% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/user_authorization.py index c5422610..1d00360d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/user_authorization.py @@ -68,7 +68,7 @@ async def _handle_flow_response( await context.send_activity("Sign-in failed. Please try again.") async def sign_in( - self, context: TurnContext, auth_handler_id: str + self, context: TurnContext, auth_handler_id: str, scopes: Optional[list[str]] = None ) -> SignInResponse: """Begins or continues an OAuth flow. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/user_authorization_base.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/user_authorization_base.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/user_authorization_base.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index e89a36b5..4deb7c92 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -431,8 +431,4 @@ def get_mentions(activity: Activity) -> list[Mention]: if entity.type.lower() == "mention": result.append(entity) - return result - - @staticmethod - def is_agentic_request(context: TurnContext) -> bool: - return context.activity.is_agentic() + return result \ No newline at end of file diff --git a/tests/_common/testing_objects/mocks/mock_authorization.py b/tests/_common/testing_objects/mocks/mock_authorization.py index 4caa4fde..e094fa3d 100644 --- a/tests/_common/testing_objects/mocks/mock_authorization.py +++ b/tests/_common/testing_objects/mocks/mock_authorization.py @@ -3,7 +3,7 @@ UserAuthorization, AgenticAuthorization, ) -from microsoft_agents.hosting.core.app.auth import SignInResponse +from microsoft_agents.hosting.core.app.oauth import SignInResponse def mock_class_UserAuthorization(mocker, sign_in_return=None): diff --git a/tests/hosting_core/app/auth/test_sign_in_state.py b/tests/hosting_core/app/auth/test_sign_in_state.py index 2621cf31..36710f47 100644 --- a/tests/hosting_core/app/auth/test_sign_in_state.py +++ b/tests/hosting_core/app/auth/test_sign_in_state.py @@ -1,6 +1,6 @@ import pytest -from microsoft_agents.hosting.core.app.auth import SignInState +from microsoft_agents.hosting.core.app.oauth import SignInState from ._common import testing_Activity, testing_TurnContext From 770cf6976a8b6ab8ffb1072b609ab0f2fddc3606 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 26 Sep 2025 23:37:50 -0700 Subject: [PATCH 16/36] Shifting around exchange token logic --- .../msal/msal_connection_manager.py | 28 +++++- .../hosting/core/app/oauth/authorization.py | 72 ---------------- .../core/app/oauth/authorization_variant.py | 76 +++++++++++++++- .../hosting/core/turn_context.py | 2 +- .../_common/testing_objects/http/__init__.py | 0 .../testing_objects/http/mock_abs_api.py | 0 .../testing_channel_service_client_factory.py | 86 ------------------- .../http/testing_client_session.py | 2 - .../http/testing_connector_client.py | 40 --------- .../app/test_agent_application.py | 36 ++++++++ 10 files changed, 138 insertions(+), 204 deletions(-) delete mode 100644 tests/_common/testing_objects/http/__init__.py delete mode 100644 tests/_common/testing_objects/http/mock_abs_api.py delete mode 100644 tests/_common/testing_objects/http/testing_channel_service_client_factory.py delete mode 100644 tests/_common/testing_objects/http/testing_client_session.py delete mode 100644 tests/_common/testing_objects/http/testing_connector_client.py create mode 100644 tests/hosting_core/app/test_agent_application.py diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py index 3abf4543..89b9c794 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py @@ -1,3 +1,4 @@ +import re from typing import Dict, List, Optional from microsoft_agents.hosting.core import ( AgentAuthConfiguration, @@ -61,9 +62,32 @@ def get_token_provider( """ if not self._connections_map: return self.get_default_connection() + + aud = claims_identity.get_app_id() + if aud: + aud = aud.lower() - return self.get_default_connection() - # TODO: Implement logic to select the appropriate connection based on the connection map + for item in self._connections_map: + if item.get("audience", "").lower() == aud: + item_service_url = item.get("serviceUrl", "") + + if item_service_url == "*" or item_service_url == "": + connection_name = item.get("connectionName") + connection = self.get_connection(connection_name) + if connection: + return connection + + else: + match = re.match(item_service_url, service_url) + if match: + connection_name = item.get("connectionName") + connection = self.get_connection(connection_name) + if connection: + return connection + + raise ValueError( + f"No connection found for audience '{aud}' and serviceUrl '{service_url}'." + ) def get_default_connection_configuration(self) -> AgentAuthConfiguration: """ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index c399b3d6..94b807c6 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -328,78 +328,6 @@ async def get_token( token = sign_in_state.tokens[auth_handler_id] return TokenResponse(token=token) - async def exchange_token( - self, - context: TurnContext, - scopes: list[str], - auth_handler_id: str, - ) -> TokenResponse: - """ - Exchanges a token for another token with different scopes. - - :param context: The context object for the current turn. - :type context: TurnContext - :param scopes: The scopes to request for the new token. - :type scopes: list[str] - :param auth_handler_id: Optional ID of the auth handler to use, defaults to first - :type auth_handler_id: str - :return: The token response from the OAuth provider from the exchange. - If the cached token is not exchangeable, returns the cached token. - :rtype: TokenResponse - """ - - token_response = await self.get_token(context, auth_handler_id) - - if token_response and self._is_exchangeable(token_response.token): - logger.debug("Token is exchangeable, performing OBO flow") - return await self._handle_obo(token_response.token, scopes, auth_handler_id) - - return token_response - - def _is_exchangeable(self, token: str) -> bool: - """ - Checks if a token is exchangeable (has api:// audience). - - :param token: The token to check. - :type token: str - :return: True if the token is exchangeable, False otherwise. - """ - try: - # Decode without verification to check the audience - payload = jwt.decode(token, options={"verify_signature": False}) - aud = payload.get("aud") - return isinstance(aud, str) and aud.startswith("api://") - except Exception: - logger.error("Failed to decode token to check audience") - return False - - async def _handle_obo( - self, token: str, scopes: list[str], handler_id: str = None - ) -> TokenResponse: - """ - Handles On-Behalf-Of token exchange. - - :param token: The original token. - :type token: str - :param scopes: The scopes to request. - :type scopes: list[str] - :param handler_id: The ID of the auth handler to use, defaults to first - :type handler_id: str, optional - :return: The new token response. - :rtype: TokenResponse - """ - auth_handler = self.resolve_handler(handler_id) - token_provider = self._connection_manager.get_connection( - auth_handler.obo_connection_name - ) - - logger.info("Attempting to exchange token on behalf of user") - new_token = await token_provider.aquire_token_on_behalf_of( - scopes=scopes, - user_assertion=token, - ) - return TokenResponse(token=new_token) - def on_sign_in_success( self, handler: Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]], diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization_variant.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization_variant.py index e188516d..167298bb 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization_variant.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization_variant.py @@ -2,8 +2,9 @@ from typing import Optional import logging +from microsoft_agents.activity import TokenResponse + from ...turn_context import TurnContext -from ...oauth import FlowStateTag from ...storage import Storage from ...authorization import Connections from .auth_handler import AuthHandler @@ -56,6 +57,78 @@ def __init__( self._auth_handlers = auth_handlers or {} + async def exchange_token( + self, + context: TurnContext, + scopes: list[str], + auth_handler_id: str, + ) -> TokenResponse: + """ + Exchanges a token for another token with different scopes. + + :param context: The context object for the current turn. + :type context: TurnContext + :param scopes: The scopes to request for the new token. + :type scopes: list[str] + :param auth_handler_id: Optional ID of the auth handler to use, defaults to first + :type auth_handler_id: str + :return: The token response from the OAuth provider from the exchange. + If the cached token is not exchangeable, returns the cached token. + :rtype: TokenResponse + """ + + token_response = await self.get_token(context, auth_handler_id) + + if token_response and self._is_exchangeable(token_response.token): + logger.debug("Token is exchangeable, performing OBO flow") + return await self._handle_obo(token_response.token, scopes, auth_handler_id) + + return token_response + + def _is_exchangeable(self, token: str) -> bool: + """ + Checks if a token is exchangeable (has api:// audience). + + :param token: The token to check. + :type token: str + :return: True if the token is exchangeable, False otherwise. + """ + try: + # Decode without verification to check the audience + payload = jwt.decode(token, options={"verify_signature": False}) + aud = payload.get("aud") + return isinstance(aud, str) and aud.startswith("api://") + except Exception: + logger.error("Failed to decode token to check audience") + return False + + async def _handle_obo( + self, token: str, scopes: list[str], handler_id: str = None + ) -> TokenResponse: + """ + Handles On-Behalf-Of token exchange. + + :param token: The original token. + :type token: str + :param scopes: The scopes to request. + :type scopes: list[str] + :param handler_id: The ID of the auth handler to use, defaults to first + :type handler_id: str, optional + :return: The new token response. + :rtype: TokenResponse + """ + auth_handler = self.resolve_handler(handler_id) + token_provider = self._connection_manager.get_connection( + auth_handler.obo_connection_name + ) + + logger.info("Attempting to exchange token on behalf of user") + new_token = await token_provider.aquire_token_on_behalf_of( + scopes=scopes, + user_assertion=token, + ) + return TokenResponse(token=new_token) + async def sign_in( self, context: TurnContext, auth_handler_id: str, scopes: Optional[list[str]] = None ) -> SignInResponse: @@ -83,3 +156,4 @@ async def sign_out( :type auth_handler_id: Optional[str] """ raise NotImplementedError() + diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index 4deb7c92..fc0ce050 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -431,4 +431,4 @@ def get_mentions(activity: Activity) -> list[Mention]: if entity.type.lower() == "mention": result.append(entity) - return result \ No newline at end of file + return result diff --git a/tests/_common/testing_objects/http/__init__.py b/tests/_common/testing_objects/http/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/_common/testing_objects/http/mock_abs_api.py b/tests/_common/testing_objects/http/mock_abs_api.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/_common/testing_objects/http/testing_channel_service_client_factory.py b/tests/_common/testing_objects/http/testing_channel_service_client_factory.py deleted file mode 100644 index 8dd54e97..00000000 --- a/tests/_common/testing_objects/http/testing_channel_service_client_factory.py +++ /dev/null @@ -1,86 +0,0 @@ -from typing import Optional - -from microsoft_agents.hosting.core.authorization import ( - AuthenticationConstants, - AnonymousTokenProvider, - ClaimsIdentity, - Connections, -) -from microsoft_agents.hosting.core.authorization import AccessTokenProviderBase -from microsoft_agents.hosting.core.connector import ConnectorClientBase -from microsoft_agents.hosting.core.connector.client import UserTokenClient -from microsoft_agents.hosting.core.connector.teams import TeamsConnectorClient - -from .channel_service_client_factory_base import ChannelServiceClientFactoryBase -from .testing_connector_client import TestingConnectorClient - - -class TestingRestChannelServiceClientFactory(ChannelServiceClientFactoryBase): - _ANONYMOUS_TOKEN_PROVIDER = AnonymousTokenProvider() - - def __init__( - self, - mocker, - connection_manager: Connections, - token_service_endpoint=AuthenticationConstants.AGENTS_SDK_OAUTH_URL, - token_service_audience=AuthenticationConstants.AGENTS_SDK_SCOPE, - connector_client_class: type[BaseConnectorClient] = TestingConnectorClient, - user_token_client_class: type[BaseUserTokenClient] = TestingUserTokenClient, - ) -> None: - self._mocker = mocker - self._connection_manager = connection_manager - self._token_service_endpoint = token_service_endpoint - self._token_service_audience = token_service_audience - self._connector_client_class = connector_client_class - self._user_token_client_class = user_token_client_class - - async def create_connector_client( - self, - claims_identity: ClaimsIdentity, - service_url: str, - audience: str, - scopes: Optional[list[str]] = None, - use_anonymous: bool = False, - ) -> ConnectorClientBase: - if not service_url: - raise TypeError( - "RestChannelServiceClientFactory.create_connector_client: service_url can't be None or Empty" - ) - if not audience: - raise TypeError( - "RestChannelServiceClientFactory.create_connector_client: audience can't be None or Empty" - ) - - token_provider: AccessTokenProviderBase = ( - self._connection_manager.get_token_provider(claims_identity, service_url) - if not use_anonymous - else self._ANONYMOUS_TOKEN_PROVIDER - ) - - token = await token_provider.get_access_token( - audience, scopes or [f"{audience}/.default"] - ) - - return self._connector_client_class( - endpoint=service_url, - token=token, - ) - - async def create_user_token_client( - self, claims_identity: ClaimsIdentity, use_anonymous: bool = False - ) -> UserTokenClient: - token_provider = ( - self._connection_manager.get_token_provider( - claims_identity, self._token_service_endpoint - ) - if not use_anonymous - else self._ANONYMOUS_TOKEN_PROVIDER - ) - - token = await token_provider.get_access_token( - self._token_service_audience, [f"{self._token_service_audience}/.default"] - ) - return self._user_token_client_class( - endpoint=self._token_service_endpoint, - token=token, - ) diff --git a/tests/_common/testing_objects/http/testing_client_session.py b/tests/_common/testing_objects/http/testing_client_session.py deleted file mode 100644 index a00a89de..00000000 --- a/tests/_common/testing_objects/http/testing_client_session.py +++ /dev/null @@ -1,2 +0,0 @@ -class TestingClientSessionBase: - pass diff --git a/tests/_common/testing_objects/http/testing_connector_client.py b/tests/_common/testing_objects/http/testing_connector_client.py deleted file mode 100644 index fd797814..00000000 --- a/tests/_common/testing_objects/http/testing_connector_client.py +++ /dev/null @@ -1,40 +0,0 @@ -from microsft_agents.hosting.core import ( - AgentAuthConfiguration, - AccessTokenProviderBase, - TeamsConnectorClient, -) - -from tests._common.testing_objects.http.testing_client_session import ( - TestingClientSession, -) - - -class TestingConnectorClient(TeamsConnectorClient): - """Teams Connector Client for interacting with Teams-specific APIs.""" - - @classmethod - async def create_client_with_auth_async( - cls, - base_url: str, - auth_config: AgentAuthConfiguration, - auth_provider: AccessTokenProviderBase, - scope: str, - ) -> "TeamsConnectorClient": - """ - Creates a new instance of TeamsConnectorClient with authentication. - - :param base_url: The base URL for the API. - :param auth_config: The authentication configuration. - :param auth_provider: The authentication provider. - :param scope: The scope for the authentication token. - :return: A new instance of TeamsConnectorClient. - """ - session = TestingClientSession( - base_url=base_url, headers={"Accept": "application/json"} - ) - - token = await auth_provider.get_access_token(auth_config, scope) - if len(token) > 1: - session.headers.update({"Authorization": f"Bearer {token}"}) - - return cls(session) diff --git a/tests/hosting_core/app/test_agent_application.py b/tests/hosting_core/app/test_agent_application.py new file mode 100644 index 00000000..28c3ccef --- /dev/null +++ b/tests/hosting_core/app/test_agent_application.py @@ -0,0 +1,36 @@ +from microsoft_agents.authentication.msal.msal_connection_manager import MsalConnectionManager +from microsoft_agents.hosting.core.turn_context import TurnContext +import pytest + +from microsoft_agents.authentication.msal import MsalAuthentication +from microsoft_agents.hosting.core import ( + MemoryStorage, + AgentApplication, + ApplicationOptions, + Connections +) + +def mock_send_activity(mocker): + mocker.patch.object(TurnContext, 'send_activity', new=) + +class TestUtils: + + @pytest.fixture + def options(self): + return ApplicationOptions() + + @pytest.fixture + def storage(self): + return MemoryStorage() + + @pytest.fixture + def connection_manager(self): + return MsalConnectionManager() + + @pytest.fixture + def + + +class TestAgentApplication: + + pass \ No newline at end of file From 389b6cd58417bcfbc753b59fc7d315a574694ae9 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Sat, 27 Sep 2025 11:34:27 -0700 Subject: [PATCH 17/36] Adding dynamic loading of connection and related tests --- .../activity/_load_configuration.py | 18 ++- .../msal/msal_connection_manager.py | 9 +- .../microsoft_agents/hosting/core/__init__.py | 2 +- .../hosting/core/app/__init__.py | 2 +- .../hosting/core/app/agent_application.py | 2 +- .../hosting/core/app/app_options.py | 2 +- .../core/app/{oauth => auth}/__init__.py | 4 +- .../core/app/{oauth => auth}/auth_handler.py | 0 .../core/app/{oauth => auth}/authorization.py | 18 +-- .../app/{oauth => auth}/sign_in_response.py | 0 .../core/app/{oauth => auth}/sign_in_state.py | 0 .../core/app/auth/variants/__init__.py | 11 ++ .../variants}/agentic_authorization.py | 6 +- .../variants}/authorization_variant.py | 12 +- .../variants/authorization_variant_map.py | 8 ++ .../variants/user_authorization.py} | 78 +++++++++++- .../core/app/oauth/user_authorization.py | 101 ---------------- tests/_common/__init__.py | 2 + .../_tests/test_create_env_var_dict.py | 2 + tests/_common/create_env_var_dict.py | 8 ++ tests/_common/data/__init__.py | 4 +- tests/_common/data/configs/__init__.py | 9 ++ .../{ => configs}/test_agentic_auth_config.py | 23 ++-- .../data/{ => configs}/test_auth_config.py | 8 +- tests/_common/data/test_defaults.py | 9 ++ .../mocks/mock_authorization.py | 2 +- tests/activity/test_load_configuration.py | 112 ++++++++++++++++++ .../test_msal_connection_manager.py | 13 +- .../app/auth/test_sign_in_state.py | 2 +- 29 files changed, 319 insertions(+), 148 deletions(-) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{oauth => auth}/__init__.py (77%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{oauth => auth}/auth_handler.py (100%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{oauth => auth}/authorization.py (97%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{oauth => auth}/sign_in_response.py (100%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{oauth => auth}/sign_in_state.py (100%) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/__init__.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{oauth => auth/variants}/agentic_authorization.py (97%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{oauth => auth/variants}/authorization_variant.py (96%) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/authorization_variant_map.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{oauth/user_authorization_base.py => auth/variants/user_authorization.py} (64%) delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/user_authorization.py create mode 100644 tests/_common/_tests/test_create_env_var_dict.py create mode 100644 tests/_common/create_env_var_dict.py create mode 100644 tests/_common/data/configs/__init__.py rename tests/_common/data/{ => configs}/test_agentic_auth_config.py (75%) rename tests/_common/data/{ => configs}/test_auth_config.py (85%) create mode 100644 tests/activity/test_load_configuration.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/_load_configuration.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/_load_configuration.py index f3c6afa3..4f5f20ab 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/_load_configuration.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/_load_configuration.py @@ -1,7 +1,19 @@ -from typing import Any, Dict +from typing import Any +def _list_out_seq_dicts(node) -> Any: + """ Converts any dictionaries with integer keys to a list if the keys are sequential integers starting from 0.""" -def load_configuration_from_env(env_vars: Dict[str, Any]) -> dict: + if isinstance(node, dict): + keys = node.keys() + num_keys = len(keys) + if set(keys) == set(range(num_keys)): + # this is a seq dict + return [ + _list_out_seq_dicts(node[i]) for i in range(num_keys) + ] + return node + +def load_configuration_from_env(env_vars: dict[str, Any]) -> dict: """ Parses environment variables and returns a dictionary with the relevant configuration. """ @@ -18,6 +30,8 @@ def load_configuration_from_env(env_vars: Dict[str, Any]) -> dict: current_level = current_level[next_level] last_level[levels[-1]] = value + result = _list_out_seq_dicts(result) + return { "AGENTAPPLICATION": result.get("AGENTAPPLICATION", {}), "CONNECTIONS": result.get("CONNECTIONS", {}), diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py index 89b9c794..cfddee0d 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py @@ -68,9 +68,14 @@ def get_token_provider( aud = aud.lower() for item in self._connections_map: - if item.get("audience", "").lower() == aud: - item_service_url = item.get("serviceUrl", "") + audience_match = True + + item_aud = item.get("AUDIENCE", "") + if item_aud: + audience_match = item_aud.lower() == aud + if audience_match: + item_service_url = item.get("serviceUrl", "") if item_service_url == "*" or item_service_url == "": connection_name = item.get("connectionName") connection = self.get_connection(connection_name) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py index 6cf78066..0db68b84 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py @@ -20,7 +20,7 @@ from .app.typing_indicator import TypingIndicator # App Auth -from .app.oauth import ( +from .app.auth import ( Authorization, AuthorizationHandlers, AuthHandler, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py index 9e4f871e..e2767221 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py @@ -14,7 +14,7 @@ from .typing_indicator import TypingIndicator # Auth -from .oauth import ( +from .auth import ( Authorization, AuthHandler, AuthorizationHandlers, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index 48cdff63..4bbebb47 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -41,7 +41,7 @@ from .route import Route, RouteHandler from .state import TurnState from ..channel_service_adapter import ChannelServiceAdapter -from .oauth import Authorization +from .auth import Authorization from .typing_indicator import TypingIndicator logger = logging.getLogger(__name__) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py index 21312c76..ed5defa7 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py @@ -9,7 +9,7 @@ from logging import Logger from typing import Callable, List, Optional -from microsoft_agents.hosting.core.app.oauth import AuthHandler +from microsoft_agents.hosting.core.app.auth import AuthHandler from microsoft_agents.hosting.core.storage import Storage # from .auth import AuthOptions diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py similarity index 77% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py index 3e7018f4..654c78d9 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py @@ -1,8 +1,8 @@ from .authorization import Authorization from .auth_handler import AuthHandler, AuthorizationHandlers -from .agentic_authorization import AgenticAuthorization +from .variants.agentic_authorization import AgenticAuthorization from .user_authorization import UserAuthorization -from .authorization_variant import AuthorizationVariant +from .variants.authorization_variant import AuthorizationVariant from .sign_in_state import SignInState from .sign_in_response import SignInResponse diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py similarity index 97% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py index 94b807c6..7fbb77e3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py @@ -11,16 +11,11 @@ from ..state import TurnState from .auth_handler import AuthHandler from .user_authorization import UserAuthorization -from .agentic_authorization import AgenticAuthorization -from .authorization_variant import AuthorizationVariant +from .variants.agentic_authorization import AgenticAuthorization +from .variants.authorization_variant import AuthorizationVariant from .sign_in_state import SignInState from .sign_in_response import SignInResponse -AUTHORIZATION_TYPE_MAP: dict[str, type[AuthorizationVariant]] = { - UserAuthorization.__name__.lower(): UserAuthorization, - AgenticAuthorization.__name__.lower(): AgenticAuthorization, -} - logger = logging.getLogger(__name__) class Authorization: @@ -161,7 +156,6 @@ def _resolve_auth_variant(self, auth_variant: str) -> AuthorizationVariant: raise ValueError( f"Auth variant {auth_variant} not recognized or not configured." ) - return self._authorization_variants[auth_variant] def resolve_handler(self, handler_id: str) -> AuthHandler: @@ -309,7 +303,7 @@ async def on_turn_auth_intercept( return False, None async def get_token( - self, context: TurnContext, auth_handler_id: str + self, context: TurnContext, auth_handler_id: str, scopes: Optional[list[str]] = None ) -> TokenResponse: """Gets the token for a specific auth handler. @@ -326,6 +320,12 @@ async def get_token( if not sign_in_state or not sign_in_state.tokens.get(auth_handler_id): return TokenResponse() token = sign_in_state.tokens[auth_handler_id] + + handler = self.resolve_handler(auth_handler_id) + variant = self._resolve_auth_variant(handler.auth_type) + + variant.exchange_token(context, auth_handler_id, token=token, scopes=scopes) + return TokenResponse(token=token) def on_sign_in_success( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/sign_in_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/sign_in_response.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/sign_in_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/sign_in_state.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/__init__.py new file mode 100644 index 00000000..b9ed54dd --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/__init__.py @@ -0,0 +1,11 @@ +from .agentic_authorization import AgenticAuthorization +from .user_authorization import UserAuthorization +from .authorization_variant_map import AuthorizationVariantMap +from .authorization_variant import AuthorizationVariant + +__all__ = [ + "AgenticAuthorization", + "UserAuthorization", + "AuthorizationVariantMap", + "AuthorizationVariant", +] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/agentic_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/agentic_authorization.py similarity index 97% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/agentic_authorization.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/agentic_authorization.py index c32dc441..283ef6b4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/agentic_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/agentic_authorization.py @@ -4,11 +4,11 @@ from microsoft_agents.activity import TokenResponse -from ...turn_context import TurnContext -from ...oauth import FlowStateTag +from ....turn_context import TurnContext +from ....oauth import FlowStateTag from .authorization_variant import AuthorizationVariant -from .sign_in_response import SignInResponse +from ..sign_in_response import SignInResponse logger = logging.getLogger(__name__) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization_variant.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/authorization_variant.py similarity index 96% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization_variant.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/authorization_variant.py index 167298bb..6f7d4c1c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization_variant.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/authorization_variant.py @@ -4,11 +4,11 @@ from microsoft_agents.activity import TokenResponse -from ...turn_context import TurnContext -from ...storage import Storage -from ...authorization import Connections -from .auth_handler import AuthHandler -from .sign_in_response import SignInResponse +from ....turn_context import TurnContext +from ....storage import Storage +from ....authorization import Connections +from ..auth_handler import AuthHandler +from ..sign_in_response import SignInResponse logger = logging.getLogger(__name__) @@ -76,7 +76,7 @@ async def exchange_token( If the cached token is not exchangeable, returns the cached token. :rtype: TokenResponse """ - + token_response = await self.get_token(context, auth_handler_id) if token_response and self._is_exchangeable(token_response.token): diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/authorization_variant_map.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/authorization_variant_map.py new file mode 100644 index 00000000..2310853d --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/authorization_variant_map.py @@ -0,0 +1,8 @@ +from .authorization_variant import AuthorizationVariant +from .agentic_authorization import AgenticAuthorization +from .user_authorization import UserAuthorization + +AUTHORIZATION_TYPE_MAP: dict[str, type[AuthorizationVariant]] = { + UserAuthorization.__name__.lower(): UserAuthorization, + AgenticAuthorization.__name__.lower(): AgenticAuthorization, +} diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/user_authorization_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/user_authorization.py similarity index 64% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/user_authorization_base.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/user_authorization.py index 93e239b0..6b6d34de 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/user_authorization_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/user_authorization.py @@ -10,7 +10,7 @@ from microsoft_agents.hosting.core.connector.client import UserTokenClient from ...turn_context import TurnContext -from ...oauth import OAuthFlow, FlowResponse, FlowState, FlowStorageClient +from ...auth import OAuthFlow, FlowResponse, FlowState, FlowStorageClient from .authorization_variant import AuthorizationVariant from .auth_handler import AuthHandler @@ -151,3 +151,79 @@ async def sign_out( await self._sign_out(context, [auth_handler_id]) else: await self._sign_out(context, self._auth_handlers.keys()) + + async def _handle_flow_response( + self, context: TurnContext, flow_response: FlowResponse + ) -> None: + """Handles CONTINUE and FAILURE flow responses, sending activities back.""" + flow_state: FlowState = flow_response.flow_state + + if flow_state.tag == FlowStateTag.BEGIN: + # Create the OAuth card + sign_in_resource = flow_response.sign_in_resource + assert sign_in_resource + o_card: Attachment = CardFactory.oauth_card( + OAuthCard( + text="Sign in", + connection_name=flow_state.connection, + buttons=[ + CardAction( + title="Sign in", + type=ActionTypes.signin, + value=sign_in_resource.sign_in_link, + channel_data=None, + ) + ], + token_exchange_resource=sign_in_resource.token_exchange_resource, + token_post_resource=sign_in_resource.token_post_resource, + ) + ) + # Send the card to the user + await context.send_activity(MessageFactory.attachment(o_card)) + elif flow_state.tag == FlowStateTag.FAILURE: + if flow_state.reached_max_attempts(): + await context.send_activity( + MessageFactory.text( + "Sign-in failed. Max retries reached. Please try again later." + ) + ) + elif flow_state.is_expired(): + await context.send_activity( + MessageFactory.text("Sign-in session expired. Please try again.") + ) + else: + logger.warning("Sign-in flow failed for unknown reasons.") + await context.send_activity("Sign-in failed. Please try again.") + + async def sign_in( + self, context: TurnContext, auth_handler_id: str, scopes: Optional[list[str]] = None + ) -> SignInResponse: + """Begins or continues an OAuth flow. + + Handles the flow response, sending the OAuth card to the context. + + :param context: The context object for the current turn. + :type context: TurnContext + :param auth_handler_id: The ID of the auth handler to use. + :type auth_handler_id: str + :return: The SignInResponse containing the token response and flow state tag. + :rtype: SignInResponse + """ + + logger.debug( + "Beginning or continuing flow for auth handler %s", + auth_handler_id, + ) + flow_response = await self.begin_or_continue_flow(context, auth_handler_id) + await self._handle_flow_response(context, flow_response) + logger.debug( + "Flow response flow_state.tag: %s", + flow_response.flow_state.tag, + ) + + sign_in_response = SignInResponse( + token_response=flow_response.token_response, + tag=flow_response.flow_state.tag, + ) + + return sign_in_response diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/user_authorization.py deleted file mode 100644 index 1d00360d..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/user_authorization.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations -import logging - -from microsoft_agents.activity import ( - ActionTypes, - CardAction, - OAuthCard, - Attachment, -) - -from ...turn_context import TurnContext -from ...oauth import FlowResponse, FlowState, FlowStateTag -from ...message_factory import MessageFactory -from ...card_factory import CardFactory -from .user_authorization_base import UserAuthorizationBase -from .sign_in_response import SignInResponse - -logger = logging.getLogger(__name__) - - -class UserAuthorization(UserAuthorizationBase): - """Class responsible for managing user authorization and OAuth flows. - - Handles the sending and receiving of OAuth cards, and manages the complete user OAuth lifecycle. - """ - - async def _handle_flow_response( - self, context: TurnContext, flow_response: FlowResponse - ) -> None: - """Handles CONTINUE and FAILURE flow responses, sending activities back.""" - flow_state: FlowState = flow_response.flow_state - - if flow_state.tag == FlowStateTag.BEGIN: - # Create the OAuth card - sign_in_resource = flow_response.sign_in_resource - assert sign_in_resource - o_card: Attachment = CardFactory.oauth_card( - OAuthCard( - text="Sign in", - connection_name=flow_state.connection, - buttons=[ - CardAction( - title="Sign in", - type=ActionTypes.signin, - value=sign_in_resource.sign_in_link, - channel_data=None, - ) - ], - token_exchange_resource=sign_in_resource.token_exchange_resource, - token_post_resource=sign_in_resource.token_post_resource, - ) - ) - # Send the card to the user - await context.send_activity(MessageFactory.attachment(o_card)) - elif flow_state.tag == FlowStateTag.FAILURE: - if flow_state.reached_max_attempts(): - await context.send_activity( - MessageFactory.text( - "Sign-in failed. Max retries reached. Please try again later." - ) - ) - elif flow_state.is_expired(): - await context.send_activity( - MessageFactory.text("Sign-in session expired. Please try again.") - ) - else: - logger.warning("Sign-in flow failed for unknown reasons.") - await context.send_activity("Sign-in failed. Please try again.") - - async def sign_in( - self, context: TurnContext, auth_handler_id: str, scopes: Optional[list[str]] = None - ) -> SignInResponse: - """Begins or continues an OAuth flow. - - Handles the flow response, sending the OAuth card to the context. - - :param context: The context object for the current turn. - :type context: TurnContext - :param auth_handler_id: The ID of the auth handler to use. - :type auth_handler_id: str - :return: The SignInResponse containing the token response and flow state tag. - :rtype: SignInResponse - """ - - logger.debug( - "Beginning or continuing flow for auth handler %s", - auth_handler_id, - ) - flow_response = await self.begin_or_continue_flow(context, auth_handler_id) - await self._handle_flow_response(context, flow_response) - logger.debug( - "Flow response flow_state.tag: %s", - flow_response.flow_state.tag, - ) - - sign_in_response = SignInResponse( - token_response=flow_response.token_response, - tag=flow_response.flow_state.tag, - ) - - return sign_in_response diff --git a/tests/_common/__init__.py b/tests/_common/__init__.py index bb8ba4f3..bc07d46d 100644 --- a/tests/_common/__init__.py +++ b/tests/_common/__init__.py @@ -1,5 +1,7 @@ from .approx_equal import approx_eq +from .create_env_var_dict import create_env_var_dict __all__ = [ "approx_eq", + "create_env_var_dict", ] diff --git a/tests/_common/_tests/test_create_env_var_dict.py b/tests/_common/_tests/test_create_env_var_dict.py new file mode 100644 index 00000000..085c31ec --- /dev/null +++ b/tests/_common/_tests/test_create_env_var_dict.py @@ -0,0 +1,2 @@ +def test_create_env_var_dict(): + assert False \ No newline at end of file diff --git a/tests/_common/create_env_var_dict.py b/tests/_common/create_env_var_dict.py new file mode 100644 index 00000000..9e13925e --- /dev/null +++ b/tests/_common/create_env_var_dict.py @@ -0,0 +1,8 @@ +def create_env_var_dict(env_raw: str) -> dict[str, str]: + """Create a dictionary from a string that represents a .env config file.""" + lines = env_raw.strip().split("\n") + env = {} + for line in lines: + key, value = line.split("=", 1) + env[key.strip()] = value.strip() + return env \ No newline at end of file diff --git a/tests/_common/data/__init__.py b/tests/_common/data/__init__.py index 6695407c..0d43733e 100644 --- a/tests/_common/data/__init__.py +++ b/tests/_common/data/__init__.py @@ -5,8 +5,8 @@ ) from .test_storage_data import TEST_STORAGE_DATA from .test_flow_data import TEST_FLOW_DATA -from .test_auth_config import TEST_ENV_DICT, TEST_ENV -from .test_agentic_auth_config import TEST_AGENTIC_ENV_DICT, TEST_AGENTIC_ENV +from .configs import TEST_ENV_DICT, TEST_ENV +from .configs import TEST_AGENTIC_ENV_DICT, TEST_AGENTIC_ENV __all__ = [ "TEST_DEFAULTS", diff --git a/tests/_common/data/configs/__init__.py b/tests/_common/data/configs/__init__.py new file mode 100644 index 00000000..f37326d5 --- /dev/null +++ b/tests/_common/data/configs/__init__.py @@ -0,0 +1,9 @@ +from .test_auth_config import TEST_ENV_DICT, TEST_ENV +from .test_agentic_auth_config import TEST_AGENTIC_ENV_DICT, TEST_AGENTIC_ENV + +__all__ = [ + "TEST_ENV_DICT", + "TEST_ENV", + "TEST_AGENTIC_ENV_DICT", + "TEST_AGENTIC_ENV" +] \ No newline at end of file diff --git a/tests/_common/data/test_agentic_auth_config.py b/tests/_common/data/configs/test_agentic_auth_config.py similarity index 75% rename from tests/_common/data/test_agentic_auth_config.py rename to tests/_common/data/configs/test_agentic_auth_config.py index 22af23d6..00bb67b1 100644 --- a/tests/_common/data/test_agentic_auth_config.py +++ b/tests/_common/data/configs/test_agentic_auth_config.py @@ -1,20 +1,35 @@ from microsoft_agents.activity import load_configuration_from_env +from ...create_env_var_dict import create_env_var_dict from .test_defaults import TEST_DEFAULTS DEFAULTS = TEST_DEFAULTS() _TEST_AGENTIC_ENV_RAW = """ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=service-tenant-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=service-client-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=service-client-secret + +CONNECTIONS__AGENTIC__SETTINGS__TENANTID=service-tenant-id +CONNECTIONS__AGENTIC__SETTINGS__CLIENTID=service-client-id +CONNECTIONS__AGENTIC__SETTINGS__CLIENTSECRET=service-client-secret + AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME={abs_oauth_connection_name} AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__OBOCONNECTIONNAME={obo_connection_name} AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__TITLE={auth_handler_title} AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__TEXT={auth_handler_text} AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__TYPE=UserAuthorization + AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME={agentic_abs_oauth_connection_name} AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__OBOCONNECTIONNAME={agentic_obo_connection_name} AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__TITLE={agentic_auth_handler_title} AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__TEXT={agentic_auth_handler_text} AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__TYPE=AgenticAuthorization + +CONNECTIONSMAP__0__CONNECTION=SERVICE_CONNECTION +CONNECTIONSMAP__0__SERVICEURL=* +CONNECTIONSMAP__1__CONNECTION=AGENTIC +CONNECTIONSMAP__1__SERVICEURL=agentic """.format( abs_oauth_connection_name=DEFAULTS.abs_oauth_connection_name, obo_connection_name=DEFAULTS.obo_connection_name, @@ -30,13 +45,7 @@ def TEST_AGENTIC_ENV(): - lines = _TEST_AGENTIC_ENV_RAW.strip().split("\n") - env = {} - for line in lines: - key, value = line.split("=", 1) - env[key.strip()] = value.strip() - return env - + return create_env_var_dict(_TEST_AGENTIC_ENV_RAW) def TEST_AGENTIC_ENV_DICT(): return load_configuration_from_env(TEST_AGENTIC_ENV()) diff --git a/tests/_common/data/test_auth_config.py b/tests/_common/data/configs/test_auth_config.py similarity index 85% rename from tests/_common/data/test_auth_config.py rename to tests/_common/data/configs/test_auth_config.py index 3d1dcbee..713e3fd9 100644 --- a/tests/_common/data/test_auth_config.py +++ b/tests/_common/data/configs/test_auth_config.py @@ -1,5 +1,6 @@ from microsoft_agents.activity import load_configuration_from_env +from ...create_env_var_dict import create_env_var_dict from .test_defaults import TEST_DEFAULTS DEFAULTS = TEST_DEFAULTS() @@ -20,12 +21,7 @@ def TEST_ENV(): - lines = _TEST_ENV_RAW.strip().split("\n") - env = {} - for line in lines: - key, value = line.split("=", 1) - env[key.strip()] = value.strip() - return env + create_env_var_dict(_TEST_ENV_RAW) def TEST_ENV_DICT(): diff --git a/tests/_common/data/test_defaults.py b/tests/_common/data/test_defaults.py index 9f7d67c5..58164e12 100644 --- a/tests/_common/data/test_defaults.py +++ b/tests/_common/data/test_defaults.py @@ -15,12 +15,21 @@ def __init__(self): self.bot_url = "https://botframework.com" self.ms_app_id = "__ms_app_id" + # Auth Handler Settings self.abs_oauth_connection_name = "connection_name" self.obo_connection_name = "SERVICE_CONNECTION" self.auth_handler_id = "auth_handler_id" self.auth_handler_title = "auth_handler_title" self.auth_handler_text = "auth_handler_text" + # Connections Settings + self.connections_default_tenant_id = "service-tenant-id" + self.connections_default_client_id = "service-client-id" + self.connections_default_client_secret = "service-client-secret" + self.connections_agentic_tenant_id = "agentic-tenant-id" + self.connections_agentic_client_id = "agentic-client-id" + self.connections_agentic_client_secret = "agentic-client-secret" + self.agentic_abs_oauth_connection_name = "agentic_connection_name" self.agentic_obo_connection_name = "SERVICE_CONNECTION" self.agentic_auth_handler_id = "agentic_auth_handler_id" diff --git a/tests/_common/testing_objects/mocks/mock_authorization.py b/tests/_common/testing_objects/mocks/mock_authorization.py index e094fa3d..4caa4fde 100644 --- a/tests/_common/testing_objects/mocks/mock_authorization.py +++ b/tests/_common/testing_objects/mocks/mock_authorization.py @@ -3,7 +3,7 @@ UserAuthorization, AgenticAuthorization, ) -from microsoft_agents.hosting.core.app.oauth import SignInResponse +from microsoft_agents.hosting.core.app.auth import SignInResponse def mock_class_UserAuthorization(mocker, sign_in_return=None): diff --git a/tests/activity/test_load_configuration.py b/tests/activity/test_load_configuration.py new file mode 100644 index 00000000..bcf571e6 --- /dev/null +++ b/tests/activity/test_load_configuration.py @@ -0,0 +1,112 @@ +from microsoft_agents.activity import load_configuration_from_env + +from tests._common import create_env_var_dict +from tests._common.data import TEST_DEFAULTS + +DEFAULTS = TEST_DEFAULTS() + +ENV_DICT = { + "CONNECTIONS": { + "SERVICE_CONNECTION": { + "SETTINGS": { + "TENANTID": DEFAULTS.connections_default_tenant_id, + "CLIENTID": DEFAULTS.connections_default_client_id, + "CLIENTSECRET": DEFAULTS.connections_default_client_secret, + } + }, + "AGENTIC": { + "SETTINGS": { + "TENANTID": DEFAULTS.connections_agentic_tenant_id, + "CLIENTID": DEFAULTS.connections_agentic_client_id, + "CLIENTSECRET": DEFAULTS.connections_agentic_client_secret, + } + } + }, + "AGENTAPPLICATION": { + "USERAUTHORIZATION": { + "HANDLERS": { + DEFAULTS.auth_handler_id: { + "SETTINGS": { + "AZUREBOTOAUTHCONNECTIONNAME": DEFAULTS.abs_oauth_connection_name, + "OBOCONNECTIONNAME": DEFAULTS.obo_connection_name, + "TITLE": DEFAULTS.auth_handler_title, + "TEXT": DEFAULTS.auth_handler_text, + "TYPE": "UserAuthorization", + } + }, + DEFAULTS.agentic_auth_handler_id: { + "SETTINGS": { + "AZUREBOTOAUTHCONNECTIONNAME": DEFAULTS.agentic_abs_oauth_connection_name, + "OBOCONNECTIONNAME": DEFAULTS.agentic_obo_connection_name, + "TITLE": DEFAULTS.agentic_auth_handler_title, + "TEXT": DEFAULTS.agentic_auth_handler_text, + "TYPE": "AgenticAuthorization", + } + }, + } + }, + "AGENTICAUTHORIZATION": { + "HANDLERS": {} + } + }, + "CONNECTIONSMAP": [ + { + "CONNECTION": "SERVICE_CONNECTION", + "SERVICEURL": "*" + }, + { + "CONNECTION": "AGENTIC", + "SERVICEURL": "agentic" + } + ] +} + +ENV_RAW = """ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID={connections_default_tenant_id} +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID={connections_default_client_id} +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET={connections_default_client_secret} + +CONNECTIONS__AGENTIC__SETTINGS__TENANTID={connections_agentic_tenant_id} +CONNECTIONS__AGENTIC__SETTINGS__CLIENTID={connections_agentic_client_id} +CONNECTIONS__AGENTIC__SETTINGS__CLIENTSECRET={connections_agentic_client_secret} + +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME={abs_oauth_connection_name} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__OBOCONNECTIONNAME={obo_connection_name} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__TITLE={auth_handler_title} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__TEXT={auth_handler_text} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__TYPE=UserAuthorization + +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME={agentic_abs_oauth_connection_name} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__OBOCONNECTIONNAME={agentic_obo_connection_name} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__TITLE={agentic_auth_handler_title} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__TEXT={agentic_auth_handler_text} +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__TYPE=AgenticAuthorization + +CONNECTIONSMAP__0__CONNECTION=SERVICE_CONNECTION +CONNECTIONSMAP__0__SERVICEURL=* +CONNECTIONSMAP__1__CONNECTION=AGENTIC +CONNECTIONSMAP__1__SERVICEURL=agentic +""".format( + connections_default_tenant_id=DEFAULTS.connections_default_tenant_id, + connections_default_client_id=DEFAULTS.connections_default_client_id, + connections_default_client_secret=DEFAULTS.connections_default_client_secret, + connections_agentic_tenant_id=DEFAULTS.connections_agentic_tenant_id, + connections_agentic_client_id=DEFAULTS.connections_agentic_client_id, + connections_agentic_client_secret=DEFAULTS.connections_agentic_client_secret, + abs_oauth_connection_name=DEFAULTS.abs_oauth_connection_name, + obo_connection_name=DEFAULTS.obo_connection_name, + auth_handler_id=DEFAULTS.auth_handler_id, + auth_handler_title=DEFAULTS.auth_handler_title, + auth_handler_text=DEFAULTS.auth_handler_text, + agentic_abs_oauth_connection_name=DEFAULTS.agentic_abs_oauth_connection_name, + agentic_obo_connection_name=DEFAULTS.agentic_obo_connection_name, + agentic_auth_handler_id=DEFAULTS.agentic_auth_handler_id, + agentic_auth_handler_title=DEFAULTS.agentic_auth_handler_title, + agentic_auth_handler_text=DEFAULTS.agentic_auth_handler_text, +) + + +def test_load_configuration_from_env(): + input_dict = create_env_var_dict(ENV_RAW) + config = load_configuration_from_env(input_dict) + assert config == ENV_DICT \ No newline at end of file diff --git a/tests/authentication_msal/test_msal_connection_manager.py b/tests/authentication_msal/test_msal_connection_manager.py index 723f291a..7b65311f 100644 --- a/tests/authentication_msal/test_msal_connection_manager.py +++ b/tests/authentication_msal/test_msal_connection_manager.py @@ -3,13 +3,16 @@ from microsoft_agents.hosting.core import AuthTypes from microsoft_agents.authentication.msal import MsalConnectionManager +from tests._common.data import TEST_ENV_DICT + +ENV_DICT = TEST_ENV_DICT() class TestMsalConnectionManager: """ Test suite for the Msal Connection Manager """ - def test_msal_connection_manager(self): + def test_init_from_env(self): mock_environ = { **environ, "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID": "test-tenant-id-SERVICE_CONNECTION", @@ -33,3 +36,11 @@ def test_msal_connection_manager(self): f"https://sts.windows.net/test-tenant-id-{key}/", f"https://login.microsoftonline.com/test-tenant-id-{key}/v2.0", ] + + def test_init_from_config(self): + connection_manager = MsalConnectionManager( + **ENV_DICT + ) + + + def test_get_default_connection(self): diff --git a/tests/hosting_core/app/auth/test_sign_in_state.py b/tests/hosting_core/app/auth/test_sign_in_state.py index 36710f47..2621cf31 100644 --- a/tests/hosting_core/app/auth/test_sign_in_state.py +++ b/tests/hosting_core/app/auth/test_sign_in_state.py @@ -1,6 +1,6 @@ import pytest -from microsoft_agents.hosting.core.app.oauth import SignInState +from microsoft_agents.hosting.core.app.auth import SignInState from ._common import testing_Activity, testing_TurnContext From 8a41020ae62d69fc2f9762151b48d673899723bd Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Sat, 27 Sep 2025 15:59:30 -0700 Subject: [PATCH 18/36] Aligning authorization handlers with how .NET does it --- .../authentication/msal/msal_auth.py | 2 +- .../hosting/core/app/auth/__init__.py | 11 +- .../hosting/core/app/auth/auth_handler.py | 25 ++- .../hosting/core/app/auth/authorization.py | 195 +++++++++--------- .../auth/{variants => handlers}/__init__.py | 4 +- .../agentic_authorization.py | 24 ++- .../auth/handlers/authorization_handler.py | 87 ++++++++ .../authorization_handler_map.py} | 2 +- .../user_authorization.py | 160 ++++++++------ .../auth/variants/authorization_variant.py | 159 -------------- .../access_token_provider_base.py | 2 +- .../testing_objects/testing_token_provider.py | 2 +- tests/authentication_msal/test_msal_auth.py | 8 +- 13 files changed, 337 insertions(+), 344 deletions(-) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/{variants => handlers}/__init__.py (66%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/{variants => handlers}/agentic_authorization.py (81%) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/{variants/authorization_variant_map.py => handlers/authorization_handler_map.py} (84%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/{variants => handlers}/user_authorization.py (64%) delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/authorization_variant.py diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 4fa13966..4ce2b743 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -61,7 +61,7 @@ async def get_access_token( # TODO: Handling token error / acquisition failed return auth_result_payload["access_token"] - async def aquire_token_on_behalf_of( + async def acquire_token_on_behalf_of( self, scopes: list[str], user_assertion: str ) -> str: """ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py index 654c78d9..fdb5c74c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py @@ -1,18 +1,13 @@ from .authorization import Authorization -from .auth_handler import AuthHandler, AuthorizationHandlers -from .variants.agentic_authorization import AgenticAuthorization -from .user_authorization import UserAuthorization -from .variants.authorization_variant import AuthorizationVariant +from .auth_handler import AuthHandler, AuthorizationHandler +from .handlers.authorization_handler import Authorization from .sign_in_state import SignInState from .sign_in_response import SignInResponse __all__ = [ "Authorization", "AuthHandler", - "AuthorizationHandlers", - "AgenticAuthorization", - "UserAuthorization", - "AuthorizationVariant", + "AuthorizationHandler", "SignInState", "SignInResponse", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py index 40c33658..3eaab407 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py @@ -7,6 +7,8 @@ logger = logging.getLogger(__name__) +# name due to compat. +# see AuthorizationHandler for a class that does work. class AuthHandler: """ Interface defining an authorization handler for OAuth flows. @@ -54,6 +56,25 @@ def __init__( self.auth_type = self.auth_type.lower() self.scopes = list(scopes) or kwargs.get("SCOPES", []) + @staticmethod + def from_settings(settings: dict): + """ + Creates an AuthHandler instance from a settings dictionary. + + :param settings: The settings dictionary containing configuration for the AuthHandler. + :type settings: dict + :return: An instance of AuthHandler configured with the provided settings. + :rtype: AuthHandler + """ + if not settings: + raise ValueError("Settings dictionary is required to create AuthHandler") -# # Type alias for authorization handlers dictionary -AuthorizationHandlers = Dict[str, AuthHandler] + return AuthHandler( + name=settings.get("NAME", ""), + title=settings.get("TITLE", ""), + text=settings.get("TEXT", ""), + abs_oauth_connection_name=settings.get("AZUREBOTOAUTHCONNECTIONNAME", ""), + obo_connection_name=settings.get("OBOCONNECTIONNAME", ""), + auth_type=settings.get("TYPE", ""), + scopes=settings.get("SCOPES", []), + ) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py index 7fbb77e3..b82a404f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py @@ -1,3 +1,4 @@ +from datetime import datetime import logging from typing import TypeVar, Optional, Callable, Awaitable, Generic, cast import jwt @@ -10,15 +11,29 @@ from ...oauth import FlowStateTag from ..state import TurnState from .auth_handler import AuthHandler -from .user_authorization import UserAuthorization -from .variants.agentic_authorization import AgenticAuthorization -from .variants.authorization_variant import AuthorizationVariant from .sign_in_state import SignInState from .sign_in_response import SignInResponse +from .handlers import ( + AgenticAuthorization, + UserAuthorization, + AuthorizationHandler +) +from microsoft_agents.hosting.core.app.auth import auth_handler logger = logging.getLogger(__name__) +AUTHORIZATION_TYPE_MAP = { + UserAuthorization.__name__.lower(): UserAuthorization, + AgenticAuthorization.__name__.lower(): AgenticAuthorization, +} + class Authorization: + """Class responsible for managing authorization flows.""" + + _storage: Storage + _connection_manager: Connections + _handlers: dict[str, AuthorizationHandler] + def __init__( self, storage: Storage, @@ -48,20 +63,6 @@ def __init__( self._storage = storage self._connection_manager = connection_manager - auth_configuration: dict = kwargs.get("AGENTAPPLICATION", {}).get( - "USERAUTHORIZATION", {} - ) - - handlers_config: dict[str, dict] = auth_configuration.get("HANDLERS") - if not auth_handlers and handlers_config: - auth_handlers = { - handler_name: AuthHandler( - name=handler_name, **config.get("SETTINGS", {}) - ) - for handler_name, config in handlers_config.items() - } - - self._auth_handlers = auth_handlers or {} self._sign_in_success_handler: Optional[ Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]] ] = None @@ -69,10 +70,36 @@ def __init__( Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]] ] = None - self._authorization_variants = {} - self._init_auth_variants() + self._handlers = {} - def _init_auth_variants(self) -> None: + if auth_handlers and len(auth_handlers) > 0: + self._init_auth_variants(auth_handlers) + else: + + auth_configuration: dict = kwargs.get("AGENTAPPLICATION", {}).get( + "USERAUTHORIZATION", {} + ) + + handlers_config: dict[str, dict] = auth_configuration.get("HANDLERS") + if not auth_handlers and handlers_config: + auth_handlers = { + handler_name: AuthHandler( + name=handler_name, **config.get("SETTINGS", {}) + ) + for handler_name, config in handlers_config.items() + } + + self._handler_settings = auth_handlers + + # compatibility? TODO + if not auth_handlers or len(auth_handlers) == 0: + raise ValueError("At least one auth handler configuration is required.") + + # operations default to the first handler if none specified + self._default_handler_id = next(iter(self._handler_settings.items()))[0] + self._init_handlers() + + def _init_handlers(self) -> None: """Initialize authorization variants based on the provided auth handlers. This method maps the auth types to their corresponding authorization variants, and @@ -81,19 +108,15 @@ def _init_auth_variants(self) -> None: :param auth_handlers: A dictionary of auth handler configurations. :type auth_handlers: dict[str, AuthHandler] """ - auth_types = set(handler.auth_type for handler in auth_handlers.values()) - for auth_type in auth_types: - # get handlers that match this variant type - associated_handlers = { - auth_handler.name: auth_handler - for auth_handler in self._auth_handlers.values() - if auth_handler.auth_type == auth_type - } - - self._authorization_variants[auth_type] = AUTHORIZATION_TYPE_MAP[auth_type]( + for name, auth_handler in self._handler_settings.items(): + auth_type = auth_handler.auth_type + if auth_type not in AUTHORIZATION_TYPE_MAP: + raise ValueError(f"Auth type {auth_type} not recognized.") + + self._handlers[name] = AUTHORIZATION_TYPE_MAP[auth_type]( storage=self._storage, connection_manager=self._connection_manager, - auth_handlers=associated_handlers, + auth_handler=auth_handler, ) @staticmethod @@ -127,54 +150,23 @@ async def _delete_sign_in_state(self, context: TurnContext) -> None: key = self.sign_in_state_key(context) await self._storage.delete([key]) - @property - def user_auth(self) -> UserAuthorization: - """Get the user authorization variant. Raises if not configured.""" - return cast( - UserAuthorization, self._resolve_auth_variant(UserAuthorization.__name__) - ) - - @property - def agentic_auth(self) -> AgenticAuthorization: - """Get the agentic authorization variant. Raises if not configured.""" - return cast( - AgenticAuthorization, - self._resolve_auth_variant(AgenticAuthorization.__name__), - ) - - def _resolve_auth_variant(self, auth_variant: str) -> AuthorizationVariant: - """Resolve the authorization variant by its type name. - - :param auth_variant: The type name of the authorization variant to resolve. - Should corresponde to the __name__ of the class, e.g. "UserAuthorization". - :type auth_variant: str - :return: The corresponding AuthorizationVariant instance. - :rtype: AuthorizationVariant - :raises ValueError: If the auth variant is not recognized or not configured. - """ - if auth_variant not in self._authorization_variants: - raise ValueError( - f"Auth variant {auth_variant} not recognized or not configured." - ) - return self._authorization_variants[auth_variant] - - def resolve_handler(self, handler_id: str) -> AuthHandler: + def resolve_handler(self, handler_id: str) -> AuthorizationHandler: """Resolve the auth handler by its ID. :param handler_id: The ID of the auth handler to resolve. :type handler_id: str - :return: The corresponding AuthHandler instance. - :rtype: AuthHandler + :return: The corresponding AuthorizationHandler instance. + :rtype: AuthorizationHandler :raises ValueError: If the handler ID is not recognized or not configured. """ - if handler_id not in self._auth_handlers: + if handler_id not in self._handlers: raise ValueError( f"Auth handler {handler_id} not recognized or not configured." ) - return self._auth_handlers[handler_id] + return self._handlers[handler_id] async def start_or_continue_sign_in( - self, context: TurnContext, state: TurnState, auth_handler_id: str + self, context: TurnContext, state: TurnState, auth_handler_id: Optional[str] = None ) -> SignInResponse: """Start or continue the sign-in process for the user with the given auth handler. @@ -191,6 +183,8 @@ async def start_or_continue_sign_in( :rtype: SignInResponse """ + auth_handler_id = auth_handler_id or self._default_handler_id + # check cached sign in state sign_in_state = await self._load_sign_in_state(context) if not sign_in_state: @@ -207,10 +201,9 @@ async def start_or_continue_sign_in( ) handler = self.resolve_handler(auth_handler_id) - variant = self._resolve_auth_variant(handler.auth_type) # attempt sign-in continuation (or beginning) - sign_in_response = await variant.sign_in(context, auth_handler_id, handler.scopes) + sign_in_response = await handler.sign_in(context, auth_handler_id, handler.scopes) if sign_in_response.tag == FlowStateTag.COMPLETE: if self._sign_in_success_handler: @@ -230,14 +223,8 @@ async def start_or_continue_sign_in( return sign_in_response - async def _sign_out(self, context: TurnContext, auth_handler_id) -> None: - """Helper to sign out from a specific handler.""" - handler = self.resolve_handler(auth_handler_id) - variant = self._resolve_auth_variant(handler.auth_type) - await variant.sign_out(context, auth_handler_id) - async def sign_out( - self, context: TurnContext, state: TurnState, auth_handler_id=None + self, context: TurnContext, state: TurnState, auth_handler_id: Optional[str] = None ) -> None: """Attempts to sign out the user from the specified auth handler or all handlers if none specified. @@ -249,19 +236,12 @@ async def sign_out( :type auth_handler_id: Optional[str] :return: None """ + auth_handler_id = auth_handler_id or self._default_handler_id sign_in_state = await self._load_sign_in_state(context) - if sign_in_state: - - if not auth_handler_id: - # sign out from all handlers - for handler_id in sign_in_state.tokens.keys(): - if handler_id in sign_in_state.tokens: - await self._sign_out(context, handler_id) - await self._delete_sign_in_state(context) - - elif auth_handler_id in sign_in_state.tokens: + if sign_in_state and auth_handler_id in sign_in_state.tokens: # sign out from specific handler - await self._sign_out(context, auth_handler_id) + handler = self.resolve_handler(auth_handler_id) + await handler.sign_out(context) del sign_in_state.tokens[auth_handler_id] await self._save_sign_in_state(context, sign_in_state) @@ -303,8 +283,8 @@ async def on_turn_auth_intercept( return False, None async def get_token( - self, context: TurnContext, auth_handler_id: str, scopes: Optional[list[str]] = None - ) -> TokenResponse: + self, context: TurnContext, auth_handler_id: Optional[str] = None + ) -> str: """Gets the token for a specific auth handler. The token is taken from cache, so this does not initiate nor continue a sign-in flow. @@ -316,17 +296,38 @@ async def get_token( :return: The token response from the OAuth provider. :rtype: TokenResponse """ - sign_in_state = await self._load_sign_in_state(context) - if not sign_in_state or not sign_in_state.tokens.get(auth_handler_id): - return TokenResponse() - token = sign_in_state.tokens[auth_handler_id] + return self.exchange_token(context, auth_handler_id) + async def exchange_token( + self, + context: TurnContext, + auth_handler_id: Optional[str] = None, + exchange_connection: Optional[str] = None, + scopes: Optional[list[str]] = None + ) -> Optional[str]: + handler = self.resolve_handler(auth_handler_id) - variant = self._resolve_auth_variant(handler.auth_type) - variant.exchange_token(context, auth_handler_id, token=token, scopes=scopes) + sign_in_state = await self._load_sign_in_state(context) + if not sign_in_state or not sign_in_state.tokens.get(auth_handler_id): + return None + + token_res = sign_in_state.tokens[auth_handler_id] + if not context.activity.is_agentic(): + if not token_res.is_exchangeable: + if token.expiration is not None: + diff = token.expiration - datetime.now().timestamp() + if diff >= SOME_VALUE: + return token_res.token + + handler = self.resolve_handler(auth_handler_id) + res = await handler.get_refreshed_token(context, auth_handler_id, exchange_connection, scopes) + if res: + sign_in_state.tokens[auth_handler_id] = res.token + await self._save_sign_in_state(context, sign_in_state) + return res.token + raise Exception("Failed to exchange token") - return TokenResponse(token=token) def on_sign_in_success( self, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/__init__.py similarity index 66% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/__init__.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/__init__.py index b9ed54dd..26c84482 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/__init__.py @@ -1,7 +1,7 @@ from .agentic_authorization import AgenticAuthorization from .user_authorization import UserAuthorization -from .authorization_variant_map import AuthorizationVariantMap -from .authorization_variant import AuthorizationVariant +from .authorization_handler_map import AuthorizationVariantMap +from .authorization_handler import AuthorizationVariant __all__ = [ "AgenticAuthorization", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/agentic_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_authorization.py similarity index 81% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/agentic_authorization.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_authorization.py index 283ef6b4..f2adb554 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/agentic_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_authorization.py @@ -7,13 +7,13 @@ from ....turn_context import TurnContext from ....oauth import FlowStateTag -from .authorization_variant import AuthorizationVariant +from .authorization_handler import AuthorizationVariant from ..sign_in_response import SignInResponse logger = logging.getLogger(__name__) -class AgenticAuthorization(AuthorizationVariant): +class AgenticAuthorization(AuthorizationHandler): """Class responsible for managing agentic authorization""" async def get_agentic_instance_token(self, context: TurnContext) -> Optional[str]: @@ -67,7 +67,7 @@ async def get_agentic_user_token( async def sign_in( self, context: TurnContext, - connection_name: str, + exchange_connection: Optional[str] = None, scopes: Optional[list[str]] = None, ) -> SignInResponse: """Retrieves the agentic user token if available. @@ -81,6 +81,19 @@ async def sign_in( :return: A SignInResponse containing the token response and flow state tag. :rtype: SignInResponse """ + token_response = await self.get_refreshed_token(context, exchange_connection, scopes) + if token_response: + return SignInResponse(token_response=token_response, tag=FlowStateTag.COMPLETE) + return SignInResponse() + + async def get_refreshed_token(self, + context: TurnContext, + auth_handler_id: str, + exchange_connection: Optional[str] = None, + scopes: Optional[list[str]] = None + ) -> TokenResponse: + if not scopes: + scopes = self.resolve_handler(connection_name).scopes scopes = scopes or [] token = await self.get_agentic_user_token(context, scopes) return ( @@ -91,9 +104,8 @@ async def sign_in( else SignInResponse() ) - async def sign_out(self, context: TurnContext) -> None: - """Signs out the agentic user by clearing any stored tokens.""" - pass + async def sign_out(self, context: TurnContext, auth_handler_id: Optional[str] = None) -> None: + """Nothing to do for agentic sign out.""" @staticmethod def get_agent_instance_id(context: TurnContext) -> Optional[str]: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler.py new file mode 100644 index 00000000..a4bf0fad --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler.py @@ -0,0 +1,87 @@ +from abc import ABC +from typing import Optional +import logging + +from microsoft_agents.activity import TokenResponse + +from ....turn_context import TurnContext +from ....storage import Storage +from ....authorization import Connections +from ..auth_handler import AuthHandler +from ..sign_in_response import SignInResponse + +logger = logging.getLogger(__name__) + + +class AuthorizationVariant(ABC): + """Base class for different authorization strategies.""" + + _storage: Storage + _connection_manager: Connections + _handler: AuthHandler + + def __init__( + self, + storage: Storage, + connection_manager: Connections, + auth_handler: Optional[AuthHandler] = None, + auth_handler_settings: Optional[dict] = None, + **kwargs, + ) -> None: + """ + Creates a new instance of Authorization. + + :param storage: The storage system to use for state management. + :type storage: Storage + :param connection_manager: The connection manager for OAuth providers. + :type connection_manager: Connections + :param auth_handlers: Configuration for OAuth providers. + :type auth_handlers: dict[str, AuthHandler], optional + :raises ValueError: When storage is None or no auth handlers provided. + """ + if not storage: + raise ValueError("Storage is required for Authorization") + if not auth_handler and not auth_handler_settings: + raise ValueError("At least one of auth_handler or auth_handler_settings is required.") + + self._storage = storage + self._connection_manager = connection_manager + + if auth_handler: + self._handler = auth_handler + else: + self._handler = AuthHandler.from_settings(auth_handler_settings) + + async def sign_in( + self, context: TurnContext, auth_handler_id: str, scopes: Optional[list[str]] = None + ) -> SignInResponse: + """Initiate or continue the sign-in process for the user with the given auth handler. + + :param context: The turn context for the current turn of conversation. + :type context: TurnContext + :param auth_handler_id: The ID of the auth handler to use for sign-in. If None, the first handler will be used. + :type auth_handler_id: Optional[str] + :return: A SignInResponse indicating the result of the sign-in attempt. + :rtype: SignInResponse + """ + raise NotImplementedError() + + async def get_refreshed_token( + self, context: TurnContext, auth_handler_id: str, exchange_connection, exchange_scopes: Optional[list[str]] = None + ) -> TokenResponse: + raise NotImplementedError() + + async def sign_out( + self, + context: TurnContext, + auth_handler_id: Optional[str] = None, + ) -> None: + """Attempts to sign out the user from the specified auth handler or all handlers if none specified. + + :param context: The turn context for the current turn of conversation. + :type context: TurnContext + :param auth_handler_id: The ID of the auth handler to sign out from. If None, sign out from all handlers. + :type auth_handler_id: Optional[str] + """ + raise NotImplementedError() + diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/authorization_variant_map.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler_map.py similarity index 84% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/authorization_variant_map.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler_map.py index 2310853d..20b1973a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/authorization_variant_map.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler_map.py @@ -1,4 +1,4 @@ -from .authorization_variant import AuthorizationVariant +from .authorization_handler import AuthorizationVariant from .agentic_authorization import AgenticAuthorization from .user_authorization import UserAuthorization diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/user_authorization.py similarity index 64% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/user_authorization.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/user_authorization.py index 6b6d34de..e6873055 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/user_authorization.py @@ -7,24 +7,25 @@ from typing import Optional from collections.abc import Iterable -from microsoft_agents.hosting.core.connector.client import UserTokenClient - +from microsoft_agents.activity import ( + TokenResponse +) +...connector.client import UserTokenClient from ...turn_context import TurnContext from ...auth import OAuthFlow, FlowResponse, FlowState, FlowStorageClient -from .authorization_variant import AuthorizationVariant -from .auth_handler import AuthHandler +from .authorization_handler import AuthorizationHandler logger = logging.getLogger(__name__) -class UserAuthorizationBase(AuthorizationVariant, ABC): +class UserAuthorization(AuthorizationHandler): """ Class responsible for managing authorization and OAuth flows. Handles multiple OAuth providers and manages the complete authentication lifecycle. """ async def _load_flow( - self, context: TurnContext, auth_handler_id: str + self, context: TurnContext ) -> tuple[OAuthFlow, FlowStorageClient]: """Loads the OAuth flow for a specific auth handler. @@ -43,10 +44,6 @@ async def _load_flow( context.adapter.USER_TOKEN_CLIENT_KEY ) - # resolve handler id - auth_handler: AuthHandler = self._auth_handlers[auth_handler_id] - auth_handler_id = auth_handler.name - if ( not context.activity.channel_id or not context.activity.from_property @@ -64,7 +61,7 @@ async def _load_flow( # try to load existing state flow_storage_client = FlowStorageClient(channel_id, user_id, self._storage) logger.info("Loading OAuth flow state from storage") - flow_state: FlowState = await flow_storage_client.read(auth_handler_id) + flow_state: FlowState = await flow_storage_client.read(self._auth_handler_id) if not flow_state: logger.info("No existing flow state found, creating new flow state") @@ -79,65 +76,71 @@ async def _load_flow( flow = OAuthFlow(flow_state, user_token_client) return flow, flow_storage_client - - async def begin_or_continue_flow( - self, context: TurnContext, auth_handler_id: str - ) -> FlowResponse: - """Begins or continues an OAuth flow. - - Delegates to the OAuthFlow to handle the activity and manage the flow state. + + async def _handle_obo( + self, + context: TurnContext, + input_token_response: TokenResponse, + exchange_connection: Optional[str] = None, + scopes: Optional[list[str]] = None, + ) -> TokenResponse: + """ + Exchanges a token for another token with different scopes. :param context: The context object for the current turn. :type context: TurnContext - :param auth_handler_id: The ID of the auth handler to use. + :param scopes: The scopes to request for the new token. + :type scopes: list[str] + :param auth_handler_id: Optional ID of the auth handler to use, defaults to first :type auth_handler_id: str - :return: The FlowResponse from the OAuth flow. - :rtype: FlowResponse + :return: The token response from the OAuth provider from the exchange. + If the cached token is not exchangeable, returns the cached token. + :rtype: TokenResponse """ - - logger.debug("Beginning or continuing OAuth flow") - - flow, flow_storage_client = await self._load_flow(context, auth_handler_id) - # prev_tag = flow.flow_state.tag - flow_response: FlowResponse = await flow.begin_or_continue_flow( - context.activity + if not input_token_response: + return input_token_response + + token = input_token_response.token + + connection_name = exchange_connection or self._handler.obo_connection_name + scopes = scopes or self._handler.scopes + + if not connection_name or not scopes: + return input_token_response + + if not self._is_exchangeable(input_token_response.token): + raise ValueError("Token is not exchangeable") + + token_provider = self._connection_manager.get_connection(connection_name) + if not token_provider: + raise ValueError(f"Connection '{connection_name}' not found") + + token = await token_provider.acquire_token_on_behalf_of( + scopes=scopes, + user_assertion=input_token_response.token, ) + return TokenResponse(token=token) - logger.info("Saving OAuth flow state to storage") - await flow_storage_client.write(flow_response.flow_state) - - # optimization for the future. Would like to double check this logic. - # if prev_tag != flow_response.flow_state.tag and flow_response.flow_state.tag == FlowStateTag.COMPLETE: - # # Clear the flow state on completion - # await flow_storage_client.delete(auth_handler_id) - - return flow_response - - async def _sign_out( - self, - context: TurnContext, - auth_handler_ids: Iterable[str], - ) -> None: - """Signs out from the specified auth handlers. - - Deletes the associated flows from storage. + def _is_exchangeable(self, token: str) -> bool: + """ + Checks if a token is exchangeable (has api:// audience). - :param context: The context object for the current turn. - :type context: TurnContext - :param auth_handler_ids: Iterable of auth handler IDs to sign out from. - :type auth_handler_ids: Iterable[str] - :return: None + :param token: The token to check. + :type token: str + :return: True if the token is exchangeable, False otherwise. """ - for auth_handler_id in auth_handler_ids: - flow, flow_storage_client = await self._load_flow(context, auth_handler_id) - logger.info("Signing out from handler: %s", auth_handler_id) - await flow.sign_out() - await flow_storage_client.delete(auth_handler_id) + try: + # Decode without verification to check the audience + payload = jwt.decode(token, options={"verify_signature": False}) + aud = payload.get("aud") + return isinstance(aud, str) and aud.startswith("api://") + except Exception: + logger.error("Failed to decode token to check audience") + return False async def sign_out( self, context: TurnContext, - auth_handler_id: Optional[str] = None, ) -> None: """ Signs out the current user. @@ -147,10 +150,10 @@ async def sign_out( :param auth_handler_id: Optional ID of the auth handler to use for sign out. If None, signs out from all the handlers. """ - if auth_handler_id: - await self._sign_out(context, [auth_handler_id]) - else: - await self._sign_out(context, self._auth_handlers.keys()) + flow, flow_storage_client = await self._load_flow(context) + logger.info("Signing out from handler: %s", self._handler.name) + await flow.sign_out() + await flow_storage_client.delete(auth_handler_id))) async def _handle_flow_response( self, context: TurnContext, flow_response: FlowResponse @@ -214,7 +217,14 @@ async def sign_in( "Beginning or continuing flow for auth handler %s", auth_handler_id, ) - flow_response = await self.begin_or_continue_flow(context, auth_handler_id) + flow, flow_storage_client = await self._load_flow(context) + # prev_tag = flow.flow_state.tag + flow_response: FlowResponse = await flow.begin_or_continue_flow( + context.activity + ) + + logger.info("Saving OAuth flow state to storage") + await flow_storage_client.write(flow_response.flow_state) await self._handle_flow_response(context, flow_response) logger.debug( "Flow response flow_state.tag: %s", @@ -227,3 +237,29 @@ async def sign_in( ) return sign_in_response + + async def get_refreshed_token( + self, context: TurnContext, exchange_connection, exchange_scopes: Optional[list[str]] = None + ) -> TokenResponse: + """ + Gets a refreshed token for the user. + + :param context: The context object for the current turn. + :type context: TurnContext + :param auth_handler_id: The ID of the auth handler to use. + :type auth_handler_id: str + :param exchange_connection: The connection to use for token exchange. + :type exchange_connection: str + :param exchange_scopes: The scopes to request for the new token. + :type exchange_scopes: Optional[list[str]] + :return: The token response from the OAuth provider. + :rtype: TokenResponse + """ + flow, _ = await self._load_flow(context) + input_token_response = await flow.get_user_token() # TODO + return self._handle_obo( + context, + input_token_response, + exchange_connection, + exchange_scopes, + ) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/authorization_variant.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/authorization_variant.py deleted file mode 100644 index 6f7d4c1c..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/variants/authorization_variant.py +++ /dev/null @@ -1,159 +0,0 @@ -from abc import ABC -from typing import Optional -import logging - -from microsoft_agents.activity import TokenResponse - -from ....turn_context import TurnContext -from ....storage import Storage -from ....authorization import Connections -from ..auth_handler import AuthHandler -from ..sign_in_response import SignInResponse - -logger = logging.getLogger(__name__) - - -class AuthorizationVariant(ABC): - """Base class for different authorization strategies.""" - - def __init__( - self, - storage: Storage, - connection_manager: Connections, - auth_handlers: dict[str, AuthHandler] = None, - auto_signin: bool = None, - use_cache: bool = False, - **kwargs, - ) -> None: - """ - Creates a new instance of Authorization. - - :param storage: The storage system to use for state management. - :type storage: Storage - :param connection_manager: The connection manager for OAuth providers. - :type connection_manager: Connections - :param auth_handlers: Configuration for OAuth providers. - :type auth_handlers: dict[str, AuthHandler], optional - :raises ValueError: When storage is None or no auth handlers provided. - """ - if not storage: - raise ValueError("Storage is required for Authorization") - - self._storage = storage - self._connection_manager = connection_manager - - auth_configuration: dict = kwargs.get("AGENTAPPLICATION", {}).get( - "USERAUTHORIZATION", {} - ) - - handlers_config: dict[str, dict] = auth_configuration.get("HANDLERS", {}) - if not auth_handlers and handlers_config: - auth_handlers = { - handler_name: AuthHandler( - name=handler_name, **config.get("SETTINGS", {}) - ) - for handler_name, config in handlers_config.items() - } - - self._auth_handlers = auth_handlers or {} - - async def exchange_token( - self, - context: TurnContext, - scopes: list[str], - auth_handler_id: str, - ) -> TokenResponse: - """ - Exchanges a token for another token with different scopes. - - :param context: The context object for the current turn. - :type context: TurnContext - :param scopes: The scopes to request for the new token. - :type scopes: list[str] - :param auth_handler_id: Optional ID of the auth handler to use, defaults to first - :type auth_handler_id: str - :return: The token response from the OAuth provider from the exchange. - If the cached token is not exchangeable, returns the cached token. - :rtype: TokenResponse - """ - - token_response = await self.get_token(context, auth_handler_id) - - if token_response and self._is_exchangeable(token_response.token): - logger.debug("Token is exchangeable, performing OBO flow") - return await self._handle_obo(token_response.token, scopes, auth_handler_id) - - return token_response - - def _is_exchangeable(self, token: str) -> bool: - """ - Checks if a token is exchangeable (has api:// audience). - - :param token: The token to check. - :type token: str - :return: True if the token is exchangeable, False otherwise. - """ - try: - # Decode without verification to check the audience - payload = jwt.decode(token, options={"verify_signature": False}) - aud = payload.get("aud") - return isinstance(aud, str) and aud.startswith("api://") - except Exception: - logger.error("Failed to decode token to check audience") - return False - - async def _handle_obo( - self, token: str, scopes: list[str], handler_id: str = None - ) -> TokenResponse: - """ - Handles On-Behalf-Of token exchange. - - :param token: The original token. - :type token: str - :param scopes: The scopes to request. - :type scopes: list[str] - :param handler_id: The ID of the auth handler to use, defaults to first - :type handler_id: str, optional - :return: The new token response. - :rtype: TokenResponse - """ - auth_handler = self.resolve_handler(handler_id) - token_provider = self._connection_manager.get_connection( - auth_handler.obo_connection_name - ) - - logger.info("Attempting to exchange token on behalf of user") - new_token = await token_provider.aquire_token_on_behalf_of( - scopes=scopes, - user_assertion=token, - ) - return TokenResponse(token=new_token) - - async def sign_in( - self, context: TurnContext, auth_handler_id: str, scopes: Optional[list[str]] = None - ) -> SignInResponse: - """Initiate or continue the sign-in process for the user with the given auth handler. - - :param context: The turn context for the current turn of conversation. - :type context: TurnContext - :param auth_handler_id: The ID of the auth handler to use for sign-in. If None, the first handler will be used. - :type auth_handler_id: Optional[str] - :return: A SignInResponse indicating the result of the sign-in attempt. - :rtype: SignInResponse - """ - raise NotImplementedError() - - async def sign_out( - self, - context: TurnContext, - auth_handler_id: Optional[str] = None, - ) -> None: - """Attempts to sign out the user from the specified auth handler or all handlers if none specified. - - :param context: The turn context for the current turn of conversation. - :type context: TurnContext - :param auth_handler_id: The ID of the auth handler to sign out from. If None, sign out from all handlers. - :type auth_handler_id: Optional[str] - """ - raise NotImplementedError() - diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py index 37f0e236..e69647cd 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py @@ -17,7 +17,7 @@ async def get_access_token( """ pass - async def aquire_token_on_behalf_of( + async def acquire_token_on_behalf_of( self, scopes: list[str], user_assertion: str ) -> str: """ diff --git a/tests/_common/testing_objects/testing_token_provider.py b/tests/_common/testing_objects/testing_token_provider.py index 28baffc9..66dcf002 100644 --- a/tests/_common/testing_objects/testing_token_provider.py +++ b/tests/_common/testing_objects/testing_token_provider.py @@ -38,7 +38,7 @@ async def get_access_token( """ return f"{self.name}-token" - async def aquire_token_on_behalf_of( + async def acquire_token_on_behalf_of( self, scopes: list[str], user_assertion: str ) -> str: """ diff --git a/tests/authentication_msal/test_msal_auth.py b/tests/authentication_msal/test_msal_auth.py index 0da1909b..7198d190 100644 --- a/tests/authentication_msal/test_msal_auth.py +++ b/tests/authentication_msal/test_msal_auth.py @@ -37,11 +37,11 @@ async def test_get_access_token_confidential(self, mocker): ) @pytest.mark.asyncio - async def test_aquire_token_on_behalf_of_managed_identity(self, mocker): + async def test_acquire_token_on_behalf_of_managed_identity(self, mocker): mock_auth = MockMsalAuth(mocker, ManagedIdentityClient) try: - await mock_auth.aquire_token_on_behalf_of( + await mock_auth.acquire_token_on_behalf_of( scopes=["test-scope"], user_assertion="test-assertion" ) except NotImplementedError: @@ -50,13 +50,13 @@ async def test_aquire_token_on_behalf_of_managed_identity(self, mocker): assert False @pytest.mark.asyncio - async def test_aquire_token_on_behalf_of_confidential(self, mocker): + async def test_acquire_token_on_behalf_of_confidential(self, mocker): mock_auth = MockMsalAuth(mocker, ConfidentialClientApplication) mock_auth._create_client_application = mocker.Mock( return_value=mock_auth.mock_client ) - token = await mock_auth.aquire_token_on_behalf_of( + token = await mock_auth.acquire_token_on_behalf_of( scopes=["test-scope"], user_assertion="test-assertion" ) From a0df1771b98be26da5b1a687f6f0389e91a191a3 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 29 Sep 2025 10:14:28 -0700 Subject: [PATCH 19/36] get_token_provider implemented and tested --- .../activity/_load_configuration.py | 18 +- .../msal/msal_connection_manager.py | 45 +- .../microsoft_agents/hosting/core/__init__.py | 12 +- .../hosting/core/app/__init__.py | 9 +- .../hosting/core/app/auth/__init__.py | 11 +- .../hosting/core/app/auth/auth_handler.py | 16 +- .../hosting/core/app/auth/authorization.py | 5 +- .../core/app/auth/handlers/__init__.py | 10 +- ...ation.py => agentic_user_authorization.py} | 20 +- .../auth/handlers/authorization_handler.py | 14 +- .../handlers/authorization_handler_map.py | 8 - .../app/auth/handlers/user_authorization.py | 37 +- .../hosting/core/turn_context.py | 2 +- tests/_common/create_env_var_dict.py | 1 + .../data/configs/test_agentic_auth_config.py | 2 +- .../_common/data/configs/test_auth_config.py | 4 +- tests/_common/mock_utils.py | 14 + tests/_common/testing_objects/__init__.py | 3 - .../mocks/mock_authorization.py | 9 +- tests/authentication_msal/_data.py | 82 ++ .../test_msal_connection_manager.py | 85 +- .../app/auth/handlers/__init__.py | 0 .../hosting_core/app/auth/handlers/_common.py | 19 + .../test_agentic_user_authorization.py | 323 +++++ .../handlers/test_authorization_handler.py | 0 .../auth/handlers/test_user_authorization.py | 0 .../app/auth/test_agentic_authorization.py | 540 ++++---- .../app/auth/test_authorization.py | 1168 ++++++++--------- .../app/auth/test_user_authorization.py | 526 ++++---- .../app/test_agent_application.py | 52 +- 30 files changed, 1763 insertions(+), 1272 deletions(-) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/{agentic_authorization.py => agentic_user_authorization.py} (89%) delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler_map.py create mode 100644 tests/_common/mock_utils.py create mode 100644 tests/authentication_msal/_data.py create mode 100644 tests/hosting_core/app/auth/handlers/__init__.py create mode 100644 tests/hosting_core/app/auth/handlers/_common.py create mode 100644 tests/hosting_core/app/auth/handlers/test_agentic_user_authorization.py create mode 100644 tests/hosting_core/app/auth/handlers/test_authorization_handler.py create mode 100644 tests/hosting_core/app/auth/handlers/test_user_authorization.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/_load_configuration.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/_load_configuration.py index 4f5f20ab..3cbd27f4 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/_load_configuration.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/_load_configuration.py @@ -1,18 +1,5 @@ from typing import Any -def _list_out_seq_dicts(node) -> Any: - """ Converts any dictionaries with integer keys to a list if the keys are sequential integers starting from 0.""" - - if isinstance(node, dict): - keys = node.keys() - num_keys = len(keys) - if set(keys) == set(range(num_keys)): - # this is a seq dict - return [ - _list_out_seq_dicts(node[i]) for i in range(num_keys) - ] - return node - def load_configuration_from_env(env_vars: dict[str, Any]) -> dict: """ Parses environment variables and returns a dictionary with the relevant configuration. @@ -30,7 +17,10 @@ def load_configuration_from_env(env_vars: dict[str, Any]) -> dict: current_level = current_level[next_level] last_level[levels[-1]] = value - result = _list_out_seq_dicts(result) + if result.get("CONNECTIONSMAP") and isinstance(result["CONNECTIONSMAP"], dict): + result["CONNECTIONSMAP"] = [ + conn for conn in result.get("CONNECTIONSMAP", {}).values() + ] return { "AGENTAPPLICATION": result.get("AGENTAPPLICATION", {}), diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py index cfddee0d..f10283e8 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py @@ -18,7 +18,7 @@ def __init__( **kwargs ): self._connections: Dict[str, MsalAuth] = {} - self._connections_map = connections_map or kwargs.get("CONNECTIONS_MAP", {}) + self._connections_map = connections_map or kwargs.get("CONNECTIONSMAP", {}) self._service_connection_configuration: AgentAuthConfiguration = None if connections_configurations: @@ -60,35 +60,34 @@ def get_token_provider( """ Get the OAuth token provider for the agent. """ + if not claims_identity or not service_url: + raise ValueError("Claims identity and Service URL are required to get the token provider.") + if not self._connections_map: return self.get_default_connection() - aud = claims_identity.get_app_id() - if aud: - aud = aud.lower() - - for item in self._connections_map: - audience_match = True - - item_aud = item.get("AUDIENCE", "") - if item_aud: - audience_match = item_aud.lower() == aud + aud = claims_identity.get_app_id() or "" + for item in self._connections_map: + audience_match = True + item_aud = item.get("AUDIENCE", "") + if item_aud: + audience_match = item_aud.lower() == aud.lower() - if audience_match: - item_service_url = item.get("serviceUrl", "") - if item_service_url == "*" or item_service_url == "": - connection_name = item.get("connectionName") + if audience_match: + item_service_url = item.get("SERVICEURL", "") + if item_service_url == "*" or item_service_url == "": + connection_name = item.get("CONNECTION") + connection = self.get_connection(connection_name) + if connection: + return connection + + else: + res = re.match(item_service_url, service_url, re.IGNORECASE) + if res: + connection_name = item.get("CONNECTION") connection = self.get_connection(connection_name) if connection: return connection - - else: - match = re.match(item_service_url, service_url) - if match: - connection_name = item.get("connectionName") - connection = self.get_connection(connection_name) - if connection: - return connection raise ValueError( f"No connection found for audience '{aud}' and serviceUrl '{service_url}'." diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py index 0db68b84..28dfc778 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py @@ -22,10 +22,10 @@ # App Auth from .app.auth import ( Authorization, - AuthorizationHandlers, + AuthorizationHandler, AuthHandler, UserAuthorization, - AgenticAuthorization, + AgenticUserAuthorization, SignInState, SignInResponse, ) @@ -109,15 +109,11 @@ "Middleware", "RestChannelServiceClientFactory", "TurnContext", - "ActivityType", "AgentApplication", "ApplicationError", "ApplicationOptions", - "ConversationUpdateType", "InputFile", "InputFileDownloader", - "MessageReactionType", - "MessageUpdateType", "Query", "Route", "RouteHandler", @@ -128,7 +124,7 @@ "TurnState", "TempState", "Authorization", - "AuthorizationHandlers", + "AuthorizationHandler", "AuthHandler", "SignInState", "AccessTokenProviderBase", @@ -173,7 +169,7 @@ "FlowStorageClient", "OAuthFlow", "UserAuthorization", - "AgenticAuthorization", + "AgenticUserAuthorization", "Authorization", "SignInState", "SignInResponse", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py index e2767221..cd5b28e7 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py @@ -17,9 +17,9 @@ from .auth import ( Authorization, AuthHandler, - AuthorizationHandlers, + AuthorizationHandler, UserAuthorization, - AgenticAuthorization, + AgenticUserAuthorization, SignInResponse, SignInState, ) @@ -49,10 +49,9 @@ "TempState", "Authorization", "AuthHandler", - "AuthorizationHandlers", - "AuthorizationVariant", + "AuthorizationHandler", "UserAuthorization", - "AgenticAuthorization", + "AgenticUserAuthorization", "SignInState", "SignInResponse", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py index fdb5c74c..2e69ee71 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py @@ -1,8 +1,12 @@ from .authorization import Authorization -from .auth_handler import AuthHandler, AuthorizationHandler -from .handlers.authorization_handler import Authorization +from .auth_handler import AuthHandler from .sign_in_state import SignInState from .sign_in_response import SignInResponse +from .handlers import ( + UserAuthorization, + AgenticUserAuthorization, + AuthorizationHandler +) __all__ = [ "Authorization", @@ -10,4 +14,7 @@ "AuthorizationHandler", "SignInState", "SignInResponse", + "UserAuthorization", + "AgenticUserAuthorization", + "AuthorizationHandler", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py index 3eaab407..ac2aeb77 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. import logging -from typing import Dict +from typing import Optional logger = logging.getLogger(__name__) @@ -13,6 +13,13 @@ class AuthHandler: """ Interface defining an authorization handler for OAuth flows. """ + name: str + title: str + text: str + abs_oauth_connection_name: str + obo_connection_name: str + auth_type: str + scopes: list[str] def __init__( self, @@ -22,7 +29,7 @@ def __init__( abs_oauth_connection_name: str = "", obo_connection_name: str = "", auth_type: str = "", - scopes: list[str] = None + scopes: Optional[list[str]] = None, **kwargs, ): """ @@ -54,7 +61,10 @@ def __init__( ) self.auth_type = auth_type or kwargs.get("TYPE", "") self.auth_type = self.auth_type.lower() - self.scopes = list(scopes) or kwargs.get("SCOPES", []) + if scopes: + self.scopes = list(scopes) + else: + self.scopes = kwargs.get("SCOPES", []) @staticmethod def from_settings(settings: dict): diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py index b82a404f..6fe1a778 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py @@ -14,17 +14,16 @@ from .sign_in_state import SignInState from .sign_in_response import SignInResponse from .handlers import ( - AgenticAuthorization, + AgenticUserAuthorization, UserAuthorization, AuthorizationHandler ) -from microsoft_agents.hosting.core.app.auth import auth_handler logger = logging.getLogger(__name__) AUTHORIZATION_TYPE_MAP = { UserAuthorization.__name__.lower(): UserAuthorization, - AgenticAuthorization.__name__.lower(): AgenticAuthorization, + AgenticUserAuthorization.__name__.lower(): AgenticUserAuthorization, } class Authorization: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/__init__.py index 26c84482..fd372a13 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/__init__.py @@ -1,11 +1,9 @@ -from .agentic_authorization import AgenticAuthorization +from .agentic_user_authorization import AgenticUserAuthorization from .user_authorization import UserAuthorization -from .authorization_handler_map import AuthorizationVariantMap -from .authorization_handler import AuthorizationVariant +from .authorization_handler import AuthorizationHandler __all__ = [ - "AgenticAuthorization", + "AgenticUserAuthorization", "UserAuthorization", - "AuthorizationVariantMap", - "AuthorizationVariant", + "AuthorizationHandler", ] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_user_authorization.py similarity index 89% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_authorization.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_user_authorization.py index f2adb554..237bbd2e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_user_authorization.py @@ -6,14 +6,13 @@ from ....turn_context import TurnContext from ....oauth import FlowStateTag - -from .authorization_handler import AuthorizationVariant from ..sign_in_response import SignInResponse +from .authorization_handler import AuthorizationHandler logger = logging.getLogger(__name__) -class AgenticAuthorization(AuthorizationHandler): +class AgenticUserAuthorization(AuthorizationHandler): """Class responsible for managing agentic authorization""" async def get_agentic_instance_token(self, context: TurnContext) -> Optional[str]: @@ -84,25 +83,18 @@ async def sign_in( token_response = await self.get_refreshed_token(context, exchange_connection, scopes) if token_response: return SignInResponse(token_response=token_response, tag=FlowStateTag.COMPLETE) - return SignInResponse() + return SignInResponse(tag=FlowStateTag.FAILURE) async def get_refreshed_token(self, context: TurnContext, - auth_handler_id: str, exchange_connection: Optional[str] = None, scopes: Optional[list[str]] = None ) -> TokenResponse: + """Gets a refreshed agentic user token if available.""" if not scopes: - scopes = self.resolve_handler(connection_name).scopes - scopes = scopes or [] + scopes = self._handler.scopes or [] token = await self.get_agentic_user_token(context, scopes) - return ( - SignInResponse( - token_response=TokenResponse(token=token), tag=FlowStateTag.COMPLETE - ) - if token - else SignInResponse() - ) + return TokenResponse(token=token) if token else TokenResponse() async def sign_out(self, context: TurnContext, auth_handler_id: Optional[str] = None) -> None: """Nothing to do for agentic sign out.""" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler.py index a4bf0fad..36538433 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler.py @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) -class AuthorizationVariant(ABC): +class AuthorizationHandler(ABC): """Base class for different authorization strategies.""" _storage: Storage @@ -25,6 +25,7 @@ def __init__( storage: Storage, connection_manager: Connections, auth_handler: Optional[AuthHandler] = None, + *, auth_handler_settings: Optional[dict] = None, **kwargs, ) -> None: @@ -53,7 +54,7 @@ def __init__( self._handler = AuthHandler.from_settings(auth_handler_settings) async def sign_in( - self, context: TurnContext, auth_handler_id: str, scopes: Optional[list[str]] = None + self, context: TurnContext, scopes: Optional[list[str]] = None ) -> SignInResponse: """Initiate or continue the sign-in process for the user with the given auth handler. @@ -67,15 +68,12 @@ async def sign_in( raise NotImplementedError() async def get_refreshed_token( - self, context: TurnContext, auth_handler_id: str, exchange_connection, exchange_scopes: Optional[list[str]] = None + self, context: TurnContext, exchange_connection: Optional[str]=None, exchange_scopes: Optional[list[str]] = None ) -> TokenResponse: + """Attempts to get a refreshed token for the user with the given scopes""" raise NotImplementedError() - async def sign_out( - self, - context: TurnContext, - auth_handler_id: Optional[str] = None, - ) -> None: + async def sign_out(self, context: TurnContext) -> None: """Attempts to sign out the user from the specified auth handler or all handlers if none specified. :param context: The turn context for the current turn of conversation. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler_map.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler_map.py deleted file mode 100644 index 20b1973a..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler_map.py +++ /dev/null @@ -1,8 +0,0 @@ -from .authorization_handler import AuthorizationVariant -from .agentic_authorization import AgenticAuthorization -from .user_authorization import UserAuthorization - -AUTHORIZATION_TYPE_MAP: dict[str, type[AuthorizationVariant]] = { - UserAuthorization.__name__.lower(): UserAuthorization, - AgenticAuthorization.__name__.lower(): AgenticAuthorization, -} diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/user_authorization.py index e6873055..5855eb86 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/user_authorization.py @@ -3,16 +3,29 @@ from __future__ import annotations import logging -from abc import ABC +import jwt from typing import Optional -from collections.abc import Iterable from microsoft_agents.activity import ( + Attachment, + ActionTypes, + CardAction, + OAuthCard, TokenResponse ) -...connector.client import UserTokenClient -from ...turn_context import TurnContext -from ...auth import OAuthFlow, FlowResponse, FlowState, FlowStorageClient + +from microsoft_agents.hosting.core.card_factory import CardFactory +from microsoft_agents.hosting.core.message_factory import MessageFactory +from microsoft_agents.hosting.core.connector.client import UserTokenClient +from microsoft_agents.hosting.core.turn_context import TurnContext +from microsoft_agents.hosting.core.oauth import ( + OAuthFlow, + FlowResponse, + FlowState, + FlowStorageClient, + FlowStateTag +) +from ..sign_in_response import SignInResponse from .authorization_handler import AuthorizationHandler logger = logging.getLogger(__name__) @@ -61,15 +74,15 @@ async def _load_flow( # try to load existing state flow_storage_client = FlowStorageClient(channel_id, user_id, self._storage) logger.info("Loading OAuth flow state from storage") - flow_state: FlowState = await flow_storage_client.read(self._auth_handler_id) + flow_state: FlowState = await flow_storage_client.read(self._handler.name) if not flow_state: logger.info("No existing flow state found, creating new flow state") flow_state = FlowState( channel_id=channel_id, user_id=user_id, - auth_handler_id=auth_handler_id, - connection=auth_handler.abs_oauth_connection_name, + auth_handler_id=self._handler, + connection=self._handler.abs_oauth_connection_name, ms_app_id=ms_app_id, ) await flow_storage_client.write(flow_state) @@ -153,7 +166,7 @@ async def sign_out( flow, flow_storage_client = await self._load_flow(context) logger.info("Signing out from handler: %s", self._handler.name) await flow.sign_out() - await flow_storage_client.delete(auth_handler_id))) + await flow_storage_client.delete(self._handler.name) async def _handle_flow_response( self, context: TurnContext, flow_response: FlowResponse @@ -199,7 +212,7 @@ async def _handle_flow_response( await context.send_activity("Sign-in failed. Please try again.") async def sign_in( - self, context: TurnContext, auth_handler_id: str, scopes: Optional[list[str]] = None + self, context: TurnContext, exchange_connection: Optional[str] = None, scopes: Optional[list[str]] = None ) -> SignInResponse: """Begins or continues an OAuth flow. @@ -239,7 +252,7 @@ async def sign_in( return sign_in_response async def get_refreshed_token( - self, context: TurnContext, exchange_connection, exchange_scopes: Optional[list[str]] = None + self, context: TurnContext, exchange_connection: Optional[str] = None, exchange_scopes: Optional[list[str]] = None ) -> TokenResponse: """ Gets a refreshed token for the user. @@ -257,7 +270,7 @@ async def get_refreshed_token( """ flow, _ = await self._load_flow(context) input_token_response = await flow.get_user_token() # TODO - return self._handle_obo( + return await self._handle_obo( context, input_token_response, exchange_connection, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index fc0ce050..4deb7c92 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -431,4 +431,4 @@ def get_mentions(activity: Activity) -> list[Mention]: if entity.type.lower() == "mention": result.append(entity) - return result + return result \ No newline at end of file diff --git a/tests/_common/create_env_var_dict.py b/tests/_common/create_env_var_dict.py index 9e13925e..8af02f1b 100644 --- a/tests/_common/create_env_var_dict.py +++ b/tests/_common/create_env_var_dict.py @@ -3,6 +3,7 @@ def create_env_var_dict(env_raw: str) -> dict[str, str]: lines = env_raw.strip().split("\n") env = {} for line in lines: + if not line.strip(): continue key, value = line.split("=", 1) env[key.strip()] = value.strip() return env \ No newline at end of file diff --git a/tests/_common/data/configs/test_agentic_auth_config.py b/tests/_common/data/configs/test_agentic_auth_config.py index 00bb67b1..898219d3 100644 --- a/tests/_common/data/configs/test_agentic_auth_config.py +++ b/tests/_common/data/configs/test_agentic_auth_config.py @@ -1,7 +1,7 @@ from microsoft_agents.activity import load_configuration_from_env from ...create_env_var_dict import create_env_var_dict -from .test_defaults import TEST_DEFAULTS +from ..test_defaults import TEST_DEFAULTS DEFAULTS = TEST_DEFAULTS() diff --git a/tests/_common/data/configs/test_auth_config.py b/tests/_common/data/configs/test_auth_config.py index 713e3fd9..67152bad 100644 --- a/tests/_common/data/configs/test_auth_config.py +++ b/tests/_common/data/configs/test_auth_config.py @@ -1,7 +1,7 @@ from microsoft_agents.activity import load_configuration_from_env from ...create_env_var_dict import create_env_var_dict -from .test_defaults import TEST_DEFAULTS +from ..test_defaults import TEST_DEFAULTS DEFAULTS = TEST_DEFAULTS() @@ -21,7 +21,7 @@ def TEST_ENV(): - create_env_var_dict(_TEST_ENV_RAW) + return create_env_var_dict(_TEST_ENV_RAW) def TEST_ENV_DICT(): diff --git a/tests/_common/mock_utils.py b/tests/_common/mock_utils.py new file mode 100644 index 00000000..c4b986c3 --- /dev/null +++ b/tests/_common/mock_utils.py @@ -0,0 +1,14 @@ +def mock_instance(mocker, cls, methods={}, default_mock_type=None, **kwargs): + """Create a mock instance of a class with specified methods mocked.""" + if not default_mock_type: + default_mock_type = mocker.AsyncMock + instance = mocker.Mock(spec=cls, **kwargs) + for method_name, return_value in methods.items(): + if not isinstance(return_value, mocker.Mock) and not isinstance(return_value, mocker.AsyncMock): + return_value = default_mock_type(return_value=return_value) + setattr(instance, method_name, return_value) + return instance + +def mock_class(mocker, cls, instance): + """Replace a class with a mock instance.""" + mocker.patch.object(cls, new=instance) \ No newline at end of file diff --git a/tests/_common/testing_objects/__init__.py b/tests/_common/testing_objects/__init__.py index 92ab0041..0e6aefeb 100644 --- a/tests/_common/testing_objects/__init__.py +++ b/tests/_common/testing_objects/__init__.py @@ -1,6 +1,3 @@ -from tests._common.testing_objects.mocks.mock_msal_auth import ( - agentic_mock_class_MsalAuth, -) from .adapters import TestingAdapter from .mocks import ( diff --git a/tests/_common/testing_objects/mocks/mock_authorization.py b/tests/_common/testing_objects/mocks/mock_authorization.py index 4caa4fde..2c529bcb 100644 --- a/tests/_common/testing_objects/mocks/mock_authorization.py +++ b/tests/_common/testing_objects/mocks/mock_authorization.py @@ -1,10 +1,9 @@ from microsoft_agents.hosting.core import ( Authorization, UserAuthorization, - AgenticAuthorization, + AgenticUserAuthorization, + SignInResponse ) -from microsoft_agents.hosting.core.app.auth import SignInResponse - def mock_class_UserAuthorization(mocker, sign_in_return=None): if sign_in_return is None: @@ -16,8 +15,8 @@ def mock_class_UserAuthorization(mocker, sign_in_return=None): def mock_class_AgenticAuthorization(mocker, sign_in_return=None): if sign_in_return is None: sign_in_return = SignInResponse() - mocker.patch.object(AgenticAuthorization, "sign_in", return_value=sign_in_return) - mocker.patch.object(AgenticAuthorization, "sign_out") + mocker.patch.object(AgenticUserAuthorization, "sign_in", return_value=sign_in_return) + mocker.patch.object(AgenticUserAuthorization, "sign_out") def mock_class_Authorization(mocker, start_or_continue_sign_in_return=False): diff --git a/tests/authentication_msal/_data.py b/tests/authentication_msal/_data.py new file mode 100644 index 00000000..2c5407f1 --- /dev/null +++ b/tests/authentication_msal/_data.py @@ -0,0 +1,82 @@ +ENV_CONFIG = { + "CONNECTIONS": { + "SERVICE_CONNECTION": { + "SETTINGS": { + "TENANTID": "test-tenant-id-SERVICE_CONNECTION", + "CLIENTID": "test-client-id-SERVICE_CONNECTION", + "CLIENTSECRET": "test-client-secret-SERVICE_CONNECTION" + } + }, + "AGENTIC": { + "SETTINGS": { + "TENANTID": "test-tenant-id-AGENTIC", + "CLIENTID": "test-client-id-AGENTIC", + "CLIENTSECRET": "test-client-secret-AGENTIC" + } + }, + "MISC": { + "SETTINGS": { + "TENANTID": "test-tenant-id-MISC", + "CLIENTID": "test-client-id-MISC", + "CLIENTSECRET": "test-client-secret-MISC" + } + } + }, + "AGENTAPPLICATION": { + "USERAUTHORIZATION": { + "HANDLERS": { + "graph": { + "SETTINGS": { + "AZUREBOTOAUTHCONNECTIONNAME": "graph", + "OBOCONNECTIONNAME": "MISC", + "SCOPES": ["User.Read"], + "TITLE": "Sign in with Microsoft", + "TEXT": "Sign in with your Microsoft account", + "TYPE": "UserAuthorization" + } + }, + "github": { + "SETTINGS": { + "AZUREBOTOAUTHCONNECTIONNAME": "github", + "OBOCONNECTIONNAME": "SERVICE_CONNECTION", + "TYPE": "UserAuthorization" + } + }, + "agentic": { + "SETTINGS": { + "AZUREBOTOAUTHCONNECTIONNAME": "AGENTIC", + "OBOCONNECTIONNAME": "MISC", + "SCOPES": ["https://graph.microsoft.com/.default"], + "TITLE": "Sign in with Agentic", + "TEXT": "Sign in with your Agentic account", + "TYPE": "AgenticUserAuthorization" + } + } + } + } + }, + "CONNECTIONSMAP": [ + { + "CONNECTION": "AGENTIC", + "SERVICEURL": "agentic", + }, + { + "CONNECTION": "MISC", + "AUDIENCE": "api://misc", + "SERVICEURL": "*" + }, + { + "CONNECTION": "MISC", + "AUDIENCE": "api://misc_other", + }, + { + "CONNECTION": "SERVICE_CONNECTION", + "AUDIENCE": "api://service", + "SERVICEURL": "https://service*" + }, + { + "CONNECTION": "MISC", + "SERVICEURL": "https://microsoft.com/*" + } + ] +} \ No newline at end of file diff --git a/tests/authentication_msal/test_msal_connection_manager.py b/tests/authentication_msal/test_msal_connection_manager.py index 7b65311f..bd73f9b9 100644 --- a/tests/authentication_msal/test_msal_connection_manager.py +++ b/tests/authentication_msal/test_msal_connection_manager.py @@ -1,18 +1,26 @@ +import pytest + +from copy import deepcopy + from os import environ from microsoft_agents.activity import load_configuration_from_env -from microsoft_agents.hosting.core import AuthTypes +from microsoft_agents.hosting.core import AuthTypes, ClaimsIdentity from microsoft_agents.authentication.msal import MsalConnectionManager -from tests._common.data import TEST_ENV_DICT +from tests._common.create_env_var_dict import create_env_var_dict -ENV_DICT = TEST_ENV_DICT() +from ._data import ENV_CONFIG class TestMsalConnectionManager: """ Test suite for the Msal Connection Manager """ - def test_init_from_env(self): + @pytest.fixture + def config(self): + return deepcopy(ENV_CONFIG) + + def test_init_from_config(self): mock_environ = { **environ, "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID": "test-tenant-id-SERVICE_CONNECTION", @@ -37,10 +45,67 @@ def test_init_from_env(self): f"https://login.microsoftonline.com/test-tenant-id-{key}/v2.0", ] - def test_init_from_config(self): - connection_manager = MsalConnectionManager( - **ENV_DICT - ) - + # TODO -> test other init paths - def test_get_default_connection(self): + @pytest.mark.parametrize( + "claims_identity, service_url", + [ + [None, ""], + [None, None], + [None, "agentic"], + [ClaimsIdentity(claims={}, is_authenticated=False), None], + [ClaimsIdentity(claims={}, is_authenticated=False), ""], + [ClaimsIdentity(claims={}, is_authenticated=False), "https://example.com"], + [ClaimsIdentity(claims={"aud": "api://misc"}, is_authenticated=False), ""], + ] + ) + def test_get_token_provider_errors(self, claims_identity, service_url): + connection_manager = MsalConnectionManager(**ENV_CONFIG) + with pytest.raises(ValueError): + connection_manager.get_token_provider(claims_identity, service_url) + + def test_get_token_provider_no_map(self, config): + del config["CONNECTIONSMAP"] + connection_manager = MsalConnectionManager(**config) + claims_identity = ClaimsIdentity(claims={"aud": "api://misc"}, is_authenticated=True) + token_provider = connection_manager.get_token_provider(claims_identity, "https://example.com") + assert token_provider == connection_manager.get_default_connection() + + def test_get_token_provider_aud_match(self, config): + connection_manager = MsalConnectionManager(**config) + claims_identity = ClaimsIdentity(claims={"aud": "api://misc"}, is_authenticated=True) + token_provider = connection_manager.get_token_provider(claims_identity, "https://example.com") + assert token_provider == connection_manager.get_connection("MISC") + + def test_get_token_provider_aud_and_service_url_match(self, config): + connection_manager = MsalConnectionManager(**config) + claims_identity = ClaimsIdentity(claims={"aud": "api://service"}, is_authenticated=True) + token_provider = connection_manager.get_token_provider(claims_identity, "https://service.com/api") + assert token_provider == connection_manager.get_connection("SERVICE_CONNECTION") + + def test_get_token_provider_service_url_wildcard_star(self, config): + connection_manager = MsalConnectionManager(**config) + claims_identity = ClaimsIdentity(claims={"aud": "api://misc"}, is_authenticated=False) + token_provider = connection_manager.get_token_provider(claims_identity, "https://service.com/api") + assert token_provider == connection_manager.get_connection("MISC") + + def test_get_token_provider_service_url_wildcard_empty(self, config): + connection_manager = MsalConnectionManager(**config) + claims_identity = ClaimsIdentity(claims={"aud": "api://misc_other"}, is_authenticated=False) + token_provider = connection_manager.get_token_provider(claims_identity, "https://service.com/api") + assert token_provider == connection_manager.get_connection("MISC") + + @pytest.mark.parametrize( + "service_url, expected_connection", + [ + ["agentic", "AGENTIC"], + ["https://microsoft.com/api", "MISC"], + ["https://microsoft.com/some-url", "MISC"], + ["https://microsoft.com/", "MISC"] + ] + ) + def test_get_token_provider_service_url_match(self, config, service_url, expected_connection): + connection_manager = MsalConnectionManager(**config) + claims_identity = ClaimsIdentity(claims={}, is_authenticated=False) + token_provider = connection_manager.get_token_provider(claims_identity, service_url) + assert token_provider == connection_manager.get_connection(expected_connection) \ No newline at end of file diff --git a/tests/hosting_core/app/auth/handlers/__init__.py b/tests/hosting_core/app/auth/handlers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/app/auth/handlers/_common.py b/tests/hosting_core/app/auth/handlers/_common.py new file mode 100644 index 00000000..6d05971a --- /dev/null +++ b/tests/hosting_core/app/auth/handlers/_common.py @@ -0,0 +1,19 @@ +from microsoft_agents.activity import ( + Activity, + ChannelAccount, + RoleTypes, +) + +from tests._common.data import TEST_DEFAULTS + +DEFAULTS = TEST_DEFAULTS() + +def AGENTIC_ACTIVITY(): + return Activity( + type="message", + recipient=ChannelAccount( + id="bot_id", + agentic_app_id=DEFAULTS.agentic_instance_id, + role=RoleTypes.agentic_instance, + ), + ) \ No newline at end of file diff --git a/tests/hosting_core/app/auth/handlers/test_agentic_user_authorization.py b/tests/hosting_core/app/auth/handlers/test_agentic_user_authorization.py new file mode 100644 index 00000000..a78d1d4a --- /dev/null +++ b/tests/hosting_core/app/auth/handlers/test_agentic_user_authorization.py @@ -0,0 +1,323 @@ +from math import exp +import pytest + +from microsoft_agents.activity import ( + Activity, + ChannelAccount, + RoleTypes, + TokenResponse +) + +from microsoft_agents.authentication.msal import MsalAuth, MsalConnectionManager + +from microsoft_agents.hosting.core import ( + AgenticUserAuthorization, + SignInResponse, + MemoryStorage, + FlowStateTag, +) + +from tests._common.data import ( + # TEST_FLOW_DATA, + # TEST_AUTH_DATA, + # TEST_STORAGE_DATA, + TEST_DEFAULTS, + # TEST_ENV_DICT, + TEST_AGENTIC_ENV_DICT, + # create_test_auth_handler, +) + +from tests._common.testing_objects import ( + # TestingConnectionManager, + # TestingTokenProvider, + # agentic_mock_class_MsalAuth, + TestingConnectionManager as MockConnectionManager, +) + +from tests._common.mock_utils import mock_class, mock_instance + +from .._common import ( + testing_TurnContext_magic, +) + +DEFAULTS = TEST_DEFAULTS() +AGENTIC_ENV_DICT = TEST_AGENTIC_ENV_DICT() + + +class TestUtils: + def setup_method(self, mocker): + self.TurnContext = testing_TurnContext_magic + + @pytest.fixture + def storage(self): + return MemoryStorage() + + @pytest.fixture + def connection_manager(self, mocker): + return MsalConnectionManager(**AGENTIC_ENV_DICT) + + @pytest.fixture + def auth_handler_settings(self): + return AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][DEFAULTS.auth_handler_id]["SETTINGS"] + + @pytest.fixture + def agentic_auth(self, storage, connection_manager, auth_handler_settings): + return AgenticUserAuthorization(storage, connection_manager, + auth_handler_settings=auth_handler_settings) + + @pytest.fixture(params=[RoleTypes.user, RoleTypes.skill, RoleTypes.agent]) + def non_agentic_role(self, request): + return request.param + + @pytest.fixture(params=[RoleTypes.agentic_user, RoleTypes.agentic_identity]) + def agentic_role(self, request): + return request.param + + def mock_provider(self, mocker, app_token="bot_token", instance_token=None, user_token=None): + mock_provider = mocker.Mock(spec=MsalAuth) + mock_provider.get_agentic_instance_token = mocker.AsyncMock( + return_value=[instance_token, app_token] + ) + mock_provider.get_agentic_user_token = mocker.AsyncMock( + return_value=user_token + ) + return mock_provider + + def mock_class_provider(self, mocker, app_token="bot_token", instance_token=None, user_token=None): + instance = self.mock_provider(mocker, app_token, instance_token, user_token) + mock_class(mocker, MsalAuth, instance) + + +class TestAgenticUserAuthorization(TestUtils): + # @pytest.mark.parametrize( + # "activity", + # [ + # Activity( + # type="message", + # recipient=ChannelAccount( + # id="bot_id", + # agentic_app_id=DEFAULTS.agentic_instance_id, + # role=RoleTypes.agent, + # ), + # ), + # Activity( + # type="message", + # recipient=ChannelAccount( + # id=DEFAULTS.agentic_user_id, + # agentic_app_id=DEFAULTS.agentic_instance_id, + # role=RoleTypes.agentic_user, + # ), + # ), + # Activity( + # type="message", + # recipient=ChannelAccount( + # id=DEFAULTS.agentic_user_id, + # ), + # ), + # Activity(type="message", recipient=ChannelAccount(id="some_id")), + # ], + # ) + # def test_is_agentic_request(self, mocker, activity): + # assert activity.is_agentic() == AgenticUserAuthorization.is_agentic_request( + # activity + # ) + # context = self.TurnContext(mocker, activity=activity) + # assert activity.is_agentic() == AgenticUserAuthorization.is_agentic_request(context) + + def test_get_agent_instance_id_is_agentic(self, mocker, agentic_role): + activity = Activity( + type="message", + recipient=ChannelAccount( + id="some_id", + agentic_app_id=DEFAULTS.agentic_instance_id, + role=agentic_role, + ), + ) + context = self.TurnContext(mocker, activity=activity) + assert ( + AgenticUserAuthorization.get_agent_instance_id(context) + == DEFAULTS.agentic_instance_id + ) + + def test_get_agent_instance_id_not_agentic(self, mocker, non_agentic_role): + activity = Activity( + type="message", + recipient=ChannelAccount( + id="some_id", + agentic_app_id=DEFAULTS.agentic_instance_id, + role=non_agentic_role, + ), + ) + context = self.TurnContext(mocker, activity=activity) + assert AgenticUserAuthorization.get_agent_instance_id(context) is None + + def test_get_agentic_user_is_agentic(self, mocker, agentic_role): + activity = Activity( + type="message", + recipient=ChannelAccount( + id=DEFAULTS.agentic_user_id, + agentic_app_id=DEFAULTS.agentic_instance_id, + role=agentic_role, + ), + ) + context = self.TurnContext(mocker, activity=activity) + assert ( + AgenticUserAuthorization.get_agentic_user(context) == DEFAULTS.agentic_user_id + ) + + def test_get_agentic_user_not_agentic(self, mocker, non_agentic_role): + activity = Activity( + type="message", + recipient=ChannelAccount( + id=DEFAULTS.agentic_user_id, + agentic_app_id=DEFAULTS.agentic_instance_id, + role=non_agentic_role, + ), + ) + context = self.TurnContext(mocker, activity=activity) + assert AgenticUserAuthorization.get_agentic_user(context) is None + + @pytest.mark.asyncio + async def test_get_agentic_instance_token_not_agentic( + self, mocker, non_agentic_role, agentic_auth + ): + activity = Activity( + type="message", + recipient=ChannelAccount( + id=DEFAULTS.agentic_user_id, + agentic_app_id=DEFAULTS.agentic_instance_id, + role=non_agentic_role, + ), + ) + context = self.TurnContext(mocker, activity=activity) + assert await agentic_auth.get_agentic_instance_token(context) is None + + @pytest.mark.asyncio + async def test_get_agentic_user_token_not_agentic( + self, mocker, non_agentic_role, agentic_auth + ): + activity = Activity( + type="message", + recipient=ChannelAccount( + id=DEFAULTS.agentic_user_id, + agentic_app_id=DEFAULTS.agentic_instance_id, + role=non_agentic_role, + ), + ) + context = self.TurnContext(mocker, activity=activity) + assert await agentic_auth.get_agentic_user_token(context, ["user.Read"]) is None + + @pytest.mark.asyncio + async def test_get_agentic_user_token_agentic_no_user_id( + self, mocker, agentic_role, agentic_auth + ): + activity = Activity( + type="message", + recipient=ChannelAccount( + agentic_app_id=DEFAULTS.agentic_instance_id, role=agentic_role + ), + ) + context = self.TurnContext(mocker, activity=activity) + assert await agentic_auth.get_agentic_user_token(context, ["user.Read"]) is None + + @pytest.mark.asyncio + async def test_get_agentic_instance_token_is_agentic( + self, mocker, agentic_role, agentic_auth, auth_handler_settings + ): + mock_provider = self.mock_provider(mocker, instance_token=DEFAULTS.token) + connection_manager = mocker.Mock(spec=MsalConnectionManager) + connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) + + agentic_auth = AgenticUserAuthorization( + MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings + ) + + activity = Activity( + type="message", + recipient=ChannelAccount( + id="some_id", + agentic_app_id=DEFAULTS.agentic_instance_id, + role=agentic_role, + ), + ) + context = self.TurnContext(mocker, activity=activity) + + token = await agentic_auth.get_agentic_instance_token(context) + assert token == DEFAULTS.token + mock_provider.get_agentic_instance_token.assert_called_once_with(DEFAULTS.agentic_instance_id) + + @pytest.mark.asyncio + async def test_get_agentic_user_token_is_agentic( + self, mocker, agentic_role, agentic_auth, auth_handler_settings + ): + mock_provider = self.mock_provider(mocker, user_token=DEFAULTS.token) + + connection_manager = mocker.Mock(spec=MsalConnectionManager) + connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) + + agentic_auth = AgenticUserAuthorization( + MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings + ) + + activity = Activity( + type="message", + recipient=ChannelAccount( + id="some_id", + agentic_app_id=DEFAULTS.agentic_instance_id, + role=agentic_role, + ), + ) + context = self.TurnContext(mocker, activity=activity) + + token = await agentic_auth.get_agentic_user_token(context, ["user.Read"]) + assert token == DEFAULTS.token + mock_provider.get_agentic_user_token.assert_called_once_with( + DEFAULTS.agentic_instance_id, "some_id", ["user.Read"] + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "scopes_list, expected_scopes_list", + [ + (["user.Read"], ["user.Read"]), + # (["User.Read"], ["user.Read"]), + # (["USER.READ"], ["user.Read"]), + # ([" user.read "], ["user.Read"]), + # (["user.read", "Mail.Read"], ["user.Read", "mail.Read"]), + # ([" user.read ", " mail.read "], ["user.Read", "mail.Read"]), + # ([], []), + # (None, []), + ], + ) + async def test_sign_in_success(self, mocker, scopes_list, expected_scopes_list, auth_handler_settings): + mock_provider = self.mock_provider(mocker, user_token="my_token") + + connection_manager = mocker.Mock(spec=MsalConnectionManager) + connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) + + agentic_auth = AgenticUserAuthorization( + MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings + ) + context = self.TurnContext(mocker) + res = await agentic_auth.sign_in(context, "my_connection", scopes_list) + assert res.token_response.token == "my_token" + assert res.tag == FlowStateTag.COMPLETE + + assert mock_provider.get_agentic_user_token.call_count == 1 + args = mock_provider.get_agentic_user_token.call_args[0] + assert args[0] == context + assert args[1] == "my_connection" + assert args[2] == expected_scopes_list + + # @pytest.mark.asyncio + # async def test_sign_in_failure(self, mocker, agentic_auth): + # mocker.patch.object( + # AgenticUserAuthorization, "get_refreshed_token", return_value=TokenResponse() + # ) + # context = self.TurnContext(mocker) + # res = await agentic_auth.sign_in(context, "my_connection", ["user.Write"]) + # assert not res.token_response + # assert res.tag == FlowStateTag.FAILURE + # AgenticUserAuthorization.get_refreshed_token.assert_called_once_with( + # context, "my_connection", ["user.Read"] + # ) \ No newline at end of file diff --git a/tests/hosting_core/app/auth/handlers/test_authorization_handler.py b/tests/hosting_core/app/auth/handlers/test_authorization_handler.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/app/auth/handlers/test_user_authorization.py b/tests/hosting_core/app/auth/handlers/test_user_authorization.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/app/auth/test_agentic_authorization.py b/tests/hosting_core/app/auth/test_agentic_authorization.py index dd6e9b56..fc0ec1c4 100644 --- a/tests/hosting_core/app/auth/test_agentic_authorization.py +++ b/tests/hosting_core/app/auth/test_agentic_authorization.py @@ -1,270 +1,270 @@ -import pytest - -from microsoft_agents.activity import Activity, ChannelAccount, RoleTypes - -from microsoft_agents.authentication.msal import MsalAuth, MsalConnectionManager - -from microsoft_agents.hosting.core import ( - AgenticAuthorization, - SignInResponse, - MemoryStorage, - FlowStateTag, -) - -from tests._common.data import ( - TEST_FLOW_DATA, - TEST_AUTH_DATA, - TEST_STORAGE_DATA, - TEST_DEFAULTS, - TEST_ENV_DICT, - TEST_AGENTIC_ENV_DICT, - create_test_auth_handler, -) - -from tests._common.testing_objects import ( - TestingConnectionManager, - TestingTokenProvider, - agentic_mock_class_MsalAuth, - TestingConnectionManager as MockConnectionManager, -) - -from ._common import ( - testing_TurnContext_magic, -) - -DEFAULTS = TEST_DEFAULTS() -AGENTIC_ENV_DICT = TEST_AGENTIC_ENV_DICT() - - -class TestUtils: - def setup_method(self): - self.TurnContext = testing_TurnContext_magic - - @pytest.fixture - def storage(self): - return MemoryStorage() - - @pytest.fixture - def connection_manager(self, mocker): - return MockConnectionManager() - - @pytest.fixture - def agentic_auth(self, mocker, storage, connection_manager): - return AgenticAuthorization(storage, connection_manager, **AGENTIC_ENV_DICT) - - @pytest.fixture(params=[RoleTypes.user, RoleTypes.skill, RoleTypes.agent]) - def non_agentic_role(self, request): - return request.param - - @pytest.fixture(params=[RoleTypes.agentic_user, RoleTypes.agentic_identity]) - def agentic_role(self, request): - return request.param - - -class TestAgenticAuthorization(TestUtils): - @pytest.mark.parametrize( - "activity", - [ - Activity( - type="message", - recipient=ChannelAccount( - id="bot_id", - agentic_app_id=DEFAULTS.agentic_instance_id, - role=RoleTypes.agent, - ), - ), - Activity( - type="message", - recipient=ChannelAccount( - id=DEFAULTS.agentic_user_id, - agentic_app_id=DEFAULTS.agentic_instance_id, - role=RoleTypes.agentic_user, - ), - ), - Activity( - type="message", - recipient=ChannelAccount( - id=DEFAULTS.agentic_user_id, - ), - ), - Activity(type="message", recipient=ChannelAccount(id="some_id")), - ], - ) - def test_is_agentic_request(self, mocker, activity): - assert activity.is_agentic() == AgenticAuthorization.is_agentic_request( - activity - ) - context = self.TurnContext(mocker, activity=activity) - assert activity.is_agentic() == AgenticAuthorization.is_agentic_request(context) - - def test_get_agent_instance_id_is_agentic(self, mocker, agentic_role): - activity = Activity( - type="message", - recipient=ChannelAccount( - id="some_id", - agentic_app_id=DEFAULTS.agentic_instance_id, - role=agentic_role, - ), - ) - context = self.TurnContext(mocker, activity=activity) - assert ( - AgenticAuthorization.get_agent_instance_id(context) - == DEFAULTS.agentic_instance_id - ) - - def test_get_agent_instance_id_not_agentic(self, mocker, non_agentic_role): - activity = Activity( - type="message", - recipient=ChannelAccount( - id="some_id", - agentic_app_id=DEFAULTS.agentic_instance_id, - role=non_agentic_role, - ), - ) - context = self.TurnContext(mocker, activity=activity) - assert AgenticAuthorization.get_agent_instance_id(context) is None - - def test_get_agentic_user_is_agentic(self, mocker, agentic_role): - activity = Activity( - type="message", - recipient=ChannelAccount( - id=DEFAULTS.agentic_user_id, - agentic_app_id=DEFAULTS.agentic_instance_id, - role=agentic_role, - ), - ) - context = self.TurnContext(mocker, activity=activity) - assert ( - AgenticAuthorization.get_agentic_user(context) == DEFAULTS.agentic_user_id - ) - - def test_get_agentic_user_not_agentic(self, mocker, non_agentic_role): - activity = Activity( - type="message", - recipient=ChannelAccount( - id=DEFAULTS.agentic_user_id, - agentic_app_id=DEFAULTS.agentic_instance_id, - role=non_agentic_role, - ), - ) - context = self.TurnContext(mocker, activity=activity) - assert AgenticAuthorization.get_agentic_user(context) is None - - @pytest.mark.asyncio - async def test_get_agentic_instance_token_not_agentic( - self, mocker, non_agentic_role, agentic_auth - ): - activity = Activity( - type="message", - recipient=ChannelAccount( - id=DEFAULTS.agentic_user_id, - agentic_app_id=DEFAULTS.agentic_instance_id, - role=non_agentic_role, - ), - ) - context = self.TurnContext(mocker, activity=activity) - assert await agentic_auth.get_agentic_instance_token(context) is None - - @pytest.mark.asyncio - async def test_get_agentic_user_token_not_agentic( - self, mocker, non_agentic_role, agentic_auth - ): - activity = Activity( - type="message", - recipient=ChannelAccount( - id=DEFAULTS.agentic_user_id, - agentic_app_id=DEFAULTS.agentic_instance_id, - role=non_agentic_role, - ), - ) - context = self.TurnContext(mocker, activity=activity) - assert await agentic_auth.get_agentic_user_token(context, ["user.Read"]) is None - - @pytest.mark.asyncio - async def test_get_agentic_user_token_agentic_no_user_id( - self, mocker, agentic_role, agentic_auth - ): - activity = Activity( - type="message", - recipient=ChannelAccount( - agentic_app_id=DEFAULTS.agentic_instance_id, role=agentic_role - ), - ) - context = self.TurnContext(mocker, activity=activity) - assert await agentic_auth.get_agentic_user_token(context, ["user.Read"]) is None - - @pytest.mark.asyncio - async def test_get_agentic_instance_token_is_agentic( - self, mocker, agentic_role, agentic_auth - ): - mock_provider = mocker.Mock(spec=MsalAuth) - mock_provider.get_agentic_instance_token = mocker.AsyncMock( - return_value=[DEFAULTS.token, "bot_id"] - ) - - connection_manager = mocker.Mock(spec=MsalConnectionManager) - connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) - - agentic_auth = AgenticAuthorization( - MemoryStorage(), connection_manager, **AGENTIC_ENV_DICT - ) - - activity = Activity( - type="message", - recipient=ChannelAccount( - id="some_id", - agentic_app_id=DEFAULTS.agentic_instance_id, - role=agentic_role, - ), - ) - context = self.TurnContext(mocker, activity=activity) - - token = await agentic_auth.get_agentic_instance_token(context) - assert token == DEFAULTS.token - - @pytest.mark.asyncio - async def test_get_agentic_user_token_is_agentic( - self, mocker, agentic_role, agentic_auth - ): - mock_provider = mocker.Mock(spec=MsalAuth) - mock_provider.get_agentic_user_token = mocker.AsyncMock( - return_value=DEFAULTS.token - ) - - connection_manager = mocker.Mock(spec=MsalConnectionManager) - connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) - - agentic_auth = AgenticAuthorization( - MemoryStorage(), connection_manager, **AGENTIC_ENV_DICT - ) - - activity = Activity( - type="message", - recipient=ChannelAccount( - id="some_id", - agentic_app_id=DEFAULTS.agentic_instance_id, - role=agentic_role, - ), - ) - context = self.TurnContext(mocker, activity=activity) - - token = await agentic_auth.get_agentic_user_token(context, ["user.Read"]) - assert token == DEFAULTS.token - - @pytest.mark.asyncio - async def test_sign_in_success(self, mocker, agentic_auth): - mocker.patch.object( - AgenticAuthorization, "get_agentic_user_token", return_value=DEFAULTS.token - ) - res = await agentic_auth.sign_in(None, ["user.Read"]) - assert res.token_response.token == DEFAULTS.token - assert res.tag == FlowStateTag.COMPLETE - - @pytest.mark.asyncio - async def test_sign_in_failure(self, mocker, agentic_auth): - mocker.patch.object( - AgenticAuthorization, "get_agentic_user_token", return_value=None - ) - res = await agentic_auth.sign_in(None, ["user.Read"]) - assert not res.token_response - assert res.tag == FlowStateTag.FAILURE +# import pytest + +# from microsoft_agents.activity import Activity, ChannelAccount, RoleTypes + +# from microsoft_agents.authentication.msal import MsalAuth, MsalConnectionManager + +# from microsoft_agents.hosting.core import ( +# AgenticAuthorization, +# SignInResponse, +# MemoryStorage, +# FlowStateTag, +# ) + +# from tests._common.data import ( +# TEST_FLOW_DATA, +# TEST_AUTH_DATA, +# TEST_STORAGE_DATA, +# TEST_DEFAULTS, +# TEST_ENV_DICT, +# TEST_AGENTIC_ENV_DICT, +# create_test_auth_handler, +# ) + +# from tests._common.testing_objects import ( +# TestingConnectionManager, +# TestingTokenProvider, +# agentic_mock_class_MsalAuth, +# TestingConnectionManager as MockConnectionManager, +# ) + +# from ._common import ( +# testing_TurnContext_magic, +# ) + +# DEFAULTS = TEST_DEFAULTS() +# AGENTIC_ENV_DICT = TEST_AGENTIC_ENV_DICT() + + +# class TestUtils: +# def setup_method(self): +# self.TurnContext = testing_TurnContext_magic + +# @pytest.fixture +# def storage(self): +# return MemoryStorage() + +# @pytest.fixture +# def connection_manager(self, mocker): +# return MockConnectionManager() + +# @pytest.fixture +# def agentic_auth(self, mocker, storage, connection_manager): +# return AgenticAuthorization(storage, connection_manager, **AGENTIC_ENV_DICT) + +# @pytest.fixture(params=[RoleTypes.user, RoleTypes.skill, RoleTypes.agent]) +# def non_agentic_role(self, request): +# return request.param + +# @pytest.fixture(params=[RoleTypes.agentic_user, RoleTypes.agentic_identity]) +# def agentic_role(self, request): +# return request.param + + +# class TestAgenticAuthorization(TestUtils): +# @pytest.mark.parametrize( +# "activity", +# [ +# Activity( +# type="message", +# recipient=ChannelAccount( +# id="bot_id", +# agentic_app_id=DEFAULTS.agentic_instance_id, +# role=RoleTypes.agent, +# ), +# ), +# Activity( +# type="message", +# recipient=ChannelAccount( +# id=DEFAULTS.agentic_user_id, +# agentic_app_id=DEFAULTS.agentic_instance_id, +# role=RoleTypes.agentic_user, +# ), +# ), +# Activity( +# type="message", +# recipient=ChannelAccount( +# id=DEFAULTS.agentic_user_id, +# ), +# ), +# Activity(type="message", recipient=ChannelAccount(id="some_id")), +# ], +# ) +# def test_is_agentic_request(self, mocker, activity): +# assert activity.is_agentic() == AgenticAuthorization.is_agentic_request( +# activity +# ) +# context = self.TurnContext(mocker, activity=activity) +# assert activity.is_agentic() == AgenticAuthorization.is_agentic_request(context) + +# def test_get_agent_instance_id_is_agentic(self, mocker, agentic_role): +# activity = Activity( +# type="message", +# recipient=ChannelAccount( +# id="some_id", +# agentic_app_id=DEFAULTS.agentic_instance_id, +# role=agentic_role, +# ), +# ) +# context = self.TurnContext(mocker, activity=activity) +# assert ( +# AgenticAuthorization.get_agent_instance_id(context) +# == DEFAULTS.agentic_instance_id +# ) + +# def test_get_agent_instance_id_not_agentic(self, mocker, non_agentic_role): +# activity = Activity( +# type="message", +# recipient=ChannelAccount( +# id="some_id", +# agentic_app_id=DEFAULTS.agentic_instance_id, +# role=non_agentic_role, +# ), +# ) +# context = self.TurnContext(mocker, activity=activity) +# assert AgenticAuthorization.get_agent_instance_id(context) is None + +# def test_get_agentic_user_is_agentic(self, mocker, agentic_role): +# activity = Activity( +# type="message", +# recipient=ChannelAccount( +# id=DEFAULTS.agentic_user_id, +# agentic_app_id=DEFAULTS.agentic_instance_id, +# role=agentic_role, +# ), +# ) +# context = self.TurnContext(mocker, activity=activity) +# assert ( +# AgenticAuthorization.get_agentic_user(context) == DEFAULTS.agentic_user_id +# ) + +# def test_get_agentic_user_not_agentic(self, mocker, non_agentic_role): +# activity = Activity( +# type="message", +# recipient=ChannelAccount( +# id=DEFAULTS.agentic_user_id, +# agentic_app_id=DEFAULTS.agentic_instance_id, +# role=non_agentic_role, +# ), +# ) +# context = self.TurnContext(mocker, activity=activity) +# assert AgenticAuthorization.get_agentic_user(context) is None + +# @pytest.mark.asyncio +# async def test_get_agentic_instance_token_not_agentic( +# self, mocker, non_agentic_role, agentic_auth +# ): +# activity = Activity( +# type="message", +# recipient=ChannelAccount( +# id=DEFAULTS.agentic_user_id, +# agentic_app_id=DEFAULTS.agentic_instance_id, +# role=non_agentic_role, +# ), +# ) +# context = self.TurnContext(mocker, activity=activity) +# assert await agentic_auth.get_agentic_instance_token(context) is None + +# @pytest.mark.asyncio +# async def test_get_agentic_user_token_not_agentic( +# self, mocker, non_agentic_role, agentic_auth +# ): +# activity = Activity( +# type="message", +# recipient=ChannelAccount( +# id=DEFAULTS.agentic_user_id, +# agentic_app_id=DEFAULTS.agentic_instance_id, +# role=non_agentic_role, +# ), +# ) +# context = self.TurnContext(mocker, activity=activity) +# assert await agentic_auth.get_agentic_user_token(context, ["user.Read"]) is None + +# @pytest.mark.asyncio +# async def test_get_agentic_user_token_agentic_no_user_id( +# self, mocker, agentic_role, agentic_auth +# ): +# activity = Activity( +# type="message", +# recipient=ChannelAccount( +# agentic_app_id=DEFAULTS.agentic_instance_id, role=agentic_role +# ), +# ) +# context = self.TurnContext(mocker, activity=activity) +# assert await agentic_auth.get_agentic_user_token(context, ["user.Read"]) is None + +# @pytest.mark.asyncio +# async def test_get_agentic_instance_token_is_agentic( +# self, mocker, agentic_role, agentic_auth +# ): +# mock_provider = mocker.Mock(spec=MsalAuth) +# mock_provider.get_agentic_instance_token = mocker.AsyncMock( +# return_value=[DEFAULTS.token, "bot_id"] +# ) + +# connection_manager = mocker.Mock(spec=MsalConnectionManager) +# connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) + +# agentic_auth = AgenticAuthorization( +# MemoryStorage(), connection_manager, **AGENTIC_ENV_DICT +# ) + +# activity = Activity( +# type="message", +# recipient=ChannelAccount( +# id="some_id", +# agentic_app_id=DEFAULTS.agentic_instance_id, +# role=agentic_role, +# ), +# ) +# context = self.TurnContext(mocker, activity=activity) + +# token = await agentic_auth.get_agentic_instance_token(context) +# assert token == DEFAULTS.token + +# @pytest.mark.asyncio +# async def test_get_agentic_user_token_is_agentic( +# self, mocker, agentic_role, agentic_auth +# ): +# mock_provider = mocker.Mock(spec=MsalAuth) +# mock_provider.get_agentic_user_token = mocker.AsyncMock( +# return_value=DEFAULTS.token +# ) + +# connection_manager = mocker.Mock(spec=MsalConnectionManager) +# connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) + +# agentic_auth = AgenticAuthorization( +# MemoryStorage(), connection_manager, **AGENTIC_ENV_DICT +# ) + +# activity = Activity( +# type="message", +# recipient=ChannelAccount( +# id="some_id", +# agentic_app_id=DEFAULTS.agentic_instance_id, +# role=agentic_role, +# ), +# ) +# context = self.TurnContext(mocker, activity=activity) + +# token = await agentic_auth.get_agentic_user_token(context, ["user.Read"]) +# assert token == DEFAULTS.token + +# @pytest.mark.asyncio +# async def test_sign_in_success(self, mocker, agentic_auth): +# mocker.patch.object( +# AgenticAuthorization, "get_agentic_user_token", return_value=DEFAULTS.token +# ) +# res = await agentic_auth.sign_in(None, ["user.Read"]) +# assert res.token_response.token == DEFAULTS.token +# assert res.tag == FlowStateTag.COMPLETE + +# @pytest.mark.asyncio +# async def test_sign_in_failure(self, mocker, agentic_auth): +# mocker.patch.object( +# AgenticAuthorization, "get_agentic_user_token", return_value=None +# ) +# res = await agentic_auth.sign_in(None, ["user.Read"]) +# assert not res.token_response +# assert res.tag == FlowStateTag.FAILURE diff --git a/tests/hosting_core/app/auth/test_authorization.py b/tests/hosting_core/app/auth/test_authorization.py index 0433aaa6..3effe819 100644 --- a/tests/hosting_core/app/auth/test_authorization.py +++ b/tests/hosting_core/app/auth/test_authorization.py @@ -1,584 +1,584 @@ -import pytest -from datetime import datetime -import jwt - -from typing import Optional - -from microsoft_agents.activity import Activity, ActivityTypes, TokenResponse - -from microsoft_agents.hosting.core import ( - FlowStorageClient, - FlowErrorTag, - FlowStateTag, - FlowState, - FlowResponse, - OAuthFlow, - Authorization, - UserAuthorization, - Storage, - TurnContext, - MemoryStorage, - AuthHandler, - FlowStateTag, - SignInState, - SignInResponse, -) - -from tests._common.storage.utils import StorageBaseline - -# test constants -from tests._common.data import ( - TEST_FLOW_DATA, - TEST_AUTH_DATA, - TEST_STORAGE_DATA, - TEST_DEFAULTS, - TEST_ENV_DICT, - TEST_AGENTIC_ENV_DICT, - create_test_auth_handler, -) -from tests._common.fixtures import FlowStateFixtures -from tests._common.testing_objects import ( - TestingConnectionManager as MockConnectionManager, - mock_class_OAuthFlow, - mock_UserTokenClient, - mock_class_UserAuthorization, - mock_class_AgenticAuthorization, - mock_class_Authorization, -) -from tests.hosting_core._common import flow_state_eq - -from ._common import testing_TurnContext, testing_Activity - -DEFAULTS = TEST_DEFAULTS() -FLOW_DATA = TEST_FLOW_DATA() -STORAGE_DATA = TEST_STORAGE_DATA() -ENV_DICT = TEST_ENV_DICT() -AGENTIC_ENV_DICT = TEST_AGENTIC_ENV_DICT() - - -async def get_sign_in_state( - auth: Authorization, storage: Storage, context: TurnContext -) -> Optional[SignInState]: - key = auth.sign_in_state_key(context) - return (await storage.read([key], target_cls=SignInState)).get(key) - - -async def set_sign_in_state( - auth: Authorization, storage: Storage, context: TurnContext, state: SignInState -): - key = auth.sign_in_state_key(context) - await storage.write({key: state}) - - -def mock_variants(mocker, sign_in_return=None): - mock_class_UserAuthorization(mocker, sign_in_return=sign_in_return) - mock_class_AgenticAuthorization(mocker, sign_in_return=sign_in_return) - - -def sign_in_state_eq(a: Optional[SignInState], b: Optional[SignInState]) -> bool: - if a is None and b is None: - return True - if a is None or b is None: - return False - return a.tokens == b.tokens and a.continuation_activity == b.continuation_activity - - -def copy_sign_in_state(state: SignInState) -> SignInState: - return SignInState( - tokens=state.tokens.copy(), - continuation_activity=( - state.continuation_activity.model_copy() - if state.continuation_activity - else None - ), - ) - - -class TestEnv(FlowStateFixtures): - def setup_method(self): - self.TurnContext = testing_TurnContext - self.UserTokenClient = mock_UserTokenClient - self.ConnectionManager = lambda mocker: MockConnectionManager() - - @pytest.fixture - def context(self, mocker): - return self.TurnContext(mocker) - - @pytest.fixture - def activity(self): - return testing_Activity() - - @pytest.fixture - def baseline_storage(self): - return StorageBaseline(TEST_STORAGE_DATA().dict) - - @pytest.fixture - def storage(self): - return MemoryStorage(STORAGE_DATA.get_init_data()) - - @pytest.fixture - def connection_manager(self, mocker): - return self.ConnectionManager(mocker) - - @pytest.fixture - def auth_handlers(self): - return TEST_AUTH_DATA().auth_handlers - - @pytest.fixture - def authorization(self, connection_manager, storage): - return Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) - - @pytest.fixture(params=[ENV_DICT, AGENTIC_ENV_DICT]) - def env_dict(self, request): - return request.param - - @pytest.fixture(params=[DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id]) - def auth_handler_id(self, request): - return request.param - - -class TestAuthorizationSetup(TestEnv): - def test_init_user_auth(self, connection_manager, storage, env_dict): - auth = Authorization(storage, connection_manager, **env_dict) - assert auth.user_auth is not None - - def test_init_agentic_auth_not_configured(self, connection_manager, storage): - auth = Authorization(storage, connection_manager, **ENV_DICT) - with pytest.raises(ValueError): - agentic_auth = auth.agentic_auth - - def test_init_agentic_auth(self, connection_manager, storage): - auth = Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) - assert auth.agentic_auth is not None - - @pytest.mark.parametrize( - "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] - ) - def test_resolve_handler(self, connection_manager, storage, auth_handler_id): - auth = Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) - handler_config = AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"][ - "HANDLERS" - ][auth_handler_id] - auth.resolve_handler(auth_handler_id) == AuthHandler( - auth_handler_id, **handler_config - ) - - def test_sign_in_state_key(self, mocker, connection_manager, storage): - auth = Authorization(storage, connection_manager, **ENV_DICT) - context = self.TurnContext(mocker) - key = auth.sign_in_state_key(context) - assert key == f"auth:SignInState:{DEFAULTS.channel_id}:{DEFAULTS.user_id}" - - -class TestAuthorizationUsage(TestEnv): - @pytest.mark.asyncio - async def test_get_token(self, mocker, storage, authorization): - context = self.TurnContext(mocker) - token_response = await authorization.get_token( - context, DEFAULTS.auth_handler_id - ) - assert not token_response - - @pytest.mark.asyncio - async def test_get_token_with_sign_in_state_empty( - self, mocker, storage, authorization, context - ): - # setup - key = authorization.sign_in_state_key(context) - await storage.write( - { - key: SignInState( - tokens={ - DEFAULTS.auth_handler_id: "", - DEFAULTS.agentic_auth_handler_id: "", - } - ) - } - ) - - # test - token_response = await authorization.get_token( - context, DEFAULTS.auth_handler_id - ) - assert not token_response - - @pytest.mark.asyncio - async def test_get_token_with_sign_in_state_empty_alt( - self, mocker, storage, authorization, context - ): - # setup - key = authorization.sign_in_state_key(context) - await storage.write( - { - key: SignInState( - tokens={ - DEFAULTS.auth_handler_id: "token", - DEFAULTS.agentic_auth_handler_id: "", - } - ) - } - ) - - # test - token_response = await authorization.get_token( - context, DEFAULTS.agentic_auth_handler_id - ) - assert not token_response - - @pytest.mark.asyncio - async def test_get_token_with_sign_in_state_valid( - self, mocker, storage, authorization - ): - # setup - context = self.TurnContext(mocker) - key = authorization.sign_in_state_key(context) - await storage.write( - {key: SignInState(tokens={DEFAULTS.auth_handler_id: "valid_token"})} - ) - - # test - token_response = await authorization.get_token( - context, DEFAULTS.auth_handler_id - ) - assert token_response.token == "valid_token" - - @pytest.mark.asyncio - async def test_start_or_continue_sign_in_cached( - self, storage, authorization, context, activity - ): - # setup - initial_state = SignInState( - tokens={DEFAULTS.auth_handler_id: "valid_token"}, - continuation_activity=activity, - ) - await set_sign_in_state(authorization, storage, context, initial_state) - sign_in_response = await authorization.start_or_continue_sign_in( - context, None, DEFAULTS.auth_handler_id - ) - assert sign_in_response.tag == FlowStateTag.COMPLETE - assert sign_in_response.token_response.token == "valid_token" - - assert sign_in_state_eq( - await get_sign_in_state(authorization, storage, context), initial_state - ) - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] - ) - async def test_start_or_continue_sign_in_no_initial_state_to_complete( - self, mocker, storage, authorization, context, auth_handler_id - ): - mock_variants( - mocker, - sign_in_return=SignInResponse( - token_response=TokenResponse(token=DEFAULTS.token), - tag=FlowStateTag.COMPLETE, - ), - ) - sign_in_response = await authorization.start_or_continue_sign_in( - context, None, auth_handler_id - ) - assert sign_in_response.tag == FlowStateTag.COMPLETE - assert sign_in_response.token_response.token == DEFAULTS.token - - final_state = await get_sign_in_state(authorization, storage, context) - assert final_state.tokens[auth_handler_id] == DEFAULTS.token - assert final_state.continuation_activity is None - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] - ) - async def test_start_or_continue_sign_in_to_complete_with_prev_state( - self, mocker, storage, authorization, context, auth_handler_id - ): - # setup - initial_state = SignInState( - tokens={"my_handler": "old_token"}, - continuation_activity=Activity( - type=ActivityTypes.message, text="old activity" - ), - ) - await set_sign_in_state(authorization, storage, context, initial_state) - mock_variants( - mocker, - sign_in_return=SignInResponse( - token_response=TokenResponse(token=DEFAULTS.token), - tag=FlowStateTag.COMPLETE, - ), - ) - - # test - sign_in_response = await authorization.start_or_continue_sign_in( - context, None, auth_handler_id - ) - assert sign_in_response.tag == FlowStateTag.COMPLETE - assert sign_in_response.token_response.token == DEFAULTS.token - - # verify - final_state = await get_sign_in_state(authorization, storage, context) - assert final_state.tokens[auth_handler_id] == DEFAULTS.token - assert final_state.tokens["my_handler"] == "old_token" - assert final_state.continuation_activity == initial_state.continuation_activity - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] - ) - async def test_start_or_continue_sign_in_to_failure_with_prev_state( - self, mocker, storage, authorization, context, auth_handler_id - ): - # setup - initial_state = SignInState( - tokens={"my_handler": "old_token"}, - continuation_activity=Activity( - type=ActivityTypes.message, text="old activity" - ), - ) - await set_sign_in_state(authorization, storage, context, initial_state) - mock_variants( - mocker, - sign_in_return=SignInResponse( - token_response=TokenResponse(), tag=FlowStateTag.FAILURE - ), - ) - - # test - sign_in_response = await authorization.start_or_continue_sign_in( - context, None, auth_handler_id - ) - assert sign_in_response.tag == FlowStateTag.FAILURE - assert not sign_in_response.token_response - - # verify - final_state = await get_sign_in_state(authorization, storage, context) - assert not final_state.tokens.get(auth_handler_id) - assert final_state.tokens["my_handler"] == "old_token" - assert final_state.continuation_activity == initial_state.continuation_activity - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "auth_handler_id, tag", - [ - (DEFAULTS.auth_handler_id, FlowStateTag.BEGIN), - (DEFAULTS.agentic_auth_handler_id, FlowStateTag.BEGIN), - (DEFAULTS.auth_handler_id, FlowStateTag.CONTINUE), - (DEFAULTS.agentic_auth_handler_id, FlowStateTag.CONTINUE), - ], - ) - async def test_start_or_continue_sign_in_to_pending_with_prev_state( - self, mocker, storage, authorization, context, auth_handler_id, tag - ): - # setup - initial_state = SignInState( - tokens={"my_handler": "old_token"}, - continuation_activity=Activity( - type=ActivityTypes.message, text="old activity" - ), - ) - await set_sign_in_state(authorization, storage, context, initial_state) - mock_variants( - mocker, - sign_in_return=SignInResponse(token_response=TokenResponse(), tag=tag), - ) - - # test - sign_in_response = await authorization.start_or_continue_sign_in( - context, None, auth_handler_id - ) - assert sign_in_response.tag == tag - assert not sign_in_response.token_response - - # verify - final_state = await get_sign_in_state(authorization, storage, context) - assert not final_state.tokens.get(auth_handler_id) - assert final_state.tokens["my_handler"] == "old_token" - assert final_state.continuation_activity == context.activity - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] - ) - async def test_sign_out_not_signed_in_single_handler( - self, mocker, storage, authorization, context, activity, auth_handler_id - ): - mock_variants(mocker) - initial_state = SignInState( - tokens={DEFAULTS.auth_handler_id: "", "my_handler": "old_token"}, - continuation_activity=activity, - ) - await set_sign_in_state( - authorization, storage, context, copy_sign_in_state(initial_state) - ) - await authorization.sign_out(context, None, auth_handler_id) - final_state = await get_sign_in_state(authorization, storage, context) - if auth_handler_id in initial_state.tokens: - del initial_state.tokens[auth_handler_id] - assert sign_in_state_eq(final_state, initial_state) - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] - ) - async def test_sign_out_signed_in_in_single_handler( - self, mocker, storage, authorization, context, activity, auth_handler_id - ): - mock_variants(mocker) - initial_state = SignInState( - tokens={ - DEFAULTS.auth_handler_id: "token", - DEFAULTS.agentic_auth_handler_id: "another_token", - "my_handler": "old_token", - }, - continuation_activity=activity, - ) - await set_sign_in_state( - authorization, storage, context, copy_sign_in_state(initial_state) - ) - await authorization.sign_out(context, None, auth_handler_id) - final_state = await get_sign_in_state(authorization, storage, context) - del initial_state.tokens[auth_handler_id] - assert sign_in_state_eq(final_state, initial_state) - - @pytest.mark.asyncio - async def test_sign_out_not_signed_in_all_handlers( - self, mocker, storage, authorization, context, activity - ): - mock_variants(mocker) - initial_state = SignInState( - tokens={DEFAULTS.auth_handler_id: ""}, continuation_activity=activity - ) - await set_sign_in_state(authorization, storage, context, initial_state) - await authorization.sign_out(context, None) - final_state = await get_sign_in_state(authorization, storage, context) - assert final_state is None - - @pytest.mark.asyncio - async def test_sign_out_signed_in_in_all_handlers( - self, mocker, storage, authorization, context, activity - ): - mock_variants(mocker) - initial_state = SignInState( - tokens={ - DEFAULTS.auth_handler_id: "token", - DEFAULTS.agentic_auth_handler_id: "another_token", - }, - continuation_activity=activity, - ) - await set_sign_in_state(authorization, storage, context, initial_state) - await authorization.sign_out(context, None) - final_state = await get_sign_in_state(authorization, storage, context) - assert final_state is None - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "sign_in_state", - [ - SignInState(), - SignInState( - tokens={DEFAULTS.auth_handler_id: "token"}, - continuation_activity=Activity( - type=ActivityTypes.message, text="activity" - ), - ), - SignInState( - tokens={ - DEFAULTS.auth_handler_id: "token", - DEFAULTS.agentic_auth_handler_id: "another_token", - }, - continuation_activity=Activity( - type=ActivityTypes.message, text="activity" - ), - ), - SignInState( - tokens={DEFAULTS.auth_handler_id: "token", "my_handler": "old_token"}, - continuation_activity=Activity( - type=ActivityTypes.message, text="activity" - ), - ), - ], - ) - async def test_on_turn_auth_intercept_no_intercept( - self, storage, authorization, context, sign_in_state - ): - await set_sign_in_state( - authorization, storage, context, copy_sign_in_state(sign_in_state) - ) - - intercepts, continuation_activity = await authorization.on_turn_auth_intercept( - context, None - ) - - assert not continuation_activity - assert not intercepts - - final_state = await get_sign_in_state(authorization, storage, context) - - assert sign_in_state_eq(final_state, sign_in_state) - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "sign_in_response", - [ - SignInResponse(tag=FlowStateTag.BEGIN), - SignInResponse(tag=FlowStateTag.CONTINUE), - SignInResponse(tag=FlowStateTag.FAILURE), - ], - ) - async def test_on_turn_auth_intercept_with_intercept_incomplete( - self, mocker, storage, authorization, context, sign_in_response, auth_handler_id - ): - mock_class_Authorization( - mocker, start_or_continue_sign_in_return=sign_in_response - ) - - initial_state = SignInState( - tokens={"some_handler": "old_token", auth_handler_id: ""}, - continuation_activity=Activity( - type=ActivityTypes.message, text="old activity" - ), - ) - await set_sign_in_state( - authorization, storage, context, copy_sign_in_state(initial_state) - ) - - intercepts, continuation_activity = await authorization.on_turn_auth_intercept( - context, auth_handler_id - ) - - assert not continuation_activity - assert intercepts - - final_state = await get_sign_in_state(authorization, storage, context) - assert sign_in_state_eq(final_state, initial_state) - - @pytest.mark.asyncio - async def test_on_turn_auth_intercept_with_intercept_complete( - self, mocker, storage, authorization, context, auth_handler_id - ): - mock_class_Authorization( - mocker, - start_or_continue_sign_in_return=SignInResponse(tag=FlowStateTag.COMPLETE), - ) - - old_activity = Activity(type=ActivityTypes.message, text="old activity") - initial_state = SignInState( - tokens={"some_handler": "old_token", auth_handler_id: ""}, - continuation_activity=old_activity, - ) - await set_sign_in_state( - authorization, storage, context, copy_sign_in_state(initial_state) - ) - - intercepts, continuation_activity = await authorization.on_turn_auth_intercept( - context, auth_handler_id - ) - - assert continuation_activity == old_activity - assert intercepts - - # start_or_continue_sign_in is the only method that modifies the state, - # so since it is mocked, the state should not be changed - final_state = await get_sign_in_state(authorization, storage, context) - assert sign_in_state_eq(final_state, initial_state) +# import pytest +# from datetime import datetime +# import jwt + +# from typing import Optional + +# from microsoft_agents.activity import Activity, ActivityTypes, TokenResponse + +# from microsoft_agents.hosting.core import ( +# FlowStorageClient, +# FlowErrorTag, +# FlowStateTag, +# FlowState, +# FlowResponse, +# OAuthFlow, +# Authorization, +# UserAuthorization, +# Storage, +# TurnContext, +# MemoryStorage, +# AuthHandler, +# FlowStateTag, +# SignInState, +# SignInResponse, +# ) + +# from tests._common.storage.utils import StorageBaseline + +# # test constants +# from tests._common.data import ( +# TEST_FLOW_DATA, +# TEST_AUTH_DATA, +# TEST_STORAGE_DATA, +# TEST_DEFAULTS, +# TEST_ENV_DICT, +# TEST_AGENTIC_ENV_DICT, +# create_test_auth_handler, +# ) +# from tests._common.fixtures import FlowStateFixtures +# from tests._common.testing_objects import ( +# TestingConnectionManager as MockConnectionManager, +# mock_class_OAuthFlow, +# mock_UserTokenClient, +# mock_class_UserAuthorization, +# mock_class_AgenticAuthorization, +# mock_class_Authorization, +# ) +# from tests.hosting_core._common import flow_state_eq + +# from ._common import testing_TurnContext, testing_Activity + +# DEFAULTS = TEST_DEFAULTS() +# FLOW_DATA = TEST_FLOW_DATA() +# STORAGE_DATA = TEST_STORAGE_DATA() +# ENV_DICT = TEST_ENV_DICT() +# AGENTIC_ENV_DICT = TEST_AGENTIC_ENV_DICT() + + +# async def get_sign_in_state( +# auth: Authorization, storage: Storage, context: TurnContext +# ) -> Optional[SignInState]: +# key = auth.sign_in_state_key(context) +# return (await storage.read([key], target_cls=SignInState)).get(key) + + +# async def set_sign_in_state( +# auth: Authorization, storage: Storage, context: TurnContext, state: SignInState +# ): +# key = auth.sign_in_state_key(context) +# await storage.write({key: state}) + + +# def mock_variants(mocker, sign_in_return=None): +# mock_class_UserAuthorization(mocker, sign_in_return=sign_in_return) +# mock_class_AgenticAuthorization(mocker, sign_in_return=sign_in_return) + + +# def sign_in_state_eq(a: Optional[SignInState], b: Optional[SignInState]) -> bool: +# if a is None and b is None: +# return True +# if a is None or b is None: +# return False +# return a.tokens == b.tokens and a.continuation_activity == b.continuation_activity + + +# def copy_sign_in_state(state: SignInState) -> SignInState: +# return SignInState( +# tokens=state.tokens.copy(), +# continuation_activity=( +# state.continuation_activity.model_copy() +# if state.continuation_activity +# else None +# ), +# ) + + +# class TestEnv(FlowStateFixtures): +# def setup_method(self): +# self.TurnContext = testing_TurnContext +# self.UserTokenClient = mock_UserTokenClient +# self.ConnectionManager = lambda mocker: MockConnectionManager() + +# @pytest.fixture +# def context(self, mocker): +# return self.TurnContext(mocker) + +# @pytest.fixture +# def activity(self): +# return testing_Activity() + +# @pytest.fixture +# def baseline_storage(self): +# return StorageBaseline(TEST_STORAGE_DATA().dict) + +# @pytest.fixture +# def storage(self): +# return MemoryStorage(STORAGE_DATA.get_init_data()) + +# @pytest.fixture +# def connection_manager(self, mocker): +# return self.ConnectionManager(mocker) + +# @pytest.fixture +# def auth_handlers(self): +# return TEST_AUTH_DATA().auth_handlers + +# @pytest.fixture +# def authorization(self, connection_manager, storage): +# return Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) + +# @pytest.fixture(params=[ENV_DICT, AGENTIC_ENV_DICT]) +# def env_dict(self, request): +# return request.param + +# @pytest.fixture(params=[DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id]) +# def auth_handler_id(self, request): +# return request.param + + +# class TestAuthorizationSetup(TestEnv): +# def test_init_user_auth(self, connection_manager, storage, env_dict): +# auth = Authorization(storage, connection_manager, **env_dict) +# assert auth.user_auth is not None + +# def test_init_agentic_auth_not_configured(self, connection_manager, storage): +# auth = Authorization(storage, connection_manager, **ENV_DICT) +# with pytest.raises(ValueError): +# agentic_auth = auth.agentic_auth + +# def test_init_agentic_auth(self, connection_manager, storage): +# auth = Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) +# assert auth.agentic_auth is not None + +# @pytest.mark.parametrize( +# "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] +# ) +# def test_resolve_handler(self, connection_manager, storage, auth_handler_id): +# auth = Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) +# handler_config = AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"][ +# "HANDLERS" +# ][auth_handler_id] +# auth.resolve_handler(auth_handler_id) == AuthHandler( +# auth_handler_id, **handler_config +# ) + +# def test_sign_in_state_key(self, mocker, connection_manager, storage): +# auth = Authorization(storage, connection_manager, **ENV_DICT) +# context = self.TurnContext(mocker) +# key = auth.sign_in_state_key(context) +# assert key == f"auth:SignInState:{DEFAULTS.channel_id}:{DEFAULTS.user_id}" + + +# class TestAuthorizationUsage(TestEnv): +# @pytest.mark.asyncio +# async def test_get_token(self, mocker, storage, authorization): +# context = self.TurnContext(mocker) +# token_response = await authorization.get_token( +# context, DEFAULTS.auth_handler_id +# ) +# assert not token_response + +# @pytest.mark.asyncio +# async def test_get_token_with_sign_in_state_empty( +# self, mocker, storage, authorization, context +# ): +# # setup +# key = authorization.sign_in_state_key(context) +# await storage.write( +# { +# key: SignInState( +# tokens={ +# DEFAULTS.auth_handler_id: "", +# DEFAULTS.agentic_auth_handler_id: "", +# } +# ) +# } +# ) + +# # test +# token_response = await authorization.get_token( +# context, DEFAULTS.auth_handler_id +# ) +# assert not token_response + +# @pytest.mark.asyncio +# async def test_get_token_with_sign_in_state_empty_alt( +# self, mocker, storage, authorization, context +# ): +# # setup +# key = authorization.sign_in_state_key(context) +# await storage.write( +# { +# key: SignInState( +# tokens={ +# DEFAULTS.auth_handler_id: "token", +# DEFAULTS.agentic_auth_handler_id: "", +# } +# ) +# } +# ) + +# # test +# token_response = await authorization.get_token( +# context, DEFAULTS.agentic_auth_handler_id +# ) +# assert not token_response + +# @pytest.mark.asyncio +# async def test_get_token_with_sign_in_state_valid( +# self, mocker, storage, authorization +# ): +# # setup +# context = self.TurnContext(mocker) +# key = authorization.sign_in_state_key(context) +# await storage.write( +# {key: SignInState(tokens={DEFAULTS.auth_handler_id: "valid_token"})} +# ) + +# # test +# token_response = await authorization.get_token( +# context, DEFAULTS.auth_handler_id +# ) +# assert token_response.token == "valid_token" + +# @pytest.mark.asyncio +# async def test_start_or_continue_sign_in_cached( +# self, storage, authorization, context, activity +# ): +# # setup +# initial_state = SignInState( +# tokens={DEFAULTS.auth_handler_id: "valid_token"}, +# continuation_activity=activity, +# ) +# await set_sign_in_state(authorization, storage, context, initial_state) +# sign_in_response = await authorization.start_or_continue_sign_in( +# context, None, DEFAULTS.auth_handler_id +# ) +# assert sign_in_response.tag == FlowStateTag.COMPLETE +# assert sign_in_response.token_response.token == "valid_token" + +# assert sign_in_state_eq( +# await get_sign_in_state(authorization, storage, context), initial_state +# ) + +# @pytest.mark.asyncio +# @pytest.mark.parametrize( +# "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] +# ) +# async def test_start_or_continue_sign_in_no_initial_state_to_complete( +# self, mocker, storage, authorization, context, auth_handler_id +# ): +# mock_variants( +# mocker, +# sign_in_return=SignInResponse( +# token_response=TokenResponse(token=DEFAULTS.token), +# tag=FlowStateTag.COMPLETE, +# ), +# ) +# sign_in_response = await authorization.start_or_continue_sign_in( +# context, None, auth_handler_id +# ) +# assert sign_in_response.tag == FlowStateTag.COMPLETE +# assert sign_in_response.token_response.token == DEFAULTS.token + +# final_state = await get_sign_in_state(authorization, storage, context) +# assert final_state.tokens[auth_handler_id] == DEFAULTS.token +# assert final_state.continuation_activity is None + +# @pytest.mark.asyncio +# @pytest.mark.parametrize( +# "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] +# ) +# async def test_start_or_continue_sign_in_to_complete_with_prev_state( +# self, mocker, storage, authorization, context, auth_handler_id +# ): +# # setup +# initial_state = SignInState( +# tokens={"my_handler": "old_token"}, +# continuation_activity=Activity( +# type=ActivityTypes.message, text="old activity" +# ), +# ) +# await set_sign_in_state(authorization, storage, context, initial_state) +# mock_variants( +# mocker, +# sign_in_return=SignInResponse( +# token_response=TokenResponse(token=DEFAULTS.token), +# tag=FlowStateTag.COMPLETE, +# ), +# ) + +# # test +# sign_in_response = await authorization.start_or_continue_sign_in( +# context, None, auth_handler_id +# ) +# assert sign_in_response.tag == FlowStateTag.COMPLETE +# assert sign_in_response.token_response.token == DEFAULTS.token + +# # verify +# final_state = await get_sign_in_state(authorization, storage, context) +# assert final_state.tokens[auth_handler_id] == DEFAULTS.token +# assert final_state.tokens["my_handler"] == "old_token" +# assert final_state.continuation_activity == initial_state.continuation_activity + +# @pytest.mark.asyncio +# @pytest.mark.parametrize( +# "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] +# ) +# async def test_start_or_continue_sign_in_to_failure_with_prev_state( +# self, mocker, storage, authorization, context, auth_handler_id +# ): +# # setup +# initial_state = SignInState( +# tokens={"my_handler": "old_token"}, +# continuation_activity=Activity( +# type=ActivityTypes.message, text="old activity" +# ), +# ) +# await set_sign_in_state(authorization, storage, context, initial_state) +# mock_variants( +# mocker, +# sign_in_return=SignInResponse( +# token_response=TokenResponse(), tag=FlowStateTag.FAILURE +# ), +# ) + +# # test +# sign_in_response = await authorization.start_or_continue_sign_in( +# context, None, auth_handler_id +# ) +# assert sign_in_response.tag == FlowStateTag.FAILURE +# assert not sign_in_response.token_response + +# # verify +# final_state = await get_sign_in_state(authorization, storage, context) +# assert not final_state.tokens.get(auth_handler_id) +# assert final_state.tokens["my_handler"] == "old_token" +# assert final_state.continuation_activity == initial_state.continuation_activity + +# @pytest.mark.asyncio +# @pytest.mark.parametrize( +# "auth_handler_id, tag", +# [ +# (DEFAULTS.auth_handler_id, FlowStateTag.BEGIN), +# (DEFAULTS.agentic_auth_handler_id, FlowStateTag.BEGIN), +# (DEFAULTS.auth_handler_id, FlowStateTag.CONTINUE), +# (DEFAULTS.agentic_auth_handler_id, FlowStateTag.CONTINUE), +# ], +# ) +# async def test_start_or_continue_sign_in_to_pending_with_prev_state( +# self, mocker, storage, authorization, context, auth_handler_id, tag +# ): +# # setup +# initial_state = SignInState( +# tokens={"my_handler": "old_token"}, +# continuation_activity=Activity( +# type=ActivityTypes.message, text="old activity" +# ), +# ) +# await set_sign_in_state(authorization, storage, context, initial_state) +# mock_variants( +# mocker, +# sign_in_return=SignInResponse(token_response=TokenResponse(), tag=tag), +# ) + +# # test +# sign_in_response = await authorization.start_or_continue_sign_in( +# context, None, auth_handler_id +# ) +# assert sign_in_response.tag == tag +# assert not sign_in_response.token_response + +# # verify +# final_state = await get_sign_in_state(authorization, storage, context) +# assert not final_state.tokens.get(auth_handler_id) +# assert final_state.tokens["my_handler"] == "old_token" +# assert final_state.continuation_activity == context.activity + +# @pytest.mark.asyncio +# @pytest.mark.parametrize( +# "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] +# ) +# async def test_sign_out_not_signed_in_single_handler( +# self, mocker, storage, authorization, context, activity, auth_handler_id +# ): +# mock_variants(mocker) +# initial_state = SignInState( +# tokens={DEFAULTS.auth_handler_id: "", "my_handler": "old_token"}, +# continuation_activity=activity, +# ) +# await set_sign_in_state( +# authorization, storage, context, copy_sign_in_state(initial_state) +# ) +# await authorization.sign_out(context, None, auth_handler_id) +# final_state = await get_sign_in_state(authorization, storage, context) +# if auth_handler_id in initial_state.tokens: +# del initial_state.tokens[auth_handler_id] +# assert sign_in_state_eq(final_state, initial_state) + +# @pytest.mark.asyncio +# @pytest.mark.parametrize( +# "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] +# ) +# async def test_sign_out_signed_in_in_single_handler( +# self, mocker, storage, authorization, context, activity, auth_handler_id +# ): +# mock_variants(mocker) +# initial_state = SignInState( +# tokens={ +# DEFAULTS.auth_handler_id: "token", +# DEFAULTS.agentic_auth_handler_id: "another_token", +# "my_handler": "old_token", +# }, +# continuation_activity=activity, +# ) +# await set_sign_in_state( +# authorization, storage, context, copy_sign_in_state(initial_state) +# ) +# await authorization.sign_out(context, None, auth_handler_id) +# final_state = await get_sign_in_state(authorization, storage, context) +# del initial_state.tokens[auth_handler_id] +# assert sign_in_state_eq(final_state, initial_state) + +# @pytest.mark.asyncio +# async def test_sign_out_not_signed_in_all_handlers( +# self, mocker, storage, authorization, context, activity +# ): +# mock_variants(mocker) +# initial_state = SignInState( +# tokens={DEFAULTS.auth_handler_id: ""}, continuation_activity=activity +# ) +# await set_sign_in_state(authorization, storage, context, initial_state) +# await authorization.sign_out(context, None) +# final_state = await get_sign_in_state(authorization, storage, context) +# assert final_state is None + +# @pytest.mark.asyncio +# async def test_sign_out_signed_in_in_all_handlers( +# self, mocker, storage, authorization, context, activity +# ): +# mock_variants(mocker) +# initial_state = SignInState( +# tokens={ +# DEFAULTS.auth_handler_id: "token", +# DEFAULTS.agentic_auth_handler_id: "another_token", +# }, +# continuation_activity=activity, +# ) +# await set_sign_in_state(authorization, storage, context, initial_state) +# await authorization.sign_out(context, None) +# final_state = await get_sign_in_state(authorization, storage, context) +# assert final_state is None + +# @pytest.mark.asyncio +# @pytest.mark.parametrize( +# "sign_in_state", +# [ +# SignInState(), +# SignInState( +# tokens={DEFAULTS.auth_handler_id: "token"}, +# continuation_activity=Activity( +# type=ActivityTypes.message, text="activity" +# ), +# ), +# SignInState( +# tokens={ +# DEFAULTS.auth_handler_id: "token", +# DEFAULTS.agentic_auth_handler_id: "another_token", +# }, +# continuation_activity=Activity( +# type=ActivityTypes.message, text="activity" +# ), +# ), +# SignInState( +# tokens={DEFAULTS.auth_handler_id: "token", "my_handler": "old_token"}, +# continuation_activity=Activity( +# type=ActivityTypes.message, text="activity" +# ), +# ), +# ], +# ) +# async def test_on_turn_auth_intercept_no_intercept( +# self, storage, authorization, context, sign_in_state +# ): +# await set_sign_in_state( +# authorization, storage, context, copy_sign_in_state(sign_in_state) +# ) + +# intercepts, continuation_activity = await authorization.on_turn_auth_intercept( +# context, None +# ) + +# assert not continuation_activity +# assert not intercepts + +# final_state = await get_sign_in_state(authorization, storage, context) + +# assert sign_in_state_eq(final_state, sign_in_state) + +# @pytest.mark.asyncio +# @pytest.mark.parametrize( +# "sign_in_response", +# [ +# SignInResponse(tag=FlowStateTag.BEGIN), +# SignInResponse(tag=FlowStateTag.CONTINUE), +# SignInResponse(tag=FlowStateTag.FAILURE), +# ], +# ) +# async def test_on_turn_auth_intercept_with_intercept_incomplete( +# self, mocker, storage, authorization, context, sign_in_response, auth_handler_id +# ): +# mock_class_Authorization( +# mocker, start_or_continue_sign_in_return=sign_in_response +# ) + +# initial_state = SignInState( +# tokens={"some_handler": "old_token", auth_handler_id: ""}, +# continuation_activity=Activity( +# type=ActivityTypes.message, text="old activity" +# ), +# ) +# await set_sign_in_state( +# authorization, storage, context, copy_sign_in_state(initial_state) +# ) + +# intercepts, continuation_activity = await authorization.on_turn_auth_intercept( +# context, auth_handler_id +# ) + +# assert not continuation_activity +# assert intercepts + +# final_state = await get_sign_in_state(authorization, storage, context) +# assert sign_in_state_eq(final_state, initial_state) + +# @pytest.mark.asyncio +# async def test_on_turn_auth_intercept_with_intercept_complete( +# self, mocker, storage, authorization, context, auth_handler_id +# ): +# mock_class_Authorization( +# mocker, +# start_or_continue_sign_in_return=SignInResponse(tag=FlowStateTag.COMPLETE), +# ) + +# old_activity = Activity(type=ActivityTypes.message, text="old activity") +# initial_state = SignInState( +# tokens={"some_handler": "old_token", auth_handler_id: ""}, +# continuation_activity=old_activity, +# ) +# await set_sign_in_state( +# authorization, storage, context, copy_sign_in_state(initial_state) +# ) + +# intercepts, continuation_activity = await authorization.on_turn_auth_intercept( +# context, auth_handler_id +# ) + +# assert continuation_activity == old_activity +# assert intercepts + +# # start_or_continue_sign_in is the only method that modifies the state, +# # so since it is mocked, the state should not be changed +# final_state = await get_sign_in_state(authorization, storage, context) +# assert sign_in_state_eq(final_state, initial_state) diff --git a/tests/hosting_core/app/auth/test_user_authorization.py b/tests/hosting_core/app/auth/test_user_authorization.py index 2c461a6a..8c90a01a 100644 --- a/tests/hosting_core/app/auth/test_user_authorization.py +++ b/tests/hosting_core/app/auth/test_user_authorization.py @@ -1,263 +1,263 @@ -import pytest -from datetime import datetime -import jwt - -from microsoft_agents.activity import Activity, ActivityTypes, TokenResponse - -from microsoft_agents.hosting.core import ( - FlowStorageClient, - FlowErrorTag, - FlowStateTag, - FlowState, - FlowResponse, - OAuthFlow, - UserAuthorization, - MemoryStorage, -) - -from tests._common.storage.utils import StorageBaseline - -# test constants -from tests._common.data import ( - TEST_FLOW_DATA, - TEST_AUTH_DATA, - TEST_STORAGE_DATA, - TEST_DEFAULTS, - TEST_ENV_DICT, - create_test_auth_handler, -) -from tests._common.fixtures import FlowStateFixtures -from tests._common.testing_objects import ( - TestingConnectionManager as MockConnectionManager, - mock_class_OAuthFlow, - mock_UserTokenClient, -) -from tests.hosting_core._common import flow_state_eq - -DEFAULTS = TEST_DEFAULTS() -FLOW_DATA = TEST_FLOW_DATA() -ENV_DICT = TEST_ENV_DICT() -STORAGE_DATA = TEST_STORAGE_DATA() - - -class MyUserAuthorization(UserAuthorization): - def _handle_flow_response(self, *args, **kwargs): - pass - - -def testing_TurnContext( - mocker, - channel_id=DEFAULTS.channel_id, - user_id=DEFAULTS.user_id, - user_token_client=None, -): - if not user_token_client: - user_token_client = mock_UserTokenClient(mocker) - - turn_context = mocker.Mock() - turn_context.activity.channel_id = channel_id - turn_context.activity.from_property.id = user_id - turn_context.activity.type = ActivityTypes.message - turn_context.adapter.USER_TOKEN_CLIENT_KEY = "__user_token_client" - turn_context.adapter.AGENT_IDENTITY_KEY = "__agent_identity_key" - agent_identity = mocker.Mock() - agent_identity.claims = {"aud": DEFAULTS.ms_app_id} - turn_context.turn_state = { - "__user_token_client": user_token_client, - "__agent_identity_key": agent_identity, - } - return turn_context - - -class TestEnv(FlowStateFixtures): - def setup_method(self): - self.TurnContext = testing_TurnContext - self.UserTokenClient = mock_UserTokenClient - self.ConnectionManager = lambda mocker: MockConnectionManager() - - @pytest.fixture - def turn_context(self, mocker): - return self.TurnContext(mocker) - - @pytest.fixture - def baseline_storage(self): - return StorageBaseline(TEST_STORAGE_DATA().dict) - - @pytest.fixture - def storage(self): - return MemoryStorage(STORAGE_DATA.get_init_data()) - - @pytest.fixture - def connection_manager(self, mocker): - return self.ConnectionManager(mocker) - - @pytest.fixture - def auth_handlers(self): - return TEST_AUTH_DATA().auth_handlers - - @pytest.fixture - def user_authorization(self, connection_manager, storage, auth_handlers): - return UserAuthorization( - storage, connection_manager, auth_handlers=auth_handlers - ) - - -class TestUserAuthorization(TestEnv): - - # TODO -> test init - - @pytest.mark.asyncio - async def test_begin_or_continue_flow_success(self, mocker, user_authorization): - # robrandao: TODO -> lower priority -> more testing here - # setup - mock_class_OAuthFlow( - mocker, - begin_or_continue_flow_return=FlowResponse( - token_response=TokenResponse(token="token"), - flow_state=FlowState( - tag=FlowStateTag.COMPLETE, auth_handler_id="github" - ), - ), - ) - context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") - context.dummy_val = None - - flow_response = await user_authorization.begin_or_continue_flow( - context, "github" - ) - assert flow_response.token_response == TokenResponse(token="token") - - @pytest.mark.asyncio - async def test_begin_or_continue_flow_already_completed( - self, mocker, user_authorization - ): - # robrandao: TODO -> lower priority -> more testing here - # setup - context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") - # test - flow_response = await user_authorization.begin_or_continue_flow( - context, "graph" - ) - assert flow_response.token_response == TokenResponse(token="test_token") - assert flow_response.continuation_activity is None - - @pytest.mark.asyncio - async def test_begin_or_continue_flow_failure(self, mocker, user_authorization): - # robrandao: TODO -> lower priority -> more testing here - # setup - mock_class_OAuthFlow( - mocker, - begin_or_continue_flow_return=FlowResponse( - token_response=TokenResponse(token="token"), - flow_state=FlowState( - tag=FlowStateTag.FAILURE, auth_handler_id="github" - ), - flow_error_tag=FlowErrorTag.MAGIC_FORMAT, - ), - ) - context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") - # test - flow_response = await user_authorization.begin_or_continue_flow( - context, "github" - ) - assert flow_response.token_response == TokenResponse(token="token") - - @pytest.mark.asyncio - async def test_sign_out_individual( - self, - mocker, - storage, - connection_manager, - auth_handlers, - ): - # setup - mock_class_OAuthFlow(mocker) - storage_client = FlowStorageClient("teams", "Alice", storage) - context = self.TurnContext(mocker, channel_id="teams", user_id="Alice") - auth = UserAuthorization(storage, connection_manager, auth_handlers) - - # test - await auth.sign_out(context, "graph") - - # verify - assert ( - await storage.read([storage_client.key("graph")], target_cls=FlowState) - == {} - ) - OAuthFlow.sign_out.assert_called_once() - - @pytest.mark.asyncio - async def test_sign_out_all( - self, - mocker, - storage, - connection_manager, - auth_handlers, - ): - # setup - mock_class_OAuthFlow(mocker) - context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") - storage_client = FlowStorageClient("webchat", "Alice", storage) - auth = UserAuthorization(storage, connection_manager, auth_handlers) - - # test - await auth.sign_out(context) - - # verify - assert ( - await storage.read([storage_client.key("graph")], target_cls=FlowState) - == {} - ) - assert ( - await storage.read([storage_client.key("github")], target_cls=FlowState) - == {} - ) - assert ( - await storage.read([storage_client.key("slack")], target_cls=FlowState) - == {} - ) - OAuthFlow.sign_out.assert_called() # ignore red squiggly -> mocked - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "flow_response", - [ - FlowResponse( - token_response=TokenResponse(token="token"), - flow_state=FlowState( - tag=FlowStateTag.COMPLETE, auth_handler_id="github" - ), - ), - FlowResponse( - token_response=TokenResponse(), - flow_state=FlowState( - tag=FlowStateTag.CONTINUE, auth_handler_id="github" - ), - continuation_activity=Activity( - type=ActivityTypes.message, text="Please sign in" - ), - ), - FlowResponse( - token_response=TokenResponse(token="wow"), - flow_state=FlowState( - tag=FlowStateTag.FAILURE, auth_handler_id="github" - ), - flow_error_tag=FlowErrorTag.MAGIC_FORMAT, - continuation_activity=Activity( - type=ActivityTypes.message, text="There was an error" - ), - ), - ], - ) - async def test_sign_in_success( - self, mocker, user_authorization, turn_context, flow_response - ): - mocker.patch.object( - user_authorization, "_handle_flow_response", return_value=None - ) - user_authorization.begin_or_continue_flow = mocker.AsyncMock( - return_value=flow_response - ) - res = await user_authorization.sign_in(turn_context, "github") - assert res.token_response == flow_response.token_response - assert res.tag == flow_response.flow_state.tag +# import pytest +# from datetime import datetime +# import jwt + +# from microsoft_agents.activity import Activity, ActivityTypes, TokenResponse + +# from microsoft_agents.hosting.core import ( +# FlowStorageClient, +# FlowErrorTag, +# FlowStateTag, +# FlowState, +# FlowResponse, +# OAuthFlow, +# UserAuthorization, +# MemoryStorage, +# ) + +# from tests._common.storage.utils import StorageBaseline + +# # test constants +# from tests._common.data import ( +# TEST_FLOW_DATA, +# TEST_AUTH_DATA, +# TEST_STORAGE_DATA, +# TEST_DEFAULTS, +# TEST_ENV_DICT, +# create_test_auth_handler, +# ) +# from tests._common.fixtures import FlowStateFixtures +# from tests._common.testing_objects import ( +# TestingConnectionManager as MockConnectionManager, +# mock_class_OAuthFlow, +# mock_UserTokenClient, +# ) +# from tests.hosting_core._common import flow_state_eq + +# DEFAULTS = TEST_DEFAULTS() +# FLOW_DATA = TEST_FLOW_DATA() +# ENV_DICT = TEST_ENV_DICT() +# STORAGE_DATA = TEST_STORAGE_DATA() + + +# class MyUserAuthorization(UserAuthorization): +# def _handle_flow_response(self, *args, **kwargs): +# pass + + +# def testing_TurnContext( +# mocker, +# channel_id=DEFAULTS.channel_id, +# user_id=DEFAULTS.user_id, +# user_token_client=None, +# ): +# if not user_token_client: +# user_token_client = mock_UserTokenClient(mocker) + +# turn_context = mocker.Mock() +# turn_context.activity.channel_id = channel_id +# turn_context.activity.from_property.id = user_id +# turn_context.activity.type = ActivityTypes.message +# turn_context.adapter.USER_TOKEN_CLIENT_KEY = "__user_token_client" +# turn_context.adapter.AGENT_IDENTITY_KEY = "__agent_identity_key" +# agent_identity = mocker.Mock() +# agent_identity.claims = {"aud": DEFAULTS.ms_app_id} +# turn_context.turn_state = { +# "__user_token_client": user_token_client, +# "__agent_identity_key": agent_identity, +# } +# return turn_context + + +# class TestEnv(FlowStateFixtures): +# def setup_method(self): +# self.TurnContext = testing_TurnContext +# self.UserTokenClient = mock_UserTokenClient +# self.ConnectionManager = lambda mocker: MockConnectionManager() + +# @pytest.fixture +# def turn_context(self, mocker): +# return self.TurnContext(mocker) + +# @pytest.fixture +# def baseline_storage(self): +# return StorageBaseline(TEST_STORAGE_DATA().dict) + +# @pytest.fixture +# def storage(self): +# return MemoryStorage(STORAGE_DATA.get_init_data()) + +# @pytest.fixture +# def connection_manager(self, mocker): +# return self.ConnectionManager(mocker) + +# @pytest.fixture +# def auth_handlers(self): +# return TEST_AUTH_DATA().auth_handlers + +# @pytest.fixture +# def user_authorization(self, connection_manager, storage, auth_handlers): +# return UserAuthorization( +# storage, connection_manager, auth_handlers=auth_handlers +# ) + + +# class TestUserAuthorization(TestEnv): + +# # TODO -> test init + +# @pytest.mark.asyncio +# async def test_begin_or_continue_flow_success(self, mocker, user_authorization): +# # robrandao: TODO -> lower priority -> more testing here +# # setup +# mock_class_OAuthFlow( +# mocker, +# begin_or_continue_flow_return=FlowResponse( +# token_response=TokenResponse(token="token"), +# flow_state=FlowState( +# tag=FlowStateTag.COMPLETE, auth_handler_id="github" +# ), +# ), +# ) +# context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") +# context.dummy_val = None + +# flow_response = await user_authorization.begin_or_continue_flow( +# context, "github" +# ) +# assert flow_response.token_response == TokenResponse(token="token") + +# @pytest.mark.asyncio +# async def test_begin_or_continue_flow_already_completed( +# self, mocker, user_authorization +# ): +# # robrandao: TODO -> lower priority -> more testing here +# # setup +# context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") +# # test +# flow_response = await user_authorization.begin_or_continue_flow( +# context, "graph" +# ) +# assert flow_response.token_response == TokenResponse(token="test_token") +# assert flow_response.continuation_activity is None + +# @pytest.mark.asyncio +# async def test_begin_or_continue_flow_failure(self, mocker, user_authorization): +# # robrandao: TODO -> lower priority -> more testing here +# # setup +# mock_class_OAuthFlow( +# mocker, +# begin_or_continue_flow_return=FlowResponse( +# token_response=TokenResponse(token="token"), +# flow_state=FlowState( +# tag=FlowStateTag.FAILURE, auth_handler_id="github" +# ), +# flow_error_tag=FlowErrorTag.MAGIC_FORMAT, +# ), +# ) +# context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") +# # test +# flow_response = await user_authorization.begin_or_continue_flow( +# context, "github" +# ) +# assert flow_response.token_response == TokenResponse(token="token") + +# @pytest.mark.asyncio +# async def test_sign_out_individual( +# self, +# mocker, +# storage, +# connection_manager, +# auth_handlers, +# ): +# # setup +# mock_class_OAuthFlow(mocker) +# storage_client = FlowStorageClient("teams", "Alice", storage) +# context = self.TurnContext(mocker, channel_id="teams", user_id="Alice") +# auth = UserAuthorization(storage, connection_manager, auth_handlers) + +# # test +# await auth.sign_out(context, "graph") + +# # verify +# assert ( +# await storage.read([storage_client.key("graph")], target_cls=FlowState) +# == {} +# ) +# OAuthFlow.sign_out.assert_called_once() + +# @pytest.mark.asyncio +# async def test_sign_out_all( +# self, +# mocker, +# storage, +# connection_manager, +# auth_handlers, +# ): +# # setup +# mock_class_OAuthFlow(mocker) +# context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") +# storage_client = FlowStorageClient("webchat", "Alice", storage) +# auth = UserAuthorization(storage, connection_manager, auth_handlers) + +# # test +# await auth.sign_out(context) + +# # verify +# assert ( +# await storage.read([storage_client.key("graph")], target_cls=FlowState) +# == {} +# ) +# assert ( +# await storage.read([storage_client.key("github")], target_cls=FlowState) +# == {} +# ) +# assert ( +# await storage.read([storage_client.key("slack")], target_cls=FlowState) +# == {} +# ) +# OAuthFlow.sign_out.assert_called() # ignore red squiggly -> mocked + +# @pytest.mark.asyncio +# @pytest.mark.parametrize( +# "flow_response", +# [ +# FlowResponse( +# token_response=TokenResponse(token="token"), +# flow_state=FlowState( +# tag=FlowStateTag.COMPLETE, auth_handler_id="github" +# ), +# ), +# FlowResponse( +# token_response=TokenResponse(), +# flow_state=FlowState( +# tag=FlowStateTag.CONTINUE, auth_handler_id="github" +# ), +# continuation_activity=Activity( +# type=ActivityTypes.message, text="Please sign in" +# ), +# ), +# FlowResponse( +# token_response=TokenResponse(token="wow"), +# flow_state=FlowState( +# tag=FlowStateTag.FAILURE, auth_handler_id="github" +# ), +# flow_error_tag=FlowErrorTag.MAGIC_FORMAT, +# continuation_activity=Activity( +# type=ActivityTypes.message, text="There was an error" +# ), +# ), +# ], +# ) +# async def test_sign_in_success( +# self, mocker, user_authorization, turn_context, flow_response +# ): +# mocker.patch.object( +# user_authorization, "_handle_flow_response", return_value=None +# ) +# user_authorization.begin_or_continue_flow = mocker.AsyncMock( +# return_value=flow_response +# ) +# res = await user_authorization.sign_in(turn_context, "github") +# assert res.token_response == flow_response.token_response +# assert res.tag == flow_response.flow_state.tag diff --git a/tests/hosting_core/app/test_agent_application.py b/tests/hosting_core/app/test_agent_application.py index 28c3ccef..0516841f 100644 --- a/tests/hosting_core/app/test_agent_application.py +++ b/tests/hosting_core/app/test_agent_application.py @@ -1,36 +1,34 @@ -from microsoft_agents.authentication.msal.msal_connection_manager import MsalConnectionManager -from microsoft_agents.hosting.core.turn_context import TurnContext -import pytest +# from microsoft_agents.authentication.msal.msal_connection_manager import MsalConnectionManager +# from microsoft_agents.hosting.core.turn_context import TurnContext +# import pytest -from microsoft_agents.authentication.msal import MsalAuthentication -from microsoft_agents.hosting.core import ( - MemoryStorage, - AgentApplication, - ApplicationOptions, - Connections -) +# from microsoft_agents.authentication.msal import MsalAuthentication +# from microsoft_agents.hosting.core import ( +# MemoryStorage, +# AgentApplication, +# ApplicationOptions, +# Connections +# ) -def mock_send_activity(mocker): - mocker.patch.object(TurnContext, 'send_activity', new=) +# # def mock_send_activity(mocker): +# # mocker.patch.object(TurnContext, 'send_activity', new=) -class TestUtils: +# class TestUtils: - @pytest.fixture - def options(self): - return ApplicationOptions() +# @pytest.fixture +# def options(self): +# return ApplicationOptions() - @pytest.fixture - def storage(self): - return MemoryStorage() +# @pytest.fixture +# def storage(self): +# return MemoryStorage() - @pytest.fixture - def connection_manager(self): - return MsalConnectionManager() - - @pytest.fixture - def +# @pytest.fixture +# def connection_manager(self): +# return MsalConnectionManager() + -class TestAgentApplication: +# class TestAgentApplication: - pass \ No newline at end of file +# pass \ No newline at end of file From ea54c51867f948ec2cff964b8eca8cf12794312a Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 29 Sep 2025 13:54:24 -0700 Subject: [PATCH 20/36] Tested UserAuthorization and AgenticUserAuthorization classes --- .../hosting/core/app/auth/auth_handler.py | 8 +- .../handlers/agentic_user_authorization.py | 12 +- .../auth/handlers/authorization_handler.py | 5 + .../app/auth/handlers/user_authorization.py | 57 ++-- .../data/configs/test_agentic_auth_config.py | 2 + .../test_agentic_user_authorization.py | 180 ++++++---- .../auth/handlers/test_user_authorization.py | 320 ++++++++++++++++++ .../app/auth/test_auth_handler.py | 21 +- 8 files changed, 505 insertions(+), 100 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py index ac2aeb77..31fb2411 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py @@ -64,7 +64,11 @@ def __init__( if scopes: self.scopes = list(scopes) else: - self.scopes = kwargs.get("SCOPES", []) + self.scopes = AuthHandler.format_scopes(kwargs.get("SCOPES", "")) + @staticmethod + def format_scopes(scopes: str) -> list[str]: + lst = scopes.strip().split(" ") + return [ s for s in lst if s ] @staticmethod def from_settings(settings: dict): @@ -86,5 +90,5 @@ def from_settings(settings: dict): abs_oauth_connection_name=settings.get("AZUREBOTOAUTHCONNECTIONNAME", ""), obo_connection_name=settings.get("OBOCONNECTIONNAME", ""), auth_type=settings.get("TYPE", ""), - scopes=settings.get("SCOPES", []), + scopes=AuthHandler.format_scopes(settings.get("SCOPES", "")), ) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_user_authorization.py index 237bbd2e..73d0eab1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_user_authorization.py @@ -67,7 +67,7 @@ async def sign_in( self, context: TurnContext, exchange_connection: Optional[str] = None, - scopes: Optional[list[str]] = None, + exchange_scopes: Optional[list[str]] = None, ) -> SignInResponse: """Retrieves the agentic user token if available. @@ -80,7 +80,7 @@ async def sign_in( :return: A SignInResponse containing the token response and flow state tag. :rtype: SignInResponse """ - token_response = await self.get_refreshed_token(context, exchange_connection, scopes) + token_response = await self.get_refreshed_token(context, exchange_connection, exchange_scopes) if token_response: return SignInResponse(token_response=token_response, tag=FlowStateTag.COMPLETE) return SignInResponse(tag=FlowStateTag.FAILURE) @@ -88,12 +88,12 @@ async def sign_in( async def get_refreshed_token(self, context: TurnContext, exchange_connection: Optional[str] = None, - scopes: Optional[list[str]] = None + exchange_scopes: Optional[list[str]] = None ) -> TokenResponse: """Gets a refreshed agentic user token if available.""" - if not scopes: - scopes = self._handler.scopes or [] - token = await self.get_agentic_user_token(context, scopes) + if not exchange_scopes: + exchange_scopes = self._handler.exchange_scopes or [] + token = await self.get_agentic_user_token(context, exchange_scopes) return TokenResponse(token=token) if token else TokenResponse() async def sign_out(self, context: TurnContext, auth_handler_id: Optional[str] = None) -> None: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler.py index 36538433..1de0ccc9 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler.py @@ -26,6 +26,7 @@ def __init__( connection_manager: Connections, auth_handler: Optional[AuthHandler] = None, *, + auth_handler_id: Optional[str] = None, auth_handler_settings: Optional[dict] = None, **kwargs, ) -> None: @@ -53,6 +54,10 @@ def __init__( else: self._handler = AuthHandler.from_settings(auth_handler_settings) + self._id = auth_handler_id or self._handler.name + if not self._id: + raise ValueError("Auth handler must have an ID. Could not be deduced from settings or constructor args.") + async def sign_in( self, context: TurnContext, scopes: Optional[list[str]] = None ) -> SignInResponse: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/user_authorization.py index 5855eb86..011eca5c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/user_authorization.py @@ -74,18 +74,17 @@ async def _load_flow( # try to load existing state flow_storage_client = FlowStorageClient(channel_id, user_id, self._storage) logger.info("Loading OAuth flow state from storage") - flow_state: FlowState = await flow_storage_client.read(self._handler.name) - + flow_state: FlowState = await flow_storage_client.read(self._id) if not flow_state: logger.info("No existing flow state found, creating new flow state") flow_state = FlowState( channel_id=channel_id, user_id=user_id, - auth_handler_id=self._handler, + auth_handler_id=self._id, connection=self._handler.abs_oauth_connection_name, ms_app_id=ms_app_id, ) - await flow_storage_client.write(flow_state) + # await flow_storage_client.write(flow_state) flow = OAuthFlow(flow_state, user_token_client) return flow, flow_storage_client @@ -95,7 +94,7 @@ async def _handle_obo( context: TurnContext, input_token_response: TokenResponse, exchange_connection: Optional[str] = None, - scopes: Optional[list[str]] = None, + exchange_scopes: Optional[list[str]] = None, ) -> TokenResponse: """ Exchanges a token for another token with different scopes. @@ -116,23 +115,23 @@ async def _handle_obo( token = input_token_response.token connection_name = exchange_connection or self._handler.obo_connection_name - scopes = scopes or self._handler.scopes + exchange_scopes = exchange_scopes or self._handler.scopes - if not connection_name or not scopes: + if not connection_name or not exchange_scopes: return input_token_response if not self._is_exchangeable(input_token_response.token): - raise ValueError("Token is not exchangeable") + return input_token_response token_provider = self._connection_manager.get_connection(connection_name) if not token_provider: raise ValueError(f"Connection '{connection_name}' not found") token = await token_provider.acquire_token_on_behalf_of( - scopes=scopes, + scopes=exchange_scopes, user_assertion=input_token_response.token, ) - return TokenResponse(token=token) + return TokenResponse(token=token) if token else TokenResponse() def _is_exchangeable(self, token: str) -> bool: """ @@ -164,9 +163,9 @@ async def sign_out( signs out from all the handlers. """ flow, flow_storage_client = await self._load_flow(context) - logger.info("Signing out from handler: %s", self._handler.name) + logger.info("Signing out from handler: %s", self._id) await flow.sign_out() - await flow_storage_client.delete(self._handler.name) + await flow_storage_client.delete(self._id) async def _handle_flow_response( self, context: TurnContext, flow_response: FlowResponse @@ -212,7 +211,7 @@ async def _handle_flow_response( await context.send_activity("Sign-in failed. Please try again.") async def sign_in( - self, context: TurnContext, exchange_connection: Optional[str] = None, scopes: Optional[list[str]] = None + self, context: TurnContext, exchange_connection: Optional[str] = None, exchange_scopes: Optional[list[str]] = None ) -> SignInResponse: """Begins or continues an OAuth flow. @@ -225,13 +224,7 @@ async def sign_in( :return: The SignInResponse containing the token response and flow state tag. :rtype: SignInResponse """ - - logger.debug( - "Beginning or continuing flow for auth handler %s", - auth_handler_id, - ) flow, flow_storage_client = await self._load_flow(context) - # prev_tag = flow.flow_state.tag flow_response: FlowResponse = await flow.begin_or_continue_flow( context.activity ) @@ -239,17 +232,23 @@ async def sign_in( logger.info("Saving OAuth flow state to storage") await flow_storage_client.write(flow_response.flow_state) await self._handle_flow_response(context, flow_response) - logger.debug( - "Flow response flow_state.tag: %s", - flow_response.flow_state.tag, - ) - sign_in_response = SignInResponse( - token_response=flow_response.token_response, - tag=flow_response.flow_state.tag, - ) + if flow_response.token_response: + # attempt exchange if needed + # if not needed, returns the same token + token_response = await self._handle_obo( + context, + flow_response.token_response, + exchange_connection, + exchange_scopes, + ) - return sign_in_response + return SignInResponse( + token_response=token_response, + tag=FlowStateTag.COMPLETE if token_response else FlowStateTag.FAILURE + ) + + return SignInResponse(tag=flow_response.flow_state.tag) async def get_refreshed_token( self, context: TurnContext, exchange_connection: Optional[str] = None, exchange_scopes: Optional[list[str]] = None @@ -269,7 +268,7 @@ async def get_refreshed_token( :rtype: TokenResponse """ flow, _ = await self._load_flow(context) - input_token_response = await flow.get_user_token() # TODO + input_token_response = await flow.get_user_token() return await self._handle_obo( context, input_token_response, diff --git a/tests/_common/data/configs/test_agentic_auth_config.py b/tests/_common/data/configs/test_agentic_auth_config.py index 898219d3..00bb6fb1 100644 --- a/tests/_common/data/configs/test_agentic_auth_config.py +++ b/tests/_common/data/configs/test_agentic_auth_config.py @@ -19,12 +19,14 @@ AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__TITLE={auth_handler_title} AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__TEXT={auth_handler_text} AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__TYPE=UserAuthorization +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{auth_handler_id}__SETTINGS__SCOPES=scope1 scope2 AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME={agentic_abs_oauth_connection_name} AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__OBOCONNECTIONNAME={agentic_obo_connection_name} AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__TITLE={agentic_auth_handler_title} AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__TEXT={agentic_auth_handler_text} AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__TYPE=AgenticAuthorization +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__SCOPES=user.Read Mail.Read CONNECTIONSMAP__0__CONNECTION=SERVICE_CONNECTION CONNECTIONSMAP__0__SERVICEURL=* diff --git a/tests/hosting_core/app/auth/handlers/test_agentic_user_authorization.py b/tests/hosting_core/app/auth/handlers/test_agentic_user_authorization.py index a78d1d4a..8cdac31e 100644 --- a/tests/hosting_core/app/auth/handlers/test_agentic_user_authorization.py +++ b/tests/hosting_core/app/auth/handlers/test_agentic_user_authorization.py @@ -58,12 +58,12 @@ def connection_manager(self, mocker): @pytest.fixture def auth_handler_settings(self): - return AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][DEFAULTS.auth_handler_id]["SETTINGS"] + return AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][DEFAULTS.agentic_auth_handler_id]["SETTINGS"] @pytest.fixture def agentic_auth(self, storage, connection_manager, auth_handler_settings): return AgenticUserAuthorization(storage, connection_manager, - auth_handler_settings=auth_handler_settings) + auth_handler_settings=auth_handler_settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id) @pytest.fixture(params=[RoleTypes.user, RoleTypes.skill, RoleTypes.agent]) def non_agentic_role(self, request): @@ -89,40 +89,6 @@ def mock_class_provider(self, mocker, app_token="bot_token", instance_token=None class TestAgenticUserAuthorization(TestUtils): - # @pytest.mark.parametrize( - # "activity", - # [ - # Activity( - # type="message", - # recipient=ChannelAccount( - # id="bot_id", - # agentic_app_id=DEFAULTS.agentic_instance_id, - # role=RoleTypes.agent, - # ), - # ), - # Activity( - # type="message", - # recipient=ChannelAccount( - # id=DEFAULTS.agentic_user_id, - # agentic_app_id=DEFAULTS.agentic_instance_id, - # role=RoleTypes.agentic_user, - # ), - # ), - # Activity( - # type="message", - # recipient=ChannelAccount( - # id=DEFAULTS.agentic_user_id, - # ), - # ), - # Activity(type="message", recipient=ChannelAccount(id="some_id")), - # ], - # ) - # def test_is_agentic_request(self, mocker, activity): - # assert activity.is_agentic() == AgenticUserAuthorization.is_agentic_request( - # activity - # ) - # context = self.TurnContext(mocker, activity=activity) - # assert activity.is_agentic() == AgenticUserAuthorization.is_agentic_request(context) def test_get_agent_instance_id_is_agentic(self, mocker, agentic_role): activity = Activity( @@ -280,16 +246,11 @@ async def test_get_agentic_user_token_is_agentic( "scopes_list, expected_scopes_list", [ (["user.Read"], ["user.Read"]), - # (["User.Read"], ["user.Read"]), - # (["USER.READ"], ["user.Read"]), - # ([" user.read "], ["user.Read"]), - # (["user.read", "Mail.Read"], ["user.Read", "mail.Read"]), - # ([" user.read ", " mail.read "], ["user.Read", "mail.Read"]), - # ([], []), - # (None, []), + ([], ["user.Read", "Mail.Read"]), + (None, ["user.Read", "Mail.Read"]), ], ) - async def test_sign_in_success(self, mocker, scopes_list, expected_scopes_list, auth_handler_settings): + async def test_sign_in_success(self, mocker, scopes_list, agentic_role, expected_scopes_list, auth_handler_settings): mock_provider = self.mock_provider(mocker, user_token="my_token") connection_manager = mocker.Mock(spec=MsalConnectionManager) @@ -298,26 +259,121 @@ async def test_sign_in_success(self, mocker, scopes_list, expected_scopes_list, agentic_auth = AgenticUserAuthorization( MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings ) - context = self.TurnContext(mocker) + activity = Activity( + type="message", + recipient=ChannelAccount( + id="some_id", + agentic_app_id=DEFAULTS.agentic_instance_id, + role=agentic_role, + ), + ) + context = self.TurnContext(mocker, activity=activity) res = await agentic_auth.sign_in(context, "my_connection", scopes_list) assert res.token_response.token == "my_token" assert res.tag == FlowStateTag.COMPLETE - assert mock_provider.get_agentic_user_token.call_count == 1 - args = mock_provider.get_agentic_user_token.call_args[0] - assert args[0] == context - assert args[1] == "my_connection" - assert args[2] == expected_scopes_list + mock_provider.get_agentic_user_token.assert_called_once_with( + DEFAULTS.agentic_instance_id, "some_id", expected_scopes_list + ) - # @pytest.mark.asyncio - # async def test_sign_in_failure(self, mocker, agentic_auth): - # mocker.patch.object( - # AgenticUserAuthorization, "get_refreshed_token", return_value=TokenResponse() - # ) - # context = self.TurnContext(mocker) - # res = await agentic_auth.sign_in(context, "my_connection", ["user.Write"]) - # assert not res.token_response - # assert res.tag == FlowStateTag.FAILURE - # AgenticUserAuthorization.get_refreshed_token.assert_called_once_with( - # context, "my_connection", ["user.Read"] - # ) \ No newline at end of file + @pytest.mark.asyncio + @pytest.mark.parametrize( + "scopes_list, expected_scopes_list", + [ + (["user.Read"], ["user.Read"]), + ([], ["user.Read", "Mail.Read"]), + (None, ["user.Read", "Mail.Read"]), + ], + ) + async def test_sign_in_failure(self, mocker, scopes_list, agentic_role, expected_scopes_list, auth_handler_settings): + mock_provider = self.mock_provider(mocker, user_token=None) + + connection_manager = mocker.Mock(spec=MsalConnectionManager) + connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) + + agentic_auth = AgenticUserAuthorization( + MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings + ) + activity = Activity( + type="message", + recipient=ChannelAccount( + id="some_id", + agentic_app_id=DEFAULTS.agentic_instance_id, + role=agentic_role, + ), + ) + context = self.TurnContext(mocker, activity=activity) + res = await agentic_auth.sign_in(context, "my_connection", scopes_list) + assert not res.token_response + assert res.tag == FlowStateTag.FAILURE + + mock_provider.get_agentic_user_token.assert_called_once_with( + DEFAULTS.agentic_instance_id, "some_id", expected_scopes_list + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "scopes_list, expected_scopes_list", + [ + (["user.Read"], ["user.Read"]), + ([], ["user.Read", "Mail.Read"]), + (None, ["user.Read", "Mail.Read"]), + ], + ) + async def test_get_refreshed_token_success(self, mocker, scopes_list, agentic_role, expected_scopes_list, auth_handler_settings): + mock_provider = self.mock_provider(mocker, user_token="my_token") + + connection_manager = mocker.Mock(spec=MsalConnectionManager) + connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) + + agentic_auth = AgenticUserAuthorization( + MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings + ) + activity = Activity( + type="message", + recipient=ChannelAccount( + id="some_id", + agentic_app_id=DEFAULTS.agentic_instance_id, + role=agentic_role, + ), + ) + context = self.TurnContext(mocker, activity=activity) + res = await agentic_auth.get_refreshed_token(context, "my_connection", scopes_list) + assert res.token == "my_token" + + mock_provider.get_agentic_user_token.assert_called_once_with( + DEFAULTS.agentic_instance_id, "some_id", expected_scopes_list + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "scopes_list, expected_scopes_list", + [ + (["user.Read"], ["user.Read"]), + ([], ["user.Read", "Mail.Read"]), + (None, ["user.Read", "Mail.Read"]), + ], + ) + async def test_get_refreshed_token_failure(self, mocker, scopes_list, agentic_role, expected_scopes_list, auth_handler_settings): + mock_provider = self.mock_provider(mocker, user_token=None) + + connection_manager = mocker.Mock(spec=MsalConnectionManager) + connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) + + agentic_auth = AgenticUserAuthorization( + MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings + ) + activity = Activity( + type="message", + recipient=ChannelAccount( + id="some_id", + agentic_app_id=DEFAULTS.agentic_instance_id, + role=agentic_role, + ), + ) + context = self.TurnContext(mocker, activity=activity) + res = await agentic_auth.get_refreshed_token(context, "my_connection", scopes_list) + assert not res + mock_provider.get_agentic_user_token.assert_called_once_with( + DEFAULTS.agentic_instance_id, "some_id", expected_scopes_list + ) \ No newline at end of file diff --git a/tests/hosting_core/app/auth/handlers/test_user_authorization.py b/tests/hosting_core/app/auth/handlers/test_user_authorization.py index e69de29b..bf97992d 100644 --- a/tests/hosting_core/app/auth/handlers/test_user_authorization.py +++ b/tests/hosting_core/app/auth/handlers/test_user_authorization.py @@ -0,0 +1,320 @@ +import pytest +import jwt + +from microsoft_agents.activity import ActivityTypes, TokenResponse + +from microsoft_agents.authentication.msal import ( + MsalAuth, + MsalConnectionManager +) + +from microsoft_agents.hosting.core import ( + FlowStorageClient, + FlowStateTag, + FlowState, + FlowResponse, + UserAuthorization, + MemoryStorage, + SignInResponse, + OAuthFlow, +) + +# test constants +from tests._common.data import ( + TEST_FLOW_DATA, + TEST_AUTH_DATA, + TEST_STORAGE_DATA, + TEST_DEFAULTS, + TEST_AGENTIC_ENV_DICT, +) +from tests._common.mock_utils import mock_instance +from tests._common.fixtures import FlowStateFixtures +from tests._common.testing_objects import ( + mock_class_OAuthFlow, + mock_UserTokenClient, +) +from tests.hosting_core._common import flow_state_eq + +DEFAULTS = TEST_DEFAULTS() +FLOW_DATA = TEST_FLOW_DATA() +STORAGE_DATA = TEST_STORAGE_DATA() +AGENTIC_ENV_DICT = TEST_AGENTIC_ENV_DICT() + +def make_jwt(token: str = DEFAULTS.token, aud="api://default"): + if aud: + return jwt.encode({"aud": aud}, token, algorithm="HS256") + else: + return jwt.encode({}, token, algorithm="HS256") + + +class MyUserAuthorization(UserAuthorization): + async def _handle_flow_response(self, *args, **kwargs): + pass + +def testing_TurnContext( + mocker, + channel_id=DEFAULTS.channel_id, + user_id=DEFAULTS.user_id, + user_token_client=None, +): + if not user_token_client: + user_token_client = mock_UserTokenClient(mocker) + + turn_context = mocker.Mock() + turn_context.activity.channel_id = channel_id + turn_context.activity.from_property.id = user_id + turn_context.activity.type = ActivityTypes.message + turn_context.adapter.USER_TOKEN_CLIENT_KEY = "__user_token_client" + turn_context.adapter.AGENT_IDENTITY_KEY = "__agent_identity_key" + agent_identity = mocker.Mock() + agent_identity.claims = {"aud": DEFAULTS.ms_app_id} + turn_context.turn_state = { + "__user_token_client": user_token_client, + "__agent_identity_key": agent_identity, + } + return turn_context + +async def read_state(storage, channel_id=DEFAULTS.channel_id, user_id=DEFAULTS.user_id, auth_handler_id=DEFAULTS.auth_handler_id): + storage_client = FlowStorageClient(channel_id, user_id, storage) + key = storage_client.key(auth_handler_id) + return (await storage.read([key], target_cls=FlowState)).get(key) + +def mock_provider(mocker, exchange_token=None): + instance = mock_instance(mocker, MsalAuth, {"acquire_token_on_behalf_of": exchange_token}) + mocker.patch.object(MsalConnectionManager, "get_connection", return_value=instance) + return instance + +class TestEnv(FlowStateFixtures): + def setup_method(self): + self.TurnContext = testing_TurnContext + + @pytest.fixture + def context(self, mocker): + return self.TurnContext(mocker) + + @pytest.fixture + def storage(self): + return MemoryStorage(STORAGE_DATA.get_init_data()) + + @pytest.fixture + def connection_manager(self): + return MsalConnectionManager(**AGENTIC_ENV_DICT) + + @pytest.fixture + def auth_handlers(self): + return TEST_AUTH_DATA().auth_handlers + + @pytest.fixture + def auth_handler_settings(self): + return AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][ + DEFAULTS.auth_handler_id + ]["SETTINGS"] + + @pytest.fixture + def user_authorization(self, connection_manager, storage, auth_handler_settings): + return MyUserAuthorization( + storage, connection_manager, auth_handler_settings=auth_handler_settings, auth_handler_id=DEFAULTS.auth_handler_id + ) + + @pytest.fixture + def exchangeable_token(self): + jwt.encode({"aud": "exchange_audience"}, "secret", algorithm="HS256") + + @pytest.fixture(params=[ + [None, ["scope1", "scope2"]], + [[], ["scope1", "scope2"]], + [["scope1"], ["scope1"]], + ]) + def scope_set(self, request): + return request.param + + @pytest.fixture(params=[ + ["AGENTIC", "AGENTIC"], + [None, DEFAULTS.obo_connection_name], + ["", DEFAULTS.obo_connection_name], + ]) + def connection_set(self, request): + return request.param + +class TestUserAuthorization(TestEnv): + + # TODO -> test init + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "flow_response, exchange_attempted, token_exchange_response, expected_response", + [ + [ + FlowResponse( + token_response=TokenResponse(token=make_jwt()), + flow_state=FlowState( + tag=FlowStateTag.COMPLETE, auth_handler_id=DEFAULTS.auth_handler_id + ), + ), + True, "wow", + SignInResponse(token_response=TokenResponse(token="wow"), tag=FlowStateTag.COMPLETE) + ], + [ + FlowResponse( + token_response=TokenResponse(token=make_jwt(aud=None)), + flow_state=FlowState( + tag=FlowStateTag.COMPLETE, auth_handler_id=DEFAULTS.auth_handler_id + ), + ), + False, "wow", + SignInResponse(token_response=TokenResponse(token=make_jwt(aud=None)), tag=FlowStateTag.COMPLETE) + ], + [ + FlowResponse( + token_response=TokenResponse(token=make_jwt(token="some_value", aud="other")), + flow_state=FlowState( + tag=FlowStateTag.COMPLETE, auth_handler_id=DEFAULTS.auth_handler_id + ), + ), + False, DEFAULTS.token, + SignInResponse(token_response=TokenResponse(token=make_jwt("some_value", aud="other")), tag=FlowStateTag.COMPLETE) + ], + [ + FlowResponse( + token_response=TokenResponse(token=make_jwt(token="some_value")), + flow_state=FlowState( + tag=FlowStateTag.COMPLETE, auth_handler_id=DEFAULTS.auth_handler_id + ), + ), + True, None, + SignInResponse(tag=FlowStateTag.FAILURE) + ], + [ + FlowResponse( + flow_state=FlowState( + tag=FlowStateTag.BEGIN, auth_handler_id=DEFAULTS.auth_handler_id + ), + ), + False, None, + SignInResponse(tag=FlowStateTag.BEGIN) + ], + [ + FlowResponse( + flow_state=FlowState( + tag=FlowStateTag.CONTINUE, auth_handler_id=DEFAULTS.auth_handler_id + ), + ), + False, None, + SignInResponse(tag=FlowStateTag.CONTINUE) + ], + [ + FlowResponse( + flow_state=FlowState( + tag=FlowStateTag.FAILURE, auth_handler_id=DEFAULTS.auth_handler_id + ), + ), + False, None, + SignInResponse(tag=FlowStateTag.FAILURE) + ], + ] + ) + async def test_sign_in( + self, + mocker, + user_authorization, + context, + storage, + flow_response, + exchange_attempted, + token_exchange_response, + expected_response, + scope_set, + connection_set + ): + request_scopes, expected_scopes = scope_set + request_connection, expected_connection = connection_set + mock_class_OAuthFlow(mocker, begin_or_continue_flow_return=flow_response) + provider = mock_provider(mocker, exchange_token=token_exchange_response) + + sign_in_response = await user_authorization.sign_in(context, request_connection, request_scopes) + assert sign_in_response.token_response == expected_response.token_response + assert sign_in_response.tag == expected_response.tag + + state = await read_state(storage, auth_handler_id=DEFAULTS.auth_handler_id) + assert flow_state_eq(state, flow_response.flow_state) + if exchange_attempted: + MsalConnectionManager.get_connection.assert_called_once_with(expected_connection) + provider.acquire_token_on_behalf_of.assert_called_once_with( + scopes=expected_scopes, user_assertion=flow_response.token_response.token + ) + + @pytest.mark.asyncio + async def test_sign_out_individual( + self, + mocker, + storage, + user_authorization, + context + ): + mock_class_OAuthFlow(mocker) + await user_authorization.sign_out(context) + assert await read_state(storage, auth_handler_id=DEFAULTS.auth_handler_id) is None + OAuthFlow.sign_out.assert_called_once() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "get_user_token_return, exchange_attempted, token_exchange_response, expected_response", + [ + [ + TokenResponse(token=make_jwt()), + True, "wow", + TokenResponse(token="wow") + ], + [ + TokenResponse(token=make_jwt(aud=None)), + False, "wow", + TokenResponse(token=make_jwt(aud=None)) + ], + [ + TokenResponse(token=make_jwt(token="some_value", aud="other")), + False, DEFAULTS.token, + TokenResponse(token=make_jwt("some_value", aud="other")) + ], + [ + TokenResponse(token=make_jwt(token="some_value")), + True, None, + TokenResponse() + ], + [ + TokenResponse(), + False, None, + TokenResponse() + ], + ] + ) + async def test_get_refreshed_token( + self, + mocker, + user_authorization, + context, + storage, + get_user_token_return, + exchange_attempted, + token_exchange_response, + expected_response, + scope_set, + connection_set + ): + request_scopes, expected_scopes = scope_set + request_connection, expected_connection = connection_set + mock_class_OAuthFlow(mocker, get_user_token_return=get_user_token_return) + provider = mock_provider(mocker, exchange_token=token_exchange_response) + + state_before = await read_state(storage, auth_handler_id=DEFAULTS.auth_handler_id) + token_response = await user_authorization.get_refreshed_token(context, request_connection, request_scopes) + assert token_response == expected_response + + state = await read_state(storage, auth_handler_id=DEFAULTS.auth_handler_id) + + if state: + assert flow_state_eq(state, state_before) + if exchange_attempted: + MsalConnectionManager.get_connection.assert_called_once_with(expected_connection) + provider.acquire_token_on_behalf_of.assert_called_once_with( + scopes=expected_scopes, user_assertion=get_user_token_return.token + ) \ No newline at end of file diff --git a/tests/hosting_core/app/auth/test_auth_handler.py b/tests/hosting_core/app/auth/test_auth_handler.py index 9d426e23..d79a6d07 100644 --- a/tests/hosting_core/app/auth/test_auth_handler.py +++ b/tests/hosting_core/app/auth/test_auth_handler.py @@ -2,10 +2,11 @@ from microsoft_agents.hosting.core import AuthHandler -from tests._common.data import TEST_DEFAULTS, TEST_ENV_DICT +from tests._common.data import TEST_DEFAULTS, TEST_ENV_DICT, TEST_AGENTIC_ENV_DICT DEFAULTS = TEST_DEFAULTS() ENV_DICT = TEST_ENV_DICT() +AGENTIC_ENV_DICT = TEST_AGENTIC_ENV_DICT() class TestAuthHandler: @@ -14,6 +15,12 @@ def auth_setting(self): return ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][ DEFAULTS.auth_handler_id ]["SETTINGS"] + + @pytest.fixture + def agentic_auth_setting(self): + return AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][ + DEFAULTS.agentic_auth_handler_id + ]["SETTINGS"] def test_init(self, auth_setting): auth_handler = AuthHandler(DEFAULTS.auth_handler_id, **auth_setting) @@ -24,3 +31,15 @@ def test_init(self, auth_setting): assert ( auth_handler.abs_oauth_connection_name == DEFAULTS.abs_oauth_connection_name ) + + def test_init_agentic(self, agentic_auth_setting): + auth_handler = AuthHandler(DEFAULTS.agentic_auth_handler_id, **agentic_auth_setting) + assert auth_handler.name == DEFAULTS.agentic_auth_handler_id + assert auth_handler.title == DEFAULTS.agentic_auth_handler_title + assert auth_handler.text == DEFAULTS.agentic_auth_handler_text + assert auth_handler.obo_connection_name == DEFAULTS.agentic_obo_connection_name + assert auth_handler.scopes == [ "user.Read", "Mail.Read" ] + assert ( + auth_handler.abs_oauth_connection_name == DEFAULTS.agentic_abs_oauth_connection_name + ) + From 84edf3aef55a7cda55e37375808cb6e4b9cda07b Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 29 Sep 2025 14:53:40 -0700 Subject: [PATCH 21/36] Finalized refactor tests --- .../activity/token_response.py | 18 + .../hosting/core/app/auth/authorization.py | 35 +- .../handlers/agentic_user_authorization.py | 2 +- .../app/auth/handlers/user_authorization.py | 19 +- .../_tests/test_create_env_var_dict.py | 2 - .../data/configs/test_agentic_auth_config.py | 2 +- tests/_common/testing_objects/__init__.py | 4 +- .../_common/testing_objects/mocks/__init__.py | 4 +- .../mocks/mock_authorization.py | 12 +- tests/_integration/test_quickstart.py | 54 +- tests/activity/test_load_configuration.py | 3 - .../hosting_core/app/auth/handlers/_common.py | 19 - .../test_agentic_user_authorization.py | 12 +- .../handlers/test_authorization_handler.py | 0 .../app/auth/test_agentic_authorization.py | 270 ---- .../app/auth/test_authorization.py | 1212 +++++++++-------- .../app/auth/test_user_authorization.py | 263 ---- 17 files changed, 717 insertions(+), 1214 deletions(-) delete mode 100644 tests/_common/_tests/test_create_env_var_dict.py delete mode 100644 tests/hosting_core/app/auth/handlers/_common.py delete mode 100644 tests/hosting_core/app/auth/handlers/test_authorization_handler.py delete mode 100644 tests/hosting_core/app/auth/test_agentic_authorization.py delete mode 100644 tests/hosting_core/app/auth/test_user_authorization.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/token_response.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/token_response.py index 00d6aa91..9b80841e 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/token_response.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/token_response.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import jwt + from .agents_model import AgentsModel from ._type_aliases import NonEmptyString @@ -26,3 +28,19 @@ class TokenResponse(AgentsModel): def __bool__(self): return bool(self.token) + + def is_exchangeable(self) -> bool: + """ + Checks if a token is exchangeable (has api:// audience). + + :param token: The token to check. + :type token: str + :return: True if the token is exchangeable, False otherwise. + """ + try: + # Decode without verification to check the audience + payload = jwt.decode(self.token, options={"verify_signature": False}) + aud = payload.get("aud") + return isinstance(aud, str) and aud.startswith("api://") + except Exception: + return False \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py index 6fe1a778..b2893eb9 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py @@ -18,6 +18,7 @@ UserAuthorization, AuthorizationHandler ) +from microsoft_agents.hosting.core.app.auth import auth_handler logger = logging.getLogger(__name__) @@ -71,9 +72,7 @@ def __init__( self._handlers = {} - if auth_handlers and len(auth_handlers) > 0: - self._init_auth_variants(auth_handlers) - else: + if not auth_handlers: auth_configuration: dict = kwargs.get("AGENTAPPLICATION", {}).get( "USERAUTHORIZATION", {} @@ -202,7 +201,7 @@ async def start_or_continue_sign_in( handler = self.resolve_handler(auth_handler_id) # attempt sign-in continuation (or beginning) - sign_in_response = await handler.sign_in(context, auth_handler_id, handler.scopes) + sign_in_response = await handler.sign_in(context) if sign_in_response.tag == FlowStateTag.COMPLETE: if self._sign_in_success_handler: @@ -283,7 +282,7 @@ async def on_turn_auth_intercept( async def get_token( self, context: TurnContext, auth_handler_id: Optional[str] = None - ) -> str: + ) -> Optional[str]: """Gets the token for a specific auth handler. The token is taken from cache, so this does not initiate nor continue a sign-in flow. @@ -295,7 +294,7 @@ async def get_token( :return: The token response from the OAuth provider. :rtype: TokenResponse """ - return self.exchange_token(context, auth_handler_id) + return await self.exchange_token(context, auth_handler_id) async def exchange_token( self, @@ -305,22 +304,30 @@ async def exchange_token( scopes: Optional[list[str]] = None ) -> Optional[str]: + auth_handler_id = auth_handler_id or self._default_handler_id + if auth_handler_id not in self._handlers: + raise ValueError( + f"Auth handler {auth_handler_id} not recognized or not configured." + ) + handler = self.resolve_handler(auth_handler_id) sign_in_state = await self._load_sign_in_state(context) if not sign_in_state or not sign_in_state.tokens.get(auth_handler_id): return None - token_res = sign_in_state.tokens[auth_handler_id] - if not context.activity.is_agentic(): - if not token_res.is_exchangeable: - if token.expiration is not None: - diff = token.expiration - datetime.now().timestamp() - if diff >= SOME_VALUE: - return token_res.token + # for later -> parity with .NET + # token_res = sign_in_state.tokens[auth_handler_id] + # if not context.activity.is_agentic(): + # if token_res and not token_res.is_exchangeable(): + # token = token_res.token + # if token.expiration is not None: + # diff = token.expiration - datetime.now().timestamp() + # if diff > 0: + # return token_res.token handler = self.resolve_handler(auth_handler_id) - res = await handler.get_refreshed_token(context, auth_handler_id, exchange_connection, scopes) + res = await handler.get_refreshed_token(context, exchange_connection, scopes) if res: sign_in_state.tokens[auth_handler_id] = res.token await self._save_sign_in_state(context, sign_in_state) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_user_authorization.py index 73d0eab1..3d9dd887 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_user_authorization.py @@ -92,7 +92,7 @@ async def get_refreshed_token(self, ) -> TokenResponse: """Gets a refreshed agentic user token if available.""" if not exchange_scopes: - exchange_scopes = self._handler.exchange_scopes or [] + exchange_scopes = self._handler.scopes or [] token = await self.get_agentic_user_token(context, exchange_scopes) return TokenResponse(token=token) if token else TokenResponse() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/user_authorization.py index 011eca5c..8a32fa65 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/user_authorization.py @@ -120,7 +120,7 @@ async def _handle_obo( if not connection_name or not exchange_scopes: return input_token_response - if not self._is_exchangeable(input_token_response.token): + if not input_token_response.is_exchangeable(): return input_token_response token_provider = self._connection_manager.get_connection(connection_name) @@ -133,23 +133,6 @@ async def _handle_obo( ) return TokenResponse(token=token) if token else TokenResponse() - def _is_exchangeable(self, token: str) -> bool: - """ - Checks if a token is exchangeable (has api:// audience). - - :param token: The token to check. - :type token: str - :return: True if the token is exchangeable, False otherwise. - """ - try: - # Decode without verification to check the audience - payload = jwt.decode(token, options={"verify_signature": False}) - aud = payload.get("aud") - return isinstance(aud, str) and aud.startswith("api://") - except Exception: - logger.error("Failed to decode token to check audience") - return False - async def sign_out( self, context: TurnContext, diff --git a/tests/_common/_tests/test_create_env_var_dict.py b/tests/_common/_tests/test_create_env_var_dict.py deleted file mode 100644 index 085c31ec..00000000 --- a/tests/_common/_tests/test_create_env_var_dict.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_create_env_var_dict(): - assert False \ No newline at end of file diff --git a/tests/_common/data/configs/test_agentic_auth_config.py b/tests/_common/data/configs/test_agentic_auth_config.py index 00bb6fb1..f7f2e261 100644 --- a/tests/_common/data/configs/test_agentic_auth_config.py +++ b/tests/_common/data/configs/test_agentic_auth_config.py @@ -25,7 +25,7 @@ AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__OBOCONNECTIONNAME={agentic_obo_connection_name} AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__TITLE={agentic_auth_handler_title} AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__TEXT={agentic_auth_handler_text} -AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__TYPE=AgenticAuthorization +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__TYPE=AgenticUserAuthorization AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__{agentic_auth_handler_id}__SETTINGS__SCOPES=user.Read Mail.Read CONNECTIONSMAP__0__CONNECTION=SERVICE_CONNECTION diff --git a/tests/_common/testing_objects/__init__.py b/tests/_common/testing_objects/__init__.py index 0e6aefeb..875165dc 100644 --- a/tests/_common/testing_objects/__init__.py +++ b/tests/_common/testing_objects/__init__.py @@ -7,7 +7,7 @@ mock_UserTokenClient, mock_class_UserTokenClient, mock_class_UserAuthorization, - mock_class_AgenticAuthorization, + mock_class_AgenticUserAuthorization, mock_class_Authorization, agentic_mock_class_MsalAuth, ) @@ -31,7 +31,7 @@ "TestingUserTokenClient", "TestingAdapter", "mock_class_UserAuthorization", - "mock_class_AgenticAuthorization", + "mock_class_AgenticUserAuthorization", "mock_class_Authorization", "agentic_mock_class_MsalAuth", ] diff --git a/tests/_common/testing_objects/mocks/__init__.py b/tests/_common/testing_objects/mocks/__init__.py index 780b218c..a6f7c85d 100644 --- a/tests/_common/testing_objects/mocks/__init__.py +++ b/tests/_common/testing_objects/mocks/__init__.py @@ -3,7 +3,7 @@ from .mock_user_token_client import mock_UserTokenClient, mock_class_UserTokenClient from .mock_authorization import ( mock_class_UserAuthorization, - mock_class_AgenticAuthorization, + mock_class_AgenticUserAuthorization, mock_class_Authorization, ) @@ -14,7 +14,7 @@ "mock_UserTokenClient", "mock_class_UserTokenClient", "mock_class_UserAuthorization", - "mock_class_AgenticAuthorization", + "mock_class_AgenticUserAuthorization", "mock_class_Authorization", "agentic_mock_class_MsalAuth", ] diff --git a/tests/_common/testing_objects/mocks/mock_authorization.py b/tests/_common/testing_objects/mocks/mock_authorization.py index 2c529bcb..19a2d3fa 100644 --- a/tests/_common/testing_objects/mocks/mock_authorization.py +++ b/tests/_common/testing_objects/mocks/mock_authorization.py @@ -1,3 +1,5 @@ +from microsoft_agents.activity import TokenResponse + from microsoft_agents.hosting.core import ( Authorization, UserAuthorization, @@ -5,18 +7,24 @@ SignInResponse ) -def mock_class_UserAuthorization(mocker, sign_in_return=None): +def mock_class_UserAuthorization(mocker, sign_in_return=None, get_refreshed_token_return=None): if sign_in_return is None: sign_in_return = SignInResponse() + if get_refreshed_token_return is None: + get_refreshed_token_return = TokenResponse() mocker.patch.object(UserAuthorization, "sign_in", return_value=sign_in_return) mocker.patch.object(UserAuthorization, "sign_out") + mocker.patch.object(UserAuthorization, "get_refreshed_token", return_value=get_refreshed_token_return) -def mock_class_AgenticAuthorization(mocker, sign_in_return=None): +def mock_class_AgenticUserAuthorization(mocker, sign_in_return=None, get_refreshed_token_return=None): if sign_in_return is None: sign_in_return = SignInResponse() + if get_refreshed_token_return is None: + get_refreshed_token_return = TokenResponse() mocker.patch.object(AgenticUserAuthorization, "sign_in", return_value=sign_in_return) mocker.patch.object(AgenticUserAuthorization, "sign_out") + mocker.patch.object(AgenticUserAuthorization, "get_refreshed_token", return_value=get_refreshed_token_return) def mock_class_Authorization(mocker, start_or_continue_sign_in_return=False): diff --git a/tests/_integration/test_quickstart.py b/tests/_integration/test_quickstart.py index 32fac1bf..bbb3f997 100644 --- a/tests/_integration/test_quickstart.py +++ b/tests/_integration/test_quickstart.py @@ -1,37 +1,37 @@ -import pytest +# import pytest -from tests._integration.common.testing_environment import ( - TestingEnvironment, - MockTestingEnvironment, -) -from tests._integration.scenarios.quickstart import main +# from tests._integration.common.testing_environment import ( +# TestingEnvironment, +# MockTestingEnvironment, +# ) +# from tests._integration.scenarios.quickstart import main -class _TestQuickstart: - @pytest.fixture - def testenv(self, mocker) -> TestingEnvironment: - raise NotImplementedError() +# class _TestQuickstart: +# @pytest.fixture +# def testenv(self, mocker) -> TestingEnvironment: +# raise NotImplementedError() - # @pytest.fixture - # def client(self, testenv) -> TestClient: - # return TestClient(testenv.adapter) +# # @pytest.fixture +# # def client(self, testenv) -> TestClient: +# # return TestClient(testenv.adapter) - @pytest.mark.asyncio - async def test_quickstart(self, testenv): - main(testenv) - # testenv.adapter.send_activity("Hello World") +# @pytest.mark.asyncio +# async def test_quickstart(self, testenv): +# main(testenv) +# # testenv.adapter.send_activity("Hello World") -# class TestQuickstartMultipleEnvs(_TestQuickstart): +# # class TestQuickstartMultipleEnvs(_TestQuickstart): -# @pytest.fixture( -# params=[MockTestingEnvironment, SampleEnvironment], -# ) -# def testenv(self, mocker, request) -> TestingEnvironment: -# return request.param(mocker) +# # @pytest.fixture( +# # params=[MockTestingEnvironment, SampleEnvironment], +# # ) +# # def testenv(self, mocker, request) -> TestingEnvironment: +# # return request.param(mocker) -class TestQuickstartMockEnv(_TestQuickstart): - @pytest.fixture - def testenv(self, mocker) -> TestingEnvironment: - return MockTestingEnvironment(mocker) +# class TestQuickstartMockEnv(_TestQuickstart): +# @pytest.fixture +# def testenv(self, mocker) -> TestingEnvironment: +# return MockTestingEnvironment(mocker) diff --git a/tests/activity/test_load_configuration.py b/tests/activity/test_load_configuration.py index bcf571e6..b121c87a 100644 --- a/tests/activity/test_load_configuration.py +++ b/tests/activity/test_load_configuration.py @@ -45,9 +45,6 @@ }, } }, - "AGENTICAUTHORIZATION": { - "HANDLERS": {} - } }, "CONNECTIONSMAP": [ { diff --git a/tests/hosting_core/app/auth/handlers/_common.py b/tests/hosting_core/app/auth/handlers/_common.py deleted file mode 100644 index 6d05971a..00000000 --- a/tests/hosting_core/app/auth/handlers/_common.py +++ /dev/null @@ -1,19 +0,0 @@ -from microsoft_agents.activity import ( - Activity, - ChannelAccount, - RoleTypes, -) - -from tests._common.data import TEST_DEFAULTS - -DEFAULTS = TEST_DEFAULTS() - -def AGENTIC_ACTIVITY(): - return Activity( - type="message", - recipient=ChannelAccount( - id="bot_id", - agentic_app_id=DEFAULTS.agentic_instance_id, - role=RoleTypes.agentic_instance, - ), - ) \ No newline at end of file diff --git a/tests/hosting_core/app/auth/handlers/test_agentic_user_authorization.py b/tests/hosting_core/app/auth/handlers/test_agentic_user_authorization.py index 8cdac31e..3075db6a 100644 --- a/tests/hosting_core/app/auth/handlers/test_agentic_user_authorization.py +++ b/tests/hosting_core/app/auth/handlers/test_agentic_user_authorization.py @@ -195,7 +195,7 @@ async def test_get_agentic_instance_token_is_agentic( connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) agentic_auth = AgenticUserAuthorization( - MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings + MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id ) activity = Activity( @@ -222,7 +222,7 @@ async def test_get_agentic_user_token_is_agentic( connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) agentic_auth = AgenticUserAuthorization( - MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings + MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id ) activity = Activity( @@ -257,7 +257,7 @@ async def test_sign_in_success(self, mocker, scopes_list, agentic_role, expected connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) agentic_auth = AgenticUserAuthorization( - MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings + MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id ) activity = Activity( type="message", @@ -292,7 +292,7 @@ async def test_sign_in_failure(self, mocker, scopes_list, agentic_role, expected connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) agentic_auth = AgenticUserAuthorization( - MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings + MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id ) activity = Activity( type="message", @@ -327,7 +327,7 @@ async def test_get_refreshed_token_success(self, mocker, scopes_list, agentic_ro connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) agentic_auth = AgenticUserAuthorization( - MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings + MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id ) activity = Activity( type="message", @@ -361,7 +361,7 @@ async def test_get_refreshed_token_failure(self, mocker, scopes_list, agentic_ro connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) agentic_auth = AgenticUserAuthorization( - MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings + MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id ) activity = Activity( type="message", diff --git a/tests/hosting_core/app/auth/handlers/test_authorization_handler.py b/tests/hosting_core/app/auth/handlers/test_authorization_handler.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/hosting_core/app/auth/test_agentic_authorization.py b/tests/hosting_core/app/auth/test_agentic_authorization.py deleted file mode 100644 index fc0ec1c4..00000000 --- a/tests/hosting_core/app/auth/test_agentic_authorization.py +++ /dev/null @@ -1,270 +0,0 @@ -# import pytest - -# from microsoft_agents.activity import Activity, ChannelAccount, RoleTypes - -# from microsoft_agents.authentication.msal import MsalAuth, MsalConnectionManager - -# from microsoft_agents.hosting.core import ( -# AgenticAuthorization, -# SignInResponse, -# MemoryStorage, -# FlowStateTag, -# ) - -# from tests._common.data import ( -# TEST_FLOW_DATA, -# TEST_AUTH_DATA, -# TEST_STORAGE_DATA, -# TEST_DEFAULTS, -# TEST_ENV_DICT, -# TEST_AGENTIC_ENV_DICT, -# create_test_auth_handler, -# ) - -# from tests._common.testing_objects import ( -# TestingConnectionManager, -# TestingTokenProvider, -# agentic_mock_class_MsalAuth, -# TestingConnectionManager as MockConnectionManager, -# ) - -# from ._common import ( -# testing_TurnContext_magic, -# ) - -# DEFAULTS = TEST_DEFAULTS() -# AGENTIC_ENV_DICT = TEST_AGENTIC_ENV_DICT() - - -# class TestUtils: -# def setup_method(self): -# self.TurnContext = testing_TurnContext_magic - -# @pytest.fixture -# def storage(self): -# return MemoryStorage() - -# @pytest.fixture -# def connection_manager(self, mocker): -# return MockConnectionManager() - -# @pytest.fixture -# def agentic_auth(self, mocker, storage, connection_manager): -# return AgenticAuthorization(storage, connection_manager, **AGENTIC_ENV_DICT) - -# @pytest.fixture(params=[RoleTypes.user, RoleTypes.skill, RoleTypes.agent]) -# def non_agentic_role(self, request): -# return request.param - -# @pytest.fixture(params=[RoleTypes.agentic_user, RoleTypes.agentic_identity]) -# def agentic_role(self, request): -# return request.param - - -# class TestAgenticAuthorization(TestUtils): -# @pytest.mark.parametrize( -# "activity", -# [ -# Activity( -# type="message", -# recipient=ChannelAccount( -# id="bot_id", -# agentic_app_id=DEFAULTS.agentic_instance_id, -# role=RoleTypes.agent, -# ), -# ), -# Activity( -# type="message", -# recipient=ChannelAccount( -# id=DEFAULTS.agentic_user_id, -# agentic_app_id=DEFAULTS.agentic_instance_id, -# role=RoleTypes.agentic_user, -# ), -# ), -# Activity( -# type="message", -# recipient=ChannelAccount( -# id=DEFAULTS.agentic_user_id, -# ), -# ), -# Activity(type="message", recipient=ChannelAccount(id="some_id")), -# ], -# ) -# def test_is_agentic_request(self, mocker, activity): -# assert activity.is_agentic() == AgenticAuthorization.is_agentic_request( -# activity -# ) -# context = self.TurnContext(mocker, activity=activity) -# assert activity.is_agentic() == AgenticAuthorization.is_agentic_request(context) - -# def test_get_agent_instance_id_is_agentic(self, mocker, agentic_role): -# activity = Activity( -# type="message", -# recipient=ChannelAccount( -# id="some_id", -# agentic_app_id=DEFAULTS.agentic_instance_id, -# role=agentic_role, -# ), -# ) -# context = self.TurnContext(mocker, activity=activity) -# assert ( -# AgenticAuthorization.get_agent_instance_id(context) -# == DEFAULTS.agentic_instance_id -# ) - -# def test_get_agent_instance_id_not_agentic(self, mocker, non_agentic_role): -# activity = Activity( -# type="message", -# recipient=ChannelAccount( -# id="some_id", -# agentic_app_id=DEFAULTS.agentic_instance_id, -# role=non_agentic_role, -# ), -# ) -# context = self.TurnContext(mocker, activity=activity) -# assert AgenticAuthorization.get_agent_instance_id(context) is None - -# def test_get_agentic_user_is_agentic(self, mocker, agentic_role): -# activity = Activity( -# type="message", -# recipient=ChannelAccount( -# id=DEFAULTS.agentic_user_id, -# agentic_app_id=DEFAULTS.agentic_instance_id, -# role=agentic_role, -# ), -# ) -# context = self.TurnContext(mocker, activity=activity) -# assert ( -# AgenticAuthorization.get_agentic_user(context) == DEFAULTS.agentic_user_id -# ) - -# def test_get_agentic_user_not_agentic(self, mocker, non_agentic_role): -# activity = Activity( -# type="message", -# recipient=ChannelAccount( -# id=DEFAULTS.agentic_user_id, -# agentic_app_id=DEFAULTS.agentic_instance_id, -# role=non_agentic_role, -# ), -# ) -# context = self.TurnContext(mocker, activity=activity) -# assert AgenticAuthorization.get_agentic_user(context) is None - -# @pytest.mark.asyncio -# async def test_get_agentic_instance_token_not_agentic( -# self, mocker, non_agentic_role, agentic_auth -# ): -# activity = Activity( -# type="message", -# recipient=ChannelAccount( -# id=DEFAULTS.agentic_user_id, -# agentic_app_id=DEFAULTS.agentic_instance_id, -# role=non_agentic_role, -# ), -# ) -# context = self.TurnContext(mocker, activity=activity) -# assert await agentic_auth.get_agentic_instance_token(context) is None - -# @pytest.mark.asyncio -# async def test_get_agentic_user_token_not_agentic( -# self, mocker, non_agentic_role, agentic_auth -# ): -# activity = Activity( -# type="message", -# recipient=ChannelAccount( -# id=DEFAULTS.agentic_user_id, -# agentic_app_id=DEFAULTS.agentic_instance_id, -# role=non_agentic_role, -# ), -# ) -# context = self.TurnContext(mocker, activity=activity) -# assert await agentic_auth.get_agentic_user_token(context, ["user.Read"]) is None - -# @pytest.mark.asyncio -# async def test_get_agentic_user_token_agentic_no_user_id( -# self, mocker, agentic_role, agentic_auth -# ): -# activity = Activity( -# type="message", -# recipient=ChannelAccount( -# agentic_app_id=DEFAULTS.agentic_instance_id, role=agentic_role -# ), -# ) -# context = self.TurnContext(mocker, activity=activity) -# assert await agentic_auth.get_agentic_user_token(context, ["user.Read"]) is None - -# @pytest.mark.asyncio -# async def test_get_agentic_instance_token_is_agentic( -# self, mocker, agentic_role, agentic_auth -# ): -# mock_provider = mocker.Mock(spec=MsalAuth) -# mock_provider.get_agentic_instance_token = mocker.AsyncMock( -# return_value=[DEFAULTS.token, "bot_id"] -# ) - -# connection_manager = mocker.Mock(spec=MsalConnectionManager) -# connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) - -# agentic_auth = AgenticAuthorization( -# MemoryStorage(), connection_manager, **AGENTIC_ENV_DICT -# ) - -# activity = Activity( -# type="message", -# recipient=ChannelAccount( -# id="some_id", -# agentic_app_id=DEFAULTS.agentic_instance_id, -# role=agentic_role, -# ), -# ) -# context = self.TurnContext(mocker, activity=activity) - -# token = await agentic_auth.get_agentic_instance_token(context) -# assert token == DEFAULTS.token - -# @pytest.mark.asyncio -# async def test_get_agentic_user_token_is_agentic( -# self, mocker, agentic_role, agentic_auth -# ): -# mock_provider = mocker.Mock(spec=MsalAuth) -# mock_provider.get_agentic_user_token = mocker.AsyncMock( -# return_value=DEFAULTS.token -# ) - -# connection_manager = mocker.Mock(spec=MsalConnectionManager) -# connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) - -# agentic_auth = AgenticAuthorization( -# MemoryStorage(), connection_manager, **AGENTIC_ENV_DICT -# ) - -# activity = Activity( -# type="message", -# recipient=ChannelAccount( -# id="some_id", -# agentic_app_id=DEFAULTS.agentic_instance_id, -# role=agentic_role, -# ), -# ) -# context = self.TurnContext(mocker, activity=activity) - -# token = await agentic_auth.get_agentic_user_token(context, ["user.Read"]) -# assert token == DEFAULTS.token - -# @pytest.mark.asyncio -# async def test_sign_in_success(self, mocker, agentic_auth): -# mocker.patch.object( -# AgenticAuthorization, "get_agentic_user_token", return_value=DEFAULTS.token -# ) -# res = await agentic_auth.sign_in(None, ["user.Read"]) -# assert res.token_response.token == DEFAULTS.token -# assert res.tag == FlowStateTag.COMPLETE - -# @pytest.mark.asyncio -# async def test_sign_in_failure(self, mocker, agentic_auth): -# mocker.patch.object( -# AgenticAuthorization, "get_agentic_user_token", return_value=None -# ) -# res = await agentic_auth.sign_in(None, ["user.Read"]) -# assert not res.token_response -# assert res.tag == FlowStateTag.FAILURE diff --git a/tests/hosting_core/app/auth/test_authorization.py b/tests/hosting_core/app/auth/test_authorization.py index 3effe819..800da75b 100644 --- a/tests/hosting_core/app/auth/test_authorization.py +++ b/tests/hosting_core/app/auth/test_authorization.py @@ -1,584 +1,628 @@ -# import pytest -# from datetime import datetime -# import jwt - -# from typing import Optional - -# from microsoft_agents.activity import Activity, ActivityTypes, TokenResponse - -# from microsoft_agents.hosting.core import ( -# FlowStorageClient, -# FlowErrorTag, -# FlowStateTag, -# FlowState, -# FlowResponse, -# OAuthFlow, -# Authorization, -# UserAuthorization, -# Storage, -# TurnContext, -# MemoryStorage, -# AuthHandler, -# FlowStateTag, -# SignInState, -# SignInResponse, -# ) - -# from tests._common.storage.utils import StorageBaseline - -# # test constants -# from tests._common.data import ( -# TEST_FLOW_DATA, -# TEST_AUTH_DATA, -# TEST_STORAGE_DATA, -# TEST_DEFAULTS, -# TEST_ENV_DICT, -# TEST_AGENTIC_ENV_DICT, -# create_test_auth_handler, -# ) -# from tests._common.fixtures import FlowStateFixtures -# from tests._common.testing_objects import ( -# TestingConnectionManager as MockConnectionManager, -# mock_class_OAuthFlow, -# mock_UserTokenClient, -# mock_class_UserAuthorization, -# mock_class_AgenticAuthorization, -# mock_class_Authorization, -# ) -# from tests.hosting_core._common import flow_state_eq - -# from ._common import testing_TurnContext, testing_Activity - -# DEFAULTS = TEST_DEFAULTS() -# FLOW_DATA = TEST_FLOW_DATA() -# STORAGE_DATA = TEST_STORAGE_DATA() -# ENV_DICT = TEST_ENV_DICT() -# AGENTIC_ENV_DICT = TEST_AGENTIC_ENV_DICT() - - -# async def get_sign_in_state( -# auth: Authorization, storage: Storage, context: TurnContext -# ) -> Optional[SignInState]: -# key = auth.sign_in_state_key(context) -# return (await storage.read([key], target_cls=SignInState)).get(key) - - -# async def set_sign_in_state( -# auth: Authorization, storage: Storage, context: TurnContext, state: SignInState -# ): -# key = auth.sign_in_state_key(context) -# await storage.write({key: state}) - - -# def mock_variants(mocker, sign_in_return=None): -# mock_class_UserAuthorization(mocker, sign_in_return=sign_in_return) -# mock_class_AgenticAuthorization(mocker, sign_in_return=sign_in_return) - - -# def sign_in_state_eq(a: Optional[SignInState], b: Optional[SignInState]) -> bool: -# if a is None and b is None: -# return True -# if a is None or b is None: -# return False -# return a.tokens == b.tokens and a.continuation_activity == b.continuation_activity - - -# def copy_sign_in_state(state: SignInState) -> SignInState: -# return SignInState( -# tokens=state.tokens.copy(), -# continuation_activity=( -# state.continuation_activity.model_copy() -# if state.continuation_activity -# else None -# ), -# ) - - -# class TestEnv(FlowStateFixtures): -# def setup_method(self): -# self.TurnContext = testing_TurnContext -# self.UserTokenClient = mock_UserTokenClient -# self.ConnectionManager = lambda mocker: MockConnectionManager() - -# @pytest.fixture -# def context(self, mocker): -# return self.TurnContext(mocker) - -# @pytest.fixture -# def activity(self): -# return testing_Activity() - -# @pytest.fixture -# def baseline_storage(self): -# return StorageBaseline(TEST_STORAGE_DATA().dict) - -# @pytest.fixture -# def storage(self): -# return MemoryStorage(STORAGE_DATA.get_init_data()) - -# @pytest.fixture -# def connection_manager(self, mocker): -# return self.ConnectionManager(mocker) - -# @pytest.fixture -# def auth_handlers(self): -# return TEST_AUTH_DATA().auth_handlers - -# @pytest.fixture -# def authorization(self, connection_manager, storage): -# return Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) - -# @pytest.fixture(params=[ENV_DICT, AGENTIC_ENV_DICT]) -# def env_dict(self, request): -# return request.param - -# @pytest.fixture(params=[DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id]) -# def auth_handler_id(self, request): -# return request.param - - -# class TestAuthorizationSetup(TestEnv): -# def test_init_user_auth(self, connection_manager, storage, env_dict): -# auth = Authorization(storage, connection_manager, **env_dict) -# assert auth.user_auth is not None - -# def test_init_agentic_auth_not_configured(self, connection_manager, storage): -# auth = Authorization(storage, connection_manager, **ENV_DICT) -# with pytest.raises(ValueError): -# agentic_auth = auth.agentic_auth - -# def test_init_agentic_auth(self, connection_manager, storage): -# auth = Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) -# assert auth.agentic_auth is not None - -# @pytest.mark.parametrize( -# "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] -# ) -# def test_resolve_handler(self, connection_manager, storage, auth_handler_id): -# auth = Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) -# handler_config = AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"][ -# "HANDLERS" -# ][auth_handler_id] -# auth.resolve_handler(auth_handler_id) == AuthHandler( -# auth_handler_id, **handler_config -# ) - -# def test_sign_in_state_key(self, mocker, connection_manager, storage): -# auth = Authorization(storage, connection_manager, **ENV_DICT) -# context = self.TurnContext(mocker) -# key = auth.sign_in_state_key(context) -# assert key == f"auth:SignInState:{DEFAULTS.channel_id}:{DEFAULTS.user_id}" - - -# class TestAuthorizationUsage(TestEnv): -# @pytest.mark.asyncio -# async def test_get_token(self, mocker, storage, authorization): -# context = self.TurnContext(mocker) -# token_response = await authorization.get_token( -# context, DEFAULTS.auth_handler_id -# ) -# assert not token_response - -# @pytest.mark.asyncio -# async def test_get_token_with_sign_in_state_empty( -# self, mocker, storage, authorization, context -# ): -# # setup -# key = authorization.sign_in_state_key(context) -# await storage.write( -# { -# key: SignInState( -# tokens={ -# DEFAULTS.auth_handler_id: "", -# DEFAULTS.agentic_auth_handler_id: "", -# } -# ) -# } -# ) - -# # test -# token_response = await authorization.get_token( -# context, DEFAULTS.auth_handler_id -# ) -# assert not token_response - -# @pytest.mark.asyncio -# async def test_get_token_with_sign_in_state_empty_alt( -# self, mocker, storage, authorization, context -# ): -# # setup -# key = authorization.sign_in_state_key(context) -# await storage.write( -# { -# key: SignInState( -# tokens={ -# DEFAULTS.auth_handler_id: "token", -# DEFAULTS.agentic_auth_handler_id: "", -# } -# ) -# } -# ) - -# # test -# token_response = await authorization.get_token( -# context, DEFAULTS.agentic_auth_handler_id -# ) -# assert not token_response - -# @pytest.mark.asyncio -# async def test_get_token_with_sign_in_state_valid( -# self, mocker, storage, authorization -# ): -# # setup -# context = self.TurnContext(mocker) -# key = authorization.sign_in_state_key(context) -# await storage.write( -# {key: SignInState(tokens={DEFAULTS.auth_handler_id: "valid_token"})} -# ) - -# # test -# token_response = await authorization.get_token( -# context, DEFAULTS.auth_handler_id -# ) -# assert token_response.token == "valid_token" - -# @pytest.mark.asyncio -# async def test_start_or_continue_sign_in_cached( -# self, storage, authorization, context, activity -# ): -# # setup -# initial_state = SignInState( -# tokens={DEFAULTS.auth_handler_id: "valid_token"}, -# continuation_activity=activity, -# ) -# await set_sign_in_state(authorization, storage, context, initial_state) -# sign_in_response = await authorization.start_or_continue_sign_in( -# context, None, DEFAULTS.auth_handler_id -# ) -# assert sign_in_response.tag == FlowStateTag.COMPLETE -# assert sign_in_response.token_response.token == "valid_token" - -# assert sign_in_state_eq( -# await get_sign_in_state(authorization, storage, context), initial_state -# ) - -# @pytest.mark.asyncio -# @pytest.mark.parametrize( -# "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] -# ) -# async def test_start_or_continue_sign_in_no_initial_state_to_complete( -# self, mocker, storage, authorization, context, auth_handler_id -# ): -# mock_variants( -# mocker, -# sign_in_return=SignInResponse( -# token_response=TokenResponse(token=DEFAULTS.token), -# tag=FlowStateTag.COMPLETE, -# ), -# ) -# sign_in_response = await authorization.start_or_continue_sign_in( -# context, None, auth_handler_id -# ) -# assert sign_in_response.tag == FlowStateTag.COMPLETE -# assert sign_in_response.token_response.token == DEFAULTS.token - -# final_state = await get_sign_in_state(authorization, storage, context) -# assert final_state.tokens[auth_handler_id] == DEFAULTS.token -# assert final_state.continuation_activity is None - -# @pytest.mark.asyncio -# @pytest.mark.parametrize( -# "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] -# ) -# async def test_start_or_continue_sign_in_to_complete_with_prev_state( -# self, mocker, storage, authorization, context, auth_handler_id -# ): -# # setup -# initial_state = SignInState( -# tokens={"my_handler": "old_token"}, -# continuation_activity=Activity( -# type=ActivityTypes.message, text="old activity" -# ), -# ) -# await set_sign_in_state(authorization, storage, context, initial_state) -# mock_variants( -# mocker, -# sign_in_return=SignInResponse( -# token_response=TokenResponse(token=DEFAULTS.token), -# tag=FlowStateTag.COMPLETE, -# ), -# ) - -# # test -# sign_in_response = await authorization.start_or_continue_sign_in( -# context, None, auth_handler_id -# ) -# assert sign_in_response.tag == FlowStateTag.COMPLETE -# assert sign_in_response.token_response.token == DEFAULTS.token - -# # verify -# final_state = await get_sign_in_state(authorization, storage, context) -# assert final_state.tokens[auth_handler_id] == DEFAULTS.token -# assert final_state.tokens["my_handler"] == "old_token" -# assert final_state.continuation_activity == initial_state.continuation_activity - -# @pytest.mark.asyncio -# @pytest.mark.parametrize( -# "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] -# ) -# async def test_start_or_continue_sign_in_to_failure_with_prev_state( -# self, mocker, storage, authorization, context, auth_handler_id -# ): -# # setup -# initial_state = SignInState( -# tokens={"my_handler": "old_token"}, -# continuation_activity=Activity( -# type=ActivityTypes.message, text="old activity" -# ), -# ) -# await set_sign_in_state(authorization, storage, context, initial_state) -# mock_variants( -# mocker, -# sign_in_return=SignInResponse( -# token_response=TokenResponse(), tag=FlowStateTag.FAILURE -# ), -# ) - -# # test -# sign_in_response = await authorization.start_or_continue_sign_in( -# context, None, auth_handler_id -# ) -# assert sign_in_response.tag == FlowStateTag.FAILURE -# assert not sign_in_response.token_response - -# # verify -# final_state = await get_sign_in_state(authorization, storage, context) -# assert not final_state.tokens.get(auth_handler_id) -# assert final_state.tokens["my_handler"] == "old_token" -# assert final_state.continuation_activity == initial_state.continuation_activity - -# @pytest.mark.asyncio -# @pytest.mark.parametrize( -# "auth_handler_id, tag", -# [ -# (DEFAULTS.auth_handler_id, FlowStateTag.BEGIN), -# (DEFAULTS.agentic_auth_handler_id, FlowStateTag.BEGIN), -# (DEFAULTS.auth_handler_id, FlowStateTag.CONTINUE), -# (DEFAULTS.agentic_auth_handler_id, FlowStateTag.CONTINUE), -# ], -# ) -# async def test_start_or_continue_sign_in_to_pending_with_prev_state( -# self, mocker, storage, authorization, context, auth_handler_id, tag -# ): -# # setup -# initial_state = SignInState( -# tokens={"my_handler": "old_token"}, -# continuation_activity=Activity( -# type=ActivityTypes.message, text="old activity" -# ), -# ) -# await set_sign_in_state(authorization, storage, context, initial_state) -# mock_variants( -# mocker, -# sign_in_return=SignInResponse(token_response=TokenResponse(), tag=tag), -# ) - -# # test -# sign_in_response = await authorization.start_or_continue_sign_in( -# context, None, auth_handler_id -# ) -# assert sign_in_response.tag == tag -# assert not sign_in_response.token_response - -# # verify -# final_state = await get_sign_in_state(authorization, storage, context) -# assert not final_state.tokens.get(auth_handler_id) -# assert final_state.tokens["my_handler"] == "old_token" -# assert final_state.continuation_activity == context.activity - -# @pytest.mark.asyncio -# @pytest.mark.parametrize( -# "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] -# ) -# async def test_sign_out_not_signed_in_single_handler( -# self, mocker, storage, authorization, context, activity, auth_handler_id -# ): -# mock_variants(mocker) -# initial_state = SignInState( -# tokens={DEFAULTS.auth_handler_id: "", "my_handler": "old_token"}, -# continuation_activity=activity, -# ) -# await set_sign_in_state( -# authorization, storage, context, copy_sign_in_state(initial_state) -# ) -# await authorization.sign_out(context, None, auth_handler_id) -# final_state = await get_sign_in_state(authorization, storage, context) -# if auth_handler_id in initial_state.tokens: -# del initial_state.tokens[auth_handler_id] -# assert sign_in_state_eq(final_state, initial_state) - -# @pytest.mark.asyncio -# @pytest.mark.parametrize( -# "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] -# ) -# async def test_sign_out_signed_in_in_single_handler( -# self, mocker, storage, authorization, context, activity, auth_handler_id -# ): -# mock_variants(mocker) -# initial_state = SignInState( -# tokens={ -# DEFAULTS.auth_handler_id: "token", -# DEFAULTS.agentic_auth_handler_id: "another_token", -# "my_handler": "old_token", -# }, -# continuation_activity=activity, -# ) -# await set_sign_in_state( -# authorization, storage, context, copy_sign_in_state(initial_state) -# ) -# await authorization.sign_out(context, None, auth_handler_id) -# final_state = await get_sign_in_state(authorization, storage, context) -# del initial_state.tokens[auth_handler_id] -# assert sign_in_state_eq(final_state, initial_state) - -# @pytest.mark.asyncio -# async def test_sign_out_not_signed_in_all_handlers( -# self, mocker, storage, authorization, context, activity -# ): -# mock_variants(mocker) -# initial_state = SignInState( -# tokens={DEFAULTS.auth_handler_id: ""}, continuation_activity=activity -# ) -# await set_sign_in_state(authorization, storage, context, initial_state) -# await authorization.sign_out(context, None) -# final_state = await get_sign_in_state(authorization, storage, context) -# assert final_state is None - -# @pytest.mark.asyncio -# async def test_sign_out_signed_in_in_all_handlers( -# self, mocker, storage, authorization, context, activity -# ): -# mock_variants(mocker) -# initial_state = SignInState( -# tokens={ -# DEFAULTS.auth_handler_id: "token", -# DEFAULTS.agentic_auth_handler_id: "another_token", -# }, -# continuation_activity=activity, -# ) -# await set_sign_in_state(authorization, storage, context, initial_state) -# await authorization.sign_out(context, None) -# final_state = await get_sign_in_state(authorization, storage, context) -# assert final_state is None - -# @pytest.mark.asyncio -# @pytest.mark.parametrize( -# "sign_in_state", -# [ -# SignInState(), -# SignInState( -# tokens={DEFAULTS.auth_handler_id: "token"}, -# continuation_activity=Activity( -# type=ActivityTypes.message, text="activity" -# ), -# ), -# SignInState( -# tokens={ -# DEFAULTS.auth_handler_id: "token", -# DEFAULTS.agentic_auth_handler_id: "another_token", -# }, -# continuation_activity=Activity( -# type=ActivityTypes.message, text="activity" -# ), -# ), -# SignInState( -# tokens={DEFAULTS.auth_handler_id: "token", "my_handler": "old_token"}, -# continuation_activity=Activity( -# type=ActivityTypes.message, text="activity" -# ), -# ), -# ], -# ) -# async def test_on_turn_auth_intercept_no_intercept( -# self, storage, authorization, context, sign_in_state -# ): -# await set_sign_in_state( -# authorization, storage, context, copy_sign_in_state(sign_in_state) -# ) - -# intercepts, continuation_activity = await authorization.on_turn_auth_intercept( -# context, None -# ) - -# assert not continuation_activity -# assert not intercepts - -# final_state = await get_sign_in_state(authorization, storage, context) - -# assert sign_in_state_eq(final_state, sign_in_state) - -# @pytest.mark.asyncio -# @pytest.mark.parametrize( -# "sign_in_response", -# [ -# SignInResponse(tag=FlowStateTag.BEGIN), -# SignInResponse(tag=FlowStateTag.CONTINUE), -# SignInResponse(tag=FlowStateTag.FAILURE), -# ], -# ) -# async def test_on_turn_auth_intercept_with_intercept_incomplete( -# self, mocker, storage, authorization, context, sign_in_response, auth_handler_id -# ): -# mock_class_Authorization( -# mocker, start_or_continue_sign_in_return=sign_in_response -# ) - -# initial_state = SignInState( -# tokens={"some_handler": "old_token", auth_handler_id: ""}, -# continuation_activity=Activity( -# type=ActivityTypes.message, text="old activity" -# ), -# ) -# await set_sign_in_state( -# authorization, storage, context, copy_sign_in_state(initial_state) -# ) - -# intercepts, continuation_activity = await authorization.on_turn_auth_intercept( -# context, auth_handler_id -# ) - -# assert not continuation_activity -# assert intercepts - -# final_state = await get_sign_in_state(authorization, storage, context) -# assert sign_in_state_eq(final_state, initial_state) - -# @pytest.mark.asyncio -# async def test_on_turn_auth_intercept_with_intercept_complete( -# self, mocker, storage, authorization, context, auth_handler_id -# ): -# mock_class_Authorization( -# mocker, -# start_or_continue_sign_in_return=SignInResponse(tag=FlowStateTag.COMPLETE), -# ) - -# old_activity = Activity(type=ActivityTypes.message, text="old activity") -# initial_state = SignInState( -# tokens={"some_handler": "old_token", auth_handler_id: ""}, -# continuation_activity=old_activity, -# ) -# await set_sign_in_state( -# authorization, storage, context, copy_sign_in_state(initial_state) -# ) - -# intercepts, continuation_activity = await authorization.on_turn_auth_intercept( -# context, auth_handler_id -# ) - -# assert continuation_activity == old_activity -# assert intercepts - -# # start_or_continue_sign_in is the only method that modifies the state, -# # so since it is mocked, the state should not be changed -# final_state = await get_sign_in_state(authorization, storage, context) -# assert sign_in_state_eq(final_state, initial_state) +import pytest +import jwt + +from typing import Optional + +from microsoft_agents.activity import Activity, ActivityTypes, TokenResponse + +from microsoft_agents.hosting.core import ( + FlowStateTag, + + Authorization, + UserAuthorization, + AgenticUserAuthorization, + Storage, + TurnContext, + MemoryStorage, + AuthHandler, + FlowStateTag, + SignInState, + SignInResponse, +) + +from tests._common.storage.utils import StorageBaseline + +# test constants +from tests._common.data import ( + TEST_FLOW_DATA, + TEST_AUTH_DATA, + TEST_STORAGE_DATA, + TEST_DEFAULTS, + TEST_ENV_DICT, + TEST_AGENTIC_ENV_DICT, +) +from tests._common.fixtures import FlowStateFixtures +from tests._common.testing_objects import ( + TestingConnectionManager as MockConnectionManager, + mock_UserTokenClient, + mock_class_UserAuthorization, + mock_class_AgenticUserAuthorization, + mock_class_Authorization, +) + +from ._common import testing_TurnContext, testing_Activity + +DEFAULTS = TEST_DEFAULTS() +FLOW_DATA = TEST_FLOW_DATA() +STORAGE_DATA = TEST_STORAGE_DATA() +ENV_DICT = TEST_ENV_DICT() +AGENTIC_ENV_DICT = TEST_AGENTIC_ENV_DICT() + +def make_jwt(token: str = DEFAULTS.token, aud="api://default"): + if aud: + return jwt.encode({"aud": aud}, token, algorithm="HS256") + else: + return jwt.encode({}, token, algorithm="HS256") + +async def get_sign_in_state( + auth: Authorization, storage: Storage, context: TurnContext +) -> Optional[SignInState]: + key = auth.sign_in_state_key(context) + return (await storage.read([key], target_cls=SignInState)).get(key) + + +async def set_sign_in_state( + auth: Authorization, storage: Storage, context: TurnContext, state: SignInState +): + key = auth.sign_in_state_key(context) + await storage.write({key: state}) + + +def mock_variants(mocker, sign_in_return=None, get_refreshed_token_return=None): + mock_class_UserAuthorization(mocker, sign_in_return=sign_in_return, get_refreshed_token_return=get_refreshed_token_return) + mock_class_AgenticUserAuthorization(mocker, sign_in_return=sign_in_return, get_refreshed_token_return=get_refreshed_token_return) + +def sign_in_state_eq(a: Optional[SignInState], b: Optional[SignInState]) -> bool: + if a is None and b is None: + return True + if a is None or b is None: + return False + return a.tokens == b.tokens and a.continuation_activity == b.continuation_activity + + +def copy_sign_in_state(state: SignInState) -> SignInState: + return SignInState( + tokens=state.tokens.copy(), + continuation_activity=( + state.continuation_activity.model_copy() + if state.continuation_activity + else None + ), + ) + + +class TestEnv(FlowStateFixtures): + def setup_method(self): + self.TurnContext = testing_TurnContext + self.UserTokenClient = mock_UserTokenClient + self.ConnectionManager = lambda mocker: MockConnectionManager() + + @pytest.fixture + def context(self, mocker): + return self.TurnContext(mocker) + + @pytest.fixture + def activity(self): + return testing_Activity() + + @pytest.fixture + def baseline_storage(self): + return StorageBaseline(TEST_STORAGE_DATA().dict) + + @pytest.fixture + def storage(self): + return MemoryStorage(STORAGE_DATA.get_init_data()) + + @pytest.fixture + def connection_manager(self, mocker): + return self.ConnectionManager(mocker) + + @pytest.fixture + def auth_handlers(self): + return TEST_AUTH_DATA().auth_handlers + + @pytest.fixture + def authorization(self, connection_manager, storage): + return Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) + + @pytest.fixture(params=[ENV_DICT, AGENTIC_ENV_DICT]) + def env_dict(self, request): + return request.param + + @pytest.fixture(params=[DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id]) + def auth_handler_id(self, request): + return request.param + + +class TestAuthorizationSetup(TestEnv): + def test_init_user_auth(self, connection_manager, storage, env_dict): + auth = Authorization(storage, connection_manager, **env_dict) + assert auth.resolve_handler(DEFAULTS.auth_handler_id) is not None + assert isinstance(auth.resolve_handler(DEFAULTS.auth_handler_id), UserAuthorization) + + def test_init_agentic_auth_not_configured(self, connection_manager, storage): + auth = Authorization(storage, connection_manager, **ENV_DICT) + with pytest.raises(ValueError): + auth.resolve_handler(DEFAULTS.agentic_auth_handler_id) + + def test_init_agentic_auth(self, connection_manager, storage): + auth = Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) + assert auth.resolve_handler(DEFAULTS.agentic_auth_handler_id) is not None + assert isinstance(auth.resolve_handler(DEFAULTS.agentic_auth_handler_id), AgenticUserAuthorization) + + @pytest.mark.parametrize( + "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] + ) + def test_resolve_handler(self, connection_manager, storage, auth_handler_id): + auth = Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) + handler_config = AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"][ + "HANDLERS" + ][auth_handler_id] + auth.resolve_handler(auth_handler_id) == AuthHandler( + auth_handler_id, **handler_config + ) + + def test_sign_in_state_key(self, mocker, connection_manager, storage): + auth = Authorization(storage, connection_manager, **ENV_DICT) + context = self.TurnContext(mocker) + key = auth.sign_in_state_key(context) + assert key == f"auth:SignInState:{DEFAULTS.channel_id}:{DEFAULTS.user_id}" + + +class TestAuthorizationUsage(TestEnv): + + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] + ) + async def test_sign_out_not_signed_in( + self, mocker, storage, authorization, context, activity, auth_handler_id + ): + mock_variants(mocker) + initial_state = SignInState( + tokens={DEFAULTS.auth_handler_id: "", "my_handler": "old_token"}, + continuation_activity=activity, + ) + await set_sign_in_state( + authorization, storage, context, copy_sign_in_state(initial_state) + ) + await authorization.sign_out(context, None, auth_handler_id) + final_state = await get_sign_in_state(authorization, storage, context) + if auth_handler_id in initial_state.tokens: + del initial_state.tokens[auth_handler_id] + assert sign_in_state_eq(final_state, initial_state) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] + ) + async def test_sign_out_signed_in( + self, mocker, storage, authorization, context, activity, auth_handler_id + ): + mock_variants(mocker) + initial_state = SignInState( + tokens={ + DEFAULTS.auth_handler_id: "token", + DEFAULTS.agentic_auth_handler_id: "another_token", + "my_handler": "old_token", + }, + continuation_activity=activity, + ) + await set_sign_in_state( + authorization, storage, context, copy_sign_in_state(initial_state) + ) + await authorization.sign_out(context, None, auth_handler_id) + final_state = await get_sign_in_state(authorization, storage, context) + del initial_state.tokens[auth_handler_id] + assert sign_in_state_eq(final_state, initial_state) + + @pytest.mark.asyncio + async def test_start_or_continue_sign_in_cached( + self, storage, authorization, context, activity + ): + # setup + initial_state = SignInState( + tokens={DEFAULTS.auth_handler_id: "valid_token"}, + continuation_activity=activity, + ) + await set_sign_in_state(authorization, storage, context, initial_state) + sign_in_response = await authorization.start_or_continue_sign_in( + context, None, DEFAULTS.auth_handler_id + ) + assert sign_in_response.tag == FlowStateTag.COMPLETE + assert sign_in_response.token_response.token == "valid_token" + + assert sign_in_state_eq( + await get_sign_in_state(authorization, storage, context), initial_state + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] + ) + async def test_start_or_continue_sign_in_no_initial_state_to_complete( + self, mocker, storage, authorization, context, auth_handler_id + ): + mock_variants( + mocker, + sign_in_return=SignInResponse( + token_response=TokenResponse(token=DEFAULTS.token), + tag=FlowStateTag.COMPLETE, + ), + ) + sign_in_response = await authorization.start_or_continue_sign_in( + context, None, auth_handler_id + ) + assert sign_in_response.tag == FlowStateTag.COMPLETE + assert sign_in_response.token_response.token == DEFAULTS.token + + final_state = await get_sign_in_state(authorization, storage, context) + assert final_state.tokens[auth_handler_id] == DEFAULTS.token + assert final_state.continuation_activity is None + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] + ) + async def test_start_or_continue_sign_in_to_complete_with_prev_state( + self, mocker, storage, authorization, context, auth_handler_id + ): + # setup + initial_state = SignInState( + tokens={"my_handler": "old_token"}, + continuation_activity=Activity( + type=ActivityTypes.message, text="old activity" + ), + ) + await set_sign_in_state(authorization, storage, context, initial_state) + mock_variants( + mocker, + sign_in_return=SignInResponse( + token_response=TokenResponse(token=DEFAULTS.token), + tag=FlowStateTag.COMPLETE, + ), + ) + + # test + sign_in_response = await authorization.start_or_continue_sign_in( + context, None, auth_handler_id + ) + assert sign_in_response.tag == FlowStateTag.COMPLETE + assert sign_in_response.token_response.token == DEFAULTS.token + + # verify + final_state = await get_sign_in_state(authorization, storage, context) + assert final_state.tokens[auth_handler_id] == DEFAULTS.token + assert final_state.tokens["my_handler"] == "old_token" + assert final_state.continuation_activity == initial_state.continuation_activity + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] + ) + async def test_start_or_continue_sign_in_to_failure_with_prev_state( + self, mocker, storage, authorization, context, auth_handler_id + ): + # setup + initial_state = SignInState( + tokens={"my_handler": "old_token"}, + continuation_activity=Activity( + type=ActivityTypes.message, text="old activity" + ), + ) + await set_sign_in_state(authorization, storage, context, initial_state) + mock_variants( + mocker, + sign_in_return=SignInResponse( + token_response=TokenResponse(), tag=FlowStateTag.FAILURE + ), + ) + + # test + sign_in_response = await authorization.start_or_continue_sign_in( + context, None, auth_handler_id + ) + assert sign_in_response.tag == FlowStateTag.FAILURE + assert not sign_in_response.token_response + + # verify + final_state = await get_sign_in_state(authorization, storage, context) + assert not final_state.tokens.get(auth_handler_id) + assert final_state.tokens["my_handler"] == "old_token" + assert final_state.continuation_activity == initial_state.continuation_activity + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "auth_handler_id, tag", + [ + (DEFAULTS.auth_handler_id, FlowStateTag.BEGIN), + (DEFAULTS.agentic_auth_handler_id, FlowStateTag.BEGIN), + (DEFAULTS.auth_handler_id, FlowStateTag.CONTINUE), + (DEFAULTS.agentic_auth_handler_id, FlowStateTag.CONTINUE), + ], + ) + async def test_start_or_continue_sign_in_to_pending_with_prev_state( + self, mocker, storage, authorization, context, auth_handler_id, tag + ): + # setup + initial_state = SignInState( + tokens={"my_handler": "old_token"}, + continuation_activity=Activity( + type=ActivityTypes.message, text="old activity" + ), + ) + await set_sign_in_state(authorization, storage, context, initial_state) + mock_variants( + mocker, + sign_in_return=SignInResponse(token_response=TokenResponse(), tag=tag), + ) + + # test + sign_in_response = await authorization.start_or_continue_sign_in( + context, None, auth_handler_id + ) + assert sign_in_response.tag == tag + assert not sign_in_response.token_response + + # verify + final_state = await get_sign_in_state(authorization, storage, context) + assert not final_state.tokens.get(auth_handler_id) + assert final_state.tokens["my_handler"] == "old_token" + assert final_state.continuation_activity == context.activity + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "initial_state, final_state, handler_id, refresh_token, expected", + [ + [ # no cached token + SignInState( + tokens={DEFAULTS.auth_handler_id: "token"}, + ), + SignInState( + tokens={DEFAULTS.auth_handler_id: "token"}, + ), + DEFAULTS.agentic_auth_handler_id, + None, + None + ], + [ # no cached token and default handler id resolution + SignInState( + tokens={DEFAULTS.agentic_auth_handler_id: "token"}, + ), + SignInState( + tokens={DEFAULTS.agentic_auth_handler_id: "token"}, + ), + "", + None, + None + ], + [ # no cached token pt.2 + SignInState( + tokens={DEFAULTS.agentic_auth_handler_id: "token", DEFAULTS.auth_handler_id: ""}, + ), + SignInState( + tokens={DEFAULTS.agentic_auth_handler_id: "token", DEFAULTS.auth_handler_id: ""}, + ), + DEFAULTS.auth_handler_id, + None, + None + ], + [ # refreshed, new token + SignInState( + tokens={DEFAULTS.agentic_auth_handler_id: make_jwt(), DEFAULTS.auth_handler_id: ""}, + ), + SignInState( + tokens={DEFAULTS.agentic_auth_handler_id: DEFAULTS.token, DEFAULTS.auth_handler_id: ""}, + ), + DEFAULTS.agentic_auth_handler_id, + TokenResponse(token=DEFAULTS.token), + DEFAULTS.token + ], + ] + ) + async def test_get_token(self, mocker, authorization, context, storage, initial_state, final_state, handler_id, refresh_token, expected): + # setup + await set_sign_in_state(authorization, storage, context, initial_state) + mock_variants(mocker, get_refreshed_token_return=refresh_token) + + # test + token = await authorization.get_token(context, handler_id) + assert token == expected + + final_state = await get_sign_in_state(authorization, storage, context) + assert sign_in_state_eq(initial_state, final_state) + + @pytest.mark.asyncio + async def test_get_token_error(self, mocker, authorization, context, storage): + initial_state = SignInState( + tokens={DEFAULTS.auth_handler_id: "old_token"}, + ) + await set_sign_in_state(authorization, storage, context, initial_state) + mock_variants(mocker, get_refreshed_token_return=TokenResponse()) + with pytest.raises(Exception): + await authorization.get_token(context, DEFAULTS.auth_handler_id) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "initial_state, final_state, handler_id, refreshed, refresh_token, expected", + [ + [ # no cached token + SignInState( + tokens={DEFAULTS.auth_handler_id: "token"}, + ), + SignInState( + tokens={DEFAULTS.auth_handler_id: "token"}, + ), + DEFAULTS.agentic_auth_handler_id, + False, + None, + None + ], + [ # no cached token and default handler id resolution + SignInState( + tokens={DEFAULTS.agentic_auth_handler_id: "token"}, + ), + SignInState( + tokens={DEFAULTS.agentic_auth_handler_id: "token"}, + ), + "", + False, + None, + None + ], + [ # no cached token pt.2 + SignInState( + tokens={DEFAULTS.agentic_auth_handler_id: "token", DEFAULTS.auth_handler_id: ""}, + ), + SignInState( + tokens={DEFAULTS.agentic_auth_handler_id: "token", DEFAULTS.auth_handler_id: ""}, + ), + DEFAULTS.auth_handler_id, + False, + None, + None + ], + [ # refreshed, new token + SignInState( + tokens={DEFAULTS.agentic_auth_handler_id: make_jwt(), DEFAULTS.auth_handler_id: ""}, + ), + SignInState( + tokens={DEFAULTS.agentic_auth_handler_id: DEFAULTS.token, DEFAULTS.auth_handler_id: ""}, + ), + DEFAULTS.agentic_auth_handler_id, + True, + TokenResponse(token=DEFAULTS.token), + DEFAULTS.token + ], + ] + ) + async def test_exchange_token(self, mocker, authorization, context, storage, initial_state, final_state, handler_id, refreshed, refresh_token, expected): + # setup + await set_sign_in_state(authorization, storage, context, initial_state) + mock_variants(mocker, get_refreshed_token_return=refresh_token) + + # test + token = await authorization.exchange_token(context, handler_id, exchange_connection="some_connection", scopes=["scope1", "scope2"]) + assert token == expected + + final_state = await get_sign_in_state(authorization, storage, context) + assert sign_in_state_eq(initial_state, final_state) + if refreshed: + authorization.resolve_handler(handler_id).get_refreshed_token.assert_called_once_with( + context, + "some_connection", + ["scope1", "scope2"], + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "sign_in_state", + [ + SignInState(), + SignInState( + tokens={DEFAULTS.auth_handler_id: "token"}, + continuation_activity=Activity( + type=ActivityTypes.message, text="activity" + ), + ), + SignInState( + tokens={ + DEFAULTS.auth_handler_id: "token", + DEFAULTS.agentic_auth_handler_id: "another_token", + }, + continuation_activity=Activity( + type=ActivityTypes.message, text="activity" + ), + ), + SignInState( + tokens={DEFAULTS.auth_handler_id: "token", "my_handler": "old_token"}, + continuation_activity=Activity( + type=ActivityTypes.message, text="activity" + ), + ), + ], + ) + async def test_on_turn_auth_intercept_no_intercept( + self, storage, authorization, context, sign_in_state + ): + await set_sign_in_state( + authorization, storage, context, copy_sign_in_state(sign_in_state) + ) + + intercepts, continuation_activity = await authorization.on_turn_auth_intercept( + context, None + ) + + assert not continuation_activity + assert not intercepts + + final_state = await get_sign_in_state(authorization, storage, context) + + assert sign_in_state_eq(final_state, sign_in_state) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "sign_in_response", + [ + SignInResponse(tag=FlowStateTag.BEGIN), + SignInResponse(tag=FlowStateTag.CONTINUE), + SignInResponse(tag=FlowStateTag.FAILURE), + ], + ) + async def test_on_turn_auth_intercept_with_intercept_incomplete( + self, mocker, storage, authorization, context, sign_in_response, auth_handler_id + ): + mock_class_Authorization( + mocker, start_or_continue_sign_in_return=sign_in_response + ) + + initial_state = SignInState( + tokens={"some_handler": "old_token", auth_handler_id: ""}, + continuation_activity=Activity( + type=ActivityTypes.message, text="old activity" + ), + ) + await set_sign_in_state( + authorization, storage, context, copy_sign_in_state(initial_state) + ) + + intercepts, continuation_activity = await authorization.on_turn_auth_intercept( + context, auth_handler_id + ) + + assert not continuation_activity + assert intercepts + + final_state = await get_sign_in_state(authorization, storage, context) + assert sign_in_state_eq(final_state, initial_state) + + @pytest.mark.asyncio + async def test_on_turn_auth_intercept_with_intercept_complete( + self, mocker, storage, authorization, context, auth_handler_id + ): + mock_class_Authorization( + mocker, + start_or_continue_sign_in_return=SignInResponse(tag=FlowStateTag.COMPLETE), + ) + + old_activity = Activity(type=ActivityTypes.message, text="old activity") + initial_state = SignInState( + tokens={"some_handler": "old_token", auth_handler_id: ""}, + continuation_activity=old_activity, + ) + await set_sign_in_state( + authorization, storage, context, copy_sign_in_state(initial_state) + ) + + intercepts, continuation_activity = await authorization.on_turn_auth_intercept( + context, auth_handler_id + ) + + assert continuation_activity == old_activity + assert intercepts + + # start_or_continue_sign_in is the only method that modifies the state, + # so since it is mocked, the state should not be changed + final_state = await get_sign_in_state(authorization, storage, context) + assert sign_in_state_eq(final_state, initial_state) diff --git a/tests/hosting_core/app/auth/test_user_authorization.py b/tests/hosting_core/app/auth/test_user_authorization.py deleted file mode 100644 index 8c90a01a..00000000 --- a/tests/hosting_core/app/auth/test_user_authorization.py +++ /dev/null @@ -1,263 +0,0 @@ -# import pytest -# from datetime import datetime -# import jwt - -# from microsoft_agents.activity import Activity, ActivityTypes, TokenResponse - -# from microsoft_agents.hosting.core import ( -# FlowStorageClient, -# FlowErrorTag, -# FlowStateTag, -# FlowState, -# FlowResponse, -# OAuthFlow, -# UserAuthorization, -# MemoryStorage, -# ) - -# from tests._common.storage.utils import StorageBaseline - -# # test constants -# from tests._common.data import ( -# TEST_FLOW_DATA, -# TEST_AUTH_DATA, -# TEST_STORAGE_DATA, -# TEST_DEFAULTS, -# TEST_ENV_DICT, -# create_test_auth_handler, -# ) -# from tests._common.fixtures import FlowStateFixtures -# from tests._common.testing_objects import ( -# TestingConnectionManager as MockConnectionManager, -# mock_class_OAuthFlow, -# mock_UserTokenClient, -# ) -# from tests.hosting_core._common import flow_state_eq - -# DEFAULTS = TEST_DEFAULTS() -# FLOW_DATA = TEST_FLOW_DATA() -# ENV_DICT = TEST_ENV_DICT() -# STORAGE_DATA = TEST_STORAGE_DATA() - - -# class MyUserAuthorization(UserAuthorization): -# def _handle_flow_response(self, *args, **kwargs): -# pass - - -# def testing_TurnContext( -# mocker, -# channel_id=DEFAULTS.channel_id, -# user_id=DEFAULTS.user_id, -# user_token_client=None, -# ): -# if not user_token_client: -# user_token_client = mock_UserTokenClient(mocker) - -# turn_context = mocker.Mock() -# turn_context.activity.channel_id = channel_id -# turn_context.activity.from_property.id = user_id -# turn_context.activity.type = ActivityTypes.message -# turn_context.adapter.USER_TOKEN_CLIENT_KEY = "__user_token_client" -# turn_context.adapter.AGENT_IDENTITY_KEY = "__agent_identity_key" -# agent_identity = mocker.Mock() -# agent_identity.claims = {"aud": DEFAULTS.ms_app_id} -# turn_context.turn_state = { -# "__user_token_client": user_token_client, -# "__agent_identity_key": agent_identity, -# } -# return turn_context - - -# class TestEnv(FlowStateFixtures): -# def setup_method(self): -# self.TurnContext = testing_TurnContext -# self.UserTokenClient = mock_UserTokenClient -# self.ConnectionManager = lambda mocker: MockConnectionManager() - -# @pytest.fixture -# def turn_context(self, mocker): -# return self.TurnContext(mocker) - -# @pytest.fixture -# def baseline_storage(self): -# return StorageBaseline(TEST_STORAGE_DATA().dict) - -# @pytest.fixture -# def storage(self): -# return MemoryStorage(STORAGE_DATA.get_init_data()) - -# @pytest.fixture -# def connection_manager(self, mocker): -# return self.ConnectionManager(mocker) - -# @pytest.fixture -# def auth_handlers(self): -# return TEST_AUTH_DATA().auth_handlers - -# @pytest.fixture -# def user_authorization(self, connection_manager, storage, auth_handlers): -# return UserAuthorization( -# storage, connection_manager, auth_handlers=auth_handlers -# ) - - -# class TestUserAuthorization(TestEnv): - -# # TODO -> test init - -# @pytest.mark.asyncio -# async def test_begin_or_continue_flow_success(self, mocker, user_authorization): -# # robrandao: TODO -> lower priority -> more testing here -# # setup -# mock_class_OAuthFlow( -# mocker, -# begin_or_continue_flow_return=FlowResponse( -# token_response=TokenResponse(token="token"), -# flow_state=FlowState( -# tag=FlowStateTag.COMPLETE, auth_handler_id="github" -# ), -# ), -# ) -# context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") -# context.dummy_val = None - -# flow_response = await user_authorization.begin_or_continue_flow( -# context, "github" -# ) -# assert flow_response.token_response == TokenResponse(token="token") - -# @pytest.mark.asyncio -# async def test_begin_or_continue_flow_already_completed( -# self, mocker, user_authorization -# ): -# # robrandao: TODO -> lower priority -> more testing here -# # setup -# context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") -# # test -# flow_response = await user_authorization.begin_or_continue_flow( -# context, "graph" -# ) -# assert flow_response.token_response == TokenResponse(token="test_token") -# assert flow_response.continuation_activity is None - -# @pytest.mark.asyncio -# async def test_begin_or_continue_flow_failure(self, mocker, user_authorization): -# # robrandao: TODO -> lower priority -> more testing here -# # setup -# mock_class_OAuthFlow( -# mocker, -# begin_or_continue_flow_return=FlowResponse( -# token_response=TokenResponse(token="token"), -# flow_state=FlowState( -# tag=FlowStateTag.FAILURE, auth_handler_id="github" -# ), -# flow_error_tag=FlowErrorTag.MAGIC_FORMAT, -# ), -# ) -# context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") -# # test -# flow_response = await user_authorization.begin_or_continue_flow( -# context, "github" -# ) -# assert flow_response.token_response == TokenResponse(token="token") - -# @pytest.mark.asyncio -# async def test_sign_out_individual( -# self, -# mocker, -# storage, -# connection_manager, -# auth_handlers, -# ): -# # setup -# mock_class_OAuthFlow(mocker) -# storage_client = FlowStorageClient("teams", "Alice", storage) -# context = self.TurnContext(mocker, channel_id="teams", user_id="Alice") -# auth = UserAuthorization(storage, connection_manager, auth_handlers) - -# # test -# await auth.sign_out(context, "graph") - -# # verify -# assert ( -# await storage.read([storage_client.key("graph")], target_cls=FlowState) -# == {} -# ) -# OAuthFlow.sign_out.assert_called_once() - -# @pytest.mark.asyncio -# async def test_sign_out_all( -# self, -# mocker, -# storage, -# connection_manager, -# auth_handlers, -# ): -# # setup -# mock_class_OAuthFlow(mocker) -# context = self.TurnContext(mocker, channel_id="webchat", user_id="Alice") -# storage_client = FlowStorageClient("webchat", "Alice", storage) -# auth = UserAuthorization(storage, connection_manager, auth_handlers) - -# # test -# await auth.sign_out(context) - -# # verify -# assert ( -# await storage.read([storage_client.key("graph")], target_cls=FlowState) -# == {} -# ) -# assert ( -# await storage.read([storage_client.key("github")], target_cls=FlowState) -# == {} -# ) -# assert ( -# await storage.read([storage_client.key("slack")], target_cls=FlowState) -# == {} -# ) -# OAuthFlow.sign_out.assert_called() # ignore red squiggly -> mocked - -# @pytest.mark.asyncio -# @pytest.mark.parametrize( -# "flow_response", -# [ -# FlowResponse( -# token_response=TokenResponse(token="token"), -# flow_state=FlowState( -# tag=FlowStateTag.COMPLETE, auth_handler_id="github" -# ), -# ), -# FlowResponse( -# token_response=TokenResponse(), -# flow_state=FlowState( -# tag=FlowStateTag.CONTINUE, auth_handler_id="github" -# ), -# continuation_activity=Activity( -# type=ActivityTypes.message, text="Please sign in" -# ), -# ), -# FlowResponse( -# token_response=TokenResponse(token="wow"), -# flow_state=FlowState( -# tag=FlowStateTag.FAILURE, auth_handler_id="github" -# ), -# flow_error_tag=FlowErrorTag.MAGIC_FORMAT, -# continuation_activity=Activity( -# type=ActivityTypes.message, text="There was an error" -# ), -# ), -# ], -# ) -# async def test_sign_in_success( -# self, mocker, user_authorization, turn_context, flow_response -# ): -# mocker.patch.object( -# user_authorization, "_handle_flow_response", return_value=None -# ) -# user_authorization.begin_or_continue_flow = mocker.AsyncMock( -# return_value=flow_response -# ) -# res = await user_authorization.sign_in(turn_context, "github") -# assert res.token_response == flow_response.token_response -# assert res.tag == flow_response.flow_state.tag From 82e4ae58f2214f762131611cb34a99d940053e21 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 29 Sep 2025 15:55:26 -0700 Subject: [PATCH 22/36] Sample compat --- .../hosting/core/app/auth/auth_handler.py | 2 +- .../hosting/core/app/auth/authorization.py | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py index 31fb2411..42f006fc 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py @@ -59,7 +59,7 @@ def __init__( self.obo_connection_name = obo_connection_name or kwargs.get( "OBOCONNECTIONNAME", "" ) - self.auth_type = auth_type or kwargs.get("TYPE", "") + self.auth_type = auth_type or kwargs.get("TYPE", "UserAuthorization") self.auth_type = self.auth_type.lower() if scopes: self.scopes = list(scopes) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py index b2893eb9..a392f17e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py @@ -89,13 +89,10 @@ def __init__( self._handler_settings = auth_handlers - # compatibility? TODO - if not auth_handlers or len(auth_handlers) == 0: - raise ValueError("At least one auth handler configuration is required.") - # operations default to the first handler if none specified - self._default_handler_id = next(iter(self._handler_settings.items()))[0] - self._init_handlers() + if self._handler_settings: + self._default_handler_id = next(iter(self._handler_settings.items()))[0] + self._init_handlers() def _init_handlers(self) -> None: """Initialize authorization variants based on the provided auth handlers. From 6611ed86a21829d734e2fa2a2862aaa646fef91b Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 29 Sep 2025 16:54:06 -0700 Subject: [PATCH 23/36] Compat changes --- .../microsoft_agents/hosting/core/__init__.py | 28 +--- .../hosting/core/_oauth/__init__.py | 12 ++ .../flow_state.py => _oauth/_flow_state.py} | 20 +-- .../_flow_storage_client.py} | 26 ++-- .../oauth_flow.py => _oauth/_oauth_flow.py} | 62 ++++----- .../hosting/core/app/__init__.py | 10 +- .../hosting/core/app/agent_application.py | 4 +- .../hosting/core/app/auth/__init__.py | 20 --- .../core/app/auth/handlers/__init__.py | 9 -- .../hosting/core/app/oauth/__init__.py | 19 +++ .../core/app/oauth/_handlers/__init__.py | 9 ++ .../_handlers/_authorization_handler.py} | 14 +- .../_handlers/_user_authorization.py} | 24 ++-- .../_handlers}/agentic_user_authorization.py | 4 +- .../_sign_in_response.py} | 2 +- .../_sign_in_state.py} | 8 +- .../core/app/{auth => oauth}/auth_handler.py | 6 +- .../core/app/{auth => oauth}/authorization.py | 44 +++--- .../hosting/core/oauth/__init__.py | 12 -- .../mocks/mock_authorization.py | 18 +-- .../app/auth/test_sign_in_response.py | 10 -- .../app/{auth => oauth}/__init__.py | 0 .../app/{auth => oauth}/_common.py | 0 .../hosting_core/app/{auth => oauth}/_env.py | 0 .../handlers => oauth/_handlers}/__init__.py | 0 .../test_agentic_user_authorization.py | 29 +--- .../_handlers}/test_user_authorization.py | 0 .../app/{auth => oauth}/test_auth_handler.py | 0 .../app/{auth => oauth}/test_authorization.py | 126 +++++++++--------- .../app/oauth/test_sign_in_response.py | 10 ++ .../app/{auth => oauth}/test_sign_in_state.py | 2 +- tests/hosting_core/oauth/test_flow_state.py | 102 +++++++------- .../oauth/test_flow_storage_client.py | 42 +++--- tests/hosting_core/oauth/test_oauth_flow.py | 118 ++++++++-------- 34 files changed, 368 insertions(+), 422 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/__init__.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/{oauth/flow_state.py => _oauth/_flow_state.py} (77%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/{oauth/flow_storage_client.py => _oauth/_flow_storage_client.py} (83%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/{oauth/oauth_flow.py => _oauth/_oauth_flow.py} (87%) delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/__init__.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/__init__.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{auth/handlers/authorization_handler.py => oauth/_handlers/_authorization_handler.py} (91%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{auth/handlers/user_authorization.py => oauth/_handlers/_user_authorization.py} (96%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{auth/handlers => oauth/_handlers}/agentic_user_authorization.py (97%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{auth/sign_in_response.py => oauth/_sign_in_response.py} (96%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{auth/sign_in_state.py => oauth/_sign_in_state.py} (82%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{auth => oauth}/auth_handler.py (95%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{auth => oauth}/authorization.py (92%) delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/oauth/__init__.py delete mode 100644 tests/hosting_core/app/auth/test_sign_in_response.py rename tests/hosting_core/app/{auth => oauth}/__init__.py (100%) rename tests/hosting_core/app/{auth => oauth}/_common.py (100%) rename tests/hosting_core/app/{auth => oauth}/_env.py (100%) rename tests/hosting_core/app/{auth/handlers => oauth/_handlers}/__init__.py (100%) rename tests/hosting_core/app/{auth/handlers => oauth/_handlers}/test_agentic_user_authorization.py (95%) rename tests/hosting_core/app/{auth/handlers => oauth/_handlers}/test_user_authorization.py (100%) rename tests/hosting_core/app/{auth => oauth}/test_auth_handler.py (100%) rename tests/hosting_core/app/{auth => oauth}/test_authorization.py (88%) create mode 100644 tests/hosting_core/app/oauth/test_sign_in_response.py rename tests/hosting_core/app/{auth => oauth}/test_sign_in_state.py (96%) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py index 28dfc778..50c990c8 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py @@ -20,14 +20,10 @@ from .app.typing_indicator import TypingIndicator # App Auth -from .app.auth import ( +from .app.oauth import ( Authorization, - AuthorizationHandler, AuthHandler, - UserAuthorization, AgenticUserAuthorization, - SignInState, - SignInResponse, ) # App State @@ -46,16 +42,6 @@ from .authorization.jwt_token_validator import JwtTokenValidator from .authorization.auth_types import AuthTypes -# OAuth -from .oauth import ( - FlowState, - FlowStateTag, - FlowErrorTag, - FlowResponse, - FlowStorageClient, - OAuthFlow, -) - # Client API from .client.agent_conversation_reference import AgentConversationReference from .client.channel_factory_protocol import ChannelFactoryProtocol @@ -124,9 +110,7 @@ "TurnState", "TempState", "Authorization", - "AuthorizationHandler", "AuthHandler", - "SignInState", "AccessTokenProviderBase", "AuthenticationConstants", "AnonymousTokenProvider", @@ -134,7 +118,6 @@ "AgentAuthConfiguration", "ClaimsIdentity", "JwtTokenValidator", - "AuthTypes", "AgentConversationReference", "ChannelFactoryProtocol", "ChannelHostProtocol", @@ -162,15 +145,6 @@ "StoreItem", "Storage", "MemoryStorage", - "FlowState", - "FlowStateTag", - "FlowErrorTag", - "FlowResponse", - "FlowStorageClient", - "OAuthFlow", - "UserAuthorization", "AgenticUserAuthorization", "Authorization", - "SignInState", - "SignInResponse", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/__init__.py new file mode 100644 index 00000000..c72a2f4d --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/__init__.py @@ -0,0 +1,12 @@ +from .flow_state import _FlowState, _FlowStateTag, _FlowErrorTag +from .flow_storage_client import _FlowStorageClient +from .oauth_flow import _OAuthFlow, _FlowResponse + +__all__ = [ + "_FlowState", + "_FlowStateTag", + "_FlowErrorTag", + "_FlowResponse", + "_FlowStorageClient", + "_OAuthFlow", +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/oauth/flow_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_state.py similarity index 77% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/oauth/flow_state.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_state.py index efeb7cb2..3609f754 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/oauth/flow_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_state.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from __future__ import annotations + from datetime import datetime from enum import Enum from typing import Optional @@ -12,7 +14,7 @@ from ..storage import StoreItem -class FlowStateTag(Enum): +class _FlowStateTag(Enum): """Represents the top-level state of an OAuthFlow For instance, a flow can arrive at an error, but its @@ -27,7 +29,7 @@ class FlowStateTag(Enum): COMPLETE = "complete" -class FlowErrorTag(Enum): +class _FlowErrorTag(Enum): """Represents the various error states that can occur during an OAuthFlow""" NONE = "none" @@ -36,7 +38,7 @@ class FlowErrorTag(Enum): OTHER = "other" -class FlowState(BaseModel, StoreItem): +class _FlowState(BaseModel, StoreItem): """Represents the state of an OAuthFlow""" user_token: str = "" @@ -50,14 +52,14 @@ class FlowState(BaseModel, StoreItem): expiration: float = 0 continuation_activity: Optional[Activity] = None attempts_remaining: int = 0 - tag: FlowStateTag = FlowStateTag.NOT_STARTED + tag: _FlowStateTag = _FlowStateTag.NOT_STARTED def store_item_to_json(self) -> dict: return self.model_dump(mode="json", exclude_unset=True, by_alias=True) @staticmethod - def from_json_to_store_item(json_data: dict) -> "FlowState": - return FlowState.model_validate(json_data) + def from_json_to_store_item(json_data: dict) -> _FlowState: + return _FlowState.model_validate(json_data) def is_expired(self) -> bool: return datetime.now().timestamp() >= self.expiration @@ -69,13 +71,13 @@ def is_active(self) -> bool: return ( not self.is_expired() and not self.reached_max_attempts() - and self.tag in [FlowStateTag.BEGIN, FlowStateTag.CONTINUE] + and self.tag in [_FlowStateTag.BEGIN, _FlowStateTag.CONTINUE] ) def refresh(self): if ( self.tag - in [FlowStateTag.BEGIN, FlowStateTag.CONTINUE, FlowStateTag.COMPLETE] + in [_FlowStateTag.BEGIN, _FlowStateTag.CONTINUE, _FlowStateTag.COMPLETE] and self.is_expired() ): - self.tag = FlowStateTag.NOT_STARTED + self.tag = _FlowStateTag.NOT_STARTED diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/oauth/flow_storage_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py similarity index 83% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/oauth/flow_storage_client.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py index 7ab03879..b97e5149 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/oauth/flow_storage_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py @@ -4,15 +4,15 @@ from typing import Optional from ..storage import Storage -from .flow_state import FlowState +from ._flow_state import _FlowState -class DummyCache(Storage): +class _DummyCache(Storage): - async def read(self, keys: list[str], **kwargs) -> dict[str, FlowState]: + async def read(self, keys: list[str], **kwargs) -> dict[str, _FlowState]: return {} - async def write(self, changes: dict[str, FlowState]) -> None: + async def write(self, changes: dict[str, _FlowState]) -> None: pass async def delete(self, keys: list[str]) -> None: @@ -23,7 +23,7 @@ async def delete(self, keys: list[str]) -> None: # - CachedStorage class for two-tier storage # - Namespaced/PrefixedStorage class for namespacing keying # not generally thread or async safe (operations are not atomic) -class FlowStorageClient: +class _FlowStorageClient: """Wrapper around Storage that manages sign-in state specific to each user and channel. Uses the activity's channel_id and from.id to create a key prefix for storage operations. @@ -53,7 +53,7 @@ def __init__( self._base_key = f"auth/{channel_id}/{user_id}/" self._storage = storage if cache_class is None: - cache_class = DummyCache + cache_class = _DummyCache self._cache = cache_class() @property @@ -65,21 +65,21 @@ def key(self, auth_handler_id: str) -> str: """Creates a storage key for a specific sign-in handler.""" return f"{self._base_key}{auth_handler_id}" - async def read(self, auth_handler_id: str) -> Optional[FlowState]: + async def read(self, auth_handler_id: str) -> Optional[_FlowState]: """Reads the flow state for a specific authentication handler.""" key: str = self.key(auth_handler_id) - data = await self._cache.read([key], target_cls=FlowState) + data = await self._cache.read([key], target_cls=_FlowState) if key not in data: - data = await self._storage.read([key], target_cls=FlowState) + data = await self._storage.read([key], target_cls=_FlowState) if key not in data: return None await self._cache.write({key: data[key]}) - return FlowState.model_validate(data.get(key)) + return _FlowState.model_validate(data.get(key)) - async def write(self, value: FlowState) -> None: + async def write(self, value: _FlowState) -> None: """Saves the flow state for a specific authentication handler.""" key: str = self.key(value.auth_handler_id) - cached_state = await self._cache.read([key], target_cls=FlowState) + cached_state = await self._cache.read([key], target_cls=_FlowState) if not cached_state or cached_state != value: await self._cache.write({key: value}) await self._storage.write({key: value}) @@ -87,7 +87,7 @@ async def write(self, value: FlowState) -> None: async def delete(self, auth_handler_id: str) -> None: """Deletes the flow state for a specific authentication handler.""" key: str = self.key(auth_handler_id) - cached_state = await self._cache.read([key], target_cls=FlowState) + cached_state = await self._cache.read([key], target_cls=_FlowState) if cached_state: await self._cache.delete([key]) await self._storage.delete([key]) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/oauth/oauth_flow.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py similarity index 87% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/oauth/oauth_flow.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py index 3a12b890..b764b738 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/oauth/oauth_flow.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py @@ -18,22 +18,22 @@ ) from ..connector.client import UserTokenClient -from .flow_state import FlowState, FlowStateTag, FlowErrorTag +from ._flow_state import _FlowState, _FlowStateTag, _FlowErrorTag logger = logging.getLogger(__name__) -class FlowResponse(BaseModel): +class _FlowResponse(BaseModel): """Represents the response for a flow operation.""" - flow_state: FlowState = FlowState() - flow_error_tag: FlowErrorTag = FlowErrorTag.NONE + flow_state: _FlowState = _FlowState() + flow_error_tag: _FlowErrorTag = _FlowErrorTag.NONE token_response: Optional[TokenResponse] = None sign_in_resource: Optional[SignInResource] = None continuation_activity: Optional[Activity] = None -class OAuthFlow: +class _OAuthFlow: """ Manages the OAuth flow. @@ -48,7 +48,7 @@ class OAuthFlow: """ def __init__( - self, flow_state: FlowState, user_token_client: UserTokenClient, **kwargs + self, flow_state: _FlowState, user_token_client: UserTokenClient, **kwargs ): """ Arguments: @@ -105,7 +105,7 @@ def __init__( ) @property - def flow_state(self) -> FlowState: + def flow_state(self) -> _FlowState: return self._flow_state.model_copy() async def get_user_token(self, magic_code: str = None) -> TokenResponse: @@ -140,7 +140,7 @@ async def get_user_token(self, magic_code: str = None) -> TokenResponse: self._flow_state.expiration = ( datetime.now().timestamp() + self._default_flow_duration ) - self._flow_state.tag = FlowStateTag.COMPLETE + self._flow_state.tag = _FlowStateTag.COMPLETE return token_response @@ -161,19 +161,19 @@ async def sign_out(self) -> None: channel_id=self._channel_id, ) self._flow_state.user_token = "" - self._flow_state.tag = FlowStateTag.NOT_STARTED + self._flow_state.tag = _FlowStateTag.NOT_STARTED def _use_attempt(self) -> None: """Decrements the remaining attempts for the flow, checking for failure.""" self._flow_state.attempts_remaining -= 1 if self._flow_state.attempts_remaining <= 0: - self._flow_state.tag = FlowStateTag.FAILURE + self._flow_state.tag = _FlowStateTag.FAILURE logger.debug( "Using an attempt for the OAuth flow. Attempts remaining after use: %d", self._flow_state.attempts_remaining, ) - async def begin_flow(self, activity: Activity) -> FlowResponse: + async def begin_flow(self, activity: Activity) -> _FlowResponse: """Begins the OAuthFlow. Args: @@ -187,12 +187,12 @@ async def begin_flow(self, activity: Activity) -> FlowResponse: """ token_response = await self.get_user_token() if token_response: - return FlowResponse( + return _FlowResponse( flow_state=self._flow_state, token_response=token_response ) logger.debug("Starting new OAuth flow") - self._flow_state.tag = FlowStateTag.BEGIN + self._flow_state.tag = _FlowStateTag.BEGIN self._flow_state.expiration = ( datetime.now().timestamp() + self._default_flow_duration ) @@ -216,24 +216,24 @@ async def begin_flow(self, activity: Activity) -> FlowResponse: logger.debug("Sign-in resource obtained successfully: %s", sign_in_resource) - return FlowResponse( + return _FlowResponse( flow_state=self._flow_state, sign_in_resource=sign_in_resource ) async def _continue_from_message( self, activity: Activity - ) -> tuple[TokenResponse, FlowErrorTag]: + ) -> tuple[TokenResponse, _FlowErrorTag]: """Handles the continuation of the flow from a message activity.""" magic_code: str = activity.text if magic_code and magic_code.isdigit() and len(magic_code) == 6: token_response: TokenResponse = await self.get_user_token(magic_code) if token_response: - return token_response, FlowErrorTag.NONE + return token_response, _FlowErrorTag.NONE else: - return token_response, FlowErrorTag.MAGIC_CODE_INCORRECT + return token_response, _FlowErrorTag.MAGIC_CODE_INCORRECT else: - return TokenResponse(), FlowErrorTag.MAGIC_FORMAT + return TokenResponse(), _FlowErrorTag.MAGIC_FORMAT async def _continue_from_invoke_verify_state( self, activity: Activity @@ -257,7 +257,7 @@ async def _continue_from_invoke_token_exchange( ) return token_response - async def continue_flow(self, activity: Activity) -> FlowResponse: + async def continue_flow(self, activity: Activity) -> _FlowResponse: """Continues the OAuth flow based on the incoming activity. Args: @@ -271,12 +271,12 @@ async def continue_flow(self, activity: Activity) -> FlowResponse: if not self._flow_state.is_active(): logger.debug("OAuth flow is not active, cannot continue") - self._flow_state.tag = FlowStateTag.FAILURE - return FlowResponse( + self._flow_state.tag = _FlowStateTag.FAILURE + return _FlowResponse( flow_state=self._flow_state.model_copy(), token_response=None ) - flow_error_tag = FlowErrorTag.NONE + flow_error_tag = _FlowErrorTag.NONE if activity.type == ActivityTypes.message: token_response, flow_error_tag = await self._continue_from_message(activity) elif ( @@ -292,15 +292,15 @@ async def continue_flow(self, activity: Activity) -> FlowResponse: else: raise ValueError(f"Unknown activity type {activity.type}") - if not token_response and flow_error_tag == FlowErrorTag.NONE: - flow_error_tag = FlowErrorTag.OTHER + if not token_response and flow_error_tag == _FlowErrorTag.NONE: + flow_error_tag = _FlowErrorTag.OTHER - if flow_error_tag != FlowErrorTag.NONE: + if flow_error_tag != _FlowErrorTag.NONE: logger.debug("Flow error occurred: %s", flow_error_tag) - self._flow_state.tag = FlowStateTag.CONTINUE + self._flow_state.tag = _FlowStateTag.CONTINUE self._use_attempt() else: - self._flow_state.tag = FlowStateTag.COMPLETE + self._flow_state.tag = _FlowStateTag.COMPLETE self._flow_state.expiration = ( datetime.now().timestamp() + self._default_flow_duration ) @@ -310,14 +310,14 @@ async def continue_flow(self, activity: Activity) -> FlowResponse: token_response, ) - return FlowResponse( + return _FlowResponse( flow_state=self._flow_state.model_copy(), flow_error_tag=flow_error_tag, token_response=token_response, continuation_activity=self._flow_state.continuation_activity, ) - async def begin_or_continue_flow(self, activity: Activity) -> FlowResponse: + async def begin_or_continue_flow(self, activity: Activity) -> _FlowResponse: """Begins a new OAuth flow or continues an existing one based on the activity. Args: @@ -327,9 +327,9 @@ async def begin_or_continue_flow(self, activity: Activity) -> FlowResponse: A FlowResponse object containing the updated flow state and any token response. """ self._flow_state.refresh() - if self._flow_state.tag == FlowStateTag.COMPLETE: # robrandao: TODO -> test + if self._flow_state.tag == _FlowStateTag.COMPLETE: # robrandao: TODO -> test logger.debug("OAuth flow has already been completed, nothing to do") - return FlowResponse( + return _FlowResponse( flow_state=self._flow_state.model_copy(), token_response=TokenResponse(token=self._flow_state.user_token), ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py index cd5b28e7..0cf00fc4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py @@ -14,14 +14,10 @@ from .typing_indicator import TypingIndicator # Auth -from .auth import ( +from .oauth import ( Authorization, AuthHandler, - AuthorizationHandler, - UserAuthorization, AgenticUserAuthorization, - SignInResponse, - SignInState, ) # App State @@ -49,9 +45,5 @@ "TempState", "Authorization", "AuthHandler", - "AuthorizationHandler", - "UserAuthorization", "AgenticUserAuthorization", - "SignInState", - "SignInResponse", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index 4bbebb47..2a53d33c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -610,7 +610,7 @@ async def _on_turn(self, context: TurnContext): ( auth_intercepts, continuation_activity, - ) = await self._auth.on_turn_auth_intercept(context, turn_state) + ) = await self._auth._on_turn_auth_intercept(context, turn_state) if auth_intercepts: if continuation_activity: new_context = copy(context) @@ -740,7 +740,7 @@ async def _on_activity(self, context: TurnContext, state: StateT): sign_in_complete = True for auth_handler_id in route.auth_handlers: if not ( - await self._auth.start_or_continue_sign_in( + await self._auth._start_or_continue_sign_in( context, state, auth_handler_id ) ).sign_in_complete(): diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py deleted file mode 100644 index 2e69ee71..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -from .authorization import Authorization -from .auth_handler import AuthHandler -from .sign_in_state import SignInState -from .sign_in_response import SignInResponse -from .handlers import ( - UserAuthorization, - AgenticUserAuthorization, - AuthorizationHandler -) - -__all__ = [ - "Authorization", - "AuthHandler", - "AuthorizationHandler", - "SignInState", - "SignInResponse", - "UserAuthorization", - "AgenticUserAuthorization", - "AuthorizationHandler", -] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/__init__.py deleted file mode 100644 index fd372a13..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .agentic_user_authorization import AgenticUserAuthorization -from .user_authorization import UserAuthorization -from .authorization_handler import AuthorizationHandler - -__all__ = [ - "AgenticUserAuthorization", - "UserAuthorization", - "AuthorizationHandler", -] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py new file mode 100644 index 00000000..a1a9bda2 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py @@ -0,0 +1,19 @@ +from .authorization import Authorization +from .auth_handler import AuthHandler +from ._sign_in_state import _SignInState +from ._sign_in_response import _SignInResponse +from ._handlers import ( + _UserAuthorization, + AgenticUserAuthorization, + _AuthorizationHandler +) + +__all__ = [ + "Authorization", + "AuthHandler", + "_AuthorizationHandler", + "_SignInState", + "_SignInResponse", + "_UserAuthorization", + "AgenticUserAuthorization", +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/__init__.py new file mode 100644 index 00000000..fa750c46 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/__init__.py @@ -0,0 +1,9 @@ +from .agentic_user_authorization import AgenticUserAuthorization +from .user_authorization import _UserAuthorization +from .authorization_handler import _AuthorizationHandler + +__all__ = [ + "AgenticUserAuthorization", + "_UserAuthorization", + "_AuthorizationHandler", +] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py similarity index 91% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py index 1de0ccc9..162d84d0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/authorization_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py @@ -8,12 +8,12 @@ from ....storage import Storage from ....authorization import Connections from ..auth_handler import AuthHandler -from ..sign_in_response import SignInResponse +from .._sign_in_response import _SignInResponse logger = logging.getLogger(__name__) -class AuthorizationHandler(ABC): +class _AuthorizationHandler(ABC): """Base class for different authorization strategies.""" _storage: Storage @@ -52,15 +52,15 @@ def __init__( if auth_handler: self._handler = auth_handler else: - self._handler = AuthHandler.from_settings(auth_handler_settings) + self._handler = AuthHandler._from_settings(auth_handler_settings) self._id = auth_handler_id or self._handler.name if not self._id: raise ValueError("Auth handler must have an ID. Could not be deduced from settings or constructor args.") - async def sign_in( + async def _sign_in( self, context: TurnContext, scopes: Optional[list[str]] = None - ) -> SignInResponse: + ) -> _SignInResponse: """Initiate or continue the sign-in process for the user with the given auth handler. :param context: The turn context for the current turn of conversation. @@ -72,13 +72,13 @@ async def sign_in( """ raise NotImplementedError() - async def get_refreshed_token( + async def _get_refreshed_token( self, context: TurnContext, exchange_connection: Optional[str]=None, exchange_scopes: Optional[list[str]] = None ) -> TokenResponse: """Attempts to get a refreshed token for the user with the given scopes""" raise NotImplementedError() - async def sign_out(self, context: TurnContext) -> None: + async def _sign_out(self, context: TurnContext) -> None: """Attempts to sign out the user from the specified auth handler or all handlers if none specified. :param context: The turn context for the current turn of conversation. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py similarity index 96% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/user_authorization.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py index 8a32fa65..fb1aeddb 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py @@ -18,20 +18,20 @@ from microsoft_agents.hosting.core.message_factory import MessageFactory from microsoft_agents.hosting.core.connector.client import UserTokenClient from microsoft_agents.hosting.core.turn_context import TurnContext -from microsoft_agents.hosting.core.oauth import ( - OAuthFlow, - FlowResponse, - FlowState, - FlowStorageClient, - FlowStateTag +from microsoft_agents.hosting.core._oauth import ( + _OAuthFlow, + _FlowResponse, + _FlowState, + _FlowStorageClient, + _FlowStateTag ) -from ..sign_in_response import SignInResponse -from .authorization_handler import AuthorizationHandler +from ..sign_in_response import _SignInResponse +from ._authorization_handler import _AuthorizationHandler logger = logging.getLogger(__name__) -class UserAuthorization(AuthorizationHandler): +class _UserAuthorization(_AuthorizationHandler): """ Class responsible for managing authorization and OAuth flows. Handles multiple OAuth providers and manages the complete authentication lifecycle. @@ -133,7 +133,7 @@ async def _handle_obo( ) return TokenResponse(token=token) if token else TokenResponse() - async def sign_out( + async def _sign_out( self, context: TurnContext, ) -> None: @@ -193,7 +193,7 @@ async def _handle_flow_response( logger.warning("Sign-in flow failed for unknown reasons.") await context.send_activity("Sign-in failed. Please try again.") - async def sign_in( + async def _sign_in( self, context: TurnContext, exchange_connection: Optional[str] = None, exchange_scopes: Optional[list[str]] = None ) -> SignInResponse: """Begins or continues an OAuth flow. @@ -233,7 +233,7 @@ async def sign_in( return SignInResponse(tag=flow_response.flow_state.tag) - async def get_refreshed_token( + async def _get_refreshed_token( self, context: TurnContext, exchange_connection: Optional[str] = None, exchange_scopes: Optional[list[str]] = None ) -> TokenResponse: """ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py similarity index 97% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_user_authorization.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py index 3d9dd887..2c531a3a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/handlers/agentic_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py @@ -7,12 +7,12 @@ from ....turn_context import TurnContext from ....oauth import FlowStateTag from ..sign_in_response import SignInResponse -from .authorization_handler import AuthorizationHandler +from ._authorization_handler import _AuthorizationHandler logger = logging.getLogger(__name__) -class AgenticUserAuthorization(AuthorizationHandler): +class AgenticUserAuthorization(_AuthorizationHandler): """Class responsible for managing agentic authorization""" async def get_agentic_instance_token(self, context: TurnContext) -> Optional[str]: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_sign_in_response.py similarity index 96% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_sign_in_response.py index 25f2bc4d..614eb3af 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_sign_in_response.py @@ -5,7 +5,7 @@ from ...oauth import FlowStateTag -class SignInResponse: +class _SignInResponse: """Response for a sign-in attempt, including the token response and flow state tag.""" token_response: TokenResponse diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_sign_in_state.py similarity index 82% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_sign_in_state.py index 8d4fa439..7ddeddec 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/sign_in_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_sign_in_state.py @@ -8,7 +8,7 @@ from ...storage import StoreItem -class SignInState(StoreItem): +class _SignInState(StoreItem): """Store item for sign-in state, including tokens and continuation activity. Used to cache tokens and keep track of activities during single and @@ -30,10 +30,10 @@ def store_item_to_json(self) -> JSON: } @staticmethod - def from_json_to_store_item(json_data: JSON) -> SignInState: - return SignInState(json_data["tokens"], json_data.get("continuation_activity")) + def from_json_to_store_item(json_data: JSON) -> _SignInState: + return _SignInState(json_data["tokens"], json_data.get("continuation_activity")) - def active_handler(self) -> str: + def _active_handler(self) -> str: """Return the handler ID that is missing a token, according to the state.""" for handler_id, token in self.tokens.items(): if not token: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py similarity index 95% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py index 42f006fc..565940c2 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/auth_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py @@ -64,14 +64,14 @@ def __init__( if scopes: self.scopes = list(scopes) else: - self.scopes = AuthHandler.format_scopes(kwargs.get("SCOPES", "")) + self.scopes = AuthHandler._format_scopes(kwargs.get("SCOPES", "")) @staticmethod - def format_scopes(scopes: str) -> list[str]: + def _format_scopes(scopes: str) -> list[str]: lst = scopes.strip().split(" ") return [ s for s in lst if s ] @staticmethod - def from_settings(settings: dict): + def _from_settings(settings: dict): """ Creates an AuthHandler instance from a settings dictionary. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py similarity index 92% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index a392f17e..6b64af4b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/auth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -11,20 +11,19 @@ from ...oauth import FlowStateTag from ..state import TurnState from .auth_handler import AuthHandler -from .sign_in_state import SignInState -from .sign_in_response import SignInResponse -from .handlers import ( +from ._sign_in_state import _SignInState +from ._sign_in_response import _SignInResponse +from ._handlers import ( AgenticUserAuthorization, - UserAuthorization, - AuthorizationHandler + _UserAuthorization, + _AuthorizationHandler ) -from microsoft_agents.hosting.core.app.auth import auth_handler logger = logging.getLogger(__name__) AUTHORIZATION_TYPE_MAP = { - UserAuthorization.__name__.lower(): UserAuthorization, - AgenticUserAuthorization.__name__.lower(): AgenticUserAuthorization, + "userauthorization": _UserAuthorization, + "agenticuserauthorization": AgenticUserAuthorization, } class Authorization: @@ -32,7 +31,7 @@ class Authorization: _storage: Storage _connection_manager: Connections - _handlers: dict[str, AuthorizationHandler] + _handlers: dict[str, _AuthorizationHandler] def __init__( self, @@ -115,7 +114,7 @@ def _init_handlers(self) -> None: ) @staticmethod - def sign_in_state_key(context: TurnContext) -> str: + def _sign_in_state_key(context: TurnContext) -> str: """Generate a unique storage key for the sign-in state based on the context. This is the key used to store and retrieve the sign-in state from storage, and @@ -130,22 +129,22 @@ def sign_in_state_key(context: TurnContext) -> str: async def _load_sign_in_state(self, context: TurnContext) -> Optional[SignInState]: """Load the sign-in state from storage for the given context.""" - key = self.sign_in_state_key(context) + key = self._sign_in_state_key(context) return (await self._storage.read([key], target_cls=SignInState)).get(key) async def _save_sign_in_state( self, context: TurnContext, state: SignInState ) -> None: """Save the sign-in state to storage for the given context.""" - key = self.sign_in_state_key(context) + key = self._sign_in_state_key(context) await self._storage.write({key: state}) async def _delete_sign_in_state(self, context: TurnContext) -> None: """Delete the sign-in state from storage for the given context.""" - key = self.sign_in_state_key(context) + key = self._sign_in_state_key(context) await self._storage.delete([key]) - def resolve_handler(self, handler_id: str) -> AuthorizationHandler: + def _resolve_handler(self, handler_id: str) -> _AuthorizationHandler: """Resolve the auth handler by its ID. :param handler_id: The ID of the auth handler to resolve. @@ -160,7 +159,7 @@ def resolve_handler(self, handler_id: str) -> AuthorizationHandler: ) return self._handlers[handler_id] - async def start_or_continue_sign_in( + async def _start_or_continue_sign_in( self, context: TurnContext, state: TurnState, auth_handler_id: Optional[str] = None ) -> SignInResponse: """Start or continue the sign-in process for the user with the given auth handler. @@ -195,10 +194,10 @@ async def start_or_continue_sign_in( ), ) - handler = self.resolve_handler(auth_handler_id) + handler = self._resolve_handler(auth_handler_id) # attempt sign-in continuation (or beginning) - sign_in_response = await handler.sign_in(context) + sign_in_response = await handler._sign_in(context) if sign_in_response.tag == FlowStateTag.COMPLETE: if self._sign_in_success_handler: @@ -235,12 +234,12 @@ async def sign_out( sign_in_state = await self._load_sign_in_state(context) if sign_in_state and auth_handler_id in sign_in_state.tokens: # sign out from specific handler - handler = self.resolve_handler(auth_handler_id) - await handler.sign_out(context) + handler = self._resolve_handler(auth_handler_id) + await handler._sign_out(context) del sign_in_state.tokens[auth_handler_id] await self._save_sign_in_state(context, sign_in_state) - async def on_turn_auth_intercept( + async def _on_turn_auth_intercept( self, context: TurnContext, state: TurnState ) -> tuple[bool, Optional[Activity]]: """Intercepts the turn to check for active authentication flows. @@ -307,7 +306,7 @@ async def exchange_token( f"Auth handler {auth_handler_id} not recognized or not configured." ) - handler = self.resolve_handler(auth_handler_id) + handler = self._resolve_handler(auth_handler_id) sign_in_state = await self._load_sign_in_state(context) if not sign_in_state or not sign_in_state.tokens.get(auth_handler_id): @@ -323,8 +322,7 @@ async def exchange_token( # if diff > 0: # return token_res.token - handler = self.resolve_handler(auth_handler_id) - res = await handler.get_refreshed_token(context, exchange_connection, scopes) + res = await handler._get_refreshed_token(context, exchange_connection, scopes) if res: sign_in_state.tokens[auth_handler_id] = res.token await self._save_sign_in_state(context, sign_in_state) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/oauth/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/oauth/__init__.py deleted file mode 100644 index 79858343..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/oauth/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from .flow_state import FlowState, FlowStateTag, FlowErrorTag -from .flow_storage_client import FlowStorageClient -from .oauth_flow import OAuthFlow, FlowResponse - -__all__ = [ - "FlowState", - "FlowStateTag", - "FlowErrorTag", - "FlowResponse", - "FlowStorageClient", - "OAuthFlow", -] diff --git a/tests/_common/testing_objects/mocks/mock_authorization.py b/tests/_common/testing_objects/mocks/mock_authorization.py index 19a2d3fa..3138bd35 100644 --- a/tests/_common/testing_objects/mocks/mock_authorization.py +++ b/tests/_common/testing_objects/mocks/mock_authorization.py @@ -1,25 +1,25 @@ from microsoft_agents.activity import TokenResponse -from microsoft_agents.hosting.core import ( - Authorization, - UserAuthorization, +from microsoft_agents.hosting.core import Authorization +from microsoft_agents.hosting.core.app.oauth import ( + _UserAuthorization, AgenticUserAuthorization, - SignInResponse + _SignInResponse ) def mock_class_UserAuthorization(mocker, sign_in_return=None, get_refreshed_token_return=None): if sign_in_return is None: - sign_in_return = SignInResponse() + sign_in_return = _SignInResponse() if get_refreshed_token_return is None: get_refreshed_token_return = TokenResponse() - mocker.patch.object(UserAuthorization, "sign_in", return_value=sign_in_return) - mocker.patch.object(UserAuthorization, "sign_out") - mocker.patch.object(UserAuthorization, "get_refreshed_token", return_value=get_refreshed_token_return) + mocker.patch.object(_UserAuthorization, "sign_in", return_value=sign_in_return) + mocker.patch.object(_UserAuthorization, "sign_out") + mocker.patch.object(_UserAuthorization, "get_refreshed_token", return_value=get_refreshed_token_return) def mock_class_AgenticUserAuthorization(mocker, sign_in_return=None, get_refreshed_token_return=None): if sign_in_return is None: - sign_in_return = SignInResponse() + sign_in_return = _SignInResponse() if get_refreshed_token_return is None: get_refreshed_token_return = TokenResponse() mocker.patch.object(AgenticUserAuthorization, "sign_in", return_value=sign_in_return) diff --git a/tests/hosting_core/app/auth/test_sign_in_response.py b/tests/hosting_core/app/auth/test_sign_in_response.py deleted file mode 100644 index 99d7a894..00000000 --- a/tests/hosting_core/app/auth/test_sign_in_response.py +++ /dev/null @@ -1,10 +0,0 @@ -from microsoft_agents.hosting.core import SignInResponse, FlowStateTag - - -def test_sign_in_response_sign_in_complete(): - assert SignInResponse(tag=FlowStateTag.BEGIN).sign_in_complete() == False - assert SignInResponse(tag=FlowStateTag.CONTINUE).sign_in_complete() == False - assert SignInResponse(tag=FlowStateTag.FAILURE).sign_in_complete() == False - assert SignInResponse().sign_in_complete() == False - assert SignInResponse(tag=FlowStateTag.NOT_STARTED).sign_in_complete() == True - assert SignInResponse(tag=FlowStateTag.COMPLETE).sign_in_complete() == True diff --git a/tests/hosting_core/app/auth/__init__.py b/tests/hosting_core/app/oauth/__init__.py similarity index 100% rename from tests/hosting_core/app/auth/__init__.py rename to tests/hosting_core/app/oauth/__init__.py diff --git a/tests/hosting_core/app/auth/_common.py b/tests/hosting_core/app/oauth/_common.py similarity index 100% rename from tests/hosting_core/app/auth/_common.py rename to tests/hosting_core/app/oauth/_common.py diff --git a/tests/hosting_core/app/auth/_env.py b/tests/hosting_core/app/oauth/_env.py similarity index 100% rename from tests/hosting_core/app/auth/_env.py rename to tests/hosting_core/app/oauth/_env.py diff --git a/tests/hosting_core/app/auth/handlers/__init__.py b/tests/hosting_core/app/oauth/_handlers/__init__.py similarity index 100% rename from tests/hosting_core/app/auth/handlers/__init__.py rename to tests/hosting_core/app/oauth/_handlers/__init__.py diff --git a/tests/hosting_core/app/auth/handlers/test_agentic_user_authorization.py b/tests/hosting_core/app/oauth/_handlers/test_agentic_user_authorization.py similarity index 95% rename from tests/hosting_core/app/auth/handlers/test_agentic_user_authorization.py rename to tests/hosting_core/app/oauth/_handlers/test_agentic_user_authorization.py index 3075db6a..f507b4f4 100644 --- a/tests/hosting_core/app/auth/handlers/test_agentic_user_authorization.py +++ b/tests/hosting_core/app/oauth/_handlers/test_agentic_user_authorization.py @@ -10,31 +10,12 @@ from microsoft_agents.authentication.msal import MsalAuth, MsalConnectionManager -from microsoft_agents.hosting.core import ( - AgenticUserAuthorization, - SignInResponse, - MemoryStorage, - FlowStateTag, -) - -from tests._common.data import ( - # TEST_FLOW_DATA, - # TEST_AUTH_DATA, - # TEST_STORAGE_DATA, - TEST_DEFAULTS, - # TEST_ENV_DICT, - TEST_AGENTIC_ENV_DICT, - # create_test_auth_handler, -) - -from tests._common.testing_objects import ( - # TestingConnectionManager, - # TestingTokenProvider, - # agentic_mock_class_MsalAuth, - TestingConnectionManager as MockConnectionManager, -) +from microsoft_agents.hosting.core.app.oauth import AgenticUserAuthorization +from microsoft_agents.hosting.core.storage import MemoryStorage +from microsoft_agents.hosting.core._oauth import FlowStateTag -from tests._common.mock_utils import mock_class, mock_instance +from tests._common.data import TEST_DEFAULTS, TEST_AGENTIC_ENV_DICT +from tests._common.mock_utils import mock_class from .._common import ( testing_TurnContext_magic, diff --git a/tests/hosting_core/app/auth/handlers/test_user_authorization.py b/tests/hosting_core/app/oauth/_handlers/test_user_authorization.py similarity index 100% rename from tests/hosting_core/app/auth/handlers/test_user_authorization.py rename to tests/hosting_core/app/oauth/_handlers/test_user_authorization.py diff --git a/tests/hosting_core/app/auth/test_auth_handler.py b/tests/hosting_core/app/oauth/test_auth_handler.py similarity index 100% rename from tests/hosting_core/app/auth/test_auth_handler.py rename to tests/hosting_core/app/oauth/test_auth_handler.py diff --git a/tests/hosting_core/app/auth/test_authorization.py b/tests/hosting_core/app/oauth/test_authorization.py similarity index 88% rename from tests/hosting_core/app/auth/test_authorization.py rename to tests/hosting_core/app/oauth/test_authorization.py index 800da75b..51436e50 100644 --- a/tests/hosting_core/app/auth/test_authorization.py +++ b/tests/hosting_core/app/oauth/test_authorization.py @@ -6,18 +6,18 @@ from microsoft_agents.activity import Activity, ActivityTypes, TokenResponse from microsoft_agents.hosting.core import ( - FlowStateTag, + _FlowStateTag, Authorization, - UserAuthorization, + _UserAuthorization, AgenticUserAuthorization, Storage, TurnContext, MemoryStorage, AuthHandler, - FlowStateTag, - SignInState, - SignInResponse, + _FlowStateTag, + _SignInState, + _SignInResponse, ) from tests._common.storage.utils import StorageBaseline @@ -56,23 +56,23 @@ def make_jwt(token: str = DEFAULTS.token, aud="api://default"): async def get_sign_in_state( auth: Authorization, storage: Storage, context: TurnContext -) -> Optional[SignInState]: +) -> Optional[_SignInState]: key = auth.sign_in_state_key(context) - return (await storage.read([key], target_cls=SignInState)).get(key) + return (await storage.read([key], target_cls=_SignInState)).get(key) async def set_sign_in_state( - auth: Authorization, storage: Storage, context: TurnContext, state: SignInState + auth: Authorization, storage: Storage, context: TurnContext, state: _SignInState ): key = auth.sign_in_state_key(context) await storage.write({key: state}) def mock_variants(mocker, sign_in_return=None, get_refreshed_token_return=None): - mock_class_UserAuthorization(mocker, sign_in_return=sign_in_return, get_refreshed_token_return=get_refreshed_token_return) + mock_class__UserAuthorization(mocker, sign_in_return=sign_in_return, get_refreshed_token_return=get_refreshed_token_return) mock_class_AgenticUserAuthorization(mocker, sign_in_return=sign_in_return, get_refreshed_token_return=get_refreshed_token_return) -def sign_in_state_eq(a: Optional[SignInState], b: Optional[SignInState]) -> bool: +def sign_in_state_eq(a: Optional[_SignInState], b: Optional[_SignInState]) -> bool: if a is None and b is None: return True if a is None or b is None: @@ -80,8 +80,8 @@ def sign_in_state_eq(a: Optional[SignInState], b: Optional[SignInState]) -> bool return a.tokens == b.tokens and a.continuation_activity == b.continuation_activity -def copy_sign_in_state(state: SignInState) -> SignInState: - return SignInState( +def copy_sign_in_state(state: _SignInState) -> _SignInState: + return _SignInState( tokens=state.tokens.copy(), continuation_activity=( state.continuation_activity.model_copy() @@ -138,7 +138,7 @@ class TestAuthorizationSetup(TestEnv): def test_init_user_auth(self, connection_manager, storage, env_dict): auth = Authorization(storage, connection_manager, **env_dict) assert auth.resolve_handler(DEFAULTS.auth_handler_id) is not None - assert isinstance(auth.resolve_handler(DEFAULTS.auth_handler_id), UserAuthorization) + assert isinstance(auth.resolve_handler(DEFAULTS.auth_handler_id), _UserAuthorization) def test_init_agentic_auth_not_configured(self, connection_manager, storage): auth = Authorization(storage, connection_manager, **ENV_DICT) @@ -148,7 +148,7 @@ def test_init_agentic_auth_not_configured(self, connection_manager, storage): def test_init_agentic_auth(self, connection_manager, storage): auth = Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) assert auth.resolve_handler(DEFAULTS.agentic_auth_handler_id) is not None - assert isinstance(auth.resolve_handler(DEFAULTS.agentic_auth_handler_id), AgenticUserAuthorization) + assert isinstance(auth.resolve_handler(DEFAULTS.agentic_auth_handler_id), Agentic_UserAuthorization) @pytest.mark.parametrize( "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] @@ -166,7 +166,7 @@ def test_sign_in_state_key(self, mocker, connection_manager, storage): auth = Authorization(storage, connection_manager, **ENV_DICT) context = self.TurnContext(mocker) key = auth.sign_in_state_key(context) - assert key == f"auth:SignInState:{DEFAULTS.channel_id}:{DEFAULTS.user_id}" + assert key == f"auth:_SignInState:{DEFAULTS.channel_id}:{DEFAULTS.user_id}" class TestAuthorizationUsage(TestEnv): @@ -180,7 +180,7 @@ async def test_sign_out_not_signed_in( self, mocker, storage, authorization, context, activity, auth_handler_id ): mock_variants(mocker) - initial_state = SignInState( + initial_state = _SignInState( tokens={DEFAULTS.auth_handler_id: "", "my_handler": "old_token"}, continuation_activity=activity, ) @@ -201,7 +201,7 @@ async def test_sign_out_signed_in( self, mocker, storage, authorization, context, activity, auth_handler_id ): mock_variants(mocker) - initial_state = SignInState( + initial_state = _SignInState( tokens={ DEFAULTS.auth_handler_id: "token", DEFAULTS.agentic_auth_handler_id: "another_token", @@ -222,7 +222,7 @@ async def test_start_or_continue_sign_in_cached( self, storage, authorization, context, activity ): # setup - initial_state = SignInState( + initial_state = _SignInState( tokens={DEFAULTS.auth_handler_id: "valid_token"}, continuation_activity=activity, ) @@ -230,7 +230,7 @@ async def test_start_or_continue_sign_in_cached( sign_in_response = await authorization.start_or_continue_sign_in( context, None, DEFAULTS.auth_handler_id ) - assert sign_in_response.tag == FlowStateTag.COMPLETE + assert sign_in_response.tag == _FlowStateTag.COMPLETE assert sign_in_response.token_response.token == "valid_token" assert sign_in_state_eq( @@ -246,15 +246,15 @@ async def test_start_or_continue_sign_in_no_initial_state_to_complete( ): mock_variants( mocker, - sign_in_return=SignInResponse( + sign_in_return=_SignInResponse( token_response=TokenResponse(token=DEFAULTS.token), - tag=FlowStateTag.COMPLETE, + tag=_FlowStateTag.COMPLETE, ), ) sign_in_response = await authorization.start_or_continue_sign_in( context, None, auth_handler_id ) - assert sign_in_response.tag == FlowStateTag.COMPLETE + assert sign_in_response.tag == _FlowStateTag.COMPLETE assert sign_in_response.token_response.token == DEFAULTS.token final_state = await get_sign_in_state(authorization, storage, context) @@ -269,7 +269,7 @@ async def test_start_or_continue_sign_in_to_complete_with_prev_state( self, mocker, storage, authorization, context, auth_handler_id ): # setup - initial_state = SignInState( + initial_state = _SignInState( tokens={"my_handler": "old_token"}, continuation_activity=Activity( type=ActivityTypes.message, text="old activity" @@ -278,9 +278,9 @@ async def test_start_or_continue_sign_in_to_complete_with_prev_state( await set_sign_in_state(authorization, storage, context, initial_state) mock_variants( mocker, - sign_in_return=SignInResponse( + sign_in_return=_SignInResponse( token_response=TokenResponse(token=DEFAULTS.token), - tag=FlowStateTag.COMPLETE, + tag=_FlowStateTag.COMPLETE, ), ) @@ -288,7 +288,7 @@ async def test_start_or_continue_sign_in_to_complete_with_prev_state( sign_in_response = await authorization.start_or_continue_sign_in( context, None, auth_handler_id ) - assert sign_in_response.tag == FlowStateTag.COMPLETE + assert sign_in_response.tag == _FlowStateTag.COMPLETE assert sign_in_response.token_response.token == DEFAULTS.token # verify @@ -305,7 +305,7 @@ async def test_start_or_continue_sign_in_to_failure_with_prev_state( self, mocker, storage, authorization, context, auth_handler_id ): # setup - initial_state = SignInState( + initial_state = _SignInState( tokens={"my_handler": "old_token"}, continuation_activity=Activity( type=ActivityTypes.message, text="old activity" @@ -314,8 +314,8 @@ async def test_start_or_continue_sign_in_to_failure_with_prev_state( await set_sign_in_state(authorization, storage, context, initial_state) mock_variants( mocker, - sign_in_return=SignInResponse( - token_response=TokenResponse(), tag=FlowStateTag.FAILURE + sign_in_return=_SignInResponse( + token_response=TokenResponse(), tag=_FlowStateTag.FAILURE ), ) @@ -323,7 +323,7 @@ async def test_start_or_continue_sign_in_to_failure_with_prev_state( sign_in_response = await authorization.start_or_continue_sign_in( context, None, auth_handler_id ) - assert sign_in_response.tag == FlowStateTag.FAILURE + assert sign_in_response.tag == _FlowStateTag.FAILURE assert not sign_in_response.token_response # verify @@ -336,17 +336,17 @@ async def test_start_or_continue_sign_in_to_failure_with_prev_state( @pytest.mark.parametrize( "auth_handler_id, tag", [ - (DEFAULTS.auth_handler_id, FlowStateTag.BEGIN), - (DEFAULTS.agentic_auth_handler_id, FlowStateTag.BEGIN), - (DEFAULTS.auth_handler_id, FlowStateTag.CONTINUE), - (DEFAULTS.agentic_auth_handler_id, FlowStateTag.CONTINUE), + (DEFAULTS.auth_handler_id, _FlowStateTag.BEGIN), + (DEFAULTS.agentic_auth_handler_id, _FlowStateTag.BEGIN), + (DEFAULTS.auth_handler_id, _FlowStateTag.CONTINUE), + (DEFAULTS.agentic_auth_handler_id, _FlowStateTag.CONTINUE), ], ) async def test_start_or_continue_sign_in_to_pending_with_prev_state( self, mocker, storage, authorization, context, auth_handler_id, tag ): # setup - initial_state = SignInState( + initial_state = _SignInState( tokens={"my_handler": "old_token"}, continuation_activity=Activity( type=ActivityTypes.message, text="old activity" @@ -355,7 +355,7 @@ async def test_start_or_continue_sign_in_to_pending_with_prev_state( await set_sign_in_state(authorization, storage, context, initial_state) mock_variants( mocker, - sign_in_return=SignInResponse(token_response=TokenResponse(), tag=tag), + sign_in_return=_SignInResponse(token_response=TokenResponse(), tag=tag), ) # test @@ -376,10 +376,10 @@ async def test_start_or_continue_sign_in_to_pending_with_prev_state( "initial_state, final_state, handler_id, refresh_token, expected", [ [ # no cached token - SignInState( + _SignInState( tokens={DEFAULTS.auth_handler_id: "token"}, ), - SignInState( + _SignInState( tokens={DEFAULTS.auth_handler_id: "token"}, ), DEFAULTS.agentic_auth_handler_id, @@ -387,10 +387,10 @@ async def test_start_or_continue_sign_in_to_pending_with_prev_state( None ], [ # no cached token and default handler id resolution - SignInState( + _SignInState( tokens={DEFAULTS.agentic_auth_handler_id: "token"}, ), - SignInState( + _SignInState( tokens={DEFAULTS.agentic_auth_handler_id: "token"}, ), "", @@ -398,10 +398,10 @@ async def test_start_or_continue_sign_in_to_pending_with_prev_state( None ], [ # no cached token pt.2 - SignInState( + _SignInState( tokens={DEFAULTS.agentic_auth_handler_id: "token", DEFAULTS.auth_handler_id: ""}, ), - SignInState( + _SignInState( tokens={DEFAULTS.agentic_auth_handler_id: "token", DEFAULTS.auth_handler_id: ""}, ), DEFAULTS.auth_handler_id, @@ -409,10 +409,10 @@ async def test_start_or_continue_sign_in_to_pending_with_prev_state( None ], [ # refreshed, new token - SignInState( + _SignInState( tokens={DEFAULTS.agentic_auth_handler_id: make_jwt(), DEFAULTS.auth_handler_id: ""}, ), - SignInState( + _SignInState( tokens={DEFAULTS.agentic_auth_handler_id: DEFAULTS.token, DEFAULTS.auth_handler_id: ""}, ), DEFAULTS.agentic_auth_handler_id, @@ -435,7 +435,7 @@ async def test_get_token(self, mocker, authorization, context, storage, initial_ @pytest.mark.asyncio async def test_get_token_error(self, mocker, authorization, context, storage): - initial_state = SignInState( + initial_state = _SignInState( tokens={DEFAULTS.auth_handler_id: "old_token"}, ) await set_sign_in_state(authorization, storage, context, initial_state) @@ -448,10 +448,10 @@ async def test_get_token_error(self, mocker, authorization, context, storage): "initial_state, final_state, handler_id, refreshed, refresh_token, expected", [ [ # no cached token - SignInState( + _SignInState( tokens={DEFAULTS.auth_handler_id: "token"}, ), - SignInState( + _SignInState( tokens={DEFAULTS.auth_handler_id: "token"}, ), DEFAULTS.agentic_auth_handler_id, @@ -460,10 +460,10 @@ async def test_get_token_error(self, mocker, authorization, context, storage): None ], [ # no cached token and default handler id resolution - SignInState( + _SignInState( tokens={DEFAULTS.agentic_auth_handler_id: "token"}, ), - SignInState( + _SignInState( tokens={DEFAULTS.agentic_auth_handler_id: "token"}, ), "", @@ -472,10 +472,10 @@ async def test_get_token_error(self, mocker, authorization, context, storage): None ], [ # no cached token pt.2 - SignInState( + _SignInState( tokens={DEFAULTS.agentic_auth_handler_id: "token", DEFAULTS.auth_handler_id: ""}, ), - SignInState( + _SignInState( tokens={DEFAULTS.agentic_auth_handler_id: "token", DEFAULTS.auth_handler_id: ""}, ), DEFAULTS.auth_handler_id, @@ -484,10 +484,10 @@ async def test_get_token_error(self, mocker, authorization, context, storage): None ], [ # refreshed, new token - SignInState( + _SignInState( tokens={DEFAULTS.agentic_auth_handler_id: make_jwt(), DEFAULTS.auth_handler_id: ""}, ), - SignInState( + _SignInState( tokens={DEFAULTS.agentic_auth_handler_id: DEFAULTS.token, DEFAULTS.auth_handler_id: ""}, ), DEFAULTS.agentic_auth_handler_id, @@ -519,14 +519,14 @@ async def test_exchange_token(self, mocker, authorization, context, storage, ini @pytest.mark.parametrize( "sign_in_state", [ - SignInState(), - SignInState( + _SignInState(), + _SignInState( tokens={DEFAULTS.auth_handler_id: "token"}, continuation_activity=Activity( type=ActivityTypes.message, text="activity" ), ), - SignInState( + _SignInState( tokens={ DEFAULTS.auth_handler_id: "token", DEFAULTS.agentic_auth_handler_id: "another_token", @@ -535,7 +535,7 @@ async def test_exchange_token(self, mocker, authorization, context, storage, ini type=ActivityTypes.message, text="activity" ), ), - SignInState( + _SignInState( tokens={DEFAULTS.auth_handler_id: "token", "my_handler": "old_token"}, continuation_activity=Activity( type=ActivityTypes.message, text="activity" @@ -565,9 +565,9 @@ async def test_on_turn_auth_intercept_no_intercept( @pytest.mark.parametrize( "sign_in_response", [ - SignInResponse(tag=FlowStateTag.BEGIN), - SignInResponse(tag=FlowStateTag.CONTINUE), - SignInResponse(tag=FlowStateTag.FAILURE), + _SignInResponse(tag=_FlowStateTag.BEGIN), + _SignInResponse(tag=_FlowStateTag.CONTINUE), + _SignInResponse(tag=_FlowStateTag.FAILURE), ], ) async def test_on_turn_auth_intercept_with_intercept_incomplete( @@ -577,7 +577,7 @@ async def test_on_turn_auth_intercept_with_intercept_incomplete( mocker, start_or_continue_sign_in_return=sign_in_response ) - initial_state = SignInState( + initial_state = _SignInState( tokens={"some_handler": "old_token", auth_handler_id: ""}, continuation_activity=Activity( type=ActivityTypes.message, text="old activity" @@ -603,11 +603,11 @@ async def test_on_turn_auth_intercept_with_intercept_complete( ): mock_class_Authorization( mocker, - start_or_continue_sign_in_return=SignInResponse(tag=FlowStateTag.COMPLETE), + start_or_continue_sign_in_return=_SignInResponse(tag=_FlowStateTag.COMPLETE), ) old_activity = Activity(type=ActivityTypes.message, text="old activity") - initial_state = SignInState( + initial_state = _SignInState( tokens={"some_handler": "old_token", auth_handler_id: ""}, continuation_activity=old_activity, ) diff --git a/tests/hosting_core/app/oauth/test_sign_in_response.py b/tests/hosting_core/app/oauth/test_sign_in_response.py new file mode 100644 index 00000000..a5509c97 --- /dev/null +++ b/tests/hosting_core/app/oauth/test_sign_in_response.py @@ -0,0 +1,10 @@ +from microsoft_agents.hosting.core import _SignInResponse, _FlowStateTag + + +def test_sign_in_response_sign_in_complete(): + assert _SignInResponse(tag=_FlowStateTag.BEGIN).sign_in_complete() == False + assert _SignInResponse(tag=_FlowStateTag.CONTINUE).sign_in_complete() == False + assert _SignInResponse(tag=_FlowStateTag.FAILURE).sign_in_complete() == False + assert _SignInResponse().sign_in_complete() == False + assert _SignInResponse(tag=_FlowStateTag.NOT_STARTED).sign_in_complete() == True + assert _SignInResponse(tag=_FlowStateTag.COMPLETE).sign_in_complete() == True diff --git a/tests/hosting_core/app/auth/test_sign_in_state.py b/tests/hosting_core/app/oauth/test_sign_in_state.py similarity index 96% rename from tests/hosting_core/app/auth/test_sign_in_state.py rename to tests/hosting_core/app/oauth/test_sign_in_state.py index 2621cf31..36710f47 100644 --- a/tests/hosting_core/app/auth/test_sign_in_state.py +++ b/tests/hosting_core/app/oauth/test_sign_in_state.py @@ -1,6 +1,6 @@ import pytest -from microsoft_agents.hosting.core.app.auth import SignInState +from microsoft_agents.hosting.core.app.oauth import SignInState from ._common import testing_Activity, testing_TurnContext diff --git a/tests/hosting_core/oauth/test_flow_state.py b/tests/hosting_core/oauth/test_flow_state.py index 9e8b7266..a96468dd 100644 --- a/tests/hosting_core/oauth/test_flow_state.py +++ b/tests/hosting_core/oauth/test_flow_state.py @@ -1,6 +1,6 @@ -from datetime import datetime import pytest -from microsoft_agents.hosting.core.oauth.flow_state import FlowState, FlowStateTag +from datetime import datetime +from microsoft_agents.hosting.core._oauth._flow_state import _FlowState, _FlowStateTag class TestFlowState: @@ -8,40 +8,40 @@ class TestFlowState: "original_flow_state, refresh_to_not_started", [ ( - FlowState( - tag=FlowStateTag.CONTINUE, + _FlowState( + tag=_FlowStateTag.CONTINUE, attempts_remaining=0, expiration=datetime.now().timestamp(), ), True, ), ( - FlowState( - tag=FlowStateTag.BEGIN, + _FlowState( + tag=_FlowStateTag.BEGIN, attempts_remaining=1, expiration=datetime.now().timestamp(), ), True, ), ( - FlowState( - tag=FlowStateTag.COMPLETE, + _FlowState( + tag=_FlowStateTag.COMPLETE, attempts_remaining=0, expiration=datetime.now().timestamp() - 100, ), True, ), ( - FlowState( - tag=FlowStateTag.CONTINUE, + _FlowState( + tag=_FlowStateTag.CONTINUE, attempts_remaining=1, expiration=datetime.now().timestamp() + 1000, ), False, ), ( - FlowState( - tag=FlowStateTag.FAILURE, + _FlowState( + tag=_FlowStateTag.FAILURE, attempts_remaining=-1, expiration=datetime.now().timestamp(), ), @@ -54,47 +54,47 @@ def test_refresh(self, original_flow_state, refresh_to_not_started): new_flow_state.refresh() expected_flow_state = original_flow_state.model_copy() if refresh_to_not_started: - expected_flow_state.tag = FlowStateTag.NOT_STARTED + expected_flow_state.tag = _FlowStateTag.NOT_STARTED assert new_flow_state == expected_flow_state @pytest.mark.parametrize( "flow_state, expected", [ ( - FlowState( - tag=FlowStateTag.CONTINUE, + _FlowState( + tag=_FlowStateTag.CONTINUE, attempts_remaining=0, expiration=datetime.now().timestamp(), ), True, ), ( - FlowState( - tag=FlowStateTag.BEGIN, + _FlowState( + tag=_FlowStateTag.BEGIN, attempts_remaining=1, expiration=datetime.now().timestamp(), ), True, ), ( - FlowState( - tag=FlowStateTag.COMPLETE, + _FlowState( + tag=_FlowStateTag.COMPLETE, attempts_remaining=0, expiration=datetime.now().timestamp() - 100, ), True, ), ( - FlowState( - tag=FlowStateTag.CONTINUE, + _FlowState( + tag=_FlowStateTag.CONTINUE, attempts_remaining=1, expiration=datetime.now().timestamp() + 1000, ), False, ), ( - FlowState( - tag=FlowStateTag.FAILURE, + _FlowState( + tag=_FlowStateTag.FAILURE, attempts_remaining=-1, expiration=datetime.now().timestamp() + 1000, ), @@ -109,40 +109,40 @@ def test_is_expired(self, flow_state, expected): "flow_state, expected", [ ( - FlowState( - tag=FlowStateTag.CONTINUE, + _FlowState( + tag=_FlowStateTag.CONTINUE, attempts_remaining=0, expiration=datetime.now().timestamp(), ), True, ), ( - FlowState( - tag=FlowStateTag.BEGIN, + _FlowState( + tag=_FlowStateTag.BEGIN, attempts_remaining=1, expiration=datetime.now().timestamp(), ), False, ), ( - FlowState( - tag=FlowStateTag.COMPLETE, + _FlowState( + tag=_FlowStateTag.COMPLETE, attempts_remaining=0, expiration=datetime.now().timestamp() - 100, ), True, ), ( - FlowState( - tag=FlowStateTag.CONTINUE, + _FlowState( + tag=_FlowStateTag.CONTINUE, attempts_remaining=1, expiration=datetime.now().timestamp() - 100, ), False, ), ( - FlowState( - tag=FlowStateTag.FAILURE, + _FlowState( + tag=_FlowStateTag.FAILURE, attempts_remaining=-1, expiration=datetime.now().timestamp(), ), @@ -157,72 +157,72 @@ def test_reached_max_attempts(self, flow_state, expected): "flow_state, expected", [ ( - FlowState( - tag=FlowStateTag.CONTINUE, + _FlowState( + tag=_FlowStateTag.CONTINUE, attempts_remaining=0, expiration=datetime.now().timestamp(), ), False, ), ( - FlowState( - tag=FlowStateTag.BEGIN, + _FlowState( + tag=_FlowStateTag.BEGIN, attempts_remaining=1, expiration=datetime.now().timestamp(), ), False, ), ( - FlowState( - tag=FlowStateTag.COMPLETE, + _FlowState( + tag=_FlowStateTag.COMPLETE, attempts_remaining=0, expiration=datetime.now().timestamp() - 100, ), False, ), ( - FlowState( - tag=FlowStateTag.FAILURE, + _FlowState( + tag=_FlowStateTag.FAILURE, attempts_remaining=1, expiration=datetime.now().timestamp() - 100, ), False, ), ( - FlowState( - tag=FlowStateTag.CONTINUE, + _FlowState( + tag=_FlowStateTag.CONTINUE, attempts_remaining=2, expiration=datetime.now().timestamp() + 1000, ), True, ), ( - FlowState( - tag=FlowStateTag.BEGIN, + _FlowState( + tag=_FlowStateTag.BEGIN, attempts_remaining=0, expiration=datetime.now().timestamp() + 1000, ), False, ), ( - FlowState( - tag=FlowStateTag.COMPLETE, + _FlowState( + tag=_FlowStateTag.COMPLETE, attempts_remaining=-1, expiration=datetime.now().timestamp() + 1000, ), False, ), ( - FlowState( - tag=FlowStateTag.FAILURE, + _FlowState( + tag=_FlowStateTag.FAILURE, attempts_remaining=1, expiration=datetime.now().timestamp() + 1000, ), False, ), ( - FlowState( - tag=FlowStateTag.CONTINUE, + _FlowState( + tag=_FlowStateTag.CONTINUE, attempts_remaining=1, expiration=datetime.now().timestamp() + 1000, ), diff --git a/tests/hosting_core/oauth/test_flow_storage_client.py b/tests/hosting_core/oauth/test_flow_storage_client.py index efad76b0..88051e21 100644 --- a/tests/hosting_core/oauth/test_flow_storage_client.py +++ b/tests/hosting_core/oauth/test_flow_storage_client.py @@ -1,7 +1,7 @@ import pytest from microsoft_agents.hosting.core.storage import MemoryStorage -from microsoft_agents.hosting.core.oauth import FlowState, FlowStorageClient +from microsoft_agents.hosting.core.oauth import _FlowState, _FlowStorageClient from tests._common.storage.utils import MockStoreItem from tests._common.data import TEST_DEFAULTS @@ -16,7 +16,7 @@ def storage(self): @pytest.fixture def client(self, storage): - return FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) + return _FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) @pytest.mark.asyncio @pytest.mark.parametrize( @@ -28,18 +28,18 @@ def client(self, storage): ], ) async def test_init_base_key(self, mocker, channel_id, user_id): - client = FlowStorageClient(channel_id, user_id, mocker.Mock()) + client = _FlowStorageClient(channel_id, user_id, mocker.Mock()) assert client.base_key == f"auth/{channel_id}/{user_id}/" @pytest.mark.asyncio async def test_init_fails_without_user_id(self, storage): with pytest.raises(ValueError): - FlowStorageClient(DEFAULTS.channel_id, "", storage) + _FlowStorageClient(DEFAULTS.channel_id, "", storage) @pytest.mark.asyncio async def test_init_fails_without_channel_id(self, storage): with pytest.raises(ValueError): - FlowStorageClient("", DEFAULTS.user_id, storage) + _FlowStorageClient("", DEFAULTS.user_id, storage) @pytest.mark.parametrize( "auth_handler_id, expected", @@ -56,23 +56,23 @@ def test_key(self, client, auth_handler_id, expected): async def test_read(self, mocker, auth_handler_id): storage = mocker.AsyncMock() key = f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/{auth_handler_id}" - storage.read.return_value = {key: FlowState()} - client = FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) + storage.read.return_value = {key: _FlowState()} + client = _FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) res = await client.read(auth_handler_id) assert res is storage.read.return_value[key] storage.read.assert_called_once_with( - [client.key(auth_handler_id)], target_cls=FlowState + [client.key(auth_handler_id)], target_cls=_FlowState ) @pytest.mark.asyncio async def test_read_missing(self, mocker): storage = mocker.AsyncMock() storage.read.return_value = {} - client = FlowStorageClient("__channel_id", "__user_id", storage) + client = _FlowStorageClient("__channel_id", "__user_id", storage) res = await client.read("non_existent_handler") assert res is None storage.read.assert_called_once_with( - [client.key("non_existent_handler")], target_cls=FlowState + [client.key("non_existent_handler")], target_cls=_FlowState ) @pytest.mark.asyncio @@ -80,8 +80,8 @@ async def test_read_missing(self, mocker): async def test_write(self, mocker, auth_handler_id): storage = mocker.AsyncMock() storage.write.return_value = None - client = FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) - flow_state = mocker.Mock(spec=FlowState) + client = _FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) + flow_state = mocker.Mock(spec=_FlowState) flow_state.auth_handler_id = auth_handler_id await client.write(flow_state) storage.write.assert_called_once_with({client.key(auth_handler_id): flow_state}) @@ -91,15 +91,15 @@ async def test_write(self, mocker, auth_handler_id): async def test_delete(self, mocker, auth_handler_id): storage = mocker.AsyncMock() storage.delete.return_value = None - client = FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) + client = _FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) await client.delete(auth_handler_id) storage.delete.assert_called_once_with([client.key(auth_handler_id)]) @pytest.mark.asyncio async def test_integration_with_memory_storage(self): - flow_state_alpha = FlowState(auth_handler_id="handler") - flow_state_beta = FlowState(auth_handler_id="auth_handler", user_token="token") + flow_state_alpha = _FlowState(auth_handler_id="handler") + flow_state_beta = _FlowState(auth_handler_id="auth_handler", user_token="token") storage = MemoryStorage( { @@ -130,10 +130,10 @@ async def delete_both(*args, **kwargs): await storage.delete(*args, **kwargs) await baseline.delete(*args, **kwargs) - client = FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) + client = _FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) - new_flow_state_alpha = FlowState(auth_handler_id="handler") - flow_state_chi = FlowState(auth_handler_id="chi") + new_flow_state_alpha = _FlowState(auth_handler_id="handler") + flow_state_chi = _FlowState(auth_handler_id="chi") await client.write(new_flow_state_alpha) await client.write(flow_state_chi) @@ -164,14 +164,14 @@ async def delete_both(*args, **kwargs): await read_check( [f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/handler"], - target_cls=FlowState, + target_cls=_FlowState, ) await read_check( [f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/auth_handler"], - target_cls=FlowState, + target_cls=_FlowState, ) await read_check( - [f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/chi"], target_cls=FlowState + [f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/chi"], target_cls=_FlowState ) await read_check(["other_data"], target_cls=MockStoreItem) await read_check(["some_data"], target_cls=MockStoreItem) diff --git a/tests/hosting_core/oauth/test_oauth_flow.py b/tests/hosting_core/oauth/test_oauth_flow.py index 62b75b53..e580d653 100644 --- a/tests/hosting_core/oauth/test_oauth_flow.py +++ b/tests/hosting_core/oauth/test_oauth_flow.py @@ -9,11 +9,11 @@ TokenExchangeState, ConversationReference, ) -from microsoft_agents.hosting.core.oauth import ( - OAuthFlow, - FlowErrorTag, - FlowStateTag, - FlowResponse, +from microsoft_agents.hosting.core._oauth import ( + _OAuthFlow, + _FlowErrorTag, + _FlowStateTag, + _FlowResponse, ) from tests._common.data import TEST_DEFAULTS, TEST_FLOW_DATA @@ -65,13 +65,13 @@ def activity(self, mocker): @pytest.fixture def flow(self, flow_state, user_token_client): - return OAuthFlow(flow_state, user_token_client) + return _OAuthFlow(flow_state, user_token_client) -class TestOAuthFlow(TestUtils): +class Test_OAuthFlow(TestUtils): def test_init_no_user_token_client(self, flow_state): with pytest.raises(ValueError): - OAuthFlow(flow_state, None) + _OAuthFlow(flow_state, None) @pytest.mark.parametrize( "missing_value", ["connection", "ms_app_id", "channel_id", "user_id"] @@ -81,13 +81,13 @@ def test_init_errors(self, missing_value, user_token_client): flow_state = started_flow_state flow_state.__setattr__(missing_value, None) with pytest.raises(ValueError): - OAuthFlow(flow_state, user_token_client) + _OAuthFlow(flow_state, user_token_client) flow_state.__setattr__(missing_value, "") with pytest.raises(ValueError): - OAuthFlow(flow_state, user_token_client) + _OAuthFlow(flow_state, user_token_client) def test_init_with_state(self, flow_state, user_token_client): - flow = OAuthFlow(flow_state, user_token_client) + flow = _OAuthFlow(flow_state, user_token_client) assert flow.flow_state == flow_state def test_flow_state_prop_copy(self, flow): @@ -99,10 +99,10 @@ def test_flow_state_prop_copy(self, flow): @pytest.mark.asyncio async def test_get_user_token_success(self, flow_state, user_token_client): # setup - flow = OAuthFlow(flow_state, user_token_client) + flow = _OAuthFlow(flow_state, user_token_client) expected_final_flow_state = flow_state expected_final_flow_state.user_token = DEFAULTS.token - expected_final_flow_state.tag = FlowStateTag.COMPLETE + expected_final_flow_state.tag = _FlowStateTag.COMPLETE # test token_response = await flow.get_user_token() @@ -125,7 +125,7 @@ async def test_get_user_token_failure(self, mocker, flow_state): user_token_client = self.UserTokenClient( mocker, get_token_return=TokenResponse() ) - flow = OAuthFlow(flow_state, user_token_client) + flow = _OAuthFlow(flow_state, user_token_client) expected_final_flow_state = flow.flow_state # test @@ -144,10 +144,10 @@ async def test_get_user_token_failure(self, mocker, flow_state): @pytest.mark.asyncio async def test_sign_out(self, flow_state, user_token_client): # setup - flow = OAuthFlow(flow_state, user_token_client) + flow = _OAuthFlow(flow_state, user_token_client) expected_flow_state = flow_state expected_flow_state.user_token = "" - expected_flow_state.tag = FlowStateTag.NOT_STARTED + expected_flow_state.tag = _FlowStateTag.NOT_STARTED # test await flow.sign_out() @@ -166,10 +166,10 @@ async def test_begin_flow_easy_case(self, mocker, flow_state, activity): user_token_client = self.UserTokenClient( mocker, get_token_return=TokenResponse(token=DEFAULTS.token) ) - flow = OAuthFlow(flow_state, user_token_client) + flow = _OAuthFlow(flow_state, user_token_client) expected_flow_state = flow_state expected_flow_state.user_token = DEFAULTS.token - expected_flow_state.tag = FlowStateTag.COMPLETE + expected_flow_state.tag = _FlowStateTag.COMPLETE # test response = await flow.begin_flow(activity) @@ -181,7 +181,7 @@ async def test_begin_flow_easy_case(self, mocker, flow_state, activity): assert response.flow_state == out_flow_state assert response.sign_in_resource is None # No sign-in resource in this case - assert response.flow_error_tag == FlowErrorTag.NONE + assert response.flow_error_tag == _FlowErrorTag.NONE assert response.token_response assert response.token_response.token == DEFAULTS.token user_token_client.user_token.get_token.assert_called_once_with( @@ -207,10 +207,10 @@ async def test_begin_flow_long_case(self, mocker, flow_state, activity): ) # setup - flow = OAuthFlow(flow_state, user_token_client) + flow = _OAuthFlow(flow_state, user_token_client) expected_flow_state = flow_state expected_flow_state.user_token = "" - expected_flow_state.tag = FlowStateTag.BEGIN + expected_flow_state.tag = _FlowStateTag.BEGIN expected_flow_state.attempts_remaining = 3 expected_flow_state.continuation_activity = activity @@ -225,9 +225,9 @@ async def test_begin_flow_long_case(self, mocker, flow_state, activity): assert out_flow_state == response.flow_state assert out_flow_state == expected_flow_state - # verify FlowResponse + # verify _FlowResponse assert response.sign_in_resource == dummy_sign_in_resource - assert response.flow_error_tag == FlowErrorTag.NONE + assert response.flow_error_tag == _FlowErrorTag.NONE assert not response.token_response # robrandao: TODO more assertions on sign_in_resource @@ -236,9 +236,9 @@ async def test_continue_flow_not_active( self, inactive_flow_state, user_token_client, activity ): # setup - flow = OAuthFlow(inactive_flow_state, user_token_client) + flow = _OAuthFlow(inactive_flow_state, user_token_client) expected_flow_state = inactive_flow_state - expected_flow_state.tag = FlowStateTag.FAILURE + expected_flow_state.tag = _FlowStateTag.FAILURE # test flow_response = await flow.continue_flow(activity) @@ -253,12 +253,12 @@ async def helper_continue_flow_failure( self, active_flow_state, user_token_client, activity, flow_error_tag ): # setup - flow = OAuthFlow(active_flow_state, user_token_client) + flow = _OAuthFlow(active_flow_state, user_token_client) expected_flow_state = active_flow_state expected_flow_state.tag = ( - FlowStateTag.CONTINUE + _FlowStateTag.CONTINUE if active_flow_state.attempts_remaining > 1 - else FlowStateTag.FAILURE + else _FlowStateTag.FAILURE ) expected_flow_state.attempts_remaining = ( active_flow_state.attempts_remaining - 1 @@ -278,9 +278,9 @@ async def helper_continue_flow_success( self, active_flow_state, user_token_client, activity, expected_token ): # setup - flow = OAuthFlow(active_flow_state, user_token_client) + flow = _OAuthFlow(active_flow_state, user_token_client) expected_flow_state = active_flow_state - expected_flow_state.tag = FlowStateTag.COMPLETE + expected_flow_state.tag = _FlowStateTag.COMPLETE expected_flow_state.user_token = DEFAULTS.token expected_flow_state.attempts_remaining = active_flow_state.attempts_remaining @@ -295,7 +295,7 @@ async def helper_continue_flow_success( assert flow_response.flow_state == out_flow_state assert expected_flow_state == out_flow_state assert flow_response.token_response == TokenResponse(token=expected_token) - assert flow_response.flow_error_tag == FlowErrorTag.NONE + assert flow_response.flow_error_tag == _FlowErrorTag.NONE @pytest.mark.asyncio @pytest.mark.parametrize("magic_code", ["magic", "123", "", "1239453"]) @@ -308,7 +308,7 @@ async def test_continue_flow_active_message_magic_format_error( active_flow_state, user_token_client, activity, - FlowErrorTag.MAGIC_FORMAT, + _FlowErrorTag.MAGIC_FORMAT, ) user_token_client.user_token.get_token.assert_not_called() @@ -325,7 +325,7 @@ async def test_continue_flow_active_message_magic_code_error( active_flow_state, user_token_client, activity, - FlowErrorTag.MAGIC_CODE_INCORRECT, + _FlowErrorTag.MAGIC_CODE_INCORRECT, ) user_token_client.user_token.get_token.assert_called_once_with( user_id=active_flow_state.user_id, @@ -371,7 +371,7 @@ async def test_continue_flow_active_sign_in_verify_state_error( value={"state": "magic_code"}, ) await self.helper_continue_flow_failure( - active_flow_state, user_token_client, activity, FlowErrorTag.OTHER + active_flow_state, user_token_client, activity, _FlowErrorTag.OTHER ) user_token_client.user_token.get_token.assert_called_once_with( user_id=active_flow_state.user_id, @@ -423,7 +423,7 @@ async def test_continue_flow_active_sign_in_token_exchange_error( value=token_exchange_request, ) await self.helper_continue_flow_failure( - active_flow_state, user_token_client, activity, FlowErrorTag.OTHER + active_flow_state, user_token_client, activity, _FlowErrorTag.OTHER ) user_token_client.user_token.exchange_token.assert_called_once_with( user_id=active_flow_state.user_id, @@ -467,7 +467,7 @@ async def test_continue_flow_invalid_invoke_name( activity = self.Activity( mocker, type=ActivityTypes.invoke, name="other", value={} ) - flow = OAuthFlow(active_flow_state, user_token_client) + flow = _OAuthFlow(active_flow_state, user_token_client) await flow.continue_flow(activity) @pytest.mark.asyncio @@ -478,7 +478,7 @@ async def test_continue_flow_invalid_activity_type( activity = self.Activity( mocker, type=ActivityTypes.command, name="other", value={} ) - flow = OAuthFlow(active_flow_state, user_token_client) + flow = _OAuthFlow(active_flow_state, user_token_client) await flow.continue_flow(activity) @pytest.mark.asyncio @@ -489,62 +489,62 @@ async def test_begin_or_continue_flow_not_started_flow( ): # setup not_started_flow_state = FLOW_DATA.not_started.model_copy() - expected_response = FlowResponse( + expected_response = _FlowResponse( flow_state=not_started_flow_state, token_response=TokenResponse(token=not_started_flow_state.user_token), ) - mocker.patch.object(OAuthFlow, "begin_flow", return_value=expected_response) + mocker.patch.object(_OAuthFlow, "begin_flow", return_value=expected_response) - flow = OAuthFlow(not_started_flow_state, mocker.Mock()) + flow = _OAuthFlow(not_started_flow_state, mocker.Mock()) # test actual_response = await flow.begin_or_continue_flow(activity) # verify assert actual_response is expected_response - OAuthFlow.begin_flow.assert_called_once_with(activity) + _OAuthFlow.begin_flow.assert_called_once_with(activity) @pytest.mark.asyncio async def test_begin_or_continue_flow_inactive_flow( self, mocker, inactive_flow_state_not_completed, activity ): # mock - expected_response = FlowResponse( + expected_response = _FlowResponse( flow_state=inactive_flow_state_not_completed, token_response=TokenResponse(), ) - mocker.patch.object(OAuthFlow, "begin_flow", return_value=expected_response) + mocker.patch.object(_OAuthFlow, "begin_flow", return_value=expected_response) # setup - flow = OAuthFlow(inactive_flow_state_not_completed, mocker.Mock()) + flow = _OAuthFlow(inactive_flow_state_not_completed, mocker.Mock()) # test actual_response = await flow.begin_or_continue_flow(activity) # verify assert actual_response is expected_response - OAuthFlow.begin_flow.assert_called_once_with(activity) + _OAuthFlow.begin_flow.assert_called_once_with(activity) @pytest.mark.asyncio async def test_begin_or_continue_flow_active_flow( self, mocker, active_flow_state, activity, user_token_client ): # mock - expected_response = FlowResponse( + expected_response = _FlowResponse( flow_state=active_flow_state, token_response=TokenResponse(token=active_flow_state.user_token), ) - mocker.patch.object(OAuthFlow, "continue_flow", return_value=expected_response) + mocker.patch.object(_OAuthFlow, "continue_flow", return_value=expected_response) # setup - flow = OAuthFlow(active_flow_state, user_token_client) + flow = _OAuthFlow(active_flow_state, user_token_client) # test actual_response = await flow.begin_or_continue_flow(activity) # verify assert actual_response is expected_response - OAuthFlow.continue_flow.assert_called_once_with(activity) + _OAuthFlow.continue_flow.assert_called_once_with(activity) @pytest.mark.asyncio async def test_begin_or_continue_flow_stale_flow_state( @@ -554,37 +554,37 @@ async def test_begin_or_continue_flow_stale_flow_state( ): # mock expired_flow_state = FLOW_DATA.active_exp.model_copy() - expected_response = FlowResponse() - mocker.patch.object(OAuthFlow, "begin_flow", return_value=expected_response) + expected_response = _FlowResponse() + mocker.patch.object(_OAuthFlow, "begin_flow", return_value=expected_response) # setup - flow = OAuthFlow(expired_flow_state, mocker.Mock()) + flow = _OAuthFlow(expired_flow_state, mocker.Mock()) # test actual_response = await flow.begin_or_continue_flow(activity) # verify assert actual_response is expected_response - OAuthFlow.begin_flow.assert_called_once_with(activity) + _OAuthFlow.begin_flow.assert_called_once_with(activity) @pytest.mark.asyncio async def test_begin_or_continue_flow_completed_flow_state(self, mocker, activity): completed_flow_state = FLOW_DATA.completed.model_copy() # mock - mocker.patch.object(OAuthFlow, "begin_flow", return_value=None) - mocker.patch.object(OAuthFlow, "continue_flow", return_value=None) + mocker.patch.object(_OAuthFlow, "begin_flow", return_value=None) + mocker.patch.object(_OAuthFlow, "continue_flow", return_value=None) # setup - expected_response = FlowResponse( + expected_response = _FlowResponse( flow_state=completed_flow_state, token_response=TokenResponse(token=completed_flow_state.user_token), ) - flow = OAuthFlow(completed_flow_state, mocker.Mock()) + flow = _OAuthFlow(completed_flow_state, mocker.Mock()) # test actual_response = await flow.begin_or_continue_flow(activity) # verify assert actual_response == expected_response - OAuthFlow.begin_flow.assert_not_called() - OAuthFlow.continue_flow.assert_not_called() + _OAuthFlow.begin_flow.assert_not_called() + _OAuthFlow.continue_flow.assert_not_called() From 98436a8c24b7d8691ab533c1abd27fb8bbefcbd1 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 30 Sep 2025 08:49:43 -0700 Subject: [PATCH 24/36] Passing all tests again --- .../hosting/core/_oauth/__init__.py | 6 +- .../hosting/core/app/agent_application.py | 2 +- .../hosting/core/app/app_options.py | 2 +- .../core/app/oauth/_handlers/__init__.py | 4 +- .../oauth/_handlers/_authorization_handler.py | 2 +- .../oauth/_handlers/_user_authorization.py | 54 +++++------ .../_handlers/agentic_user_authorization.py | 32 +++---- .../core/app/oauth/_sign_in_response.py | 8 +- .../hosting/core/app/oauth/auth_handler.py | 2 +- .../hosting/core/app/oauth/authorization.py | 56 +++++------ tests/_common/data/test_flow_data.py | 38 ++++---- tests/_common/data/test_storage_data.py | 4 +- tests/_common/fixtures/flow_state_fixtures.py | 6 +- .../mocks/mock_authorization.py | 10 +- .../testing_objects/mocks/mock_oauth_flow.py | 14 +-- tests/hosting_core/_common/flow_state_eq.py | 4 +- .../{oauth => _oauth}/__init__.py | 0 .../{oauth => _oauth}/test_flow_state.py | 0 .../test_flow_storage_client.py | 2 +- .../{oauth => _oauth}/test_oauth_flow.py | 0 .../test_agentic_user_authorization.py | 26 +++--- .../_handlers/test_user_authorization.py | 85 +++++++++-------- .../app/oauth/test_authorization.py | 93 ++++++++++--------- .../app/oauth/test_sign_in_response.py | 4 +- .../app/oauth/test_sign_in_state.py | 14 +-- 25 files changed, 235 insertions(+), 233 deletions(-) rename tests/hosting_core/{oauth => _oauth}/__init__.py (100%) rename tests/hosting_core/{oauth => _oauth}/test_flow_state.py (100%) rename tests/hosting_core/{oauth => _oauth}/test_flow_storage_client.py (98%) rename tests/hosting_core/{oauth => _oauth}/test_oauth_flow.py (100%) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/__init__.py index c72a2f4d..c9b319e6 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/__init__.py @@ -1,6 +1,6 @@ -from .flow_state import _FlowState, _FlowStateTag, _FlowErrorTag -from .flow_storage_client import _FlowStorageClient -from .oauth_flow import _OAuthFlow, _FlowResponse +from ._flow_state import _FlowState, _FlowStateTag, _FlowErrorTag +from ._flow_storage_client import _FlowStorageClient +from ._oauth_flow import _OAuthFlow, _FlowResponse __all__ = [ "_FlowState", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index 2a53d33c..43a189b2 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -41,7 +41,7 @@ from .route import Route, RouteHandler from .state import TurnState from ..channel_service_adapter import ChannelServiceAdapter -from .auth import Authorization +from .oauth import Authorization from .typing_indicator import TypingIndicator logger = logging.getLogger(__name__) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py index ed5defa7..21312c76 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py @@ -9,7 +9,7 @@ from logging import Logger from typing import Callable, List, Optional -from microsoft_agents.hosting.core.app.auth import AuthHandler +from microsoft_agents.hosting.core.app.oauth import AuthHandler from microsoft_agents.hosting.core.storage import Storage # from .auth import AuthOptions diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/__init__.py index fa750c46..dd3e30a3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/__init__.py @@ -1,6 +1,6 @@ from .agentic_user_authorization import AgenticUserAuthorization -from .user_authorization import _UserAuthorization -from .authorization_handler import _AuthorizationHandler +from ._user_authorization import _UserAuthorization +from ._authorization_handler import _AuthorizationHandler __all__ = [ "AgenticUserAuthorization", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py index 162d84d0..b3c29263 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py @@ -72,7 +72,7 @@ async def _sign_in( """ raise NotImplementedError() - async def _get_refreshed_token( + async def get_refreshed_token( self, context: TurnContext, exchange_connection: Optional[str]=None, exchange_scopes: Optional[list[str]] = None ) -> TokenResponse: """Attempts to get a refreshed token for the user with the given scopes""" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py index fb1aeddb..5d9db724 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py @@ -25,7 +25,7 @@ _FlowStorageClient, _FlowStateTag ) -from ..sign_in_response import _SignInResponse +from .._sign_in_response import _SignInResponse from ._authorization_handler import _AuthorizationHandler logger = logging.getLogger(__name__) @@ -39,7 +39,7 @@ class _UserAuthorization(_AuthorizationHandler): async def _load_flow( self, context: TurnContext - ) -> tuple[OAuthFlow, FlowStorageClient]: + ) -> tuple[_OAuthFlow, _FlowStorageClient]: """Loads the OAuth flow for a specific auth handler. A new flow is created in Storage if none exists for the channel, user, and handler @@ -72,12 +72,12 @@ async def _load_flow( ] # try to load existing state - flow_storage_client = FlowStorageClient(channel_id, user_id, self._storage) + flow_storage_client = _FlowStorageClient(channel_id, user_id, self._storage) logger.info("Loading OAuth flow state from storage") - flow_state: FlowState = await flow_storage_client.read(self._id) + flow_state: _FlowState = await flow_storage_client.read(self._id) if not flow_state: logger.info("No existing flow state found, creating new flow state") - flow_state = FlowState( + flow_state = _FlowState( channel_id=channel_id, user_id=user_id, auth_handler_id=self._id, @@ -86,7 +86,7 @@ async def _load_flow( ) # await flow_storage_client.write(flow_state) - flow = OAuthFlow(flow_state, user_token_client) + flow = _OAuthFlow(flow_state, user_token_client) return flow, flow_storage_client async def _handle_obo( @@ -138,7 +138,7 @@ async def _sign_out( context: TurnContext, ) -> None: """ - Signs out the current user. + _Signs out the current user. This method clears the user's token and resets the OAuth state. :param context: The context object for the current turn. @@ -146,27 +146,27 @@ async def _sign_out( signs out from all the handlers. """ flow, flow_storage_client = await self._load_flow(context) - logger.info("Signing out from handler: %s", self._id) + logger.info("_Signing out from handler: %s", self._id) await flow.sign_out() await flow_storage_client.delete(self._id) async def _handle_flow_response( - self, context: TurnContext, flow_response: FlowResponse + self, context: TurnContext, flow_response: _FlowResponse ) -> None: """Handles CONTINUE and FAILURE flow responses, sending activities back.""" - flow_state: FlowState = flow_response.flow_state + flow_state: _FlowState = flow_response.flow_state - if flow_state.tag == FlowStateTag.BEGIN: + if flow_state.tag == _FlowStateTag.BEGIN: # Create the OAuth card sign_in_resource = flow_response.sign_in_resource assert sign_in_resource o_card: Attachment = CardFactory.oauth_card( OAuthCard( - text="Sign in", + text="_Sign in", connection_name=flow_state.connection, buttons=[ CardAction( - title="Sign in", + title="_Sign in", type=ActionTypes.signin, value=sign_in_resource.sign_in_link, channel_data=None, @@ -178,24 +178,24 @@ async def _handle_flow_response( ) # Send the card to the user await context.send_activity(MessageFactory.attachment(o_card)) - elif flow_state.tag == FlowStateTag.FAILURE: + elif flow_state.tag == _FlowStateTag.FAILURE: if flow_state.reached_max_attempts(): await context.send_activity( MessageFactory.text( - "Sign-in failed. Max retries reached. Please try again later." + "_Sign-in failed. Max retries reached. Please try again later." ) ) elif flow_state.is_expired(): await context.send_activity( - MessageFactory.text("Sign-in session expired. Please try again.") + MessageFactory.text("_Sign-in session expired. Please try again.") ) else: - logger.warning("Sign-in flow failed for unknown reasons.") - await context.send_activity("Sign-in failed. Please try again.") + logger.warning("_Sign-in flow failed for unknown reasons.") + await context.send_activity("_Sign-in failed. Please try again.") async def _sign_in( self, context: TurnContext, exchange_connection: Optional[str] = None, exchange_scopes: Optional[list[str]] = None - ) -> SignInResponse: + ) -> _SignInResponse: """Begins or continues an OAuth flow. Handles the flow response, sending the OAuth card to the context. @@ -204,11 +204,11 @@ async def _sign_in( :type context: TurnContext :param auth_handler_id: The ID of the auth handler to use. :type auth_handler_id: str - :return: The SignInResponse containing the token response and flow state tag. - :rtype: SignInResponse + :return: The _SignInResponse containing the token response and flow state tag. + :rtype: _SignInResponse """ flow, flow_storage_client = await self._load_flow(context) - flow_response: FlowResponse = await flow.begin_or_continue_flow( + flow_response: _FlowResponse = await flow.begin_or_continue_flow( context.activity ) @@ -226,14 +226,14 @@ async def _sign_in( exchange_scopes, ) - return SignInResponse( + return _SignInResponse( token_response=token_response, - tag=FlowStateTag.COMPLETE if token_response else FlowStateTag.FAILURE + tag=_FlowStateTag.COMPLETE if token_response else _FlowStateTag.FAILURE ) - - return SignInResponse(tag=flow_response.flow_state.tag) - async def _get_refreshed_token( + return _SignInResponse(tag=flow_response.flow_state.tag) + + async def get_refreshed_token( self, context: TurnContext, exchange_connection: Optional[str] = None, exchange_scopes: Optional[list[str]] = None ) -> TokenResponse: """ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py index 2c531a3a..e4946983 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py @@ -5,8 +5,8 @@ from microsoft_agents.activity import TokenResponse from ....turn_context import TurnContext -from ....oauth import FlowStateTag -from ..sign_in_response import SignInResponse +from ...._oauth import _FlowStateTag +from .._sign_in_response import _SignInResponse from ._authorization_handler import _AuthorizationHandler logger = logging.getLogger(__name__) @@ -15,7 +15,7 @@ class AgenticUserAuthorization(_AuthorizationHandler): """Class responsible for managing agentic authorization""" - async def get_agentic_instance_token(self, context: TurnContext) -> Optional[str]: + async def get_agentic_instance_token(self, context: TurnContext) -> TokenResponse: """Gets the agentic instance token for the current agent instance. :param context: The context object for the current turn. @@ -25,7 +25,7 @@ async def get_agentic_instance_token(self, context: TurnContext) -> Optional[str """ if not context.activity.is_agentic(): - return None + return TokenResponse() assert context.identity connection = self._connection_manager.get_token_provider( @@ -36,11 +36,11 @@ async def get_agentic_instance_token(self, context: TurnContext) -> Optional[str instance_token, _ = await connection.get_agentic_instance_token( agent_instance_id ) - return instance_token + return TokenResponse(token=instance_token) if instance_token else TokenResponse() async def get_agentic_user_token( self, context: TurnContext, scopes: list[str] - ) -> Optional[str]: + ) -> TokenResponse: """Gets the agentic user token for the current agent instance and user. :param context: The context object for the current turn. @@ -52,7 +52,7 @@ async def get_agentic_user_token( """ if not context.activity.is_agentic() or not self.get_agentic_user(context): - return None + return TokenResponse() assert context.identity connection = self._connection_manager.get_token_provider( @@ -61,14 +61,15 @@ async def get_agentic_user_token( upn = self.get_agentic_user(context) agentic_instance_id = self.get_agent_instance_id(context) assert upn and agentic_instance_id - return await connection.get_agentic_user_token(agentic_instance_id, upn, scopes) + token = await connection.get_agentic_user_token(agentic_instance_id, upn, scopes) + return TokenResponse(token=token) if token else TokenResponse() - async def sign_in( + async def _sign_in( self, context: TurnContext, exchange_connection: Optional[str] = None, exchange_scopes: Optional[list[str]] = None, - ) -> SignInResponse: + ) -> _SignInResponse: """Retrieves the agentic user token if available. :param context: The context object for the current turn. @@ -77,13 +78,13 @@ async def sign_in( :type connection_name: str :param scopes: The scopes to request for the token. :type scopes: Optional[list[str]] - :return: A SignInResponse containing the token response and flow state tag. - :rtype: SignInResponse + :return: A _SignInResponse containing the token response and flow state tag. + :rtype: _SignInResponse """ token_response = await self.get_refreshed_token(context, exchange_connection, exchange_scopes) if token_response: - return SignInResponse(token_response=token_response, tag=FlowStateTag.COMPLETE) - return SignInResponse(tag=FlowStateTag.FAILURE) + return _SignInResponse(token_response=token_response, tag=_FlowStateTag.COMPLETE) + return _SignInResponse(tag=_FlowStateTag.FAILURE) async def get_refreshed_token(self, context: TurnContext, @@ -93,8 +94,7 @@ async def get_refreshed_token(self, """Gets a refreshed agentic user token if available.""" if not exchange_scopes: exchange_scopes = self._handler.scopes or [] - token = await self.get_agentic_user_token(context, exchange_scopes) - return TokenResponse(token=token) if token else TokenResponse() + return await self.get_agentic_user_token(context, exchange_scopes) async def sign_out(self, context: TurnContext, auth_handler_id: Optional[str] = None) -> None: """Nothing to do for agentic sign out.""" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_sign_in_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_sign_in_response.py index 614eb3af..4c2968da 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_sign_in_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_sign_in_response.py @@ -2,23 +2,23 @@ from microsoft_agents.activity import TokenResponse -from ...oauth import FlowStateTag +from ..._oauth import _FlowStateTag class _SignInResponse: """Response for a sign-in attempt, including the token response and flow state tag.""" token_response: TokenResponse - tag: FlowStateTag + tag: _FlowStateTag def __init__( self, token_response: Optional[TokenResponse] = None, - tag: FlowStateTag = FlowStateTag.FAILURE, + tag: _FlowStateTag = _FlowStateTag.FAILURE, ) -> None: self.token_response = token_response or TokenResponse() self.tag = tag def sign_in_complete(self) -> bool: """Return True if the sign-in flow is complete (either successful or no attempt needed).""" - return self.tag in [FlowStateTag.COMPLETE, FlowStateTag.NOT_STARTED] + return self.tag in [_FlowStateTag.COMPLETE, _FlowStateTag.NOT_STARTED] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py index 565940c2..4ed93ed3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py @@ -90,5 +90,5 @@ def _from_settings(settings: dict): abs_oauth_connection_name=settings.get("AZUREBOTOAUTHCONNECTIONNAME", ""), obo_connection_name=settings.get("OBOCONNECTIONNAME", ""), auth_type=settings.get("TYPE", ""), - scopes=AuthHandler.format_scopes(settings.get("SCOPES", "")), + scopes=AuthHandler._format_scopes(settings.get("SCOPES", "")), ) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index 6b64af4b..d103180c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -8,7 +8,7 @@ from ...turn_context import TurnContext from ...storage import Storage from ...authorization import Connections -from ...oauth import FlowStateTag +from ..._oauth import _FlowStateTag from ..state import TurnState from .auth_handler import AuthHandler from ._sign_in_state import _SignInState @@ -125,15 +125,15 @@ def _sign_in_state_key(context: TurnContext) -> str: :return: A unique (across other values of channel_id and user_id) key for the sign-in state. :rtype: str """ - return f"auth:SignInState:{context.activity.channel_id}:{context.activity.from_property.id}" + return f"auth:_SignInState:{context.activity.channel_id}:{context.activity.from_property.id}" - async def _load_sign_in_state(self, context: TurnContext) -> Optional[SignInState]: + async def _load_sign_in_state(self, context: TurnContext) -> Optional[_SignInState]: """Load the sign-in state from storage for the given context.""" key = self._sign_in_state_key(context) - return (await self._storage.read([key], target_cls=SignInState)).get(key) + return (await self._storage.read([key], target_cls=_SignInState)).get(key) async def _save_sign_in_state( - self, context: TurnContext, state: SignInState + self, context: TurnContext, state: _SignInState ) -> None: """Save the sign-in state to storage for the given context.""" key = self._sign_in_state_key(context) @@ -161,11 +161,11 @@ def _resolve_handler(self, handler_id: str) -> _AuthorizationHandler: async def _start_or_continue_sign_in( self, context: TurnContext, state: TurnState, auth_handler_id: Optional[str] = None - ) -> SignInResponse: + ) -> _SignInResponse: """Start or continue the sign-in process for the user with the given auth handler. - SignInResponse output is based on the result of the variant used by the handler. - Storage is updated as needed with SignInState data for caching purposes. + _SignInResponse output is based on the result of the variant used by the handler. + Storage is updated as needed with _SignInState data for caching purposes. :param context: The turn context for the current turn of conversation. :type context: TurnContext @@ -173,8 +173,8 @@ async def _start_or_continue_sign_in( :type state: TurnState :param auth_handler_id: The ID of the auth handler to use for sign-in. If None, the first handler will be used. :type auth_handler_id: str - :return: A SignInResponse indicating the result of the sign-in attempt. - :rtype: SignInResponse + :return: A _SignInResponse indicating the result of the sign-in attempt. + :rtype: _SignInResponse """ auth_handler_id = auth_handler_id or self._default_handler_id @@ -183,12 +183,12 @@ async def _start_or_continue_sign_in( sign_in_state = await self._load_sign_in_state(context) if not sign_in_state: # no existing sign-in state, create a new one - sign_in_state = SignInState({auth_handler_id: ""}) + sign_in_state = _SignInState({auth_handler_id: ""}) if sign_in_state.tokens.get(auth_handler_id): - # already signed in with this handler, got it from cached SignInState - return SignInResponse( - tag=FlowStateTag.COMPLETE, + # already signed in with this handler, got it from cached _SignInState + return _SignInResponse( + tag=_FlowStateTag.COMPLETE, token_response=TokenResponse( token=sign_in_state.tokens[auth_handler_id] ), @@ -199,18 +199,18 @@ async def _start_or_continue_sign_in( # attempt sign-in continuation (or beginning) sign_in_response = await handler._sign_in(context) - if sign_in_response.tag == FlowStateTag.COMPLETE: + if sign_in_response.tag == _FlowStateTag.COMPLETE: if self._sign_in_success_handler: await self._sign_in_success_handler(context, state, auth_handler_id) token = sign_in_response.token_response.token sign_in_state.tokens[auth_handler_id] = token await self._save_sign_in_state(context, sign_in_state) - elif sign_in_response.tag == FlowStateTag.FAILURE: + elif sign_in_response.tag == _FlowStateTag.FAILURE: if self._sign_in_failure_handler: await self._sign_in_failure_handler(context, state, auth_handler_id) - elif sign_in_response.tag in [FlowStateTag.BEGIN, FlowStateTag.CONTINUE]: + elif sign_in_response.tag in [_FlowStateTag.BEGIN, _FlowStateTag.CONTINUE]: # store continuation activity and wait for next turn sign_in_state.continuation_activity = context.activity await self._save_sign_in_state(context, sign_in_state) @@ -247,7 +247,7 @@ async def _on_turn_auth_intercept( Returns true if the rest of the turn should be skipped because auth did not finish. Returns false if the turn should continue processing as normal. If auth completes and a new turn should be started, returns the continuation activity - from the cached SignInState. + from the cached _SignInState. :param context: The context object for the current turn. :type context: TurnContext @@ -259,12 +259,12 @@ async def _on_turn_auth_intercept( sign_in_state = await self._load_sign_in_state(context) if sign_in_state: - auth_handler_id = sign_in_state.active_handler() + auth_handler_id = sign_in_state._active_handler() if auth_handler_id: - sign_in_response = await self.start_or_continue_sign_in( + sign_in_response = await self._start_or_continue_sign_in( context, state, auth_handler_id ) - if sign_in_response.tag == FlowStateTag.COMPLETE: + if sign_in_response.tag == _FlowStateTag.COMPLETE: assert sign_in_state.continuation_activity is not None continuation_activity = ( sign_in_state.continuation_activity.model_copy() @@ -278,7 +278,7 @@ async def _on_turn_auth_intercept( async def get_token( self, context: TurnContext, auth_handler_id: Optional[str] = None - ) -> Optional[str]: + ) -> TokenResponse: """Gets the token for a specific auth handler. The token is taken from cache, so this does not initiate nor continue a sign-in flow. @@ -290,15 +290,15 @@ async def get_token( :return: The token response from the OAuth provider. :rtype: TokenResponse """ - return await self.exchange_token(context, auth_handler_id) + return await self.exchange_token(context, auth_handler_id=auth_handler_id) async def exchange_token( self, context: TurnContext, + scopes: Optional[list[str]] = None, auth_handler_id: Optional[str] = None, exchange_connection: Optional[str] = None, - scopes: Optional[list[str]] = None - ) -> Optional[str]: + ) -> TokenResponse: auth_handler_id = auth_handler_id or self._default_handler_id if auth_handler_id not in self._handlers: @@ -310,7 +310,7 @@ async def exchange_token( sign_in_state = await self._load_sign_in_state(context) if not sign_in_state or not sign_in_state.tokens.get(auth_handler_id): - return None + return TokenResponse() # for later -> parity with .NET # token_res = sign_in_state.tokens[auth_handler_id] @@ -322,11 +322,11 @@ async def exchange_token( # if diff > 0: # return token_res.token - res = await handler._get_refreshed_token(context, exchange_connection, scopes) + res = await handler.get_refreshed_token(context, exchange_connection, scopes) if res: sign_in_state.tokens[auth_handler_id] = res.token await self._save_sign_in_state(context, sign_in_state) - return res.token + return res raise Exception("Failed to exchange token") diff --git a/tests/_common/data/test_flow_data.py b/tests/_common/data/test_flow_data.py index 6cb0c7c3..16e2abbe 100644 --- a/tests/_common/data/test_flow_data.py +++ b/tests/_common/data/test_flow_data.py @@ -1,6 +1,6 @@ from datetime import datetime -from microsoft_agents.hosting.core.oauth.flow_state import FlowState, FlowStateTag +from microsoft_agents.hosting.core._oauth import _FlowState, _FlowStateTag from tests._common.storage import MockStoreItem from tests._common.data.test_defaults import TEST_DEFAULTS @@ -18,69 +18,69 @@ class TEST_FLOW_DATA: def __init__(self): - self.not_started = FlowState( + self.not_started = _FlowState( **DEF_FLOW_ARGS, - tag=FlowStateTag.NOT_STARTED, + tag=_FlowStateTag.NOT_STARTED, attempts_remaining=1, user_token="____", expiration=datetime.now().timestamp() + 1000000, ) - self.started = FlowState( + self.started = _FlowState( **DEF_FLOW_ARGS, - tag=FlowStateTag.BEGIN, + tag=_FlowStateTag.BEGIN, attempts_remaining=1, user_token="____", expiration=datetime.now().timestamp() + 1000000, ) - self.started_one_retry = FlowState( + self.started_one_retry = _FlowState( **DEF_FLOW_ARGS, - tag=FlowStateTag.BEGIN, + tag=_FlowStateTag.BEGIN, attempts_remaining=2, user_token="____", expiration=datetime.now().timestamp() + 1000000, ) - self.active = FlowState( + self.active = _FlowState( **DEF_FLOW_ARGS, - tag=FlowStateTag.CONTINUE, + tag=_FlowStateTag.CONTINUE, attempts_remaining=2, user_token="__token", expiration=datetime.now().timestamp() + 1000000, ) - self.active_one_retry = FlowState( + self.active_one_retry = _FlowState( **DEF_FLOW_ARGS, - tag=FlowStateTag.CONTINUE, + tag=_FlowStateTag.CONTINUE, attempts_remaining=1, user_token="__token", expiration=datetime.now().timestamp() + 1000000, ) - self.active_exp = FlowState( + self.active_exp = _FlowState( **DEF_FLOW_ARGS, - tag=FlowStateTag.CONTINUE, + tag=_FlowStateTag.CONTINUE, attempts_remaining=2, user_token="__token", expiration=datetime.now().timestamp(), ) - self.completed = FlowState( + self.completed = _FlowState( **DEF_FLOW_ARGS, - tag=FlowStateTag.COMPLETE, + tag=_FlowStateTag.COMPLETE, attempts_remaining=2, user_token="test_token", expiration=datetime.now().timestamp() + 1000000, ) - self.fail_by_attempts = FlowState( + self.fail_by_attempts = _FlowState( **DEF_FLOW_ARGS, - tag=FlowStateTag.FAILURE, + tag=_FlowStateTag.FAILURE, attempts_remaining=0, expiration=datetime.now().timestamp() + 1000000, ) - self.fail_by_exp = FlowState( + self.fail_by_exp = _FlowState( **DEF_FLOW_ARGS, - tag=FlowStateTag.FAILURE, + tag=_FlowStateTag.FAILURE, attempts_remaining=2, expiration=0, ) diff --git a/tests/_common/data/test_storage_data.py b/tests/_common/data/test_storage_data.py index 35b6a8d1..91bbe8cc 100644 --- a/tests/_common/data/test_storage_data.py +++ b/tests/_common/data/test_storage_data.py @@ -2,7 +2,7 @@ from .test_flow_data import ( TEST_FLOW_DATA, - FlowState, + _FlowState, update_flow_state_handler, flow_key, ) @@ -39,7 +39,7 @@ def __init__(self): def get_init_data(self): data = self.dict.copy() for key, value in data.items(): - data[key] = value.model_copy() if isinstance(value, FlowState) else value + data[key] = value.model_copy() if isinstance(value, _FlowState) else value return data diff --git a/tests/_common/fixtures/flow_state_fixtures.py b/tests/_common/fixtures/flow_state_fixtures.py index 4ce502d8..345235be 100644 --- a/tests/_common/fixtures/flow_state_fixtures.py +++ b/tests/_common/fixtures/flow_state_fixtures.py @@ -1,6 +1,6 @@ import pytest -from microsoft_agents.hosting.core import FlowStateTag +from microsoft_agents.hosting.core._oauth import _FlowStateTag from tests._common.data import TEST_FLOW_DATA @@ -24,7 +24,7 @@ def inactive_flow_state(self, request): params=[ flow_state for flow_state in FLOW_STATES.inactive_flows() - if flow_state.tag != FlowStateTag.COMPLETE + if flow_state.tag != _FlowStateTag.COMPLETE ] ) def inactive_flow_state_not_completed(self, request): @@ -38,7 +38,7 @@ def active_flow_state(self, request): params=[ flow_state for flow_state in FLOW_STATES.inactive_flows() - if flow_state.tag != FlowStateTag.COMPLETE + if flow_state.tag != _FlowStateTag.COMPLETE ] ) def sample_inactive_flow_state_not_completed(self, request): diff --git a/tests/_common/testing_objects/mocks/mock_authorization.py b/tests/_common/testing_objects/mocks/mock_authorization.py index 3138bd35..c0e05aff 100644 --- a/tests/_common/testing_objects/mocks/mock_authorization.py +++ b/tests/_common/testing_objects/mocks/mock_authorization.py @@ -12,8 +12,8 @@ def mock_class_UserAuthorization(mocker, sign_in_return=None, get_refreshed_toke sign_in_return = _SignInResponse() if get_refreshed_token_return is None: get_refreshed_token_return = TokenResponse() - mocker.patch.object(_UserAuthorization, "sign_in", return_value=sign_in_return) - mocker.patch.object(_UserAuthorization, "sign_out") + mocker.patch.object(_UserAuthorization, "_sign_in", return_value=sign_in_return) + mocker.patch.object(_UserAuthorization, "_sign_out") mocker.patch.object(_UserAuthorization, "get_refreshed_token", return_value=get_refreshed_token_return) @@ -22,14 +22,14 @@ def mock_class_AgenticUserAuthorization(mocker, sign_in_return=None, get_refresh sign_in_return = _SignInResponse() if get_refreshed_token_return is None: get_refreshed_token_return = TokenResponse() - mocker.patch.object(AgenticUserAuthorization, "sign_in", return_value=sign_in_return) - mocker.patch.object(AgenticUserAuthorization, "sign_out") + mocker.patch.object(AgenticUserAuthorization, "_sign_in", return_value=sign_in_return) + mocker.patch.object(AgenticUserAuthorization, "_sign_out") mocker.patch.object(AgenticUserAuthorization, "get_refreshed_token", return_value=get_refreshed_token_return) def mock_class_Authorization(mocker, start_or_continue_sign_in_return=False): mocker.patch.object( Authorization, - "start_or_continue_sign_in", + "_start_or_continue_sign_in", return_value=start_or_continue_sign_in_return, ) diff --git a/tests/_common/testing_objects/mocks/mock_oauth_flow.py b/tests/_common/testing_objects/mocks/mock_oauth_flow.py index 82e78328..64b5f47e 100644 --- a/tests/_common/testing_objects/mocks/mock_oauth_flow.py +++ b/tests/_common/testing_objects/mocks/mock_oauth_flow.py @@ -1,5 +1,5 @@ from microsoft_agents.activity import TokenResponse -from microsoft_agents.hosting.core import OAuthFlow +from microsoft_agents.hosting.core._oauth import _OAuthFlow from tests._common.data import TEST_DEFAULTS @@ -17,13 +17,13 @@ def mock_OAuthFlow( # mock_oauth_flow_class.sign_out = mocker.AsyncMock() if isinstance(get_user_token_return, str): get_user_token_return = TokenResponse(token=get_user_token_return) - mocker.patch.object(OAuthFlow, "get_user_token", return_value=get_user_token_return) - mocker.patch.object(OAuthFlow, "sign_out") + mocker.patch.object(_OAuthFlow, "get_user_token", return_value=get_user_token_return) + mocker.patch.object(_OAuthFlow, "sign_out") mocker.patch.object( - OAuthFlow, "begin_or_continue_flow", return_value=begin_or_continue_flow_return + _OAuthFlow, "begin_or_continue_flow", return_value=begin_or_continue_flow_return ) - mocker.patch.object(OAuthFlow, "begin_flow", return_value=begin_flow_return) - mocker.patch.object(OAuthFlow, "continue_flow", return_value=continue_flow_return) + mocker.patch.object(_OAuthFlow, "begin_flow", return_value=begin_flow_return) + mocker.patch.object(_OAuthFlow, "continue_flow", return_value=continue_flow_return) def mock_class_OAuthFlow( @@ -34,7 +34,7 @@ def mock_class_OAuthFlow( continue_flow_return=None, ): mocker.patch( - "microsoft_agents.hosting.core.OAuthFlow", + "microsoft_agents.hosting.core._oauth._OAuthFlow", new=mock_OAuthFlow( mocker, get_user_token_return=get_user_token_return, diff --git a/tests/hosting_core/_common/flow_state_eq.py b/tests/hosting_core/_common/flow_state_eq.py index fea6585c..3fbf152b 100644 --- a/tests/hosting_core/_common/flow_state_eq.py +++ b/tests/hosting_core/_common/flow_state_eq.py @@ -1,13 +1,13 @@ from typing import Optional -from microsoft_agents.hosting.core import FlowState +from microsoft_agents.hosting.core._oauth import _FlowState from tests._common import approx_eq # 100 ms tolerance def flow_state_eq( - fs1: Optional[FlowState], fs2: Optional[FlowState], tol: float = 0.1 + fs1: Optional[_FlowState], fs2: Optional[_FlowState], tol: float = 0.1 ) -> bool: if fs1 is None and fs2 is None: diff --git a/tests/hosting_core/oauth/__init__.py b/tests/hosting_core/_oauth/__init__.py similarity index 100% rename from tests/hosting_core/oauth/__init__.py rename to tests/hosting_core/_oauth/__init__.py diff --git a/tests/hosting_core/oauth/test_flow_state.py b/tests/hosting_core/_oauth/test_flow_state.py similarity index 100% rename from tests/hosting_core/oauth/test_flow_state.py rename to tests/hosting_core/_oauth/test_flow_state.py diff --git a/tests/hosting_core/oauth/test_flow_storage_client.py b/tests/hosting_core/_oauth/test_flow_storage_client.py similarity index 98% rename from tests/hosting_core/oauth/test_flow_storage_client.py rename to tests/hosting_core/_oauth/test_flow_storage_client.py index 88051e21..39848e47 100644 --- a/tests/hosting_core/oauth/test_flow_storage_client.py +++ b/tests/hosting_core/_oauth/test_flow_storage_client.py @@ -1,7 +1,7 @@ import pytest from microsoft_agents.hosting.core.storage import MemoryStorage -from microsoft_agents.hosting.core.oauth import _FlowState, _FlowStorageClient +from microsoft_agents.hosting.core._oauth import _FlowState, _FlowStorageClient from tests._common.storage.utils import MockStoreItem from tests._common.data import TEST_DEFAULTS diff --git a/tests/hosting_core/oauth/test_oauth_flow.py b/tests/hosting_core/_oauth/test_oauth_flow.py similarity index 100% rename from tests/hosting_core/oauth/test_oauth_flow.py rename to tests/hosting_core/_oauth/test_oauth_flow.py diff --git a/tests/hosting_core/app/oauth/_handlers/test_agentic_user_authorization.py b/tests/hosting_core/app/oauth/_handlers/test_agentic_user_authorization.py index f507b4f4..f217040a 100644 --- a/tests/hosting_core/app/oauth/_handlers/test_agentic_user_authorization.py +++ b/tests/hosting_core/app/oauth/_handlers/test_agentic_user_authorization.py @@ -5,14 +5,14 @@ Activity, ChannelAccount, RoleTypes, - TokenResponse + TokenResponse, ) from microsoft_agents.authentication.msal import MsalAuth, MsalConnectionManager from microsoft_agents.hosting.core.app.oauth import AgenticUserAuthorization from microsoft_agents.hosting.core.storage import MemoryStorage -from microsoft_agents.hosting.core._oauth import FlowStateTag +from microsoft_agents.hosting.core._oauth import _FlowStateTag from tests._common.data import TEST_DEFAULTS, TEST_AGENTIC_ENV_DICT from tests._common.mock_utils import mock_class @@ -137,7 +137,7 @@ async def test_get_agentic_instance_token_not_agentic( ), ) context = self.TurnContext(mocker, activity=activity) - assert await agentic_auth.get_agentic_instance_token(context) is None + assert await agentic_auth.get_agentic_instance_token(context) == TokenResponse() @pytest.mark.asyncio async def test_get_agentic_user_token_not_agentic( @@ -152,7 +152,7 @@ async def test_get_agentic_user_token_not_agentic( ), ) context = self.TurnContext(mocker, activity=activity) - assert await agentic_auth.get_agentic_user_token(context, ["user.Read"]) is None + assert await agentic_auth.get_agentic_user_token(context, ["user.Read"]) == TokenResponse() @pytest.mark.asyncio async def test_get_agentic_user_token_agentic_no_user_id( @@ -165,7 +165,7 @@ async def test_get_agentic_user_token_agentic_no_user_id( ), ) context = self.TurnContext(mocker, activity=activity) - assert await agentic_auth.get_agentic_user_token(context, ["user.Read"]) is None + assert await agentic_auth.get_agentic_user_token(context, ["user.Read"]) == TokenResponse() @pytest.mark.asyncio async def test_get_agentic_instance_token_is_agentic( @@ -190,7 +190,7 @@ async def test_get_agentic_instance_token_is_agentic( context = self.TurnContext(mocker, activity=activity) token = await agentic_auth.get_agentic_instance_token(context) - assert token == DEFAULTS.token + assert token == TokenResponse(token=DEFAULTS.token) mock_provider.get_agentic_instance_token.assert_called_once_with(DEFAULTS.agentic_instance_id) @pytest.mark.asyncio @@ -217,7 +217,7 @@ async def test_get_agentic_user_token_is_agentic( context = self.TurnContext(mocker, activity=activity) token = await agentic_auth.get_agentic_user_token(context, ["user.Read"]) - assert token == DEFAULTS.token + assert token == TokenResponse(token=DEFAULTS.token) mock_provider.get_agentic_user_token.assert_called_once_with( DEFAULTS.agentic_instance_id, "some_id", ["user.Read"] ) @@ -249,9 +249,9 @@ async def test_sign_in_success(self, mocker, scopes_list, agentic_role, expected ), ) context = self.TurnContext(mocker, activity=activity) - res = await agentic_auth.sign_in(context, "my_connection", scopes_list) + res = await agentic_auth._sign_in(context, "my_connection", scopes_list) assert res.token_response.token == "my_token" - assert res.tag == FlowStateTag.COMPLETE + assert res.tag == _FlowStateTag.COMPLETE mock_provider.get_agentic_user_token.assert_called_once_with( DEFAULTS.agentic_instance_id, "some_id", expected_scopes_list @@ -284,9 +284,9 @@ async def test_sign_in_failure(self, mocker, scopes_list, agentic_role, expected ), ) context = self.TurnContext(mocker, activity=activity) - res = await agentic_auth.sign_in(context, "my_connection", scopes_list) + res = await agentic_auth._sign_in(context, "my_connection", scopes_list) assert not res.token_response - assert res.tag == FlowStateTag.FAILURE + assert res.tag == _FlowStateTag.FAILURE mock_provider.get_agentic_user_token.assert_called_once_with( DEFAULTS.agentic_instance_id, "some_id", expected_scopes_list @@ -320,7 +320,7 @@ async def test_get_refreshed_token_success(self, mocker, scopes_list, agentic_ro ) context = self.TurnContext(mocker, activity=activity) res = await agentic_auth.get_refreshed_token(context, "my_connection", scopes_list) - assert res.token == "my_token" + assert res == TokenResponse(token="my_token") mock_provider.get_agentic_user_token.assert_called_once_with( DEFAULTS.agentic_instance_id, "some_id", expected_scopes_list @@ -354,7 +354,7 @@ async def test_get_refreshed_token_failure(self, mocker, scopes_list, agentic_ro ) context = self.TurnContext(mocker, activity=activity) res = await agentic_auth.get_refreshed_token(context, "my_connection", scopes_list) - assert not res + assert res == TokenResponse() mock_provider.get_agentic_user_token.assert_called_once_with( DEFAULTS.agentic_instance_id, "some_id", expected_scopes_list ) \ No newline at end of file diff --git a/tests/hosting_core/app/oauth/_handlers/test_user_authorization.py b/tests/hosting_core/app/oauth/_handlers/test_user_authorization.py index bf97992d..5d9d0457 100644 --- a/tests/hosting_core/app/oauth/_handlers/test_user_authorization.py +++ b/tests/hosting_core/app/oauth/_handlers/test_user_authorization.py @@ -8,15 +8,14 @@ MsalConnectionManager ) -from microsoft_agents.hosting.core import ( - FlowStorageClient, - FlowStateTag, - FlowState, - FlowResponse, - UserAuthorization, - MemoryStorage, - SignInResponse, - OAuthFlow, +from microsoft_agents.hosting.core import MemoryStorage +from microsoft_agents.hosting.core.app.oauth import _UserAuthorization, _SignInResponse +from microsoft_agents.hosting.core._oauth import ( + _FlowStorageClient, + _FlowStateTag, + _FlowState, + _FlowResponse, + _OAuthFlow ) # test constants @@ -47,7 +46,7 @@ def make_jwt(token: str = DEFAULTS.token, aud="api://default"): return jwt.encode({}, token, algorithm="HS256") -class MyUserAuthorization(UserAuthorization): +class MyUserAuthorization(_UserAuthorization): async def _handle_flow_response(self, *args, **kwargs): pass @@ -75,9 +74,9 @@ def testing_TurnContext( return turn_context async def read_state(storage, channel_id=DEFAULTS.channel_id, user_id=DEFAULTS.user_id, auth_handler_id=DEFAULTS.auth_handler_id): - storage_client = FlowStorageClient(channel_id, user_id, storage) + storage_client = _FlowStorageClient(channel_id, user_id, storage) key = storage_client.key(auth_handler_id) - return (await storage.read([key], target_cls=FlowState)).get(key) + return (await storage.read([key], target_cls=_FlowState)).get(key) def mock_provider(mocker, exchange_token=None): instance = mock_instance(mocker, MsalAuth, {"acquire_token_on_behalf_of": exchange_token}) @@ -145,71 +144,71 @@ class TestUserAuthorization(TestEnv): "flow_response, exchange_attempted, token_exchange_response, expected_response", [ [ - FlowResponse( + _FlowResponse( token_response=TokenResponse(token=make_jwt()), - flow_state=FlowState( - tag=FlowStateTag.COMPLETE, auth_handler_id=DEFAULTS.auth_handler_id + flow_state=_FlowState( + tag=_FlowStateTag.COMPLETE, auth_handler_id=DEFAULTS.auth_handler_id ), ), True, "wow", - SignInResponse(token_response=TokenResponse(token="wow"), tag=FlowStateTag.COMPLETE) + _SignInResponse(token_response=TokenResponse(token="wow"), tag=_FlowStateTag.COMPLETE) ], [ - FlowResponse( + _FlowResponse( token_response=TokenResponse(token=make_jwt(aud=None)), - flow_state=FlowState( - tag=FlowStateTag.COMPLETE, auth_handler_id=DEFAULTS.auth_handler_id + flow_state=_FlowState( + tag=_FlowStateTag.COMPLETE, auth_handler_id=DEFAULTS.auth_handler_id ), ), False, "wow", - SignInResponse(token_response=TokenResponse(token=make_jwt(aud=None)), tag=FlowStateTag.COMPLETE) + _SignInResponse(token_response=TokenResponse(token=make_jwt(aud=None)), tag=_FlowStateTag.COMPLETE) ], [ - FlowResponse( + _FlowResponse( token_response=TokenResponse(token=make_jwt(token="some_value", aud="other")), - flow_state=FlowState( - tag=FlowStateTag.COMPLETE, auth_handler_id=DEFAULTS.auth_handler_id + flow_state=_FlowState( + tag=_FlowStateTag.COMPLETE, auth_handler_id=DEFAULTS.auth_handler_id ), ), False, DEFAULTS.token, - SignInResponse(token_response=TokenResponse(token=make_jwt("some_value", aud="other")), tag=FlowStateTag.COMPLETE) + _SignInResponse(token_response=TokenResponse(token=make_jwt("some_value", aud="other")), tag=_FlowStateTag.COMPLETE) ], [ - FlowResponse( + _FlowResponse( token_response=TokenResponse(token=make_jwt(token="some_value")), - flow_state=FlowState( - tag=FlowStateTag.COMPLETE, auth_handler_id=DEFAULTS.auth_handler_id + flow_state=_FlowState( + tag=_FlowStateTag.COMPLETE, auth_handler_id=DEFAULTS.auth_handler_id ), ), True, None, - SignInResponse(tag=FlowStateTag.FAILURE) + _SignInResponse(tag=_FlowStateTag.FAILURE) ], [ - FlowResponse( - flow_state=FlowState( - tag=FlowStateTag.BEGIN, auth_handler_id=DEFAULTS.auth_handler_id + _FlowResponse( + flow_state=_FlowState( + tag=_FlowStateTag.BEGIN, auth_handler_id=DEFAULTS.auth_handler_id ), ), False, None, - SignInResponse(tag=FlowStateTag.BEGIN) + _SignInResponse(tag=_FlowStateTag.BEGIN) ], [ - FlowResponse( - flow_state=FlowState( - tag=FlowStateTag.CONTINUE, auth_handler_id=DEFAULTS.auth_handler_id + _FlowResponse( + flow_state=_FlowState( + tag=_FlowStateTag.CONTINUE, auth_handler_id=DEFAULTS.auth_handler_id ), ), False, None, - SignInResponse(tag=FlowStateTag.CONTINUE) + _SignInResponse(tag=_FlowStateTag.CONTINUE) ], [ - FlowResponse( - flow_state=FlowState( - tag=FlowStateTag.FAILURE, auth_handler_id=DEFAULTS.auth_handler_id + _FlowResponse( + flow_state=_FlowState( + tag=_FlowStateTag.FAILURE, auth_handler_id=DEFAULTS.auth_handler_id ), ), False, None, - SignInResponse(tag=FlowStateTag.FAILURE) + _SignInResponse(tag=_FlowStateTag.FAILURE) ], ] ) @@ -231,7 +230,7 @@ async def test_sign_in( mock_class_OAuthFlow(mocker, begin_or_continue_flow_return=flow_response) provider = mock_provider(mocker, exchange_token=token_exchange_response) - sign_in_response = await user_authorization.sign_in(context, request_connection, request_scopes) + sign_in_response = await user_authorization._sign_in(context, request_connection, request_scopes) assert sign_in_response.token_response == expected_response.token_response assert sign_in_response.tag == expected_response.tag @@ -252,9 +251,9 @@ async def test_sign_out_individual( context ): mock_class_OAuthFlow(mocker) - await user_authorization.sign_out(context) + await user_authorization._sign_out(context) assert await read_state(storage, auth_handler_id=DEFAULTS.auth_handler_id) is None - OAuthFlow.sign_out.assert_called_once() + _OAuthFlow.sign_out.assert_called_once() @pytest.mark.asyncio @pytest.mark.parametrize( diff --git a/tests/hosting_core/app/oauth/test_authorization.py b/tests/hosting_core/app/oauth/test_authorization.py index 51436e50..65362371 100644 --- a/tests/hosting_core/app/oauth/test_authorization.py +++ b/tests/hosting_core/app/oauth/test_authorization.py @@ -5,21 +5,24 @@ from microsoft_agents.activity import Activity, ActivityTypes, TokenResponse -from microsoft_agents.hosting.core import ( - _FlowStateTag, - - Authorization, +from microsoft_agents.hosting.core.app.oauth import ( + _SignInResponse, + _SignInState, _UserAuthorization, - AgenticUserAuthorization, + Authorization, + AgenticUserAuthorization +) + +from microsoft_agents.hosting.core._oauth import _FlowStateTag + +from microsoft_agents.hosting.core import ( + AuthHandler, Storage, - TurnContext, MemoryStorage, - AuthHandler, - _FlowStateTag, - _SignInState, - _SignInResponse, + TurnContext ) + from tests._common.storage.utils import StorageBaseline # test constants @@ -57,19 +60,19 @@ def make_jwt(token: str = DEFAULTS.token, aud="api://default"): async def get_sign_in_state( auth: Authorization, storage: Storage, context: TurnContext ) -> Optional[_SignInState]: - key = auth.sign_in_state_key(context) + key = auth._sign_in_state_key(context) return (await storage.read([key], target_cls=_SignInState)).get(key) async def set_sign_in_state( auth: Authorization, storage: Storage, context: TurnContext, state: _SignInState ): - key = auth.sign_in_state_key(context) + key = auth._sign_in_state_key(context) await storage.write({key: state}) def mock_variants(mocker, sign_in_return=None, get_refreshed_token_return=None): - mock_class__UserAuthorization(mocker, sign_in_return=sign_in_return, get_refreshed_token_return=get_refreshed_token_return) + mock_class_UserAuthorization(mocker, sign_in_return=sign_in_return, get_refreshed_token_return=get_refreshed_token_return) mock_class_AgenticUserAuthorization(mocker, sign_in_return=sign_in_return, get_refreshed_token_return=get_refreshed_token_return) def sign_in_state_eq(a: Optional[_SignInState], b: Optional[_SignInState]) -> bool: @@ -137,18 +140,18 @@ def auth_handler_id(self, request): class TestAuthorizationSetup(TestEnv): def test_init_user_auth(self, connection_manager, storage, env_dict): auth = Authorization(storage, connection_manager, **env_dict) - assert auth.resolve_handler(DEFAULTS.auth_handler_id) is not None - assert isinstance(auth.resolve_handler(DEFAULTS.auth_handler_id), _UserAuthorization) + assert auth._resolve_handler(DEFAULTS.auth_handler_id) is not None + assert isinstance(auth._resolve_handler(DEFAULTS.auth_handler_id), _UserAuthorization) def test_init_agentic_auth_not_configured(self, connection_manager, storage): auth = Authorization(storage, connection_manager, **ENV_DICT) with pytest.raises(ValueError): - auth.resolve_handler(DEFAULTS.agentic_auth_handler_id) + auth._resolve_handler(DEFAULTS.agentic_auth_handler_id) def test_init_agentic_auth(self, connection_manager, storage): auth = Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) - assert auth.resolve_handler(DEFAULTS.agentic_auth_handler_id) is not None - assert isinstance(auth.resolve_handler(DEFAULTS.agentic_auth_handler_id), Agentic_UserAuthorization) + assert auth._resolve_handler(DEFAULTS.agentic_auth_handler_id) is not None + assert isinstance(auth._resolve_handler(DEFAULTS.agentic_auth_handler_id), AgenticUserAuthorization) @pytest.mark.parametrize( "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] @@ -158,14 +161,14 @@ def test_resolve_handler(self, connection_manager, storage, auth_handler_id): handler_config = AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"][ "HANDLERS" ][auth_handler_id] - auth.resolve_handler(auth_handler_id) == AuthHandler( + auth._resolve_handler(auth_handler_id) == AuthHandler( auth_handler_id, **handler_config ) def test_sign_in_state_key(self, mocker, connection_manager, storage): auth = Authorization(storage, connection_manager, **ENV_DICT) context = self.TurnContext(mocker) - key = auth.sign_in_state_key(context) + key = auth._sign_in_state_key(context) assert key == f"auth:_SignInState:{DEFAULTS.channel_id}:{DEFAULTS.user_id}" @@ -227,7 +230,7 @@ async def test_start_or_continue_sign_in_cached( continuation_activity=activity, ) await set_sign_in_state(authorization, storage, context, initial_state) - sign_in_response = await authorization.start_or_continue_sign_in( + sign_in_response = await authorization._start_or_continue_sign_in( context, None, DEFAULTS.auth_handler_id ) assert sign_in_response.tag == _FlowStateTag.COMPLETE @@ -251,7 +254,7 @@ async def test_start_or_continue_sign_in_no_initial_state_to_complete( tag=_FlowStateTag.COMPLETE, ), ) - sign_in_response = await authorization.start_or_continue_sign_in( + sign_in_response = await authorization._start_or_continue_sign_in( context, None, auth_handler_id ) assert sign_in_response.tag == _FlowStateTag.COMPLETE @@ -285,7 +288,7 @@ async def test_start_or_continue_sign_in_to_complete_with_prev_state( ) # test - sign_in_response = await authorization.start_or_continue_sign_in( + sign_in_response = await authorization._start_or_continue_sign_in( context, None, auth_handler_id ) assert sign_in_response.tag == _FlowStateTag.COMPLETE @@ -320,7 +323,7 @@ async def test_start_or_continue_sign_in_to_failure_with_prev_state( ) # test - sign_in_response = await authorization.start_or_continue_sign_in( + sign_in_response = await authorization._start_or_continue_sign_in( context, None, auth_handler_id ) assert sign_in_response.tag == _FlowStateTag.FAILURE @@ -359,7 +362,7 @@ async def test_start_or_continue_sign_in_to_pending_with_prev_state( ) # test - sign_in_response = await authorization.start_or_continue_sign_in( + sign_in_response = await authorization._start_or_continue_sign_in( context, None, auth_handler_id ) assert sign_in_response.tag == tag @@ -383,8 +386,8 @@ async def test_start_or_continue_sign_in_to_pending_with_prev_state( tokens={DEFAULTS.auth_handler_id: "token"}, ), DEFAULTS.agentic_auth_handler_id, - None, - None + TokenResponse(), + TokenResponse() ], [ # no cached token and default handler id resolution _SignInState( @@ -394,8 +397,8 @@ async def test_start_or_continue_sign_in_to_pending_with_prev_state( tokens={DEFAULTS.agentic_auth_handler_id: "token"}, ), "", - None, - None + TokenResponse(), + TokenResponse() ], [ # no cached token pt.2 _SignInState( @@ -405,8 +408,8 @@ async def test_start_or_continue_sign_in_to_pending_with_prev_state( tokens={DEFAULTS.agentic_auth_handler_id: "token", DEFAULTS.auth_handler_id: ""}, ), DEFAULTS.auth_handler_id, - None, - None + TokenResponse(), + TokenResponse() ], [ # refreshed, new token _SignInState( @@ -417,7 +420,7 @@ async def test_start_or_continue_sign_in_to_pending_with_prev_state( ), DEFAULTS.agentic_auth_handler_id, TokenResponse(token=DEFAULTS.token), - DEFAULTS.token + TokenResponse(token=DEFAULTS.token) ], ] ) @@ -456,8 +459,8 @@ async def test_get_token_error(self, mocker, authorization, context, storage): ), DEFAULTS.agentic_auth_handler_id, False, - None, - None + TokenResponse(), + TokenResponse() ], [ # no cached token and default handler id resolution _SignInState( @@ -468,8 +471,8 @@ async def test_get_token_error(self, mocker, authorization, context, storage): ), "", False, - None, - None + TokenResponse(), + TokenResponse() ], [ # no cached token pt.2 _SignInState( @@ -480,8 +483,8 @@ async def test_get_token_error(self, mocker, authorization, context, storage): ), DEFAULTS.auth_handler_id, False, - None, - None + TokenResponse(), + TokenResponse() ], [ # refreshed, new token _SignInState( @@ -493,7 +496,7 @@ async def test_get_token_error(self, mocker, authorization, context, storage): DEFAULTS.agentic_auth_handler_id, True, TokenResponse(token=DEFAULTS.token), - DEFAULTS.token + TokenResponse(token=DEFAULTS.token) ], ] ) @@ -503,13 +506,13 @@ async def test_exchange_token(self, mocker, authorization, context, storage, ini mock_variants(mocker, get_refreshed_token_return=refresh_token) # test - token = await authorization.exchange_token(context, handler_id, exchange_connection="some_connection", scopes=["scope1", "scope2"]) - assert token == expected + token_res = await authorization.exchange_token(context, auth_handler_id=handler_id, exchange_connection="some_connection", scopes=["scope1", "scope2"]) + assert token_res == expected final_state = await get_sign_in_state(authorization, storage, context) assert sign_in_state_eq(initial_state, final_state) if refreshed: - authorization.resolve_handler(handler_id).get_refreshed_token.assert_called_once_with( + authorization._resolve_handler(handler_id).get_refreshed_token.assert_called_once_with( context, "some_connection", ["scope1", "scope2"], @@ -550,7 +553,7 @@ async def test_on_turn_auth_intercept_no_intercept( authorization, storage, context, copy_sign_in_state(sign_in_state) ) - intercepts, continuation_activity = await authorization.on_turn_auth_intercept( + intercepts, continuation_activity = await authorization._on_turn_auth_intercept( context, None ) @@ -587,7 +590,7 @@ async def test_on_turn_auth_intercept_with_intercept_incomplete( authorization, storage, context, copy_sign_in_state(initial_state) ) - intercepts, continuation_activity = await authorization.on_turn_auth_intercept( + intercepts, continuation_activity = await authorization._on_turn_auth_intercept( context, auth_handler_id ) @@ -615,7 +618,7 @@ async def test_on_turn_auth_intercept_with_intercept_complete( authorization, storage, context, copy_sign_in_state(initial_state) ) - intercepts, continuation_activity = await authorization.on_turn_auth_intercept( + intercepts, continuation_activity = await authorization._on_turn_auth_intercept( context, auth_handler_id ) diff --git a/tests/hosting_core/app/oauth/test_sign_in_response.py b/tests/hosting_core/app/oauth/test_sign_in_response.py index a5509c97..a062b60f 100644 --- a/tests/hosting_core/app/oauth/test_sign_in_response.py +++ b/tests/hosting_core/app/oauth/test_sign_in_response.py @@ -1,5 +1,5 @@ -from microsoft_agents.hosting.core import _SignInResponse, _FlowStateTag - +from microsoft_agents.hosting.core.app.oauth import _SignInResponse +from microsoft_agents.hosting.core._oauth import _FlowStateTag def test_sign_in_response_sign_in_complete(): assert _SignInResponse(tag=_FlowStateTag.BEGIN).sign_in_complete() == False diff --git a/tests/hosting_core/app/oauth/test_sign_in_state.py b/tests/hosting_core/app/oauth/test_sign_in_state.py index 36710f47..59e813ea 100644 --- a/tests/hosting_core/app/oauth/test_sign_in_state.py +++ b/tests/hosting_core/app/oauth/test_sign_in_state.py @@ -1,19 +1,19 @@ import pytest -from microsoft_agents.hosting.core.app.oauth import SignInState +from microsoft_agents.hosting.core.app.oauth import _SignInState from ._common import testing_Activity, testing_TurnContext class TestSignInState: def test_init(self): - state = SignInState() + state = _SignInState() assert state.tokens == {} assert state.continuation_activity is None def test_init_with_values(self): activity = testing_Activity() - state = SignInState({"handler": "some_token"}, activity) + state = _SignInState({"handler": "some_token"}, activity) assert state.tokens == {"handler": "some_token"} assert state.continuation_activity == activity @@ -21,14 +21,14 @@ def test_from_json_to_store_item(self): tokens = {"some_handler": "some_token", "other_handler": "other_token"} activity = testing_Activity() data = {"tokens": tokens, "continuation_activity": activity} - state = SignInState.from_json_to_store_item(data) + state = _SignInState.from_json_to_store_item(data) assert state.tokens == tokens assert state.continuation_activity == activity def test_store_item_to_json(self): tokens = {"some_handler": "some_token", "other_handler": "other_token"} activity = testing_Activity() - state = SignInState(tokens, activity) + state = _SignInState(tokens, activity) json_data = state.store_item_to_json() assert json_data["tokens"] == tokens assert json_data["continuation_activity"] == activity @@ -48,5 +48,5 @@ def test_store_item_to_json(self): ], ) def test_active_handler(self, tokens, active_handler): - state = SignInState(tokens) - assert state.active_handler() == active_handler + state = _SignInState(tokens) + assert state._active_handler() == active_handler From c3c3db3226d809339fff98ddb7dd8d374db70b29 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 30 Sep 2025 11:13:46 -0700 Subject: [PATCH 25/36] Repurposing SignInState --- .../hosting/core/_oauth/_flow_state.py | 2 - .../core/_oauth/_flow_storage_client.py | 2 +- .../hosting/core/_oauth/_oauth_flow.py | 10 -- .../hosting/core/app/oauth/_sign_in_state.py | 15 +-- .../hosting/core/app/oauth/authorization.py | 93 +++++++++++-------- tests/_common/data/test_flow_data.py | 7 -- tests/_common/data/test_storage_data.py | 3 +- .../_oauth/test_flow_storage_client.py | 2 +- tests/hosting_core/_oauth/test_oauth_flow.py | 23 ++--- .../app/oauth/test_authorization.py | 9 +- 10 files changed, 75 insertions(+), 91 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_state.py index 3609f754..50572947 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_state.py @@ -41,8 +41,6 @@ class _FlowErrorTag(Enum): class _FlowState(BaseModel, StoreItem): """Represents the state of an OAuthFlow""" - user_token: str = "" - channel_id: str = "" user_id: str = "" ms_app_id: str = "" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py index b97e5149..867b3aa6 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py @@ -34,7 +34,7 @@ def __init__( channel_id: str, user_id: str, storage: Storage, - cache_class: type[Storage] = None, + cache_class: Optional[type[Storage]] = None, ): """ Args: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py index b764b738..a3a9c808 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_oauth_flow.py @@ -136,7 +136,6 @@ async def get_user_token(self, magic_code: str = None) -> TokenResponse: ) if token_response: logger.info("User token obtained successfully: %s", token_response) - self._flow_state.user_token = token_response.token self._flow_state.expiration = ( datetime.now().timestamp() + self._default_flow_duration ) @@ -160,7 +159,6 @@ async def sign_out(self) -> None: connection_name=self._abs_oauth_connection_name, channel_id=self._channel_id, ) - self._flow_state.user_token = "" self._flow_state.tag = _FlowStateTag.NOT_STARTED def _use_attempt(self) -> None: @@ -198,7 +196,6 @@ async def begin_flow(self, activity: Activity) -> _FlowResponse: ) self._flow_state.attempts_remaining = self._max_attempts - self._flow_state.user_token = "" self._flow_state.continuation_activity = activity.model_copy() token_exchange_state = TokenExchangeState( @@ -304,7 +301,6 @@ async def continue_flow(self, activity: Activity) -> _FlowResponse: self._flow_state.expiration = ( datetime.now().timestamp() + self._default_flow_duration ) - self._flow_state.user_token = token_response.token logger.debug( "OAuth flow completed successfully, got TokenResponse: %s", token_response, @@ -327,12 +323,6 @@ async def begin_or_continue_flow(self, activity: Activity) -> _FlowResponse: A FlowResponse object containing the updated flow state and any token response. """ self._flow_state.refresh() - if self._flow_state.tag == _FlowStateTag.COMPLETE: # robrandao: TODO -> test - logger.debug("OAuth flow has already been completed, nothing to do") - return _FlowResponse( - flow_state=self._flow_state.model_copy(), - token_response=TokenResponse(token=self._flow_state.user_token), - ) if self._flow_state.is_active(): logger.debug("Active flow, continuing...") diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_sign_in_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_sign_in_state.py index 7ddeddec..4422fba1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_sign_in_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_sign_in_state.py @@ -17,25 +17,18 @@ class _SignInState(StoreItem): def __init__( self, - tokens: Optional[JSON] = None, + active_handler_id: str, continuation_activity: Optional[Activity] = None, ) -> None: - self.tokens = tokens or {} + self.active_handler_id = active_handler_id self.continuation_activity = continuation_activity def store_item_to_json(self) -> JSON: return { - "tokens": self.tokens, + "active_handler_id": self.active_handler_id, "continuation_activity": self.continuation_activity, } @staticmethod def from_json_to_store_item(json_data: JSON) -> _SignInState: - return _SignInState(json_data["tokens"], json_data.get("continuation_activity")) - - def _active_handler(self) -> str: - """Return the handler ID that is missing a token, according to the state.""" - for handler_id, token in self.tokens.items(): - if not token: - return handler_id - return "" + return _SignInState(json_data["active_handler_id"], json_data.get("continuation_activity")) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index d103180c..bbaefeca 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -37,8 +37,8 @@ def __init__( self, storage: Storage, connection_manager: Connections, - auth_handlers: dict[str, AuthHandler] = None, - auto_signin: bool = None, + auth_handlers: Optional[dict[str, AuthHandler]] = None, + auto_signin: bool = False, use_cache: bool = False, **kwargs, ): @@ -144,6 +144,28 @@ async def _delete_sign_in_state(self, context: TurnContext) -> None: key = self._sign_in_state_key(context) await self._storage.delete([key]) + @staticmethod + def _get_cached_token( + context: TurnContext, handler_id: str + ) -> Optional[TokenResponse]: + key = f"{Authorization._sign_in_state_key(context)}:{handler_id}:token" + return context.turn_state.get(key) + + @staticmethod + def _cache_token( + context: TurnContext, handler_id: str, token_response: TokenResponse + ) -> None: + key = f"{Authorization._sign_in_state_key(context)}:{handler_id}:token" + context.turn_state[key] = token_response + + @staticmethod + def _delete_cached_token( + context: TurnContext, handler_id: str + ) -> None: + key = f"{Authorization._sign_in_state_key(context)}:{handler_id}:token" + if key in context.turn_state: + del context.turn_state[key] + def _resolve_handler(self, handler_id: str) -> _AuthorizationHandler: """Resolve the auth handler by its ID. @@ -183,16 +205,9 @@ async def _start_or_continue_sign_in( sign_in_state = await self._load_sign_in_state(context) if not sign_in_state: # no existing sign-in state, create a new one - sign_in_state = _SignInState({auth_handler_id: ""}) - - if sign_in_state.tokens.get(auth_handler_id): - # already signed in with this handler, got it from cached _SignInState - return _SignInResponse( - tag=_FlowStateTag.COMPLETE, - token_response=TokenResponse( - token=sign_in_state.tokens[auth_handler_id] - ), - ) + sign_in_state = _SignInState(active_handler_id=auth_handler_id) + + auth_handler_id = sign_in_state.active_handler_id handler = self._resolve_handler(auth_handler_id) @@ -202,13 +217,13 @@ async def _start_or_continue_sign_in( if sign_in_response.tag == _FlowStateTag.COMPLETE: if self._sign_in_success_handler: await self._sign_in_success_handler(context, state, auth_handler_id) - token = sign_in_response.token_response.token - sign_in_state.tokens[auth_handler_id] = token - await self._save_sign_in_state(context, sign_in_state) + await self._delete_sign_in_state(context) + await self._cache_token(context, auth_handler_id, sign_in_response.token_response) elif sign_in_response.tag == _FlowStateTag.FAILURE: if self._sign_in_failure_handler: await self._sign_in_failure_handler(context, state, auth_handler_id) + await self._delete_sign_in_state(context) elif sign_in_response.tag in [_FlowStateTag.BEGIN, _FlowStateTag.CONTINUE]: # store continuation activity and wait for next turn @@ -232,12 +247,12 @@ async def sign_out( """ auth_handler_id = auth_handler_id or self._default_handler_id sign_in_state = await self._load_sign_in_state(context) - if sign_in_state and auth_handler_id in sign_in_state.tokens: + if sign_in_state and auth_handler_id == sign_in_state.active_handler_id: # sign out from specific handler handler = self._resolve_handler(auth_handler_id) + self._delete_cached_token(context, auth_handler_id) + await self._delete_sign_in_state(context) await handler._sign_out(context) - del sign_in_state.tokens[auth_handler_id] - await self._save_sign_in_state(context, sign_in_state) async def _on_turn_auth_intercept( self, context: TurnContext, state: TurnState @@ -259,7 +274,7 @@ async def _on_turn_auth_intercept( sign_in_state = await self._load_sign_in_state(context) if sign_in_state: - auth_handler_id = sign_in_state._active_handler() + auth_handler_id = sign_in_state.active_handler if auth_handler_id: sign_in_response = await self._start_or_continue_sign_in( context, state, auth_handler_id @@ -305,28 +320,26 @@ async def exchange_token( raise ValueError( f"Auth handler {auth_handler_id} not recognized or not configured." ) + + cached_token = await self._get_cached_token(context, auth_handler_id) - handler = self._resolve_handler(auth_handler_id) + if cached_token: - sign_in_state = await self._load_sign_in_state(context) - if not sign_in_state or not sign_in_state.tokens.get(auth_handler_id): - return TokenResponse() - - # for later -> parity with .NET - # token_res = sign_in_state.tokens[auth_handler_id] - # if not context.activity.is_agentic(): - # if token_res and not token_res.is_exchangeable(): - # token = token_res.token - # if token.expiration is not None: - # diff = token.expiration - datetime.now().timestamp() - # if diff > 0: - # return token_res.token - - res = await handler.get_refreshed_token(context, exchange_connection, scopes) - if res: - sign_in_state.tokens[auth_handler_id] = res.token - await self._save_sign_in_state(context, sign_in_state) - return res + handler = self._resolve_handler(auth_handler_id) + + # for later -> parity with .NET + # token_res = sign_in_state.tokens[auth_handler_id] + # if not context.activity.is_agentic(): + # if token_res and not token_res.is_exchangeable(): + # token = token_res.token + # if token.expiration is not None: + # diff = token.expiration - datetime.now().timestamp() + # if diff > 0: + # return token_res.token + + res = await handler.get_refreshed_token(context, exchange_connection, scopes) + if res: + return res raise Exception("Failed to exchange token") @@ -350,4 +363,4 @@ def on_sign_in_failure( :param handler: The handler function to call on sign-in failure. """ - self._sign_in_failure_handler = handler \ No newline at end of file + self._sign_in_failure_handler = handle \ No newline at end of file diff --git a/tests/_common/data/test_flow_data.py b/tests/_common/data/test_flow_data.py index 16e2abbe..ac41c0f8 100644 --- a/tests/_common/data/test_flow_data.py +++ b/tests/_common/data/test_flow_data.py @@ -22,7 +22,6 @@ def __init__(self): **DEF_FLOW_ARGS, tag=_FlowStateTag.NOT_STARTED, attempts_remaining=1, - user_token="____", expiration=datetime.now().timestamp() + 1000000, ) @@ -30,7 +29,6 @@ def __init__(self): **DEF_FLOW_ARGS, tag=_FlowStateTag.BEGIN, attempts_remaining=1, - user_token="____", expiration=datetime.now().timestamp() + 1000000, ) @@ -38,7 +36,6 @@ def __init__(self): **DEF_FLOW_ARGS, tag=_FlowStateTag.BEGIN, attempts_remaining=2, - user_token="____", expiration=datetime.now().timestamp() + 1000000, ) @@ -46,7 +43,6 @@ def __init__(self): **DEF_FLOW_ARGS, tag=_FlowStateTag.CONTINUE, attempts_remaining=2, - user_token="__token", expiration=datetime.now().timestamp() + 1000000, ) @@ -54,21 +50,18 @@ def __init__(self): **DEF_FLOW_ARGS, tag=_FlowStateTag.CONTINUE, attempts_remaining=1, - user_token="__token", expiration=datetime.now().timestamp() + 1000000, ) self.active_exp = _FlowState( **DEF_FLOW_ARGS, tag=_FlowStateTag.CONTINUE, attempts_remaining=2, - user_token="__token", expiration=datetime.now().timestamp(), ) self.completed = _FlowState( **DEF_FLOW_ARGS, tag=_FlowStateTag.COMPLETE, attempts_remaining=2, - user_token="test_token", expiration=datetime.now().timestamp() + 1000000, ) self.fail_by_attempts = _FlowState( diff --git a/tests/_common/data/test_storage_data.py b/tests/_common/data/test_storage_data.py index 91bbe8cc..31cb0030 100644 --- a/tests/_common/data/test_storage_data.py +++ b/tests/_common/data/test_storage_data.py @@ -1,8 +1,9 @@ +from microsoft_agents.hosting.core._oauth import _FlowState + from tests._common.storage import MockStoreItem from .test_flow_data import ( TEST_FLOW_DATA, - _FlowState, update_flow_state_handler, flow_key, ) diff --git a/tests/hosting_core/_oauth/test_flow_storage_client.py b/tests/hosting_core/_oauth/test_flow_storage_client.py index 39848e47..1d6e30a5 100644 --- a/tests/hosting_core/_oauth/test_flow_storage_client.py +++ b/tests/hosting_core/_oauth/test_flow_storage_client.py @@ -99,7 +99,7 @@ async def test_delete(self, mocker, auth_handler_id): async def test_integration_with_memory_storage(self): flow_state_alpha = _FlowState(auth_handler_id="handler") - flow_state_beta = _FlowState(auth_handler_id="auth_handler", user_token="token") + flow_state_beta = _FlowState(auth_handler_id="auth_handler") storage = MemoryStorage( { diff --git a/tests/hosting_core/_oauth/test_oauth_flow.py b/tests/hosting_core/_oauth/test_oauth_flow.py index e580d653..129540be 100644 --- a/tests/hosting_core/_oauth/test_oauth_flow.py +++ b/tests/hosting_core/_oauth/test_oauth_flow.py @@ -68,7 +68,7 @@ def flow(self, flow_state, user_token_client): return _OAuthFlow(flow_state, user_token_client) -class Test_OAuthFlow(TestUtils): +class TestOAuthFlow(TestUtils): def test_init_no_user_token_client(self, flow_state): with pytest.raises(ValueError): _OAuthFlow(flow_state, None) @@ -101,7 +101,6 @@ async def test_get_user_token_success(self, flow_state, user_token_client): # setup flow = _OAuthFlow(flow_state, user_token_client) expected_final_flow_state = flow_state - expected_final_flow_state.user_token = DEFAULTS.token expected_final_flow_state.tag = _FlowStateTag.COMPLETE # test @@ -146,7 +145,6 @@ async def test_sign_out(self, flow_state, user_token_client): # setup flow = _OAuthFlow(flow_state, user_token_client) expected_flow_state = flow_state - expected_flow_state.user_token = "" expected_flow_state.tag = _FlowStateTag.NOT_STARTED # test @@ -168,7 +166,6 @@ async def test_begin_flow_easy_case(self, mocker, flow_state, activity): ) flow = _OAuthFlow(flow_state, user_token_client) expected_flow_state = flow_state - expected_flow_state.user_token = DEFAULTS.token expected_flow_state.tag = _FlowStateTag.COMPLETE # test @@ -209,7 +206,6 @@ async def test_begin_flow_long_case(self, mocker, flow_state, activity): # setup flow = _OAuthFlow(flow_state, user_token_client) expected_flow_state = flow_state - expected_flow_state.user_token = "" expected_flow_state.tag = _FlowStateTag.BEGIN expected_flow_state.attempts_remaining = 3 expected_flow_state.continuation_activity = activity @@ -281,7 +277,6 @@ async def helper_continue_flow_success( flow = _OAuthFlow(active_flow_state, user_token_client) expected_flow_state = active_flow_state expected_flow_state.tag = _FlowStateTag.COMPLETE - expected_flow_state.user_token = DEFAULTS.token expected_flow_state.attempts_remaining = active_flow_state.attempts_remaining # test @@ -491,7 +486,7 @@ async def test_begin_or_continue_flow_not_started_flow( not_started_flow_state = FLOW_DATA.not_started.model_copy() expected_response = _FlowResponse( flow_state=not_started_flow_state, - token_response=TokenResponse(token=not_started_flow_state.user_token), + token_response=TokenResponse(), ) mocker.patch.object(_OAuthFlow, "begin_flow", return_value=expected_response) @@ -532,7 +527,7 @@ async def test_begin_or_continue_flow_active_flow( # mock expected_response = _FlowResponse( flow_state=active_flow_state, - token_response=TokenResponse(token=active_flow_state.user_token), + token_response=TokenResponse(token=DEFAULTS.token), ) mocker.patch.object(_OAuthFlow, "continue_flow", return_value=expected_response) @@ -571,14 +566,14 @@ async def test_begin_or_continue_flow_stale_flow_state( async def test_begin_or_continue_flow_completed_flow_state(self, mocker, activity): completed_flow_state = FLOW_DATA.completed.model_copy() # mock - mocker.patch.object(_OAuthFlow, "begin_flow", return_value=None) - mocker.patch.object(_OAuthFlow, "continue_flow", return_value=None) - - # setup expected_response = _FlowResponse( flow_state=completed_flow_state, - token_response=TokenResponse(token=completed_flow_state.user_token), + token_response=TokenResponse(token="some-token"), ) + mocker.patch.object(_OAuthFlow, "begin_flow", return_value=expected_response) + mocker.patch.object(_OAuthFlow, "continue_flow", return_value=None) + + # setup flow = _OAuthFlow(completed_flow_state, mocker.Mock()) # test @@ -586,5 +581,5 @@ async def test_begin_or_continue_flow_completed_flow_state(self, mocker, activit # verify assert actual_response == expected_response - _OAuthFlow.begin_flow.assert_not_called() + _OAuthFlow.begin_flow.assert_called_once() _OAuthFlow.continue_flow.assert_not_called() diff --git a/tests/hosting_core/app/oauth/test_authorization.py b/tests/hosting_core/app/oauth/test_authorization.py index 65362371..7853e09b 100644 --- a/tests/hosting_core/app/oauth/test_authorization.py +++ b/tests/hosting_core/app/oauth/test_authorization.py @@ -1,3 +1,4 @@ +from re import A import pytest import jwt @@ -85,7 +86,7 @@ def sign_in_state_eq(a: Optional[_SignInState], b: Optional[_SignInState]) -> bo def copy_sign_in_state(state: _SignInState) -> _SignInState: return _SignInState( - tokens=state.tokens.copy(), + active_handler_id=state.active_handler_id, continuation_activity=( state.continuation_activity.model_copy() if state.continuation_activity @@ -184,7 +185,7 @@ async def test_sign_out_not_signed_in( ): mock_variants(mocker) initial_state = _SignInState( - tokens={DEFAULTS.auth_handler_id: "", "my_handler": "old_token"}, + active_handler_id=DEFAULTS.auth_handler_id, continuation_activity=activity, ) await set_sign_in_state( @@ -192,8 +193,8 @@ async def test_sign_out_not_signed_in( ) await authorization.sign_out(context, None, auth_handler_id) final_state = await get_sign_in_state(authorization, storage, context) - if auth_handler_id in initial_state.tokens: - del initial_state.tokens[auth_handler_id] + if auth_handler_id == initial_state.active_handler_id: + final_state = None assert sign_in_state_eq(final_state, initial_state) @pytest.mark.asyncio From 3d7a96263ed8980dff4b5c0a544917acb96dbda6 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 30 Sep 2025 12:49:13 -0700 Subject: [PATCH 26/36] Completed tests for auth fix --- .../hosting/core/app/oauth/authorization.py | 27 +- .../app/oauth/test_authorization.py | 613 +++++++++--------- .../app/oauth/test_sign_in_state.py | 52 -- 3 files changed, 314 insertions(+), 378 deletions(-) delete mode 100644 tests/hosting_core/app/oauth/test_sign_in_state.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index bbaefeca..d32e40f8 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -144,25 +144,29 @@ async def _delete_sign_in_state(self, context: TurnContext) -> None: key = self._sign_in_state_key(context) await self._storage.delete([key]) + @staticmethod + def _cache_key(context: TurnContext, handler_id: str) -> str: + return f"{Authorization._sign_in_state_key(context)}:{handler_id}:token" + @staticmethod def _get_cached_token( context: TurnContext, handler_id: str ) -> Optional[TokenResponse]: - key = f"{Authorization._sign_in_state_key(context)}:{handler_id}:token" + key = Authorization._cache_key(context, handler_id) return context.turn_state.get(key) @staticmethod def _cache_token( context: TurnContext, handler_id: str, token_response: TokenResponse ) -> None: - key = f"{Authorization._sign_in_state_key(context)}:{handler_id}:token" + key = Authorization._cache_key(context, handler_id) context.turn_state[key] = token_response @staticmethod def _delete_cached_token( context: TurnContext, handler_id: str ) -> None: - key = f"{Authorization._sign_in_state_key(context)}:{handler_id}:token" + key = Authorization._cache_key(context, handler_id) if key in context.turn_state: del context.turn_state[key] @@ -218,7 +222,7 @@ async def _start_or_continue_sign_in( if self._sign_in_success_handler: await self._sign_in_success_handler(context, state, auth_handler_id) await self._delete_sign_in_state(context) - await self._cache_token(context, auth_handler_id, sign_in_response.token_response) + Authorization._cache_token(context, auth_handler_id, sign_in_response.token_response) elif sign_in_response.tag == _FlowStateTag.FAILURE: if self._sign_in_failure_handler: @@ -246,13 +250,10 @@ async def sign_out( :return: None """ auth_handler_id = auth_handler_id or self._default_handler_id - sign_in_state = await self._load_sign_in_state(context) - if sign_in_state and auth_handler_id == sign_in_state.active_handler_id: - # sign out from specific handler - handler = self._resolve_handler(auth_handler_id) - self._delete_cached_token(context, auth_handler_id) - await self._delete_sign_in_state(context) - await handler._sign_out(context) + handler = self._resolve_handler(auth_handler_id) + Authorization._delete_cached_token(context, auth_handler_id) + await self._delete_sign_in_state(context) + await handler._sign_out(context) async def _on_turn_auth_intercept( self, context: TurnContext, state: TurnState @@ -274,7 +275,7 @@ async def _on_turn_auth_intercept( sign_in_state = await self._load_sign_in_state(context) if sign_in_state: - auth_handler_id = sign_in_state.active_handler + auth_handler_id = sign_in_state.active_handler_id if auth_handler_id: sign_in_response = await self._start_or_continue_sign_in( context, state, auth_handler_id @@ -321,7 +322,7 @@ async def exchange_token( f"Auth handler {auth_handler_id} not recognized or not configured." ) - cached_token = await self._get_cached_token(context, auth_handler_id) + cached_token = Authorization._get_cached_token(context, auth_handler_id) if cached_token: diff --git a/tests/hosting_core/app/oauth/test_authorization.py b/tests/hosting_core/app/oauth/test_authorization.py index 7853e09b..35c61eef 100644 --- a/tests/hosting_core/app/oauth/test_authorization.py +++ b/tests/hosting_core/app/oauth/test_authorization.py @@ -1,3 +1,4 @@ +from mimetypes import init from re import A import pytest import jwt @@ -23,7 +24,6 @@ TurnContext ) - from tests._common.storage.utils import StorageBaseline # test constants @@ -58,20 +58,6 @@ def make_jwt(token: str = DEFAULTS.token, aud="api://default"): else: return jwt.encode({}, token, algorithm="HS256") -async def get_sign_in_state( - auth: Authorization, storage: Storage, context: TurnContext -) -> Optional[_SignInState]: - key = auth._sign_in_state_key(context) - return (await storage.read([key], target_cls=_SignInState)).get(key) - - -async def set_sign_in_state( - auth: Authorization, storage: Storage, context: TurnContext, state: _SignInState -): - key = auth._sign_in_state_key(context) - await storage.write({key: state}) - - def mock_variants(mocker, sign_in_return=None, get_refreshed_token_return=None): mock_class_UserAuthorization(mocker, sign_in_return=sign_in_return, get_refreshed_token_return=get_refreshed_token_return) mock_class_AgenticUserAuthorization(mocker, sign_in_return=sign_in_return, get_refreshed_token_return=get_refreshed_token_return) @@ -81,8 +67,15 @@ def sign_in_state_eq(a: Optional[_SignInState], b: Optional[_SignInState]) -> bo return True if a is None or b is None: return False - return a.tokens == b.tokens and a.continuation_activity == b.continuation_activity + return a.active_handler_id == b.active_handler_id and a.continuation_activity == b.continuation_activity + +def create_turn_state(context, token_cache: dict): + d = {**context.turn_state} + d.update({ + Authorization._cache_key(context, k): TokenResponse(token=v) for k, v in token_cache.items() + }) + return d def copy_sign_in_state(state: _SignInState) -> _SignInState: return _SignInState( @@ -175,384 +168,372 @@ def test_sign_in_state_key(self, mocker, connection_manager, storage): class TestAuthorizationUsage(TestEnv): - @pytest.mark.asyncio @pytest.mark.parametrize( - "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] + "initial_turn_state, final_turn_state, initial_sign_in_state, auth_handler_id", + [ + [{DEFAULTS.auth_handler_id: DEFAULTS.token}, {}, None, DEFAULTS.auth_handler_id], + [ + {DEFAULTS.auth_handler_id: DEFAULTS.token}, {}, + _SignInState(active_handler_id="some_value"), DEFAULTS.auth_handler_id + ], + [ + {DEFAULTS.agentic_auth_handler_id: DEFAULTS.token}, + {DEFAULTS.agentic_auth_handler_id: DEFAULTS.token}, + None, DEFAULTS.auth_handler_id + ], + [ + {DEFAULTS.agentic_auth_handler_id: DEFAULTS.token, DEFAULTS.auth_handler_id: "value"}, + {DEFAULTS.auth_handler_id: "value"}, + _SignInState(active_handler_id="some_val"), DEFAULTS.agentic_auth_handler_id + ], + ] ) - async def test_sign_out_not_signed_in( - self, mocker, storage, authorization, context, activity, auth_handler_id + async def test_sign_out( + self, mocker, storage, authorization, context, + initial_turn_state, final_turn_state, initial_sign_in_state, auth_handler_id ): + # setup mock_variants(mocker) - initial_state = _SignInState( - active_handler_id=DEFAULTS.auth_handler_id, - continuation_activity=activity, - ) - await set_sign_in_state( - authorization, storage, context, copy_sign_in_state(initial_state) - ) - await authorization.sign_out(context, None, auth_handler_id) - final_state = await get_sign_in_state(authorization, storage, context) - if auth_handler_id == initial_state.active_handler_id: - final_state = None - assert sign_in_state_eq(final_state, initial_state) + expected_turn_state = create_turn_state(context, final_turn_state) + context.turn_state = create_turn_state(context, initial_turn_state) + if initial_sign_in_state: + await authorization._save_sign_in_state(context, initial_sign_in_state) - @pytest.mark.asyncio - @pytest.mark.parametrize( - "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] - ) - async def test_sign_out_signed_in( - self, mocker, storage, authorization, context, activity, auth_handler_id - ): - mock_variants(mocker) - initial_state = _SignInState( - tokens={ - DEFAULTS.auth_handler_id: "token", - DEFAULTS.agentic_auth_handler_id: "another_token", - "my_handler": "old_token", - }, - continuation_activity=activity, - ) - await set_sign_in_state( - authorization, storage, context, copy_sign_in_state(initial_state) - ) + # test await authorization.sign_out(context, None, auth_handler_id) - final_state = await get_sign_in_state(authorization, storage, context) - del initial_state.tokens[auth_handler_id] - assert sign_in_state_eq(final_state, initial_state) - - @pytest.mark.asyncio - async def test_start_or_continue_sign_in_cached( - self, storage, authorization, context, activity - ): - # setup - initial_state = _SignInState( - tokens={DEFAULTS.auth_handler_id: "valid_token"}, - continuation_activity=activity, - ) - await set_sign_in_state(authorization, storage, context, initial_state) - sign_in_response = await authorization._start_or_continue_sign_in( - context, None, DEFAULTS.auth_handler_id - ) - assert sign_in_response.tag == _FlowStateTag.COMPLETE - assert sign_in_response.token_response.token == "valid_token" - - assert sign_in_state_eq( - await get_sign_in_state(authorization, storage, context), initial_state - ) - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] - ) - async def test_start_or_continue_sign_in_no_initial_state_to_complete( - self, mocker, storage, authorization, context, auth_handler_id - ): - mock_variants( - mocker, - sign_in_return=_SignInResponse( - token_response=TokenResponse(token=DEFAULTS.token), - tag=_FlowStateTag.COMPLETE, - ), - ) - sign_in_response = await authorization._start_or_continue_sign_in( - context, None, auth_handler_id - ) - assert sign_in_response.tag == _FlowStateTag.COMPLETE - assert sign_in_response.token_response.token == DEFAULTS.token - final_state = await get_sign_in_state(authorization, storage, context) - assert final_state.tokens[auth_handler_id] == DEFAULTS.token - assert final_state.continuation_activity is None + # verify + assert context.turn_state == expected_turn_state + assert (await authorization._load_sign_in_state(context)) is None + assert authorization._get_cached_token(context, auth_handler_id) is None @pytest.mark.asyncio @pytest.mark.parametrize( - "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] + "initial_cache, final_cache, auth_handler_id, expected_auth_handler_id, initial_sign_in_state, final_sign_in_state, sign_in_response", + [ + [ + {DEFAULTS.auth_handler_id: "old_token"}, + {DEFAULTS.auth_handler_id: "valid_token"}, + DEFAULTS.auth_handler_id, + DEFAULTS.auth_handler_id, + _SignInState(active_handler_id=DEFAULTS.auth_handler_id), + None, + _SignInResponse( + token_response=TokenResponse(token="valid_token"), + tag=_FlowStateTag.COMPLETE, + ), + ], + [ + {DEFAULTS.auth_handler_id: "old_token"}, + {DEFAULTS.agentic_auth_handler_id: "valid_token", DEFAULTS.auth_handler_id: "old_token"}, + None, + DEFAULTS.agentic_auth_handler_id, + _SignInState(active_handler_id=DEFAULTS.agentic_auth_handler_id), + None, + _SignInResponse( + token_response=TokenResponse(token="valid_token"), + tag=_FlowStateTag.COMPLETE, + ), + ], + [ + {DEFAULTS.auth_handler_id: "old_token"}, + {DEFAULTS.auth_handler_id: "valid_token"}, + DEFAULTS.auth_handler_id, + DEFAULTS.auth_handler_id, + None, + None, + _SignInResponse( + token_response=TokenResponse(token="valid_token"), + tag=_FlowStateTag.COMPLETE, + ), + ], + [ + {DEFAULTS.auth_handler_id: "old_token"}, + {DEFAULTS.auth_handler_id: "valid_token"}, + None, + DEFAULTS.auth_handler_id, + None, + None, + _SignInResponse( + token_response=TokenResponse(token="valid_token"), + tag=_FlowStateTag.COMPLETE, + ), + ], + [ + {DEFAULTS.agentic_auth_handler_id: "old_token", DEFAULTS.auth_handler_id: "old_token"}, + {DEFAULTS.agentic_auth_handler_id: "valid_token", DEFAULTS.auth_handler_id: "old_token"}, + DEFAULTS.agentic_auth_handler_id, + DEFAULTS.agentic_auth_handler_id, + _SignInState(active_handler_id=DEFAULTS.agentic_auth_handler_id), + None, + _SignInResponse( + token_response=TokenResponse(token="valid_token"), + tag=_FlowStateTag.COMPLETE, + ), + ], + [ + {DEFAULTS.agentic_auth_handler_id: "old_token", DEFAULTS.auth_handler_id: "old_token"}, + {DEFAULTS.agentic_auth_handler_id: "old_token", DEFAULTS.auth_handler_id: "old_token"}, + DEFAULTS.agentic_auth_handler_id, + DEFAULTS.agentic_auth_handler_id, + _SignInState(active_handler_id=DEFAULTS.agentic_auth_handler_id), + None, + _SignInResponse( + token_response=TokenResponse(), + tag=_FlowStateTag.FAILURE, + ), + ], + [ + {DEFAULTS.agentic_auth_handler_id: "old_token", DEFAULTS.auth_handler_id: "old_token"}, + {DEFAULTS.agentic_auth_handler_id: "old_token", DEFAULTS.auth_handler_id: "old_token"}, + None, + DEFAULTS.auth_handler_id, + None, + None, + _SignInResponse( + token_response=TokenResponse(), + tag=_FlowStateTag.FAILURE, + ), + ], + ] ) - async def test_start_or_continue_sign_in_to_complete_with_prev_state( - self, mocker, storage, authorization, context, auth_handler_id + async def test_start_or_continue_sign_in_complete_or_failure( + self, mocker, storage, authorization, context, + initial_cache, final_cache, auth_handler_id, expected_auth_handler_id, initial_sign_in_state, final_sign_in_state, sign_in_response ): # setup - initial_state = _SignInState( - tokens={"my_handler": "old_token"}, - continuation_activity=Activity( - type=ActivityTypes.message, text="old activity" - ), - ) - await set_sign_in_state(authorization, storage, context, initial_state) - mock_variants( - mocker, - sign_in_return=_SignInResponse( - token_response=TokenResponse(token=DEFAULTS.token), - tag=_FlowStateTag.COMPLETE, - ), - ) - + mock_variants(mocker, sign_in_return=sign_in_response) + expected_turn_state = create_turn_state(context, final_cache) + context.turn_state = create_turn_state(context, initial_cache) + if not initial_sign_in_state: + await authorization._delete_sign_in_state(context) + else: + await authorization._save_sign_in_state(context, initial_sign_in_state) + # test - sign_in_response = await authorization._start_or_continue_sign_in( + + res = await authorization._start_or_continue_sign_in( context, None, auth_handler_id ) - assert sign_in_response.tag == _FlowStateTag.COMPLETE - assert sign_in_response.token_response.token == DEFAULTS.token # verify - final_state = await get_sign_in_state(authorization, storage, context) - assert final_state.tokens[auth_handler_id] == DEFAULTS.token - assert final_state.tokens["my_handler"] == "old_token" - assert final_state.continuation_activity == initial_state.continuation_activity - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] - ) - async def test_start_or_continue_sign_in_to_failure_with_prev_state( - self, mocker, storage, authorization, context, auth_handler_id - ): - # setup - initial_state = _SignInState( - tokens={"my_handler": "old_token"}, - continuation_activity=Activity( - type=ActivityTypes.message, text="old activity" - ), - ) - await set_sign_in_state(authorization, storage, context, initial_state) - mock_variants( - mocker, - sign_in_return=_SignInResponse( - token_response=TokenResponse(), tag=_FlowStateTag.FAILURE - ), - ) + assert res.tag == sign_in_response.tag + assert res.token_response == sign_in_response.token_response - # test - sign_in_response = await authorization._start_or_continue_sign_in( - context, None, auth_handler_id - ) - assert sign_in_response.tag == _FlowStateTag.FAILURE - assert not sign_in_response.token_response + authorization._resolve_handler(expected_auth_handler_id)._sign_in.assert_called_once_with(context) + assert (await authorization._load_sign_in_state(context)) is None + assert context.turn_state == expected_turn_state - # verify - final_state = await get_sign_in_state(authorization, storage, context) - assert not final_state.tokens.get(auth_handler_id) - assert final_state.tokens["my_handler"] == "old_token" - assert final_state.continuation_activity == initial_state.continuation_activity + @pytest.fixture(params=[_FlowStateTag.BEGIN, _FlowStateTag.CONTINUE]) + def pending_tag(self, request): + return request.param @pytest.mark.asyncio @pytest.mark.parametrize( - "auth_handler_id, tag", + "initial_cache, auth_handler_id, expected_auth_handler_id, initial_sign_in_state", [ - (DEFAULTS.auth_handler_id, _FlowStateTag.BEGIN), - (DEFAULTS.agentic_auth_handler_id, _FlowStateTag.BEGIN), - (DEFAULTS.auth_handler_id, _FlowStateTag.CONTINUE), - (DEFAULTS.agentic_auth_handler_id, _FlowStateTag.CONTINUE), - ], + [ + {DEFAULTS.agentic_auth_handler_id: "old_token"}, + DEFAULTS.auth_handler_id, + DEFAULTS.auth_handler_id, + _SignInState(active_handler_id=DEFAULTS.auth_handler_id), + ], + [ + {DEFAULTS.auth_handler_id: "old_token"}, + None, + DEFAULTS.agentic_auth_handler_id, + _SignInState(active_handler_id=DEFAULTS.agentic_auth_handler_id), + ], + [ + {}, + DEFAULTS.auth_handler_id, + DEFAULTS.auth_handler_id, + None, + ], + [ + {DEFAULTS.auth_handler_id: "old_token"}, + None, + DEFAULTS.auth_handler_id, + None, + ], + [ + {}, + DEFAULTS.agentic_auth_handler_id, + DEFAULTS.auth_handler_id, + _SignInState(active_handler_id=DEFAULTS.auth_handler_id), + ], + ] ) - async def test_start_or_continue_sign_in_to_pending_with_prev_state( - self, mocker, storage, authorization, context, auth_handler_id, tag + async def test_start_or_continue_sign_in_pending( + self, mocker, storage, authorization, context, + initial_cache, auth_handler_id, expected_auth_handler_id, initial_sign_in_state, pending_tag ): # setup - initial_state = _SignInState( - tokens={"my_handler": "old_token"}, - continuation_activity=Activity( - type=ActivityTypes.message, text="old activity" - ), - ) - await set_sign_in_state(authorization, storage, context, initial_state) - mock_variants( - mocker, - sign_in_return=_SignInResponse(token_response=TokenResponse(), tag=tag), - ) - + mock_variants(mocker, sign_in_return=_SignInResponse( + tag=pending_tag + )) + expected_turn_state = create_turn_state(context, initial_cache) + context.turn_state = expected_turn_state + if not initial_sign_in_state: + await authorization._delete_sign_in_state(context) + else: + await authorization._save_sign_in_state(context, initial_sign_in_state) + # test - sign_in_response = await authorization._start_or_continue_sign_in( + + res = await authorization._start_or_continue_sign_in( context, None, auth_handler_id ) - assert sign_in_response.tag == tag - assert not sign_in_response.token_response # verify - final_state = await get_sign_in_state(authorization, storage, context) - assert not final_state.tokens.get(auth_handler_id) - assert final_state.tokens["my_handler"] == "old_token" - assert final_state.continuation_activity == context.activity + assert res.tag == pending_tag + assert not res.token_response + + authorization._resolve_handler(expected_auth_handler_id)._sign_in.assert_called_once_with(context) + final_sign_in_state = await authorization._load_sign_in_state(context) + assert final_sign_in_state.continuation_activity == context.activity + assert final_sign_in_state.active_handler_id == expected_auth_handler_id + assert context.turn_state == expected_turn_state @pytest.mark.asyncio @pytest.mark.parametrize( - "initial_state, final_state, handler_id, refresh_token, expected", + "initial_state, initial_cache, handler_id, expected_handler_id, refresh_token, expected", [ [ # no cached token - _SignInState( - tokens={DEFAULTS.auth_handler_id: "token"}, - ), - _SignInState( - tokens={DEFAULTS.auth_handler_id: "token"}, - ), + _SignInState(active_handler_id="value"), + {DEFAULTS.auth_handler_id: "token"}, + DEFAULTS.agentic_auth_handler_id, DEFAULTS.agentic_auth_handler_id, TokenResponse(), TokenResponse() ], [ # no cached token and default handler id resolution - _SignInState( - tokens={DEFAULTS.agentic_auth_handler_id: "token"}, - ), - _SignInState( - tokens={DEFAULTS.agentic_auth_handler_id: "token"}, - ), + _SignInState(active_handler_id="value"), + {DEFAULTS.agentic_auth_handler_id: "token"}, "", + DEFAULTS.auth_handler_id, TokenResponse(), TokenResponse() ], [ # no cached token pt.2 - _SignInState( - tokens={DEFAULTS.agentic_auth_handler_id: "token", DEFAULTS.auth_handler_id: ""}, - ), - _SignInState( - tokens={DEFAULTS.agentic_auth_handler_id: "token", DEFAULTS.auth_handler_id: ""}, - ), + _SignInState(active_handler_id=DEFAULTS.auth_handler_id), + {DEFAULTS.agentic_auth_handler_id: "token"}, + DEFAULTS.auth_handler_id, DEFAULTS.auth_handler_id, TokenResponse(), TokenResponse() ], [ # refreshed, new token - _SignInState( - tokens={DEFAULTS.agentic_auth_handler_id: make_jwt(), DEFAULTS.auth_handler_id: ""}, - ), - _SignInState( - tokens={DEFAULTS.agentic_auth_handler_id: DEFAULTS.token, DEFAULTS.auth_handler_id: ""}, - ), + _SignInState(active_handler_id="value"), + {DEFAULTS.agentic_auth_handler_id: make_jwt()}, + DEFAULTS.agentic_auth_handler_id, DEFAULTS.agentic_auth_handler_id, TokenResponse(token=DEFAULTS.token), TokenResponse(token=DEFAULTS.token) ], ] ) - async def test_get_token(self, mocker, authorization, context, storage, initial_state, final_state, handler_id, refresh_token, expected): + async def test_get_token(self, mocker, authorization, context, storage, initial_state, initial_cache, handler_id, expected_handler_id, refresh_token, expected): # setup - await set_sign_in_state(authorization, storage, context, initial_state) mock_variants(mocker, get_refreshed_token_return=refresh_token) + expected_turn_state = create_turn_state(context, initial_cache) + context.turn_state = expected_turn_state + if not initial_state: + await authorization._delete_sign_in_state(context) + else: + await authorization._save_sign_in_state(context, initial_state) # test - token = await authorization.get_token(context, handler_id) - assert token == expected - - final_state = await get_sign_in_state(authorization, storage, context) + if expected: + res = await authorization.get_token(context, handler_id) + assert res == expected + + if handler_id: + authorization._resolve_handler(expected_handler_id).get_refreshed_token.assert_called_once_with( + context, + None, + None + ) + else: + with pytest.raises(Exception): + await authorization.get_token(context, handler_id) + + final_state = await authorization._load_sign_in_state(context) assert sign_in_state_eq(initial_state, final_state) - - @pytest.mark.asyncio - async def test_get_token_error(self, mocker, authorization, context, storage): - initial_state = _SignInState( - tokens={DEFAULTS.auth_handler_id: "old_token"}, - ) - await set_sign_in_state(authorization, storage, context, initial_state) - mock_variants(mocker, get_refreshed_token_return=TokenResponse()) - with pytest.raises(Exception): - await authorization.get_token(context, DEFAULTS.auth_handler_id) + assert context.turn_state == expected_turn_state @pytest.mark.asyncio @pytest.mark.parametrize( - "initial_state, final_state, handler_id, refreshed, refresh_token, expected", + "initial_state, initial_cache, handler_id, refreshed, refresh_token", [ [ # no cached token - _SignInState( - tokens={DEFAULTS.auth_handler_id: "token"}, - ), - _SignInState( - tokens={DEFAULTS.auth_handler_id: "token"}, - ), + None, + { DEFAULTS.auth_handler_id: "token" }, DEFAULTS.agentic_auth_handler_id, False, TokenResponse(), - TokenResponse() ], [ # no cached token and default handler id resolution - _SignInState( - tokens={DEFAULTS.agentic_auth_handler_id: "token"}, - ), - _SignInState( - tokens={DEFAULTS.agentic_auth_handler_id: "token"}, - ), + None, + {DEFAULTS.agentic_auth_handler_id: "token"}, "", False, TokenResponse(), - TokenResponse() ], [ # no cached token pt.2 - _SignInState( - tokens={DEFAULTS.agentic_auth_handler_id: "token", DEFAULTS.auth_handler_id: ""}, - ), - _SignInState( - tokens={DEFAULTS.agentic_auth_handler_id: "token", DEFAULTS.auth_handler_id: ""}, - ), + _SignInState(active_handler_id=DEFAULTS.auth_handler_id), + {DEFAULTS.agentic_auth_handler_id: "token"}, DEFAULTS.auth_handler_id, - False, + True, TokenResponse(), - TokenResponse() ], [ # refreshed, new token - _SignInState( - tokens={DEFAULTS.agentic_auth_handler_id: make_jwt(), DEFAULTS.auth_handler_id: ""}, - ), - _SignInState( - tokens={DEFAULTS.agentic_auth_handler_id: DEFAULTS.token, DEFAULTS.auth_handler_id: ""}, - ), + _SignInState(active_handler_id=DEFAULTS.auth_handler_id), + {DEFAULTS.agentic_auth_handler_id: DEFAULTS.token}, DEFAULTS.agentic_auth_handler_id, True, TokenResponse(token=DEFAULTS.token), - TokenResponse(token=DEFAULTS.token) ], ] ) - async def test_exchange_token(self, mocker, authorization, context, storage, initial_state, final_state, handler_id, refreshed, refresh_token, expected): + async def test_exchange_token(self, mocker, authorization, context, storage, initial_state, initial_cache, handler_id, refreshed, refresh_token): # setup - await set_sign_in_state(authorization, storage, context, initial_state) mock_variants(mocker, get_refreshed_token_return=refresh_token) - - # test - token_res = await authorization.exchange_token(context, auth_handler_id=handler_id, exchange_connection="some_connection", scopes=["scope1", "scope2"]) - assert token_res == expected - - final_state = await get_sign_in_state(authorization, storage, context) + expected_turn_state = create_turn_state(context, initial_cache) + context.turn_state = expected_turn_state + if not initial_state: + await authorization._delete_sign_in_state(context) + else: + await authorization._save_sign_in_state(context, initial_state) + + + if refresh_token: + res = await authorization.exchange_token(context, auth_handler_id=handler_id, exchange_connection="some_connection", scopes=["scope1", "scope2"]) + assert res == refresh_token + + final_state = await authorization._load_sign_in_state(context) + assert sign_in_state_eq(initial_state, final_state) + if handler_id: + authorization._resolve_handler(handler_id).get_refreshed_token.assert_called_once_with( + context, + "some_connection", + ["scope1", "scope2"] + ) + else: + with pytest.raises(Exception): + token_res = await authorization.exchange_token(context, auth_handler_id=handler_id, exchange_connection="some_connection", scopes=["scope1", "scope2"]) + + final_state = await authorization._load_sign_in_state(context) assert sign_in_state_eq(initial_state, final_state) - if refreshed: - authorization._resolve_handler(handler_id).get_refreshed_token.assert_called_once_with( - context, - "some_connection", - ["scope1", "scope2"], - ) + assert context.turn_state == expected_turn_state + @pytest.mark.asyncio - @pytest.mark.parametrize( - "sign_in_state", - [ - _SignInState(), - _SignInState( - tokens={DEFAULTS.auth_handler_id: "token"}, - continuation_activity=Activity( - type=ActivityTypes.message, text="activity" - ), - ), - _SignInState( - tokens={ - DEFAULTS.auth_handler_id: "token", - DEFAULTS.agentic_auth_handler_id: "another_token", - }, - continuation_activity=Activity( - type=ActivityTypes.message, text="activity" - ), - ), - _SignInState( - tokens={DEFAULTS.auth_handler_id: "token", "my_handler": "old_token"}, - continuation_activity=Activity( - type=ActivityTypes.message, text="activity" - ), - ), - ], - ) async def test_on_turn_auth_intercept_no_intercept( - self, storage, authorization, context, sign_in_state + self, storage, authorization, context ): - await set_sign_in_state( - authorization, storage, context, copy_sign_in_state(sign_in_state) - ) + await authorization._delete_sign_in_state(context) intercepts, continuation_activity = await authorization._on_turn_auth_intercept( context, None @@ -561,9 +542,9 @@ async def test_on_turn_auth_intercept_no_intercept( assert not continuation_activity assert not intercepts - final_state = await get_sign_in_state(authorization, storage, context) + final_state = await authorization._load_sign_in_state(context) - assert sign_in_state_eq(final_state, sign_in_state) + assert sign_in_state_eq(final_state, None) @pytest.mark.asyncio @pytest.mark.parametrize( @@ -581,14 +562,17 @@ async def test_on_turn_auth_intercept_with_intercept_incomplete( mocker, start_or_continue_sign_in_return=sign_in_response ) - initial_state = _SignInState( - tokens={"some_handler": "old_token", auth_handler_id: ""}, + initial_cache = {"some_handler": "old_token"} + expected_cache = create_turn_state(context, initial_cache) + context.turn_state = expected_cache + + initial_state = _SignInState(active_handler_id=auth_handler_id, continuation_activity=Activity( type=ActivityTypes.message, text="old activity" ), ) - await set_sign_in_state( - authorization, storage, context, copy_sign_in_state(initial_state) + await authorization._save_sign_in_state( + context, copy_sign_in_state(initial_state) ) intercepts, continuation_activity = await authorization._on_turn_auth_intercept( @@ -598,8 +582,9 @@ async def test_on_turn_auth_intercept_with_intercept_incomplete( assert not continuation_activity assert intercepts - final_state = await get_sign_in_state(authorization, storage, context) + final_state = await authorization._load_sign_in_state(context) assert sign_in_state_eq(final_state, initial_state) + assert context.turn_state == expected_cache @pytest.mark.asyncio async def test_on_turn_auth_intercept_with_intercept_complete( @@ -610,13 +595,16 @@ async def test_on_turn_auth_intercept_with_intercept_complete( start_or_continue_sign_in_return=_SignInResponse(tag=_FlowStateTag.COMPLETE), ) + initial_cache = {"some_handler": "old_token"} + expected_cache = create_turn_state(context, initial_cache) + context.turn_state = expected_cache + old_activity = Activity(type=ActivityTypes.message, text="old activity") - initial_state = _SignInState( - tokens={"some_handler": "old_token", auth_handler_id: ""}, - continuation_activity=old_activity, + initial_state = _SignInState(active_handler_id=auth_handler_id, + continuation_activity=old_activity ) - await set_sign_in_state( - authorization, storage, context, copy_sign_in_state(initial_state) + await authorization._save_sign_in_state( + context, copy_sign_in_state(initial_state) ) intercepts, continuation_activity = await authorization._on_turn_auth_intercept( @@ -626,7 +614,6 @@ async def test_on_turn_auth_intercept_with_intercept_complete( assert continuation_activity == old_activity assert intercepts - # start_or_continue_sign_in is the only method that modifies the state, - # so since it is mocked, the state should not be changed - final_state = await get_sign_in_state(authorization, storage, context) + final_state = await authorization._load_sign_in_state(context) assert sign_in_state_eq(final_state, initial_state) + assert context.turn_state == expected_cache \ No newline at end of file diff --git a/tests/hosting_core/app/oauth/test_sign_in_state.py b/tests/hosting_core/app/oauth/test_sign_in_state.py deleted file mode 100644 index 59e813ea..00000000 --- a/tests/hosting_core/app/oauth/test_sign_in_state.py +++ /dev/null @@ -1,52 +0,0 @@ -import pytest - -from microsoft_agents.hosting.core.app.oauth import _SignInState - -from ._common import testing_Activity, testing_TurnContext - - -class TestSignInState: - def test_init(self): - state = _SignInState() - assert state.tokens == {} - assert state.continuation_activity is None - - def test_init_with_values(self): - activity = testing_Activity() - state = _SignInState({"handler": "some_token"}, activity) - assert state.tokens == {"handler": "some_token"} - assert state.continuation_activity == activity - - def test_from_json_to_store_item(self): - tokens = {"some_handler": "some_token", "other_handler": "other_token"} - activity = testing_Activity() - data = {"tokens": tokens, "continuation_activity": activity} - state = _SignInState.from_json_to_store_item(data) - assert state.tokens == tokens - assert state.continuation_activity == activity - - def test_store_item_to_json(self): - tokens = {"some_handler": "some_token", "other_handler": "other_token"} - activity = testing_Activity() - state = _SignInState(tokens, activity) - json_data = state.store_item_to_json() - assert json_data["tokens"] == tokens - assert json_data["continuation_activity"] == activity - - @pytest.mark.parametrize( - "tokens, active_handler", - [ - [{}, ""], - [{"some_handler": ""}, "some_handler"], - [{"some_handler": "some_token"}, ""], - [{"some_handler": "some_value", "other_handler": ""}, "other_handler"], - [{"some_handler": "some_value", "other_handler": "other_value"}, ""], - [ - {"some_handler": "some_value", "another_handler": "", "wow": "wow"}, - "another_handler", - ], - ], - ) - def test_active_handler(self, tokens, active_handler): - state = _SignInState(tokens) - assert state._active_handler() == active_handler From a34cdfbe89f348d40dd1424a21c20412a86301cc Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 30 Sep 2025 13:45:34 -0700 Subject: [PATCH 27/36] Changes to avoid auth on typing --- .../hosting/core/app/agent_application.py | 31 ++++++++++--------- .../oauth/_handlers/_user_authorization.py | 14 ++++----- .../hosting/core/app/oauth/authorization.py | 4 +-- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index 43a189b2..803e4193 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -606,21 +606,22 @@ async def _on_turn(self, context: TurnContext): logger.debug("Initializing turn state") turn_state = await self._initialize_state(context) - - ( - auth_intercepts, - continuation_activity, - ) = await self._auth._on_turn_auth_intercept(context, turn_state) - if auth_intercepts: - if continuation_activity: - new_context = copy(context) - new_context.activity = continuation_activity - logger.info( - "Resending continuation activity %s", continuation_activity.text - ) - await self.on_turn(new_context) - await turn_state.save(context) - return + if context.activity.type == ActivityTypes.message or context.activity.type == ActivityTypes.invoke: + + ( + auth_intercepts, + continuation_activity, + ) = await self._auth._on_turn_auth_intercept(context, turn_state) + if auth_intercepts: + if continuation_activity: + new_context = copy(context) + new_context.activity = continuation_activity + logger.info( + "Resending continuation activity %s", continuation_activity.text + ) + await self.on_turn(new_context) + await turn_state.save(context) + return logger.debug("Running before turn middleware") if not await self._run_before_turn_middleware(context, turn_state): diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py index 5d9db724..2da23059 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py @@ -146,7 +146,7 @@ async def _sign_out( signs out from all the handlers. """ flow, flow_storage_client = await self._load_flow(context) - logger.info("_Signing out from handler: %s", self._id) + logger.info("Signing out from handler: %s", self._id) await flow.sign_out() await flow_storage_client.delete(self._id) @@ -162,11 +162,11 @@ async def _handle_flow_response( assert sign_in_resource o_card: Attachment = CardFactory.oauth_card( OAuthCard( - text="_Sign in", + text="Sign in", connection_name=flow_state.connection, buttons=[ CardAction( - title="_Sign in", + title="Sign in", type=ActionTypes.signin, value=sign_in_resource.sign_in_link, channel_data=None, @@ -182,16 +182,16 @@ async def _handle_flow_response( if flow_state.reached_max_attempts(): await context.send_activity( MessageFactory.text( - "_Sign-in failed. Max retries reached. Please try again later." + "Sign-in failed. Max retries reached. Please try again later." ) ) elif flow_state.is_expired(): await context.send_activity( - MessageFactory.text("_Sign-in session expired. Please try again.") + MessageFactory.text("Sign-in session expired. Please try again.") ) else: - logger.warning("_Sign-in flow failed for unknown reasons.") - await context.send_activity("_Sign-in failed. Please try again.") + logger.warning("Sign-in flow failed for unknown reasons.") + await context.send_activity("Sign-in failed. Please try again.") async def _sign_in( self, context: TurnContext, exchange_connection: Optional[str] = None, exchange_scopes: Optional[list[str]] = None diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index d32e40f8..652d695a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -237,7 +237,7 @@ async def _start_or_continue_sign_in( return sign_in_response async def sign_out( - self, context: TurnContext, state: TurnState, auth_handler_id: Optional[str] = None + self, context: TurnContext, auth_handler_id: Optional[str] = None ) -> None: """Attempts to sign out the user from the specified auth handler or all handlers if none specified. @@ -341,7 +341,7 @@ async def exchange_token( res = await handler.get_refreshed_token(context, exchange_connection, scopes) if res: return res - raise Exception("Failed to exchange token") + return TokenResponse() def on_sign_in_success( From 3100fc0f870d922821f3e2cec3dbee0fc7d81d7f Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 30 Sep 2025 13:45:52 -0700 Subject: [PATCH 28/36] Changes to avoid auth on typing --- .../app/oauth/test_authorization.py | 49 ++++++++----------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/tests/hosting_core/app/oauth/test_authorization.py b/tests/hosting_core/app/oauth/test_authorization.py index 35c61eef..1ac250c2 100644 --- a/tests/hosting_core/app/oauth/test_authorization.py +++ b/tests/hosting_core/app/oauth/test_authorization.py @@ -201,7 +201,7 @@ async def test_sign_out( await authorization._save_sign_in_state(context, initial_sign_in_state) # test - await authorization.sign_out(context, None, auth_handler_id) + await authorization.sign_out(context, auth_handler_id) # verify assert context.turn_state == expected_turn_state @@ -445,19 +445,15 @@ async def test_get_token(self, mocker, authorization, context, storage, initial_ await authorization._save_sign_in_state(context, initial_state) # test - if expected: - res = await authorization.get_token(context, handler_id) - assert res == expected - - if handler_id: - authorization._resolve_handler(expected_handler_id).get_refreshed_token.assert_called_once_with( - context, - None, - None - ) - else: - with pytest.raises(Exception): - await authorization.get_token(context, handler_id) + res = await authorization.get_token(context, handler_id) + assert res == expected + + if handler_id and refresh_token: + authorization._resolve_handler(expected_handler_id).get_refreshed_token.assert_called_once_with( + context, + None, + None + ) final_state = await authorization._load_sign_in_state(context) assert sign_in_state_eq(initial_state, final_state) @@ -507,22 +503,17 @@ async def test_exchange_token(self, mocker, authorization, context, storage, ini else: await authorization._save_sign_in_state(context, initial_state) + res = await authorization.exchange_token(context, auth_handler_id=handler_id, exchange_connection="some_connection", scopes=["scope1", "scope2"]) + assert res == refresh_token - if refresh_token: - res = await authorization.exchange_token(context, auth_handler_id=handler_id, exchange_connection="some_connection", scopes=["scope1", "scope2"]) - assert res == refresh_token - - final_state = await authorization._load_sign_in_state(context) - assert sign_in_state_eq(initial_state, final_state) - if handler_id: - authorization._resolve_handler(handler_id).get_refreshed_token.assert_called_once_with( - context, - "some_connection", - ["scope1", "scope2"] - ) - else: - with pytest.raises(Exception): - token_res = await authorization.exchange_token(context, auth_handler_id=handler_id, exchange_connection="some_connection", scopes=["scope1", "scope2"]) + final_state = await authorization._load_sign_in_state(context) + assert sign_in_state_eq(initial_state, final_state) + if handler_id and refresh_token: + authorization._resolve_handler(handler_id).get_refreshed_token.assert_called_once_with( + context, + "some_connection", + ["scope1", "scope2"] + ) final_state = await authorization._load_sign_in_state(context) assert sign_in_state_eq(initial_state, final_state) From 04eaf7ea2e7c92113342ce6ba9ac2812180bf4e5 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 30 Sep 2025 16:03:11 -0700 Subject: [PATCH 29/36] Enable passing TurnContext into create_connector_client --- .../aiohttp/jwt_authorization_middleware.py | 1 + .../_handlers/agentic_user_authorization.py | 46 +++++++++- .../hosting/core/app/oauth/auth_handler.py | 2 + .../core/authorization/claims_identity.py | 2 +- .../hosting/core/channel_service_adapter.py | 84 ++++++++++--------- .../rest_channel_service_client_factory.py | 2 + 6 files changed, 92 insertions(+), 45 deletions(-) diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py index 5accb9f7..d28618cd 100644 --- a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py +++ b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py @@ -9,6 +9,7 @@ @middleware async def jwt_authorization_middleware(request: Request, handler): + auth_config: AgentAuthConfiguration = request.app["agent_configuration"] token_validator = JwtTokenValidator(auth_config) auth_header = request.headers.get("Authorization") diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py index e4946983..c00c1067 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py @@ -8,6 +8,9 @@ from ...._oauth import _FlowStateTag from .._sign_in_response import _SignInResponse from ._authorization_handler import _AuthorizationHandler +from ....storage import Storage +from ....authorization import Connections +from ..auth_handler import AuthHandler logger = logging.getLogger(__name__) @@ -15,6 +18,38 @@ class AgenticUserAuthorization(_AuthorizationHandler): """Class responsible for managing agentic authorization""" + def __init__( + self, + storage: Storage, + connection_manager: Connections, + auth_handler: Optional[AuthHandler] = None, + *, + auth_handler_id: Optional[str] = None, + auth_handler_settings: Optional[dict] = None, + **kwargs, + ) -> None: + """ + Creates a new instance of Authorization. + + :param storage: The storage system to use for state management. + :type storage: Storage + :param connection_manager: The connection manager for OAuth providers. + :type connection_manager: Connections + :param auth_handlers: Configuration for OAuth providers. + :type auth_handlers: dict[str, AuthHandler], optional + :raises ValueError: When storage is None or no auth handlers provided. + """ + super().__init__( + storage, + connection_manager, + auth_handler, + auth_handler_id=auth_handler_id, + auth_handler_settings=auth_handler_settings, + **kwargs, + ) + self._alt_blueprint_name = auth_handler._alt_blueprint_name if auth_handler else None + + async def get_agentic_instance_token(self, context: TurnContext) -> TokenResponse: """Gets the agentic instance token for the current agent instance. @@ -37,6 +72,8 @@ async def get_agentic_instance_token(self, context: TurnContext) -> TokenRespons agent_instance_id ) return TokenResponse(token=instance_token) if instance_token else TokenResponse() + + async def get_agentic_user_token( self, context: TurnContext, scopes: list[str] @@ -55,9 +92,12 @@ async def get_agentic_user_token( return TokenResponse() assert context.identity - connection = self._connection_manager.get_token_provider( - context.identity, "agentic" - ) + if self._alt_blueprint_name: + connection = self._connection_manager.get_connection(self._alt_bluerprint_name) + else: + connection = self._connection_manager.get_token_provider( + context.identity, "agentic" + ) upn = self.get_agentic_user(context) agentic_instance_id = self.get_agent_instance_id(context) assert upn and agentic_instance_id diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py index 4ed93ed3..ed702dec 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py @@ -65,6 +65,8 @@ def __init__( self.scopes = list(scopes) else: self.scopes = AuthHandler._format_scopes(kwargs.get("SCOPES", "")) + self._alt_blueprint_name = kwargs.get("ALT_BLUEPRINT_NAME", None) + @staticmethod def _format_scopes(scopes: str) -> list[str]: lst = scopes.strip().split(" ") diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/claims_identity.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/claims_identity.py index a8a92ebb..af30b409 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/claims_identity.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/claims_identity.py @@ -10,7 +10,7 @@ def __init__( self, claims: dict[str, str], is_authenticated: bool, - authentication_type: str = None, + authentication_type: Optional[str] = None, ): self.claims = claims self.is_authenticated = is_authenticated diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py index 9123287b..24a6ebd5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py @@ -7,7 +7,7 @@ from abc import ABC from copy import Error from http import HTTPStatus -from typing import Awaitable, Callable, cast +from typing import Awaitable, Callable, cast, Optional from uuid import uuid4 from microsoft_agents.activity import ( @@ -213,12 +213,28 @@ async def create_conversation( # pylint: disable=arguments-differ claims_identity = self.create_claims_identity(agent_app_id) claims_identity.claims[AuthenticationConstants.SERVICE_URL_CLAIM] = service_url + # Create a UserTokenClient instance for the application to use. (For example, in the OAuthPrompt.) + user_token_client: UserTokenClient = ( + await self._channel_service_client_factory.create_user_token_client( + claims_identity + ) + ) + + # Create a turn context and run the pipeline. + context = self._create_turn_context( + claims_identity, + None, + user_token_client, + callback, + ) + # Create the connector client to use for outbound requests. connector_client: ConnectorClient = ( await self._channel_service_client_factory.create_connector_client( - claims_identity, service_url, audience + context, claims_identity, service_url, audience ) ) + context.turn_state[self._AGENT_CONNECTOR_CLIENT_KEY] = connector_client # Make the actual create conversation call using the connector. create_conversation_result = ( @@ -232,22 +248,8 @@ async def create_conversation( # pylint: disable=arguments-differ create_conversation_result, channel_id, service_url, conversation_parameters ) - # Create a UserTokenClient instance for the application to use. (For example, in the OAuthPrompt.) - user_token_client: UserTokenClient = ( - await self._channel_service_client_factory.create_user_token_client( - claims_identity - ) - ) + context.activity = create_activity - # Create a turn context and run the pipeline. - context = self._create_turn_context( - create_activity, - claims_identity, - None, - connector_client, - user_token_client, - callback, - ) # Run the pipeline await self.run_pipeline(context, callback) @@ -262,12 +264,6 @@ async def process_proactive( audience: str, callback: Callable[[TurnContext], Awaitable], ): - # Create the connector client to use for outbound requests. - connector_client: ConnectorClient = ( - await self._channel_service_client_factory.create_connector_client( - claims_identity, continuation_activity.service_url, audience - ) - ) # Create a UserTokenClient instance for the application to use. (For example, in the OAuthPrompt.) user_token_client: UserTokenClient = ( @@ -278,14 +274,21 @@ async def process_proactive( # Create a turn context and run the pipeline. context = self._create_turn_context( - continuation_activity, claims_identity, audience, - connector_client, user_token_client, callback, + activity=continuation_activity, ) + # Create the connector client to use for outbound requests. + connector_client: ConnectorClient = ( + await self._channel_service_client_factory.create_connector_client( + context, claims_identity, continuation_activity.service_url, audience + ) + ) + context.turn_state[self._AGENT_CONNECTOR_CLIENT_KEY] = connector_client + # Run the pipeline await self.run_pipeline(context, callback) @@ -336,17 +339,6 @@ async def process_activity( ): use_anonymous_auth_callback = True - # Create the connector client to use for outbound requests. - connector_client: ConnectorClient = ( - await self._channel_service_client_factory.create_connector_client( - claims_identity, - activity.service_url, - outgoing_audience, - scopes, - use_anonymous_auth_callback, - ) - ) - # Create a UserTokenClient instance for the OAuth flow. user_token_client: UserTokenClient = ( await self._channel_service_client_factory.create_user_token_client( @@ -356,13 +348,25 @@ async def process_activity( # Create a turn context and run the pipeline. context = self._create_turn_context( - activity, claims_identity, outgoing_audience, - connector_client, user_token_client, callback, + activity=activity + ) + + # Create the connector client to use for outbound requests. + connector_client: ConnectorClient = ( + await self._channel_service_client_factory.create_connector_client( + context, + claims_identity, + activity.service_url, + outgoing_audience, + scopes, + use_anonymous_auth_callback, + ) ) + context.turn_state[self._AGENT_CONNECTOR_CLIENT_KEY] = connector_client await self.run_pipeline(context, callback) @@ -420,17 +424,15 @@ def _create_create_activity( def _create_turn_context( self, - activity: Activity, claims_identity: ClaimsIdentity, oauth_scope: str, - connector_client: ConnectorClientBase, user_token_client: UserTokenClientBase, callback: Callable[[TurnContext], Awaitable], + activity: Optional[Activity] = None, ) -> TurnContext: context = TurnContext(self, activity, claims_identity) context.turn_state[self.AGENT_IDENTITY_KEY] = claims_identity - context.turn_state[self._AGENT_CONNECTOR_CLIENT_KEY] = connector_client context.turn_state[self.USER_TOKEN_CLIENT_KEY] = user_token_client context.turn_state[self.AGENT_CALLBACK_HANDLER_KEY] = callback context.turn_state[self.CHANNEL_SERVICE_FACTORY_KEY] = ( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py index af280654..71378698 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py @@ -10,6 +10,7 @@ from microsoft_agents.hosting.core.connector import ConnectorClientBase from microsoft_agents.hosting.core.connector.client import UserTokenClient from microsoft_agents.hosting.core.connector.teams import TeamsConnectorClient +from microsoft_agents.hosting.core.turn_context import TurnContext from .channel_service_client_factory_base import ChannelServiceClientFactoryBase @@ -29,6 +30,7 @@ def __init__( async def create_connector_client( self, + context: TurnContext, claims_identity: ClaimsIdentity, service_url: str, audience: str, From 86dfac2cd761cf51a94aba96327440571afa7d29 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 30 Sep 2025 18:29:47 -0700 Subject: [PATCH 30/36] Tweaks to work almost end-to-end / fixing connector client construction --- .../authentication/msal/msal_auth.py | 20 ++++-- .../hosting/core/app/agent_application.py | 1 + .../oauth/_handlers/_authorization_handler.py | 14 +++- .../oauth/_handlers/_user_authorization.py | 19 ++---- .../_handlers/agentic_user_authorization.py | 12 +++- .../hosting/core/app/oauth/authorization.py | 25 +++++-- .../authorization/agent_auth_configuration.py | 1 + .../authorization/anonymous_token_provider.py | 17 +++++ .../authorization/authentication_constants.py | 9 +++ .../core/authorization/jwt_token_validator.py | 1 - .../rest_channel_service_client_factory.py | 66 +++++++++++++++---- 11 files changed, 147 insertions(+), 38 deletions(-) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 4ce2b743..11911de3 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -57,9 +57,16 @@ async def get_access_token( auth_result_payload = msal_auth_client.acquire_token_for_client( scopes=local_scopes ) + else: + auth_result_payload = None - # TODO: Handling token error / acquisition failed - return auth_result_payload["access_token"] + res = auth_result_payload.get("access_token") if auth_result_payload else None + if not res: + logger.error( + "Failed to acquire token for resource %s", auth_result_payload + ) + raise ValueError(f"Failed to acquire token. {str(auth_result_payload)}") + return res async def acquire_token_on_behalf_of( self, scopes: list[str], user_assertion: str @@ -255,7 +262,12 @@ async def get_agentic_instance_token( assert agent_token_result # future scenario where we don't know the blueprint id upfront - token = agent_instance_token["access_token"] + + token = agent_instance_token.get("access_token") + if not token: + logger.error("Failed to acquire agentic instance token, %s", agent_instance_token) + raise ValueError(f"Failed to acquire token. {str(agent_instance_token)}") + payload = jwt.decode(token, options={"verify_signature": False}) agentic_blueprint_id = payload.get("xms_par_app_azp") logger.debug("Agentic blueprint id: %s", agentic_blueprint_id) @@ -276,7 +288,7 @@ async def get_agentic_user_token( :return: The agentic user token, or None if not found. :rtype: Optional[str] """ - + breakpoint() if not agent_app_instance_id or not upn: raise ValueError( "Agent application instance Id and user principal name must be provided." diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index 803e4193..4bebf48e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -31,6 +31,7 @@ MessageUpdateTypes, InvokeResponse, ) +from microsoft_agents.hosting.core.app.oauth._handlers.agentic_user_authorization import AgenticUserAuthorization from ..turn_context import TurnContext from ..agent import Agent diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py index b3c29263..87e4b08c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py @@ -65,8 +65,8 @@ async def _sign_in( :param context: The turn context for the current turn of conversation. :type context: TurnContext - :param auth_handler_id: The ID of the auth handler to use for sign-in. If None, the first handler will be used. - :type auth_handler_id: Optional[str] + :param scopes: Optional list of scopes to request during sign-in. If None, default scopes will be used. + :type scopes: Optional[list[str]], optional :return: A SignInResponse indicating the result of the sign-in attempt. :rtype: SignInResponse """ @@ -75,7 +75,15 @@ async def _sign_in( async def get_refreshed_token( self, context: TurnContext, exchange_connection: Optional[str]=None, exchange_scopes: Optional[list[str]] = None ) -> TokenResponse: - """Attempts to get a refreshed token for the user with the given scopes""" + """Attempts to get a refreshed token for the user with the given scopes + + :param context: The turn context for the current turn of conversation. + :type context: TurnContext + :param exchange_connection: Optional name of the connection to use for token exchange. If None, default connection will be used. + :type exchange_connection: Optional[str], optional + :param exchange_scopes: Optional list of scopes to request during token exchange. If None, default scopes will be used. + :type exchange_scopes: Optional[list[str]], optional + """ raise NotImplementedError() async def _sign_out(self, context: TurnContext) -> None: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py index 2da23059..34bc081a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py @@ -236,19 +236,14 @@ async def _sign_in( async def get_refreshed_token( self, context: TurnContext, exchange_connection: Optional[str] = None, exchange_scopes: Optional[list[str]] = None ) -> TokenResponse: - """ - Gets a refreshed token for the user. - - :param context: The context object for the current turn. + """Attempts to get a refreshed token for the user with the given scopes + + :param context: The turn context for the current turn of conversation. :type context: TurnContext - :param auth_handler_id: The ID of the auth handler to use. - :type auth_handler_id: str - :param exchange_connection: The connection to use for token exchange. - :type exchange_connection: str - :param exchange_scopes: The scopes to request for the new token. - :type exchange_scopes: Optional[list[str]] - :return: The token response from the OAuth provider. - :rtype: TokenResponse + :param exchange_connection: Optional name of the connection to use for token exchange. If None, default connection will be used. + :type exchange_connection: Optional[str], optional + :param exchange_scopes: Optional list of scopes to request during token exchange. If None, default scopes will be used. + :type exchange_scopes: Optional[list[str]], optional """ flow, _ = await self._load_flow(context) input_token_response = await flow.get_user_token() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py index c00c1067..9162f7d7 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py @@ -93,7 +93,7 @@ async def get_agentic_user_token( assert context.identity if self._alt_blueprint_name: - connection = self._connection_manager.get_connection(self._alt_bluerprint_name) + connection = self._connection_manager.get_connection(self._alt_blueprint_name) else: connection = self._connection_manager.get_token_provider( context.identity, "agentic" @@ -131,7 +131,15 @@ async def get_refreshed_token(self, exchange_connection: Optional[str] = None, exchange_scopes: Optional[list[str]] = None ) -> TokenResponse: - """Gets a refreshed agentic user token if available.""" + """Attempts to get a refreshed token for the user with the given scopes + + :param context: The turn context for the current turn of conversation. + :type context: TurnContext + :param exchange_connection: Optional name of the connection to use for token exchange. If None, default connection will be used. + :type exchange_connection: Optional[str], optional + :param exchange_scopes: Optional list of scopes to request during token exchange. If None, default scopes will be used. + :type exchange_scopes: Optional[list[str]], optional + """ if not exchange_scopes: exchange_scopes = self._handler.scopes or [] return await self.get_agentic_user_token(context, exchange_scopes) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index 652d695a..c6a0231d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -239,12 +239,10 @@ async def _start_or_continue_sign_in( async def sign_out( self, context: TurnContext, auth_handler_id: Optional[str] = None ) -> None: - """Attempts to sign out the user from the specified auth handler or all handlers if none specified. + """Attempts to sign out the user from a specified auth handler or the default handler. :param context: The turn context for the current turn of conversation. :type context: TurnContext - :param state: The turn state for the current turn of conversation. - :type state: TurnState :param auth_handler_id: The ID of the auth handler to sign out from. If None, sign out from all handlers. :type auth_handler_id: Optional[str] :return: None @@ -295,7 +293,7 @@ async def _on_turn_auth_intercept( async def get_token( self, context: TurnContext, auth_handler_id: Optional[str] = None ) -> TokenResponse: - """Gets the token for a specific auth handler. + """Gets the token for a specific auth handler or the default handler. The token is taken from cache, so this does not initiate nor continue a sign-in flow. @@ -315,6 +313,23 @@ async def exchange_token( auth_handler_id: Optional[str] = None, exchange_connection: Optional[str] = None, ) -> TokenResponse: + """Exchanges or refreshes the token for a specific auth handler or the default handler. + + :param context: The context object for the current turn. + :type context: TurnContext + :param scopes: The scopes to request during the token exchange or refresh. Defaults + to the list given in the AuthHandler configuration if None. + :type scopes: Optional[list[str]] + :param auth_handler_id: The ID of the auth handler to exchange or refresh the token for. + If None, the default handler will be used. + :type auth_handler_id: Optional[str] + :param exchange_connection: The name of the connection to use for token exchange. If None, + the connection defined in the AuthHandler configuration will be used. + :type exchange_connection: Optional[str] + :return: The token response from the OAuth provider. + :rtype: TokenResponse + :raises ValueError: If the specified auth handler ID is not recognized or not configured. + """ auth_handler_id = auth_handler_id or self._default_handler_id if auth_handler_id not in self._handlers: @@ -364,4 +379,4 @@ def on_sign_in_failure( :param handler: The handler function to call on sign-in failure. """ - self._sign_in_failure_handler = handle \ No newline at end of file + self._sign_in_failure_handler = handler \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/agent_auth_configuration.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/agent_auth_configuration.py index 763197e6..02f6198c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/agent_auth_configuration.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/agent_auth_configuration.py @@ -40,6 +40,7 @@ def __init__( self.CERT_KEY_FILE = cert_key_file or kwargs.get("CERTKEYFILE", None) self.CONNECTION_NAME = connection_name or kwargs.get("CONNECTIONNAME", None) self.SCOPES = scopes or kwargs.get("SCOPES", None) + self.ALT_BLUEPRINT_ID = kwargs.get("ALT_BLUEPRINT_NAME", None) @property def ISSUERS(self) -> list[str]: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py index 318566a3..4179d658 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py @@ -1,3 +1,5 @@ +from typing import Optional + from .access_token_provider_base import AccessTokenProviderBase @@ -11,3 +13,18 @@ async def get_access_token( self, resource_url: str, scopes: list[str], force_refresh: bool = False ) -> str: return "" + + async def get_agentic_application_token( + self, agent_app_instance_id: str + ) -> Optional[str]: + return "" + + async def get_agentic_instance_token( + self, agent_app_instance_id: str + ) -> tuple[str, str]: + return "", "" + + async def get_agentic_user_token( + self, agent_app_instance_id: str, upn: str, scopes: list[str] + ) -> Optional[str]: + return "" \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/authentication_constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/authentication_constants.py index c370ea72..1fc30353 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/authentication_constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/authentication_constants.py @@ -100,3 +100,12 @@ class AuthenticationConstants(ABC): # Tenant Id claim name. As used in Microsoft AAD tokens. TENANT_ID_CLAIM = "tid" + + APX_LOCAL_SCOPE = "c16e153d-5d2b-4c21-b7f4-b05ee5d516f1/.default" + APX_DEV_SCOPE = "0d94caae-b412-4943-8a68-83135ad6d35f/.default" + APX_PRODUCTION_SCOPE = "5a807f24-c9de-44ee-a3a7-329e88a00ffc/.default" + APX_GCC_SCOPE = "c9475445-9789-4fef-9ec5-cde4a9bcd446/.default" + APX_GCCH_SCOPE = "6f669b9e-7701-4e2b-b624-82c9207fde26/.default" + APX_DOD_SCOPE = "0a069c81-8c7c-4712-886b-9c542d673ffb/.default" + APX_GALLATIN_SCOPE = "bd004c8e-5acf-4c48-8570-4e7d46b2f63b/.default" + \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py index 26bfc7ee..714199b5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py @@ -45,7 +45,6 @@ def _get_public_key_or_secret(self, token: str) -> PyJWK: if unverified_payload.get("iss") == "https://api.botframework.com" else f"https://login.microsoftonline.com/{self.configuration.TENANT_ID}/discovery/v2.0/keys" ) - jwks_client = PyJWKClient(jwksUri) key = jwks_client.get_signing_key(header["kid"]) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py index 71378698..0fba170d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py @@ -1,5 +1,7 @@ +import re from typing import Optional +from microsoft_agents.activity import RoleTypes from microsoft_agents.hosting.core.authorization import ( AuthenticationConstants, AnonymousTokenProvider, @@ -10,9 +12,23 @@ from microsoft_agents.hosting.core.connector import ConnectorClientBase from microsoft_agents.hosting.core.connector.client import UserTokenClient from microsoft_agents.hosting.core.connector.teams import TeamsConnectorClient -from microsoft_agents.hosting.core.turn_context import TurnContext from .channel_service_client_factory_base import ChannelServiceClientFactoryBase +from .turn_context import TurnContext + +# DIRTY HACK -> to avoid circular import +# PLEASE REMOVE, thank you. +def get_agent_instance_id(context: TurnContext) -> Optional[str]: + """Gets the agent instance ID from the context if it's an agentic request.""" + if not context.activity.is_agentic() or not context.activity.recipient: + return None + return context.activity.recipient.agentic_app_id + +def get_agentic_user(context: TurnContext) -> Optional[str]: + """Gets the agentic user (UPN) from the context if it's an agentic request.""" + if not context.activity.is_agentic() or not context.activity.recipient: + return None + return context.activity.recipient.id class RestChannelServiceClientFactory(ChannelServiceClientFactoryBase): @@ -45,16 +61,41 @@ async def create_connector_client( raise TypeError( "RestChannelServiceClientFactory.create_connector_client: audience can't be None or Empty" ) + + if context.activity.is_agentic(): - token_provider: AccessTokenProviderBase = ( - self._connection_manager.get_token_provider(claims_identity, service_url) - if not use_anonymous - else self._ANONYMOUS_TOKEN_PROVIDER - ) + # breakpoint() - token = await token_provider.get_access_token( - audience, scopes or [f"{audience}/.default"] - ) + if not context.identity: + raise ValueError("context.identity is required for agentic activities") + + connection = self._connection_manager.get_token_provider(context.identity, service_url) + if connection._msal_configuration.ALT_BLUEPRINT_ID: + connection = self._connection_manager.get_connection(connection._msal_configuration.ALT_BLUEPRINT_ID) + agent_instance_id = get_agent_instance_id(context) + if not agent_instance_id: + raise ValueError("Agent instance ID is required for agentic identity role") + + if context.activity.recipient.role == RoleTypes.agentic_identity: + token, _ = await connection.get_agentic_instance_token(agent_instance_id) + else: + agentic_user = get_agentic_user(context) + if not agentic_user: + raise ValueError("Agentic user is required for agentic user role") + token = await connection.get_agentic_user_token(agent_instance_id, agentic_user, [AuthenticationConstants.APX_PRODUCTION_SCOPE]) + + if not token: + raise ValueError("Failed to obtain token for agentic activity") + else: + token_provider: AccessTokenProviderBase = ( + self._connection_manager.get_token_provider(claims_identity, service_url) + if not use_anonymous + else self._ANONYMOUS_TOKEN_PROVIDER + ) + + token = await token_provider.get_access_token( + audience, scopes or [f"{audience}/.default"] + ) return TeamsConnectorClient( endpoint=service_url, @@ -64,12 +105,15 @@ async def create_connector_client( async def create_user_token_client( self, claims_identity: ClaimsIdentity, use_anonymous: bool = False ) -> UserTokenClient: + if use_anonymous: + return UserTokenClient(endpoint=self._token_service_endpoint, token="") + token_provider = ( self._connection_manager.get_token_provider( claims_identity, self._token_service_endpoint ) - if not use_anonymous - else self._ANONYMOUS_TOKEN_PROVIDER + # if not use_anonymous + # else self._ANONYMOUS_TOKEN_PROVIDER ) token = await token_provider.get_access_token( From b56419344c85b2d2097cc63f7eaec4d4e1f39b70 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 1 Oct 2025 08:34:07 -0700 Subject: [PATCH 31/36] Moving agentic static methods to be instance methods of Activity and fixing other tests --- .../microsoft_agents/activity/activity.py | 15 ++++- .../hosting/aiohttp/cloud_adapter.py | 4 +- .../_handlers/agentic_user_authorization.py | 30 +++------ .../hosting/core/app/oauth/authorization.py | 2 +- .../authorization/anonymous_token_provider.py | 3 + .../rest_channel_service_client_factory.py | 20 +----- .../hosting/core/turn_context.py | 2 +- tests/activity/test_activity.py | 66 ++++++++++++++++++- .../test_agentic_user_authorization.py | 53 --------------- tests/hosting_core/test_turn_context.py | 3 +- 10 files changed, 98 insertions(+), 100 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index fa31470b..839ac3dd 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -24,7 +24,6 @@ from ._model_utils import pick_model, SkipNone from ._type_aliases import NonEmptyString - # TODO: A2A Agent 2 is responding with None as id, had to mark it as optional (investigate) class Activity(AgentsModel): """An Activity is the basic communication type for the protocol. @@ -650,8 +649,20 @@ def add_ai_metadata( self.entities.append(ai_entity) - def is_agentic(self) -> bool: + def is_agentic_request(self) -> bool: return self.recipient and self.recipient.role in [ RoleTypes.agentic_identity, RoleTypes.agentic_user, ] + + def get_agentic_instance_id(self) -> Optional[str]: + """Gets the agent instance ID from the context if it's an agentic request.""" + if not self.is_agentic_request() or not self.recipient: + return None + return self.recipient.agentic_app_id + + def get_agentic_user(self) -> Optional[str]: + """Gets the agentic user (UPN) from the context if it's an agentic request.""" + if not self.is_agentic_request() or not self.recipient: + return None + return self.recipient.id \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py index e80014bf..928cfbd1 100644 --- a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py +++ b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py @@ -82,7 +82,9 @@ async def process(self, request: Request, agent: Agent) -> Optional[Response]: raise HTTPUnsupportedMediaType() activity: Activity = Activity.model_validate(body) - claims_identity: ClaimsIdentity = request.get("claims_identity") + + # default to anonymous identity with no claims + claims_identity: ClaimsIdentity = request.get("claims_identity", ClaimsIdentity({}, False)) # A POST request must contain an Activity if ( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py index 9162f7d7..d5f7c544 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py @@ -59,17 +59,17 @@ async def get_agentic_instance_token(self, context: TurnContext) -> TokenRespons :rtype: Optional[str] """ - if not context.activity.is_agentic(): + if not context.activity.is_agentic_request(): return TokenResponse() assert context.identity connection = self._connection_manager.get_token_provider( context.identity, "agentic" ) - agent_instance_id = self.get_agent_instance_id(context) - assert agent_instance_id + agentic_instance_id = context.activity.get_agentic_instance_id() + assert agentic_instance_id instance_token, _ = await connection.get_agentic_instance_token( - agent_instance_id + agentic_instance_id ) return TokenResponse(token=instance_token) if instance_token else TokenResponse() @@ -88,7 +88,7 @@ async def get_agentic_user_token( :rtype: Optional[str] """ - if not context.activity.is_agentic() or not self.get_agentic_user(context): + if not context.activity.is_agentic_request() or not context.activity.get_agentic_user(): return TokenResponse() assert context.identity @@ -98,8 +98,8 @@ async def get_agentic_user_token( connection = self._connection_manager.get_token_provider( context.identity, "agentic" ) - upn = self.get_agentic_user(context) - agentic_instance_id = self.get_agent_instance_id(context) + upn = context.activity.get_agentic_user() + agentic_instance_id = context.activity.get_agentic_instance_id() assert upn and agentic_instance_id token = await connection.get_agentic_user_token(agentic_instance_id, upn, scopes) return TokenResponse(token=token) if token else TokenResponse() @@ -145,18 +145,4 @@ async def get_refreshed_token(self, return await self.get_agentic_user_token(context, exchange_scopes) async def sign_out(self, context: TurnContext, auth_handler_id: Optional[str] = None) -> None: - """Nothing to do for agentic sign out.""" - - @staticmethod - def get_agent_instance_id(context: TurnContext) -> Optional[str]: - """Gets the agent instance ID from the context if it's an agentic request.""" - if not context.activity.is_agentic() or not context.activity.recipient: - return None - return context.activity.recipient.agentic_app_id - - @staticmethod - def get_agentic_user(context: TurnContext) -> Optional[str]: - """Gets the agentic user (UPN) from the context if it's an agentic request.""" - if not context.activity.is_agentic() or not context.activity.recipient: - return None - return context.activity.recipient.id + """Nothing to do for agentic sign out.""" \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index c6a0231d..953fd40d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -345,7 +345,7 @@ async def exchange_token( # for later -> parity with .NET # token_res = sign_in_state.tokens[auth_handler_id] - # if not context.activity.is_agentic(): + # if not context.activity.is_agentic_request(): # if token_res and not token_res.is_exchangeable(): # token = token_res.token # if token.expiration is not None: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py index 4179d658..a751930d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py @@ -13,6 +13,9 @@ async def get_access_token( self, resource_url: str, scopes: list[str], force_refresh: bool = False ) -> str: return "" + + async def acquire_token_on_behalf_of(self, scopes: list[str], user_assertion: str) -> str: + return "" async def get_agentic_application_token( self, agent_app_instance_id: str diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py index 0fba170d..7353ec43 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py @@ -16,20 +16,6 @@ from .channel_service_client_factory_base import ChannelServiceClientFactoryBase from .turn_context import TurnContext -# DIRTY HACK -> to avoid circular import -# PLEASE REMOVE, thank you. -def get_agent_instance_id(context: TurnContext) -> Optional[str]: - """Gets the agent instance ID from the context if it's an agentic request.""" - if not context.activity.is_agentic() or not context.activity.recipient: - return None - return context.activity.recipient.agentic_app_id - -def get_agentic_user(context: TurnContext) -> Optional[str]: - """Gets the agentic user (UPN) from the context if it's an agentic request.""" - if not context.activity.is_agentic() or not context.activity.recipient: - return None - return context.activity.recipient.id - class RestChannelServiceClientFactory(ChannelServiceClientFactoryBase): _ANONYMOUS_TOKEN_PROVIDER = AnonymousTokenProvider() @@ -62,7 +48,7 @@ async def create_connector_client( "RestChannelServiceClientFactory.create_connector_client: audience can't be None or Empty" ) - if context.activity.is_agentic(): + if context.activity.is_agentic_request(): # breakpoint() @@ -72,14 +58,14 @@ async def create_connector_client( connection = self._connection_manager.get_token_provider(context.identity, service_url) if connection._msal_configuration.ALT_BLUEPRINT_ID: connection = self._connection_manager.get_connection(connection._msal_configuration.ALT_BLUEPRINT_ID) - agent_instance_id = get_agent_instance_id(context) + agent_instance_id = context.activity.get_agentic_instance_id() if not agent_instance_id: raise ValueError("Agent instance ID is required for agentic identity role") if context.activity.recipient.role == RoleTypes.agentic_identity: token, _ = await connection.get_agentic_instance_token(agent_instance_id) else: - agentic_user = get_agentic_user(context) + agentic_user = context.activity.get_agentic_user() if not agentic_user: raise ValueError("Agentic user is required for agentic user role") token = await connection.get_agentic_user_token(agent_instance_id, agentic_user, [AuthenticationConstants.APX_PRODUCTION_SCOPE]) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index 4deb7c92..5f024748 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -18,7 +18,7 @@ ResourceResponse, DeliveryModes, ) -from .authorization import ClaimsIdentity +from microsoft_agents.hosting.core.authorization.claims_identity import ClaimsIdentity class TurnContext(TurnContextProtocol): diff --git a/tests/activity/test_activity.py b/tests/activity/test_activity.py index d30c40c7..b557d382 100644 --- a/tests/activity/test_activity.py +++ b/tests/activity/test_activity.py @@ -21,6 +21,9 @@ from tests.activity._common.my_channel_data import MyChannelData from tests.activity._common.testing_activity import create_test_activity +from tests._common.data import TEST_DEFAULTS + +DEFAULTS = TEST_DEFAULTS() def helper_validate_recipient_and_from( @@ -370,6 +373,16 @@ def test_get_mentions(self): Entity(type="mention", text="Another mention"), ] +class TestActivityAgenticOps: + + @pytest.fixture(params=[RoleTypes.user, RoleTypes.skill, RoleTypes.agent]) + def non_agentic_role(self, request): + return request.param + + @pytest.fixture(params=[RoleTypes.agentic_user, RoleTypes.agentic_identity]) + def agentic_role(self, request): + return request.param + @pytest.mark.parametrize( "role, expected", [ @@ -380,8 +393,57 @@ def test_get_mentions(self): [RoleTypes.agentic_identity, True], ], ) - def test_is_agentic(self, role, expected): + def test_is_agentic_request(self, role, expected): activity = Activity( type="message", recipient=ChannelAccount(id="bot", name="bot", role=role) ) - assert activity.is_agentic() == expected + assert activity.is_agentic_request() == expected + + def test_get_agentic_instance_id_is_agentic(self, mocker, agentic_role): + activity = Activity( + type="message", + recipient=ChannelAccount( + id="some_id", + agentic_app_id=DEFAULTS.agentic_instance_id, + role=agentic_role, + ), + ) + assert ( + activity.get_agentic_instance_id() + == DEFAULTS.agentic_instance_id + ) + + def test_get_agentic_instance_id_not_agentic(self, non_agentic_role): + activity = Activity( + type="message", + recipient=ChannelAccount( + id="some_id", + agentic_app_id=DEFAULTS.agentic_instance_id, + role=non_agentic_role, + ), + ) + assert activity.get_agentic_instance_id() is None + + def test_get_agentic_user_is_agentic(self, agentic_role): + activity = Activity( + type="message", + recipient=ChannelAccount( + id=DEFAULTS.agentic_user_id, + agentic_app_id=DEFAULTS.agentic_instance_id, + role=agentic_role, + ), + ) + assert ( + activity.get_agentic_user() == DEFAULTS.agentic_user_id + ) + + def test_get_agentic_user_not_agentic(self, non_agentic_role): + activity = Activity( + type="message", + recipient=ChannelAccount( + id=DEFAULTS.agentic_user_id, + agentic_app_id=DEFAULTS.agentic_instance_id, + role=non_agentic_role, + ), + ) + assert activity.get_agentic_user() is None \ No newline at end of file diff --git a/tests/hosting_core/app/oauth/_handlers/test_agentic_user_authorization.py b/tests/hosting_core/app/oauth/_handlers/test_agentic_user_authorization.py index f217040a..e73ba913 100644 --- a/tests/hosting_core/app/oauth/_handlers/test_agentic_user_authorization.py +++ b/tests/hosting_core/app/oauth/_handlers/test_agentic_user_authorization.py @@ -71,59 +71,6 @@ def mock_class_provider(self, mocker, app_token="bot_token", instance_token=None class TestAgenticUserAuthorization(TestUtils): - def test_get_agent_instance_id_is_agentic(self, mocker, agentic_role): - activity = Activity( - type="message", - recipient=ChannelAccount( - id="some_id", - agentic_app_id=DEFAULTS.agentic_instance_id, - role=agentic_role, - ), - ) - context = self.TurnContext(mocker, activity=activity) - assert ( - AgenticUserAuthorization.get_agent_instance_id(context) - == DEFAULTS.agentic_instance_id - ) - - def test_get_agent_instance_id_not_agentic(self, mocker, non_agentic_role): - activity = Activity( - type="message", - recipient=ChannelAccount( - id="some_id", - agentic_app_id=DEFAULTS.agentic_instance_id, - role=non_agentic_role, - ), - ) - context = self.TurnContext(mocker, activity=activity) - assert AgenticUserAuthorization.get_agent_instance_id(context) is None - - def test_get_agentic_user_is_agentic(self, mocker, agentic_role): - activity = Activity( - type="message", - recipient=ChannelAccount( - id=DEFAULTS.agentic_user_id, - agentic_app_id=DEFAULTS.agentic_instance_id, - role=agentic_role, - ), - ) - context = self.TurnContext(mocker, activity=activity) - assert ( - AgenticUserAuthorization.get_agentic_user(context) == DEFAULTS.agentic_user_id - ) - - def test_get_agentic_user_not_agentic(self, mocker, non_agentic_role): - activity = Activity( - type="message", - recipient=ChannelAccount( - id=DEFAULTS.agentic_user_id, - agentic_app_id=DEFAULTS.agentic_instance_id, - role=non_agentic_role, - ), - ) - context = self.TurnContext(mocker, activity=activity) - assert AgenticUserAuthorization.get_agentic_user(context) is None - @pytest.mark.asyncio async def test_get_agentic_instance_token_not_agentic( self, mocker, non_agentic_role, agentic_auth diff --git a/tests/hosting_core/test_turn_context.py b/tests/hosting_core/test_turn_context.py index 01305139..aad00067 100644 --- a/tests/hosting_core/test_turn_context.py +++ b/tests/hosting_core/test_turn_context.py @@ -1,3 +1,4 @@ +from annotated_types import T import pytest from typing import Callable, List @@ -419,4 +420,4 @@ async def aux_func( await context.send_trace_activity( "name-text", "value-text", "valueType-text", "label-text" ) - assert called + assert called \ No newline at end of file From f7918270383e45f67e1738e83789033bee2e8e69 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 1 Oct 2025 15:51:32 -0700 Subject: [PATCH 32/36] Addressing PR review comments --- .../authentication/msal/msal_auth.py | 40 ++++++++++++++----- .../hosting/core/app/agent_application.py | 1 - .../_handlers/agentic_user_authorization.py | 8 +++- .../rest_channel_service_client_factory.py | 9 ++++- 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 11911de3..778f89fd 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -211,6 +211,7 @@ async def get_agentic_application_token( if not agent_app_instance_id: raise ValueError("Agent application instance Id must be provided.") + logger.info("Attempting to get agentic application token from agent_app_instance_id %s", agent_app_instance_id) msal_auth_client = self._create_client_application() if isinstance(msal_auth_client, ConfidentialClientApplication): @@ -240,10 +241,15 @@ async def get_agentic_instance_token( if not agent_app_instance_id: raise ValueError("Agent application instance Id must be provided.") + logger.info("Attempting to get agentic instance token from agent_app_instance_id %s", agent_app_instance_id) agent_token_result = await self.get_agentic_application_token( agent_app_instance_id ) + if not agent_token_result: + logger.error("Failed to acquire agentic instance token or agent token for agent_app_instance_id %s", agent_app_instance_id) + raise Exception(f"Failed to acquire agentic instance token or agent token for agent_app_instance_id {agent_app_instance_id}") + authority = ( f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}" ) @@ -254,25 +260,26 @@ async def get_agentic_instance_token( client_credential={"client_assertion": agent_token_result}, ) - agent_instance_token = instance_app.acquire_token_for_client( + agentic_instance_token = instance_app.acquire_token_for_client( ["api://AzureAdTokenExchange/.default"] ) - assert agent_instance_token - assert agent_token_result + if not agentic_instance_token: + logger.error("Failed to acquire agentic instance token or agent token for agent_app_instance_id %s", agent_app_instance_id) + raise Exception(f"Failed to acquire agentic instance token or agent token for agent_app_instance_id {agent_app_instance_id}") # future scenario where we don't know the blueprint id upfront - token = agent_instance_token.get("access_token") + token = agentic_instance_token.get("access_token") if not token: - logger.error("Failed to acquire agentic instance token, %s", agent_instance_token) - raise ValueError(f"Failed to acquire token. {str(agent_instance_token)}") + logger.error("Failed to acquire agentic instance token, %s", agentic_instance_token) + raise ValueError(f"Failed to acquire token. {str(agentic_instance_token)}") payload = jwt.decode(token, options={"verify_signature": False}) agentic_blueprint_id = payload.get("xms_par_app_azp") logger.debug("Agentic blueprint id: %s", agentic_blueprint_id) - return agent_instance_token["access_token"], agent_token_result + return agentic_instance_token["access_token"], agent_token_result async def get_agentic_user_token( self, agent_app_instance_id: str, upn: str, scopes: list[str] @@ -288,16 +295,20 @@ async def get_agentic_user_token( :return: The agentic user token, or None if not found. :rtype: Optional[str] """ - breakpoint() if not agent_app_instance_id or not upn: raise ValueError( "Agent application instance Id and user principal name must be provided." ) + logger.info("Attempting to get agentic user token from agent_app_instance_id %s and upn %s", agent_app_instance_id, upn) instance_token, agent_token = await self.get_agentic_instance_token( agent_app_instance_id ) + if not instance_token or not agent_token: + logger.error("Failed to acquire instance token or agent token for agent_app_instance_id %s and upn %s", agent_app_instance_id, upn) + raise Exception(f"Failed to acquire instance token or agent token for agent_app_instance_id {agent_app_instance_id} and upn {upn}") + authority = ( f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}" ) @@ -308,6 +319,7 @@ async def get_agentic_user_token( client_credential={"client_assertion": agent_token}, ) + logger.info("Acquiring agentic user token for agent_app_instance_id %s and upn %s", agent_app_instance_id, upn) auth_result_payload = instance_app.acquire_token_for_client( scopes, data={ @@ -317,4 +329,14 @@ async def get_agentic_user_token( }, ) - return auth_result_payload.get("access_token") if auth_result_payload else None + if not auth_result_payload: + logger.error("Failed to acquire agentic user token for agent_app_instance_id %s and upn %s, %s", agent_app_instance_id, upn, auth_result_payload) + return None + + access_token = auth_result_payload.get("access_token") + if not access_token: + logger.error("Failed to acquire agentic user token for agent_app_instance_id %s and upn %s, %s", agent_app_instance_id, upn, auth_result_payload) + return None + + logger.info("Acquired agentic user token response.") + return access_token diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index 4bebf48e..803e4193 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -31,7 +31,6 @@ MessageUpdateTypes, InvokeResponse, ) -from microsoft_agents.hosting.core.app.oauth._handlers.agentic_user_authorization import AgenticUserAuthorization from ..turn_context import TurnContext from ..agent import Agent diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py index d5f7c544..db096a07 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py @@ -87,20 +87,26 @@ async def get_agentic_user_token( :return: The agentic user token, or None if not an agentic request or no user. :rtype: Optional[str] """ + logger.info("Retrieving agentic user token for scopes: %s", scopes) if not context.activity.is_agentic_request() or not context.activity.get_agentic_user(): return TokenResponse() assert context.identity if self._alt_blueprint_name: + logger.debug("Using alternative blueprint name for agentic user token retrieval: %s", self._alt_blueprint_name) connection = self._connection_manager.get_connection(self._alt_blueprint_name) else: + logger.debug("Using connection manager for agentic user token retrieval with handler id: %s", self._id) connection = self._connection_manager.get_token_provider( context.identity, "agentic" ) upn = context.activity.get_agentic_user() agentic_instance_id = context.activity.get_agentic_instance_id() - assert upn and agentic_instance_id + if not upn or not agentic_instance_id: + logger.error("Unable to retrieve agentic user token: missing UPN or agentic instance ID. UPN: %s, Agentic Instance ID: %s", upn, agentic_instance_id) + raise ValueError(f"Unable to retrieve agentic user token: missing UPN or agentic instance ID. UPN: {upn}, Agentic Instance ID: {agentic_instance_id}") + token = await connection.get_agentic_user_token(agentic_instance_id, upn, scopes) return TokenResponse(token=token) if token else TokenResponse() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py index 7353ec43..6c1c33f7 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py @@ -1,5 +1,6 @@ import re from typing import Optional +import logging from microsoft_agents.activity import RoleTypes from microsoft_agents.hosting.core.authorization import ( @@ -16,6 +17,7 @@ from .channel_service_client_factory_base import ChannelServiceClientFactoryBase from .turn_context import TurnContext +logger = logging.getLogger(__name__) class RestChannelServiceClientFactory(ChannelServiceClientFactoryBase): _ANONYMOUS_TOKEN_PROVIDER = AnonymousTokenProvider() @@ -49,15 +51,18 @@ async def create_connector_client( ) if context.activity.is_agentic_request(): - - # breakpoint() + logger.info("Creating connector client for agentic request to service_url: %s", service_url) if not context.identity: raise ValueError("context.identity is required for agentic activities") connection = self._connection_manager.get_token_provider(context.identity, service_url) + + # TODO: clean up linter if connection._msal_configuration.ALT_BLUEPRINT_ID: + logger.debug("Using alternative blueprint ID for agentic token retrieval: %s", connection._msal_configuration.ALT_BLUEPRINT_ID) connection = self._connection_manager.get_connection(connection._msal_configuration.ALT_BLUEPRINT_ID) + agent_instance_id = context.activity.get_agentic_instance_id() if not agent_instance_id: raise ValueError("Agent instance ID is required for agentic identity role") From a82c1f7df99a81fff5d3580f0ea59c3f2777d198 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 1 Oct 2025 16:01:31 -0700 Subject: [PATCH 33/36] Reformatting files with black --- .../activity/_load_configuration.py | 1 + .../microsoft_agents/activity/activity.py | 5 +- .../activity/token_response.py | 2 +- .../authentication/msal/msal_auth.py | 74 +++-- .../msal/msal_connection_manager.py | 45 ++- .../hosting/aiohttp/cloud_adapter.py | 4 +- .../hosting/core/app/agent_application.py | 8 +- .../hosting/core/app/oauth/__init__.py | 2 +- .../core/app/oauth/_handlers/__init__.py | 2 +- .../oauth/_handlers/_authorization_handler.py | 18 +- .../oauth/_handlers/_user_authorization.py | 32 ++- .../_handlers/agentic_user_authorization.py | 65 +++-- .../hosting/core/app/oauth/_sign_in_state.py | 4 +- .../hosting/core/app/oauth/auth_handler.py | 7 +- .../hosting/core/app/oauth/authorization.py | 46 ++-- .../authorization/agent_auth_configuration.py | 13 + .../authorization/anonymous_token_provider.py | 8 +- .../authorization/authentication_constants.py | 1 - .../hosting/core/channel_service_adapter.py | 3 +- .../rest_channel_service_client_factory.py | 49 ++-- .../hosting/core/turn_context.py | 2 +- tests/_common/create_env_var_dict.py | 5 +- tests/_common/data/configs/__init__.py | 7 +- .../data/configs/test_agentic_auth_config.py | 1 + tests/_common/mock_utils.py | 7 +- .../mocks/mock_authorization.py | 27 +- .../testing_objects/mocks/mock_oauth_flow.py | 4 +- tests/activity/test_activity.py | 12 +- tests/activity/test_load_configuration.py | 16 +- tests/authentication_msal/_data.py | 33 +-- .../test_msal_connection_manager.py | 59 ++-- .../_oauth/test_flow_storage_client.py | 3 +- .../test_agentic_user_authorization.py | 118 ++++++-- .../_handlers/test_user_authorization.py | 202 ++++++++------ .../app/oauth/test_auth_handler.py | 12 +- .../app/oauth/test_authorization.py | 256 +++++++++++++----- .../app/oauth/test_sign_in_response.py | 1 + .../app/test_agent_application.py | 7 +- tests/hosting_core/test_turn_context.py | 2 +- 39 files changed, 786 insertions(+), 377 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/_load_configuration.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/_load_configuration.py index 3cbd27f4..2a598f76 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/_load_configuration.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/_load_configuration.py @@ -1,5 +1,6 @@ from typing import Any + def load_configuration_from_env(env_vars: dict[str, Any]) -> dict: """ Parses environment variables and returns a dictionary with the relevant configuration. diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 839ac3dd..78e8ac7d 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -24,6 +24,7 @@ from ._model_utils import pick_model, SkipNone from ._type_aliases import NonEmptyString + # TODO: A2A Agent 2 is responding with None as id, had to mark it as optional (investigate) class Activity(AgentsModel): """An Activity is the basic communication type for the protocol. @@ -654,7 +655,7 @@ def is_agentic_request(self) -> bool: RoleTypes.agentic_identity, RoleTypes.agentic_user, ] - + def get_agentic_instance_id(self) -> Optional[str]: """Gets the agent instance ID from the context if it's an agentic request.""" if not self.is_agentic_request() or not self.recipient: @@ -665,4 +666,4 @@ def get_agentic_user(self) -> Optional[str]: """Gets the agentic user (UPN) from the context if it's an agentic request.""" if not self.is_agentic_request() or not self.recipient: return None - return self.recipient.id \ No newline at end of file + return self.recipient.id diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/token_response.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/token_response.py index 9b80841e..e4cbd232 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/token_response.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/token_response.py @@ -43,4 +43,4 @@ def is_exchangeable(self) -> bool: aud = payload.get("aud") return isinstance(aud, str) and aud.startswith("api://") except Exception: - return False \ No newline at end of file + return False diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 778f89fd..839aacd8 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -62,9 +62,7 @@ async def get_access_token( res = auth_result_payload.get("access_token") if auth_result_payload else None if not res: - logger.error( - "Failed to acquire token for resource %s", auth_result_payload - ) + logger.error("Failed to acquire token for resource %s", auth_result_payload) raise ValueError(f"Failed to acquire token. {str(auth_result_payload)}") return res @@ -211,7 +209,10 @@ async def get_agentic_application_token( if not agent_app_instance_id: raise ValueError("Agent application instance Id must be provided.") - logger.info("Attempting to get agentic application token from agent_app_instance_id %s", agent_app_instance_id) + logger.info( + "Attempting to get agentic application token from agent_app_instance_id %s", + agent_app_instance_id, + ) msal_auth_client = self._create_client_application() if isinstance(msal_auth_client, ConfidentialClientApplication): @@ -241,14 +242,22 @@ async def get_agentic_instance_token( if not agent_app_instance_id: raise ValueError("Agent application instance Id must be provided.") - logger.info("Attempting to get agentic instance token from agent_app_instance_id %s", agent_app_instance_id) + logger.info( + "Attempting to get agentic instance token from agent_app_instance_id %s", + agent_app_instance_id, + ) agent_token_result = await self.get_agentic_application_token( agent_app_instance_id ) if not agent_token_result: - logger.error("Failed to acquire agentic instance token or agent token for agent_app_instance_id %s", agent_app_instance_id) - raise Exception(f"Failed to acquire agentic instance token or agent token for agent_app_instance_id {agent_app_instance_id}") + logger.error( + "Failed to acquire agentic instance token or agent token for agent_app_instance_id %s", + agent_app_instance_id, + ) + raise Exception( + f"Failed to acquire agentic instance token or agent token for agent_app_instance_id {agent_app_instance_id}" + ) authority = ( f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}" @@ -265,14 +274,21 @@ async def get_agentic_instance_token( ) if not agentic_instance_token: - logger.error("Failed to acquire agentic instance token or agent token for agent_app_instance_id %s", agent_app_instance_id) - raise Exception(f"Failed to acquire agentic instance token or agent token for agent_app_instance_id {agent_app_instance_id}") + logger.error( + "Failed to acquire agentic instance token or agent token for agent_app_instance_id %s", + agent_app_instance_id, + ) + raise Exception( + f"Failed to acquire agentic instance token or agent token for agent_app_instance_id {agent_app_instance_id}" + ) # future scenario where we don't know the blueprint id upfront token = agentic_instance_token.get("access_token") if not token: - logger.error("Failed to acquire agentic instance token, %s", agentic_instance_token) + logger.error( + "Failed to acquire agentic instance token, %s", agentic_instance_token + ) raise ValueError(f"Failed to acquire token. {str(agentic_instance_token)}") payload = jwt.decode(token, options={"verify_signature": False}) @@ -300,14 +316,24 @@ async def get_agentic_user_token( "Agent application instance Id and user principal name must be provided." ) - logger.info("Attempting to get agentic user token from agent_app_instance_id %s and upn %s", agent_app_instance_id, upn) + logger.info( + "Attempting to get agentic user token from agent_app_instance_id %s and upn %s", + agent_app_instance_id, + upn, + ) instance_token, agent_token = await self.get_agentic_instance_token( agent_app_instance_id ) if not instance_token or not agent_token: - logger.error("Failed to acquire instance token or agent token for agent_app_instance_id %s and upn %s", agent_app_instance_id, upn) - raise Exception(f"Failed to acquire instance token or agent token for agent_app_instance_id {agent_app_instance_id} and upn {upn}") + logger.error( + "Failed to acquire instance token or agent token for agent_app_instance_id %s and upn %s", + agent_app_instance_id, + upn, + ) + raise Exception( + f"Failed to acquire instance token or agent token for agent_app_instance_id {agent_app_instance_id} and upn {upn}" + ) authority = ( f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}" @@ -319,7 +345,11 @@ async def get_agentic_user_token( client_credential={"client_assertion": agent_token}, ) - logger.info("Acquiring agentic user token for agent_app_instance_id %s and upn %s", agent_app_instance_id, upn) + logger.info( + "Acquiring agentic user token for agent_app_instance_id %s and upn %s", + agent_app_instance_id, + upn, + ) auth_result_payload = instance_app.acquire_token_for_client( scopes, data={ @@ -330,13 +360,23 @@ async def get_agentic_user_token( ) if not auth_result_payload: - logger.error("Failed to acquire agentic user token for agent_app_instance_id %s and upn %s, %s", agent_app_instance_id, upn, auth_result_payload) + logger.error( + "Failed to acquire agentic user token for agent_app_instance_id %s and upn %s, %s", + agent_app_instance_id, + upn, + auth_result_payload, + ) return None access_token = auth_result_payload.get("access_token") if not access_token: - logger.error("Failed to acquire agentic user token for agent_app_instance_id %s and upn %s, %s", agent_app_instance_id, upn, auth_result_payload) + logger.error( + "Failed to acquire agentic user token for agent_app_instance_id %s and upn %s, %s", + agent_app_instance_id, + upn, + auth_result_payload, + ) return None - + logger.info("Acquired agentic user token response.") return access_token diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py index f10283e8..aea4163a 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py @@ -11,12 +11,26 @@ class MsalConnectionManager(Connections): + _connections: Dict[str, MsalAuth] + _connections_map: List[Dict[str, str]] + _service_connection_configuration: AgentAuthConfiguration + def __init__( self, - connections_configurations: Dict[str, AgentAuthConfiguration] = None, - connections_map: List[Dict[str, str]] = None, - **kwargs + connections_configurations: Optional[Dict[str, AgentAuthConfiguration]] = None, + connections_map: Optional[List[Dict[str, str]]] = None, + **kwargs, ): + """ + Initialize the MSAL connection manager. + + :arg connections_configurations: A dictionary of connection configurations. + :type connections_configurations: Dict[str, AgentAuthConfiguration] + :arg connections_map: A list of connection mappings. + :type connections_map: List[Dict[str, str]] + :raises ValueError: If no service connection configuration is provided. + """ + self._connections: Dict[str, MsalAuth] = {} self._connections_map = connections_map or kwargs.get("CONNECTIONSMAP", {}) self._service_connection_configuration: AgentAuthConfiguration = None @@ -45,13 +59,20 @@ def __init__( def get_connection(self, connection_name: Optional[str]) -> AccessTokenProviderBase: """ Get the OAuth connection for the agent. + + :arg connection_name: The name of the connection. + :type connection_name: str + :return: The OAuth connection for the agent. + :rtype: AccessTokenProviderBase """ + # should never be None return self._connections.get(connection_name, None) def get_default_connection(self) -> AccessTokenProviderBase: """ Get the default OAuth connection for the agent. """ + # should never be None return self._connections.get("SERVICE_CONNECTION", None) def get_token_provider( @@ -59,13 +80,23 @@ def get_token_provider( ) -> AccessTokenProviderBase: """ Get the OAuth token provider for the agent. + + :arg claims_identity: The claims identity of the bot. + :type claims_identity: ClaimsIdentity + :arg service_url: The service URL of the bot. + :type service_url: str + :return: The OAuth token provider for the agent. + :rtype: AccessTokenProviderBase + :raises ValueError: If no connection is found for the given audience and service URL. """ if not claims_identity or not service_url: - raise ValueError("Claims identity and Service URL are required to get the token provider.") + raise ValueError( + "Claims identity and Service URL are required to get the token provider." + ) if not self._connections_map: return self.get_default_connection() - + aud = claims_identity.get_app_id() or "" for item in self._connections_map: audience_match = True @@ -80,7 +111,7 @@ def get_token_provider( connection = self.get_connection(connection_name) if connection: return connection - + else: res = re.match(item_service_url, service_url, re.IGNORECASE) if res: @@ -88,7 +119,7 @@ def get_token_provider( connection = self.get_connection(connection_name) if connection: return connection - + raise ValueError( f"No connection found for audience '{aud}' and serviceUrl '{service_url}'." ) diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py index 928cfbd1..1ef106c3 100644 --- a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py +++ b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py @@ -84,7 +84,9 @@ async def process(self, request: Request, agent: Agent) -> Optional[Response]: activity: Activity = Activity.model_validate(body) # default to anonymous identity with no claims - claims_identity: ClaimsIdentity = request.get("claims_identity", ClaimsIdentity({}, False)) + claims_identity: ClaimsIdentity = request.get( + "claims_identity", ClaimsIdentity({}, False) + ) # A POST request must contain an Activity if ( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py index 803e4193..4bf974a7 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py @@ -606,7 +606,10 @@ async def _on_turn(self, context: TurnContext): logger.debug("Initializing turn state") turn_state = await self._initialize_state(context) - if context.activity.type == ActivityTypes.message or context.activity.type == ActivityTypes.invoke: + if ( + context.activity.type == ActivityTypes.message + or context.activity.type == ActivityTypes.invoke + ): ( auth_intercepts, @@ -617,7 +620,8 @@ async def _on_turn(self, context: TurnContext): new_context = copy(context) new_context.activity = continuation_activity logger.info( - "Resending continuation activity %s", continuation_activity.text + "Resending continuation activity %s", + continuation_activity.text, ) await self.on_turn(new_context) await turn_state.save(context) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py index a1a9bda2..7fe3948d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py @@ -5,7 +5,7 @@ from ._handlers import ( _UserAuthorization, AgenticUserAuthorization, - _AuthorizationHandler + _AuthorizationHandler, ) __all__ = [ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/__init__.py index dd3e30a3..05cf6dba 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/__init__.py @@ -6,4 +6,4 @@ "AgenticUserAuthorization", "_UserAuthorization", "_AuthorizationHandler", -] \ No newline at end of file +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py index 87e4b08c..eba18b5d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py @@ -44,7 +44,9 @@ def __init__( if not storage: raise ValueError("Storage is required for Authorization") if not auth_handler and not auth_handler_settings: - raise ValueError("At least one of auth_handler or auth_handler_settings is required.") + raise ValueError( + "At least one of auth_handler or auth_handler_settings is required." + ) self._storage = storage self._connection_manager = connection_manager @@ -56,7 +58,9 @@ def __init__( self._id = auth_handler_id or self._handler.name if not self._id: - raise ValueError("Auth handler must have an ID. Could not be deduced from settings or constructor args.") + raise ValueError( + "Auth handler must have an ID. Could not be deduced from settings or constructor args." + ) async def _sign_in( self, context: TurnContext, scopes: Optional[list[str]] = None @@ -71,12 +75,15 @@ async def _sign_in( :rtype: SignInResponse """ raise NotImplementedError() - + async def get_refreshed_token( - self, context: TurnContext, exchange_connection: Optional[str]=None, exchange_scopes: Optional[list[str]] = None + self, + context: TurnContext, + exchange_connection: Optional[str] = None, + exchange_scopes: Optional[list[str]] = None, ) -> TokenResponse: """Attempts to get a refreshed token for the user with the given scopes - + :param context: The turn context for the current turn of conversation. :type context: TurnContext :param exchange_connection: Optional name of the connection to use for token exchange. If None, default connection will be used. @@ -95,4 +102,3 @@ async def _sign_out(self, context: TurnContext) -> None: :type auth_handler_id: Optional[str] """ raise NotImplementedError() - diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py index 34bc081a..1083d240 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py @@ -11,7 +11,7 @@ ActionTypes, CardAction, OAuthCard, - TokenResponse + TokenResponse, ) from microsoft_agents.hosting.core.card_factory import CardFactory @@ -23,7 +23,7 @@ _FlowResponse, _FlowState, _FlowStorageClient, - _FlowStateTag + _FlowStateTag, ) from .._sign_in_response import _SignInResponse from ._authorization_handler import _AuthorizationHandler @@ -88,7 +88,7 @@ async def _load_flow( flow = _OAuthFlow(flow_state, user_token_client) return flow, flow_storage_client - + async def _handle_obo( self, context: TurnContext, @@ -111,22 +111,22 @@ async def _handle_obo( """ if not input_token_response: return input_token_response - + token = input_token_response.token - + connection_name = exchange_connection or self._handler.obo_connection_name exchange_scopes = exchange_scopes or self._handler.scopes if not connection_name or not exchange_scopes: return input_token_response - + if not input_token_response.is_exchangeable(): return input_token_response - + token_provider = self._connection_manager.get_connection(connection_name) if not token_provider: raise ValueError(f"Connection '{connection_name}' not found") - + token = await token_provider.acquire_token_on_behalf_of( scopes=exchange_scopes, user_assertion=input_token_response.token, @@ -194,7 +194,10 @@ async def _handle_flow_response( await context.send_activity("Sign-in failed. Please try again.") async def _sign_in( - self, context: TurnContext, exchange_connection: Optional[str] = None, exchange_scopes: Optional[list[str]] = None + self, + context: TurnContext, + exchange_connection: Optional[str] = None, + exchange_scopes: Optional[list[str]] = None, ) -> _SignInResponse: """Begins or continues an OAuth flow. @@ -228,16 +231,19 @@ async def _sign_in( return _SignInResponse( token_response=token_response, - tag=_FlowStateTag.COMPLETE if token_response else _FlowStateTag.FAILURE + tag=_FlowStateTag.COMPLETE if token_response else _FlowStateTag.FAILURE, ) return _SignInResponse(tag=flow_response.flow_state.tag) async def get_refreshed_token( - self, context: TurnContext, exchange_connection: Optional[str] = None, exchange_scopes: Optional[list[str]] = None + self, + context: TurnContext, + exchange_connection: Optional[str] = None, + exchange_scopes: Optional[list[str]] = None, ) -> TokenResponse: """Attempts to get a refreshed token for the user with the given scopes - + :param context: The turn context for the current turn of conversation. :type context: TurnContext :param exchange_connection: Optional name of the connection to use for token exchange. If None, default connection will be used. @@ -252,4 +258,4 @@ async def get_refreshed_token( input_token_response, exchange_connection, exchange_scopes, - ) \ No newline at end of file + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py index db096a07..133d8145 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py @@ -47,8 +47,9 @@ def __init__( auth_handler_settings=auth_handler_settings, **kwargs, ) - self._alt_blueprint_name = auth_handler._alt_blueprint_name if auth_handler else None - + self._alt_blueprint_name = ( + auth_handler._alt_blueprint_name if auth_handler else None + ) async def get_agentic_instance_token(self, context: TurnContext) -> TokenResponse: """Gets the agentic instance token for the current agent instance. @@ -71,9 +72,9 @@ async def get_agentic_instance_token(self, context: TurnContext) -> TokenRespons instance_token, _ = await connection.get_agentic_instance_token( agentic_instance_id ) - return TokenResponse(token=instance_token) if instance_token else TokenResponse() - - + return ( + TokenResponse(token=instance_token) if instance_token else TokenResponse() + ) async def get_agentic_user_token( self, context: TurnContext, scopes: list[str] @@ -89,25 +90,44 @@ async def get_agentic_user_token( """ logger.info("Retrieving agentic user token for scopes: %s", scopes) - if not context.activity.is_agentic_request() or not context.activity.get_agentic_user(): + if ( + not context.activity.is_agentic_request() + or not context.activity.get_agentic_user() + ): return TokenResponse() assert context.identity if self._alt_blueprint_name: - logger.debug("Using alternative blueprint name for agentic user token retrieval: %s", self._alt_blueprint_name) - connection = self._connection_manager.get_connection(self._alt_blueprint_name) + logger.debug( + "Using alternative blueprint name for agentic user token retrieval: %s", + self._alt_blueprint_name, + ) + connection = self._connection_manager.get_connection( + self._alt_blueprint_name + ) else: - logger.debug("Using connection manager for agentic user token retrieval with handler id: %s", self._id) + logger.debug( + "Using connection manager for agentic user token retrieval with handler id: %s", + self._id, + ) connection = self._connection_manager.get_token_provider( context.identity, "agentic" ) upn = context.activity.get_agentic_user() agentic_instance_id = context.activity.get_agentic_instance_id() if not upn or not agentic_instance_id: - logger.error("Unable to retrieve agentic user token: missing UPN or agentic instance ID. UPN: %s, Agentic Instance ID: %s", upn, agentic_instance_id) - raise ValueError(f"Unable to retrieve agentic user token: missing UPN or agentic instance ID. UPN: {upn}, Agentic Instance ID: {agentic_instance_id}") + logger.error( + "Unable to retrieve agentic user token: missing UPN or agentic instance ID. UPN: %s, Agentic Instance ID: %s", + upn, + agentic_instance_id, + ) + raise ValueError( + f"Unable to retrieve agentic user token: missing UPN or agentic instance ID. UPN: {upn}, Agentic Instance ID: {agentic_instance_id}" + ) - token = await connection.get_agentic_user_token(agentic_instance_id, upn, scopes) + token = await connection.get_agentic_user_token( + agentic_instance_id, upn, scopes + ) return TokenResponse(token=token) if token else TokenResponse() async def _sign_in( @@ -127,18 +147,23 @@ async def _sign_in( :return: A _SignInResponse containing the token response and flow state tag. :rtype: _SignInResponse """ - token_response = await self.get_refreshed_token(context, exchange_connection, exchange_scopes) + token_response = await self.get_refreshed_token( + context, exchange_connection, exchange_scopes + ) if token_response: - return _SignInResponse(token_response=token_response, tag=_FlowStateTag.COMPLETE) + return _SignInResponse( + token_response=token_response, tag=_FlowStateTag.COMPLETE + ) return _SignInResponse(tag=_FlowStateTag.FAILURE) - async def get_refreshed_token(self, + async def get_refreshed_token( + self, context: TurnContext, exchange_connection: Optional[str] = None, - exchange_scopes: Optional[list[str]] = None + exchange_scopes: Optional[list[str]] = None, ) -> TokenResponse: """Attempts to get a refreshed token for the user with the given scopes - + :param context: The turn context for the current turn of conversation. :type context: TurnContext :param exchange_connection: Optional name of the connection to use for token exchange. If None, default connection will be used. @@ -150,5 +175,7 @@ async def get_refreshed_token(self, exchange_scopes = self._handler.scopes or [] return await self.get_agentic_user_token(context, exchange_scopes) - async def sign_out(self, context: TurnContext, auth_handler_id: Optional[str] = None) -> None: - """Nothing to do for agentic sign out.""" \ No newline at end of file + async def sign_out( + self, context: TurnContext, auth_handler_id: Optional[str] = None + ) -> None: + """Nothing to do for agentic sign out.""" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_sign_in_state.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_sign_in_state.py index 4422fba1..9ade2c80 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_sign_in_state.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_sign_in_state.py @@ -31,4 +31,6 @@ def store_item_to_json(self) -> JSON: @staticmethod def from_json_to_store_item(json_data: JSON) -> _SignInState: - return _SignInState(json_data["active_handler_id"], json_data.get("continuation_activity")) \ No newline at end of file + return _SignInState( + json_data["active_handler_id"], json_data.get("continuation_activity") + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py index ed702dec..8e298107 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py @@ -13,6 +13,7 @@ class AuthHandler: """ Interface defining an authorization handler for OAuth flows. """ + name: str title: str text: str @@ -66,11 +67,11 @@ def __init__( else: self.scopes = AuthHandler._format_scopes(kwargs.get("SCOPES", "")) self._alt_blueprint_name = kwargs.get("ALT_BLUEPRINT_NAME", None) - + @staticmethod def _format_scopes(scopes: str) -> list[str]: lst = scopes.strip().split(" ") - return [ s for s in lst if s ] + return [s for s in lst if s] @staticmethod def _from_settings(settings: dict): @@ -93,4 +94,4 @@ def _from_settings(settings: dict): obo_connection_name=settings.get("OBOCONNECTIONNAME", ""), auth_type=settings.get("TYPE", ""), scopes=AuthHandler._format_scopes(settings.get("SCOPES", "")), - ) \ No newline at end of file + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index 953fd40d..e105ccf4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -16,7 +16,7 @@ from ._handlers import ( AgenticUserAuthorization, _UserAuthorization, - _AuthorizationHandler + _AuthorizationHandler, ) logger = logging.getLogger(__name__) @@ -26,6 +26,7 @@ "agenticuserauthorization": AgenticUserAuthorization, } + class Authorization: """Class responsible for managing authorization flows.""" @@ -72,11 +73,10 @@ def __init__( self._handlers = {} if not auth_handlers: - + # get from config auth_configuration: dict = kwargs.get("AGENTAPPLICATION", {}).get( "USERAUTHORIZATION", {} ) - handlers_config: dict[str, dict] = auth_configuration.get("HANDLERS") if not auth_handlers and handlers_config: auth_handlers = { @@ -85,7 +85,7 @@ def __init__( ) for handler_name, config in handlers_config.items() } - + self._handler_settings = auth_handlers # operations default to the first handler if none specified @@ -106,7 +106,7 @@ def _init_handlers(self) -> None: auth_type = auth_handler.auth_type if auth_type not in AUTHORIZATION_TYPE_MAP: raise ValueError(f"Auth type {auth_type} not recognized.") - + self._handlers[name] = AUTHORIZATION_TYPE_MAP[auth_type]( storage=self._storage, connection_manager=self._connection_manager, @@ -153,7 +153,7 @@ def _get_cached_token( context: TurnContext, handler_id: str ) -> Optional[TokenResponse]: key = Authorization._cache_key(context, handler_id) - return context.turn_state.get(key) + return cast(Optional[TokenResponse], context.turn_state.get(key)) @staticmethod def _cache_token( @@ -161,11 +161,9 @@ def _cache_token( ) -> None: key = Authorization._cache_key(context, handler_id) context.turn_state[key] = token_response - + @staticmethod - def _delete_cached_token( - context: TurnContext, handler_id: str - ) -> None: + def _delete_cached_token(context: TurnContext, handler_id: str) -> None: key = Authorization._cache_key(context, handler_id) if key in context.turn_state: del context.turn_state[key] @@ -186,7 +184,10 @@ def _resolve_handler(self, handler_id: str) -> _AuthorizationHandler: return self._handlers[handler_id] async def _start_or_continue_sign_in( - self, context: TurnContext, state: TurnState, auth_handler_id: Optional[str] = None + self, + context: TurnContext, + state: TurnState, + auth_handler_id: Optional[str] = None, ) -> _SignInResponse: """Start or continue the sign-in process for the user with the given auth handler. @@ -222,7 +223,9 @@ async def _start_or_continue_sign_in( if self._sign_in_success_handler: await self._sign_in_success_handler(context, state, auth_handler_id) await self._delete_sign_in_state(context) - Authorization._cache_token(context, auth_handler_id, sign_in_response.token_response) + Authorization._cache_token( + context, auth_handler_id, sign_in_response.token_response + ) elif sign_in_response.tag == _FlowStateTag.FAILURE: if self._sign_in_failure_handler: @@ -314,7 +317,7 @@ async def exchange_token( exchange_connection: Optional[str] = None, ) -> TokenResponse: """Exchanges or refreshes the token for a specific auth handler or the default handler. - + :param context: The context object for the current turn. :type context: TurnContext :param scopes: The scopes to request during the token exchange or refresh. Defaults @@ -330,20 +333,20 @@ async def exchange_token( :rtype: TokenResponse :raises ValueError: If the specified auth handler ID is not recognized or not configured. """ - + auth_handler_id = auth_handler_id or self._default_handler_id if auth_handler_id not in self._handlers: raise ValueError( f"Auth handler {auth_handler_id} not recognized or not configured." ) - + cached_token = Authorization._get_cached_token(context, auth_handler_id) if cached_token: handler = self._resolve_handler(auth_handler_id) - - # for later -> parity with .NET + + # TODO: for later -> parity with .NET # token_res = sign_in_state.tokens[auth_handler_id] # if not context.activity.is_agentic_request(): # if token_res and not token_res.is_exchangeable(): @@ -352,13 +355,14 @@ async def exchange_token( # diff = token.expiration - datetime.now().timestamp() # if diff > 0: # return token_res.token - - res = await handler.get_refreshed_token(context, exchange_connection, scopes) + + res = await handler.get_refreshed_token( + context, exchange_connection, scopes + ) if res: return res return TokenResponse() - def on_sign_in_success( self, handler: Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]], @@ -379,4 +383,4 @@ def on_sign_in_failure( :param handler: The handler function to call on sign-in failure. """ - self._sign_in_failure_handler = handler \ No newline at end of file + self._sign_in_failure_handler = handler diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/agent_auth_configuration.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/agent_auth_configuration.py index 02f6198c..a6fee937 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/agent_auth_configuration.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/agent_auth_configuration.py @@ -6,6 +6,17 @@ class AgentAuthConfiguration: """ Configuration for Agent authentication. + + TENANT_ID: The tenant ID for the Azure AD. + CLIENT_ID: The client ID for the Azure AD application. + AUTH_TYPE: The type of authentication to use (microsoft_agents.hosting.core.authorization.auth_types.AuthTypes). + CLIENT_SECRET: The client secret for the Azure AD application (if using client secret authentication). + CERT_PEM_FILE: The path to the PEM file for certificate authentication (if using certificate authentication). + CERT_KEY_FILE: The path to the key file for certificate authentication (if using certificate authentication). + CONNECTION_NAME: The name of the connection + SCOPES: The scopes to request + AUTHORITY: The authority URL for the Azure AD (if different from the default).f + ALT_BLUEPRINT_ID: An optional alternative blueprint ID used when constructing a connector client. """ TENANT_ID: Optional[str] @@ -17,6 +28,7 @@ class AgentAuthConfiguration: CONNECTION_NAME: Optional[str] SCOPES: Optional[list[str]] AUTHORITY: Optional[str] + ALT_BLUEPRINT_ID: Optional[str] def __init__( self, @@ -31,6 +43,7 @@ def __init__( scopes: Optional[list[str]] = None, **kwargs: Optional[dict[str, str]], ): + self.AUTH_TYPE = auth_type or kwargs.get("AUTHTYPE", AuthTypes.client_secret) self.CLIENT_ID = client_id or kwargs.get("CLIENTID", None) self.AUTHORITY = authority or kwargs.get("AUTHORITY", None) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py index a751930d..6ed36fcf 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py @@ -13,8 +13,10 @@ async def get_access_token( self, resource_url: str, scopes: list[str], force_refresh: bool = False ) -> str: return "" - - async def acquire_token_on_behalf_of(self, scopes: list[str], user_assertion: str) -> str: + + async def acquire_token_on_behalf_of( + self, scopes: list[str], user_assertion: str + ) -> str: return "" async def get_agentic_application_token( @@ -30,4 +32,4 @@ async def get_agentic_instance_token( async def get_agentic_user_token( self, agent_app_instance_id: str, upn: str, scopes: list[str] ) -> Optional[str]: - return "" \ No newline at end of file + return "" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/authentication_constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/authentication_constants.py index 1fc30353..296a8df2 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/authentication_constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/authentication_constants.py @@ -108,4 +108,3 @@ class AuthenticationConstants(ABC): APX_GCCH_SCOPE = "6f669b9e-7701-4e2b-b624-82c9207fde26/.default" APX_DOD_SCOPE = "0a069c81-8c7c-4712-886b-9c542d673ffb/.default" APX_GALLATIN_SCOPE = "bd004c8e-5acf-4c48-8570-4e7d46b2f63b/.default" - \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py index 24a6ebd5..0fcec2cc 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py @@ -250,7 +250,6 @@ async def create_conversation( # pylint: disable=arguments-differ context.activity = create_activity - # Run the pipeline await self.run_pipeline(context, callback) @@ -352,7 +351,7 @@ async def process_activity( outgoing_audience, user_token_client, callback, - activity=activity + activity=activity, ) # Create the connector client to use for outbound requests. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py index 6c1c33f7..7c444d89 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py @@ -19,6 +19,7 @@ logger = logging.getLogger(__name__) + class RestChannelServiceClientFactory(ChannelServiceClientFactoryBase): _ANONYMOUS_TOKEN_PROVIDER = AnonymousTokenProvider() @@ -49,37 +50,57 @@ async def create_connector_client( raise TypeError( "RestChannelServiceClientFactory.create_connector_client: audience can't be None or Empty" ) - + if context.activity.is_agentic_request(): - logger.info("Creating connector client for agentic request to service_url: %s", service_url) + logger.info( + "Creating connector client for agentic request to service_url: %s", + service_url, + ) if not context.identity: raise ValueError("context.identity is required for agentic activities") - - connection = self._connection_manager.get_token_provider(context.identity, service_url) + + connection = self._connection_manager.get_token_provider( + context.identity, service_url + ) # TODO: clean up linter if connection._msal_configuration.ALT_BLUEPRINT_ID: - logger.debug("Using alternative blueprint ID for agentic token retrieval: %s", connection._msal_configuration.ALT_BLUEPRINT_ID) - connection = self._connection_manager.get_connection(connection._msal_configuration.ALT_BLUEPRINT_ID) + logger.debug( + "Using alternative blueprint ID for agentic token retrieval: %s", + connection._msal_configuration.ALT_BLUEPRINT_ID, + ) + connection = self._connection_manager.get_connection( + connection._msal_configuration.ALT_BLUEPRINT_ID + ) agent_instance_id = context.activity.get_agentic_instance_id() if not agent_instance_id: - raise ValueError("Agent instance ID is required for agentic identity role") + raise ValueError( + "Agent instance ID is required for agentic identity role" + ) if context.activity.recipient.role == RoleTypes.agentic_identity: - token, _ = await connection.get_agentic_instance_token(agent_instance_id) + token, _ = await connection.get_agentic_instance_token( + agent_instance_id + ) else: agentic_user = context.activity.get_agentic_user() if not agentic_user: raise ValueError("Agentic user is required for agentic user role") - token = await connection.get_agentic_user_token(agent_instance_id, agentic_user, [AuthenticationConstants.APX_PRODUCTION_SCOPE]) + token = await connection.get_agentic_user_token( + agent_instance_id, + agentic_user, + [AuthenticationConstants.APX_PRODUCTION_SCOPE], + ) if not token: raise ValueError("Failed to obtain token for agentic activity") else: token_provider: AccessTokenProviderBase = ( - self._connection_manager.get_token_provider(claims_identity, service_url) + self._connection_manager.get_token_provider( + claims_identity, service_url + ) if not use_anonymous else self._ANONYMOUS_TOKEN_PROVIDER ) @@ -99,12 +120,8 @@ async def create_user_token_client( if use_anonymous: return UserTokenClient(endpoint=self._token_service_endpoint, token="") - token_provider = ( - self._connection_manager.get_token_provider( - claims_identity, self._token_service_endpoint - ) - # if not use_anonymous - # else self._ANONYMOUS_TOKEN_PROVIDER + token_provider = self._connection_manager.get_token_provider( + claims_identity, self._token_service_endpoint ) token = await token_provider.get_access_token( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py index 5f024748..f39e8428 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/turn_context.py @@ -431,4 +431,4 @@ def get_mentions(activity: Activity) -> list[Mention]: if entity.type.lower() == "mention": result.append(entity) - return result \ No newline at end of file + return result diff --git a/tests/_common/create_env_var_dict.py b/tests/_common/create_env_var_dict.py index 8af02f1b..3e924bd5 100644 --- a/tests/_common/create_env_var_dict.py +++ b/tests/_common/create_env_var_dict.py @@ -3,7 +3,8 @@ def create_env_var_dict(env_raw: str) -> dict[str, str]: lines = env_raw.strip().split("\n") env = {} for line in lines: - if not line.strip(): continue + if not line.strip(): + continue key, value = line.split("=", 1) env[key.strip()] = value.strip() - return env \ No newline at end of file + return env diff --git a/tests/_common/data/configs/__init__.py b/tests/_common/data/configs/__init__.py index f37326d5..450fb5c6 100644 --- a/tests/_common/data/configs/__init__.py +++ b/tests/_common/data/configs/__init__.py @@ -1,9 +1,4 @@ from .test_auth_config import TEST_ENV_DICT, TEST_ENV from .test_agentic_auth_config import TEST_AGENTIC_ENV_DICT, TEST_AGENTIC_ENV -__all__ = [ - "TEST_ENV_DICT", - "TEST_ENV", - "TEST_AGENTIC_ENV_DICT", - "TEST_AGENTIC_ENV" -] \ No newline at end of file +__all__ = ["TEST_ENV_DICT", "TEST_ENV", "TEST_AGENTIC_ENV_DICT", "TEST_AGENTIC_ENV"] diff --git a/tests/_common/data/configs/test_agentic_auth_config.py b/tests/_common/data/configs/test_agentic_auth_config.py index f7f2e261..9de0f8bd 100644 --- a/tests/_common/data/configs/test_agentic_auth_config.py +++ b/tests/_common/data/configs/test_agentic_auth_config.py @@ -49,5 +49,6 @@ def TEST_AGENTIC_ENV(): return create_env_var_dict(_TEST_AGENTIC_ENV_RAW) + def TEST_AGENTIC_ENV_DICT(): return load_configuration_from_env(TEST_AGENTIC_ENV()) diff --git a/tests/_common/mock_utils.py b/tests/_common/mock_utils.py index c4b986c3..fec35582 100644 --- a/tests/_common/mock_utils.py +++ b/tests/_common/mock_utils.py @@ -4,11 +4,14 @@ def mock_instance(mocker, cls, methods={}, default_mock_type=None, **kwargs): default_mock_type = mocker.AsyncMock instance = mocker.Mock(spec=cls, **kwargs) for method_name, return_value in methods.items(): - if not isinstance(return_value, mocker.Mock) and not isinstance(return_value, mocker.AsyncMock): + if not isinstance(return_value, mocker.Mock) and not isinstance( + return_value, mocker.AsyncMock + ): return_value = default_mock_type(return_value=return_value) setattr(instance, method_name, return_value) return instance + def mock_class(mocker, cls, instance): """Replace a class with a mock instance.""" - mocker.patch.object(cls, new=instance) \ No newline at end of file + mocker.patch.object(cls, new=instance) diff --git a/tests/_common/testing_objects/mocks/mock_authorization.py b/tests/_common/testing_objects/mocks/mock_authorization.py index c0e05aff..4e1afdee 100644 --- a/tests/_common/testing_objects/mocks/mock_authorization.py +++ b/tests/_common/testing_objects/mocks/mock_authorization.py @@ -4,27 +4,42 @@ from microsoft_agents.hosting.core.app.oauth import ( _UserAuthorization, AgenticUserAuthorization, - _SignInResponse + _SignInResponse, ) -def mock_class_UserAuthorization(mocker, sign_in_return=None, get_refreshed_token_return=None): + +def mock_class_UserAuthorization( + mocker, sign_in_return=None, get_refreshed_token_return=None +): if sign_in_return is None: sign_in_return = _SignInResponse() if get_refreshed_token_return is None: get_refreshed_token_return = TokenResponse() mocker.patch.object(_UserAuthorization, "_sign_in", return_value=sign_in_return) mocker.patch.object(_UserAuthorization, "_sign_out") - mocker.patch.object(_UserAuthorization, "get_refreshed_token", return_value=get_refreshed_token_return) + mocker.patch.object( + _UserAuthorization, + "get_refreshed_token", + return_value=get_refreshed_token_return, + ) -def mock_class_AgenticUserAuthorization(mocker, sign_in_return=None, get_refreshed_token_return=None): +def mock_class_AgenticUserAuthorization( + mocker, sign_in_return=None, get_refreshed_token_return=None +): if sign_in_return is None: sign_in_return = _SignInResponse() if get_refreshed_token_return is None: get_refreshed_token_return = TokenResponse() - mocker.patch.object(AgenticUserAuthorization, "_sign_in", return_value=sign_in_return) + mocker.patch.object( + AgenticUserAuthorization, "_sign_in", return_value=sign_in_return + ) mocker.patch.object(AgenticUserAuthorization, "_sign_out") - mocker.patch.object(AgenticUserAuthorization, "get_refreshed_token", return_value=get_refreshed_token_return) + mocker.patch.object( + AgenticUserAuthorization, + "get_refreshed_token", + return_value=get_refreshed_token_return, + ) def mock_class_Authorization(mocker, start_or_continue_sign_in_return=False): diff --git a/tests/_common/testing_objects/mocks/mock_oauth_flow.py b/tests/_common/testing_objects/mocks/mock_oauth_flow.py index 64b5f47e..53a066d3 100644 --- a/tests/_common/testing_objects/mocks/mock_oauth_flow.py +++ b/tests/_common/testing_objects/mocks/mock_oauth_flow.py @@ -17,7 +17,9 @@ def mock_OAuthFlow( # mock_oauth_flow_class.sign_out = mocker.AsyncMock() if isinstance(get_user_token_return, str): get_user_token_return = TokenResponse(token=get_user_token_return) - mocker.patch.object(_OAuthFlow, "get_user_token", return_value=get_user_token_return) + mocker.patch.object( + _OAuthFlow, "get_user_token", return_value=get_user_token_return + ) mocker.patch.object(_OAuthFlow, "sign_out") mocker.patch.object( _OAuthFlow, "begin_or_continue_flow", return_value=begin_or_continue_flow_return diff --git a/tests/activity/test_activity.py b/tests/activity/test_activity.py index b557d382..40d695fe 100644 --- a/tests/activity/test_activity.py +++ b/tests/activity/test_activity.py @@ -373,6 +373,7 @@ def test_get_mentions(self): Entity(type="mention", text="Another mention"), ] + class TestActivityAgenticOps: @pytest.fixture(params=[RoleTypes.user, RoleTypes.skill, RoleTypes.agent]) @@ -408,10 +409,7 @@ def test_get_agentic_instance_id_is_agentic(self, mocker, agentic_role): role=agentic_role, ), ) - assert ( - activity.get_agentic_instance_id() - == DEFAULTS.agentic_instance_id - ) + assert activity.get_agentic_instance_id() == DEFAULTS.agentic_instance_id def test_get_agentic_instance_id_not_agentic(self, non_agentic_role): activity = Activity( @@ -433,9 +431,7 @@ def test_get_agentic_user_is_agentic(self, agentic_role): role=agentic_role, ), ) - assert ( - activity.get_agentic_user() == DEFAULTS.agentic_user_id - ) + assert activity.get_agentic_user() == DEFAULTS.agentic_user_id def test_get_agentic_user_not_agentic(self, non_agentic_role): activity = Activity( @@ -446,4 +442,4 @@ def test_get_agentic_user_not_agentic(self, non_agentic_role): role=non_agentic_role, ), ) - assert activity.get_agentic_user() is None \ No newline at end of file + assert activity.get_agentic_user() is None diff --git a/tests/activity/test_load_configuration.py b/tests/activity/test_load_configuration.py index b121c87a..eb9dccac 100644 --- a/tests/activity/test_load_configuration.py +++ b/tests/activity/test_load_configuration.py @@ -20,7 +20,7 @@ "CLIENTID": DEFAULTS.connections_agentic_client_id, "CLIENTSECRET": DEFAULTS.connections_agentic_client_secret, } - } + }, }, "AGENTAPPLICATION": { "USERAUTHORIZATION": { @@ -47,15 +47,9 @@ }, }, "CONNECTIONSMAP": [ - { - "CONNECTION": "SERVICE_CONNECTION", - "SERVICEURL": "*" - }, - { - "CONNECTION": "AGENTIC", - "SERVICEURL": "agentic" - } - ] + {"CONNECTION": "SERVICE_CONNECTION", "SERVICEURL": "*"}, + {"CONNECTION": "AGENTIC", "SERVICEURL": "agentic"}, + ], } ENV_RAW = """ @@ -106,4 +100,4 @@ def test_load_configuration_from_env(): input_dict = create_env_var_dict(ENV_RAW) config = load_configuration_from_env(input_dict) - assert config == ENV_DICT \ No newline at end of file + assert config == ENV_DICT diff --git a/tests/authentication_msal/_data.py b/tests/authentication_msal/_data.py index 2c5407f1..92e5ae35 100644 --- a/tests/authentication_msal/_data.py +++ b/tests/authentication_msal/_data.py @@ -4,23 +4,23 @@ "SETTINGS": { "TENANTID": "test-tenant-id-SERVICE_CONNECTION", "CLIENTID": "test-client-id-SERVICE_CONNECTION", - "CLIENTSECRET": "test-client-secret-SERVICE_CONNECTION" + "CLIENTSECRET": "test-client-secret-SERVICE_CONNECTION", } }, "AGENTIC": { "SETTINGS": { "TENANTID": "test-tenant-id-AGENTIC", "CLIENTID": "test-client-id-AGENTIC", - "CLIENTSECRET": "test-client-secret-AGENTIC" + "CLIENTSECRET": "test-client-secret-AGENTIC", } }, "MISC": { "SETTINGS": { "TENANTID": "test-tenant-id-MISC", "CLIENTID": "test-client-id-MISC", - "CLIENTSECRET": "test-client-secret-MISC" + "CLIENTSECRET": "test-client-secret-MISC", } - } + }, }, "AGENTAPPLICATION": { "USERAUTHORIZATION": { @@ -32,14 +32,14 @@ "SCOPES": ["User.Read"], "TITLE": "Sign in with Microsoft", "TEXT": "Sign in with your Microsoft account", - "TYPE": "UserAuthorization" + "TYPE": "UserAuthorization", } }, "github": { "SETTINGS": { "AZUREBOTOAUTHCONNECTIONNAME": "github", "OBOCONNECTIONNAME": "SERVICE_CONNECTION", - "TYPE": "UserAuthorization" + "TYPE": "UserAuthorization", } }, "agentic": { @@ -49,9 +49,9 @@ "SCOPES": ["https://graph.microsoft.com/.default"], "TITLE": "Sign in with Agentic", "TEXT": "Sign in with your Agentic account", - "TYPE": "AgenticUserAuthorization" + "TYPE": "AgenticUserAuthorization", } - } + }, } } }, @@ -60,11 +60,7 @@ "CONNECTION": "AGENTIC", "SERVICEURL": "agentic", }, - { - "CONNECTION": "MISC", - "AUDIENCE": "api://misc", - "SERVICEURL": "*" - }, + {"CONNECTION": "MISC", "AUDIENCE": "api://misc", "SERVICEURL": "*"}, { "CONNECTION": "MISC", "AUDIENCE": "api://misc_other", @@ -72,11 +68,8 @@ { "CONNECTION": "SERVICE_CONNECTION", "AUDIENCE": "api://service", - "SERVICEURL": "https://service*" + "SERVICEURL": "https://service*", }, - { - "CONNECTION": "MISC", - "SERVICEURL": "https://microsoft.com/*" - } - ] -} \ No newline at end of file + {"CONNECTION": "MISC", "SERVICEURL": "https://microsoft.com/*"}, + ], +} diff --git a/tests/authentication_msal/test_msal_connection_manager.py b/tests/authentication_msal/test_msal_connection_manager.py index bd73f9b9..56e9d980 100644 --- a/tests/authentication_msal/test_msal_connection_manager.py +++ b/tests/authentication_msal/test_msal_connection_manager.py @@ -11,6 +11,7 @@ from ._data import ENV_CONFIG + class TestMsalConnectionManager: """ Test suite for the Msal Connection Manager @@ -57,7 +58,7 @@ def test_init_from_config(self): [ClaimsIdentity(claims={}, is_authenticated=False), ""], [ClaimsIdentity(claims={}, is_authenticated=False), "https://example.com"], [ClaimsIdentity(claims={"aud": "api://misc"}, is_authenticated=False), ""], - ] + ], ) def test_get_token_provider_errors(self, claims_identity, service_url): connection_manager = MsalConnectionManager(**ENV_CONFIG) @@ -67,45 +68,69 @@ def test_get_token_provider_errors(self, claims_identity, service_url): def test_get_token_provider_no_map(self, config): del config["CONNECTIONSMAP"] connection_manager = MsalConnectionManager(**config) - claims_identity = ClaimsIdentity(claims={"aud": "api://misc"}, is_authenticated=True) - token_provider = connection_manager.get_token_provider(claims_identity, "https://example.com") + claims_identity = ClaimsIdentity( + claims={"aud": "api://misc"}, is_authenticated=True + ) + token_provider = connection_manager.get_token_provider( + claims_identity, "https://example.com" + ) assert token_provider == connection_manager.get_default_connection() def test_get_token_provider_aud_match(self, config): connection_manager = MsalConnectionManager(**config) - claims_identity = ClaimsIdentity(claims={"aud": "api://misc"}, is_authenticated=True) - token_provider = connection_manager.get_token_provider(claims_identity, "https://example.com") + claims_identity = ClaimsIdentity( + claims={"aud": "api://misc"}, is_authenticated=True + ) + token_provider = connection_manager.get_token_provider( + claims_identity, "https://example.com" + ) assert token_provider == connection_manager.get_connection("MISC") def test_get_token_provider_aud_and_service_url_match(self, config): connection_manager = MsalConnectionManager(**config) - claims_identity = ClaimsIdentity(claims={"aud": "api://service"}, is_authenticated=True) - token_provider = connection_manager.get_token_provider(claims_identity, "https://service.com/api") + claims_identity = ClaimsIdentity( + claims={"aud": "api://service"}, is_authenticated=True + ) + token_provider = connection_manager.get_token_provider( + claims_identity, "https://service.com/api" + ) assert token_provider == connection_manager.get_connection("SERVICE_CONNECTION") def test_get_token_provider_service_url_wildcard_star(self, config): connection_manager = MsalConnectionManager(**config) - claims_identity = ClaimsIdentity(claims={"aud": "api://misc"}, is_authenticated=False) - token_provider = connection_manager.get_token_provider(claims_identity, "https://service.com/api") + claims_identity = ClaimsIdentity( + claims={"aud": "api://misc"}, is_authenticated=False + ) + token_provider = connection_manager.get_token_provider( + claims_identity, "https://service.com/api" + ) assert token_provider == connection_manager.get_connection("MISC") def test_get_token_provider_service_url_wildcard_empty(self, config): connection_manager = MsalConnectionManager(**config) - claims_identity = ClaimsIdentity(claims={"aud": "api://misc_other"}, is_authenticated=False) - token_provider = connection_manager.get_token_provider(claims_identity, "https://service.com/api") + claims_identity = ClaimsIdentity( + claims={"aud": "api://misc_other"}, is_authenticated=False + ) + token_provider = connection_manager.get_token_provider( + claims_identity, "https://service.com/api" + ) assert token_provider == connection_manager.get_connection("MISC") @pytest.mark.parametrize( "service_url, expected_connection", - [ + [ ["agentic", "AGENTIC"], ["https://microsoft.com/api", "MISC"], ["https://microsoft.com/some-url", "MISC"], - ["https://microsoft.com/", "MISC"] - ] + ["https://microsoft.com/", "MISC"], + ], ) - def test_get_token_provider_service_url_match(self, config, service_url, expected_connection): + def test_get_token_provider_service_url_match( + self, config, service_url, expected_connection + ): connection_manager = MsalConnectionManager(**config) claims_identity = ClaimsIdentity(claims={}, is_authenticated=False) - token_provider = connection_manager.get_token_provider(claims_identity, service_url) - assert token_provider == connection_manager.get_connection(expected_connection) \ No newline at end of file + token_provider = connection_manager.get_token_provider( + claims_identity, service_url + ) + assert token_provider == connection_manager.get_connection(expected_connection) diff --git a/tests/hosting_core/_oauth/test_flow_storage_client.py b/tests/hosting_core/_oauth/test_flow_storage_client.py index 1d6e30a5..c1710de1 100644 --- a/tests/hosting_core/_oauth/test_flow_storage_client.py +++ b/tests/hosting_core/_oauth/test_flow_storage_client.py @@ -171,7 +171,8 @@ async def delete_both(*args, **kwargs): target_cls=_FlowState, ) await read_check( - [f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/chi"], target_cls=_FlowState + [f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/chi"], + target_cls=_FlowState, ) await read_check(["other_data"], target_cls=MockStoreItem) await read_check(["some_data"], target_cls=MockStoreItem) diff --git a/tests/hosting_core/app/oauth/_handlers/test_agentic_user_authorization.py b/tests/hosting_core/app/oauth/_handlers/test_agentic_user_authorization.py index e73ba913..87f8ba27 100644 --- a/tests/hosting_core/app/oauth/_handlers/test_agentic_user_authorization.py +++ b/tests/hosting_core/app/oauth/_handlers/test_agentic_user_authorization.py @@ -39,12 +39,18 @@ def connection_manager(self, mocker): @pytest.fixture def auth_handler_settings(self): - return AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][DEFAULTS.agentic_auth_handler_id]["SETTINGS"] + return AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][ + DEFAULTS.agentic_auth_handler_id + ]["SETTINGS"] @pytest.fixture def agentic_auth(self, storage, connection_manager, auth_handler_settings): - return AgenticUserAuthorization(storage, connection_manager, - auth_handler_settings=auth_handler_settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id) + return AgenticUserAuthorization( + storage, + connection_manager, + auth_handler_settings=auth_handler_settings, + auth_handler_id=DEFAULTS.agentic_auth_handler_id, + ) @pytest.fixture(params=[RoleTypes.user, RoleTypes.skill, RoleTypes.agent]) def non_agentic_role(self, request): @@ -53,18 +59,20 @@ def non_agentic_role(self, request): @pytest.fixture(params=[RoleTypes.agentic_user, RoleTypes.agentic_identity]) def agentic_role(self, request): return request.param - - def mock_provider(self, mocker, app_token="bot_token", instance_token=None, user_token=None): + + def mock_provider( + self, mocker, app_token="bot_token", instance_token=None, user_token=None + ): mock_provider = mocker.Mock(spec=MsalAuth) mock_provider.get_agentic_instance_token = mocker.AsyncMock( return_value=[instance_token, app_token] ) - mock_provider.get_agentic_user_token = mocker.AsyncMock( - return_value=user_token - ) + mock_provider.get_agentic_user_token = mocker.AsyncMock(return_value=user_token) return mock_provider - def mock_class_provider(self, mocker, app_token="bot_token", instance_token=None, user_token=None): + def mock_class_provider( + self, mocker, app_token="bot_token", instance_token=None, user_token=None + ): instance = self.mock_provider(mocker, app_token, instance_token, user_token) mock_class(mocker, MsalAuth, instance) @@ -99,7 +107,10 @@ async def test_get_agentic_user_token_not_agentic( ), ) context = self.TurnContext(mocker, activity=activity) - assert await agentic_auth.get_agentic_user_token(context, ["user.Read"]) == TokenResponse() + assert ( + await agentic_auth.get_agentic_user_token(context, ["user.Read"]) + == TokenResponse() + ) @pytest.mark.asyncio async def test_get_agentic_user_token_agentic_no_user_id( @@ -112,7 +123,10 @@ async def test_get_agentic_user_token_agentic_no_user_id( ), ) context = self.TurnContext(mocker, activity=activity) - assert await agentic_auth.get_agentic_user_token(context, ["user.Read"]) == TokenResponse() + assert ( + await agentic_auth.get_agentic_user_token(context, ["user.Read"]) + == TokenResponse() + ) @pytest.mark.asyncio async def test_get_agentic_instance_token_is_agentic( @@ -123,7 +137,10 @@ async def test_get_agentic_instance_token_is_agentic( connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) agentic_auth = AgenticUserAuthorization( - MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id + MemoryStorage(), + connection_manager, + auth_handler_settings=auth_handler_settings, + auth_handler_id=DEFAULTS.agentic_auth_handler_id, ) activity = Activity( @@ -138,7 +155,9 @@ async def test_get_agentic_instance_token_is_agentic( token = await agentic_auth.get_agentic_instance_token(context) assert token == TokenResponse(token=DEFAULTS.token) - mock_provider.get_agentic_instance_token.assert_called_once_with(DEFAULTS.agentic_instance_id) + mock_provider.get_agentic_instance_token.assert_called_once_with( + DEFAULTS.agentic_instance_id + ) @pytest.mark.asyncio async def test_get_agentic_user_token_is_agentic( @@ -150,7 +169,10 @@ async def test_get_agentic_user_token_is_agentic( connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) agentic_auth = AgenticUserAuthorization( - MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id + MemoryStorage(), + connection_manager, + auth_handler_settings=auth_handler_settings, + auth_handler_id=DEFAULTS.agentic_auth_handler_id, ) activity = Activity( @@ -178,14 +200,24 @@ async def test_get_agentic_user_token_is_agentic( (None, ["user.Read", "Mail.Read"]), ], ) - async def test_sign_in_success(self, mocker, scopes_list, agentic_role, expected_scopes_list, auth_handler_settings): + async def test_sign_in_success( + self, + mocker, + scopes_list, + agentic_role, + expected_scopes_list, + auth_handler_settings, + ): mock_provider = self.mock_provider(mocker, user_token="my_token") connection_manager = mocker.Mock(spec=MsalConnectionManager) connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) agentic_auth = AgenticUserAuthorization( - MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id + MemoryStorage(), + connection_manager, + auth_handler_settings=auth_handler_settings, + auth_handler_id=DEFAULTS.agentic_auth_handler_id, ) activity = Activity( type="message", @@ -203,7 +235,7 @@ async def test_sign_in_success(self, mocker, scopes_list, agentic_role, expected mock_provider.get_agentic_user_token.assert_called_once_with( DEFAULTS.agentic_instance_id, "some_id", expected_scopes_list ) - + @pytest.mark.asyncio @pytest.mark.parametrize( "scopes_list, expected_scopes_list", @@ -213,14 +245,24 @@ async def test_sign_in_success(self, mocker, scopes_list, agentic_role, expected (None, ["user.Read", "Mail.Read"]), ], ) - async def test_sign_in_failure(self, mocker, scopes_list, agentic_role, expected_scopes_list, auth_handler_settings): + async def test_sign_in_failure( + self, + mocker, + scopes_list, + agentic_role, + expected_scopes_list, + auth_handler_settings, + ): mock_provider = self.mock_provider(mocker, user_token=None) connection_manager = mocker.Mock(spec=MsalConnectionManager) connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) agentic_auth = AgenticUserAuthorization( - MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id + MemoryStorage(), + connection_manager, + auth_handler_settings=auth_handler_settings, + auth_handler_id=DEFAULTS.agentic_auth_handler_id, ) activity = Activity( type="message", @@ -248,14 +290,24 @@ async def test_sign_in_failure(self, mocker, scopes_list, agentic_role, expected (None, ["user.Read", "Mail.Read"]), ], ) - async def test_get_refreshed_token_success(self, mocker, scopes_list, agentic_role, expected_scopes_list, auth_handler_settings): + async def test_get_refreshed_token_success( + self, + mocker, + scopes_list, + agentic_role, + expected_scopes_list, + auth_handler_settings, + ): mock_provider = self.mock_provider(mocker, user_token="my_token") connection_manager = mocker.Mock(spec=MsalConnectionManager) connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) agentic_auth = AgenticUserAuthorization( - MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id + MemoryStorage(), + connection_manager, + auth_handler_settings=auth_handler_settings, + auth_handler_id=DEFAULTS.agentic_auth_handler_id, ) activity = Activity( type="message", @@ -266,7 +318,9 @@ async def test_get_refreshed_token_success(self, mocker, scopes_list, agentic_ro ), ) context = self.TurnContext(mocker, activity=activity) - res = await agentic_auth.get_refreshed_token(context, "my_connection", scopes_list) + res = await agentic_auth.get_refreshed_token( + context, "my_connection", scopes_list + ) assert res == TokenResponse(token="my_token") mock_provider.get_agentic_user_token.assert_called_once_with( @@ -282,14 +336,24 @@ async def test_get_refreshed_token_success(self, mocker, scopes_list, agentic_ro (None, ["user.Read", "Mail.Read"]), ], ) - async def test_get_refreshed_token_failure(self, mocker, scopes_list, agentic_role, expected_scopes_list, auth_handler_settings): + async def test_get_refreshed_token_failure( + self, + mocker, + scopes_list, + agentic_role, + expected_scopes_list, + auth_handler_settings, + ): mock_provider = self.mock_provider(mocker, user_token=None) connection_manager = mocker.Mock(spec=MsalConnectionManager) connection_manager.get_token_provider = mocker.Mock(return_value=mock_provider) agentic_auth = AgenticUserAuthorization( - MemoryStorage(), connection_manager, auth_handler_settings=auth_handler_settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id + MemoryStorage(), + connection_manager, + auth_handler_settings=auth_handler_settings, + auth_handler_id=DEFAULTS.agentic_auth_handler_id, ) activity = Activity( type="message", @@ -300,8 +364,10 @@ async def test_get_refreshed_token_failure(self, mocker, scopes_list, agentic_ro ), ) context = self.TurnContext(mocker, activity=activity) - res = await agentic_auth.get_refreshed_token(context, "my_connection", scopes_list) + res = await agentic_auth.get_refreshed_token( + context, "my_connection", scopes_list + ) assert res == TokenResponse() mock_provider.get_agentic_user_token.assert_called_once_with( DEFAULTS.agentic_instance_id, "some_id", expected_scopes_list - ) \ No newline at end of file + ) diff --git a/tests/hosting_core/app/oauth/_handlers/test_user_authorization.py b/tests/hosting_core/app/oauth/_handlers/test_user_authorization.py index 5d9d0457..f2764125 100644 --- a/tests/hosting_core/app/oauth/_handlers/test_user_authorization.py +++ b/tests/hosting_core/app/oauth/_handlers/test_user_authorization.py @@ -3,10 +3,7 @@ from microsoft_agents.activity import ActivityTypes, TokenResponse -from microsoft_agents.authentication.msal import ( - MsalAuth, - MsalConnectionManager -) +from microsoft_agents.authentication.msal import MsalAuth, MsalConnectionManager from microsoft_agents.hosting.core import MemoryStorage from microsoft_agents.hosting.core.app.oauth import _UserAuthorization, _SignInResponse @@ -15,7 +12,7 @@ _FlowStateTag, _FlowState, _FlowResponse, - _OAuthFlow + _OAuthFlow, ) # test constants @@ -39,6 +36,7 @@ STORAGE_DATA = TEST_STORAGE_DATA() AGENTIC_ENV_DICT = TEST_AGENTIC_ENV_DICT() + def make_jwt(token: str = DEFAULTS.token, aud="api://default"): if aud: return jwt.encode({"aud": aud}, token, algorithm="HS256") @@ -50,6 +48,7 @@ class MyUserAuthorization(_UserAuthorization): async def _handle_flow_response(self, *args, **kwargs): pass + def testing_TurnContext( mocker, channel_id=DEFAULTS.channel_id, @@ -73,16 +72,26 @@ def testing_TurnContext( } return turn_context -async def read_state(storage, channel_id=DEFAULTS.channel_id, user_id=DEFAULTS.user_id, auth_handler_id=DEFAULTS.auth_handler_id): + +async def read_state( + storage, + channel_id=DEFAULTS.channel_id, + user_id=DEFAULTS.user_id, + auth_handler_id=DEFAULTS.auth_handler_id, +): storage_client = _FlowStorageClient(channel_id, user_id, storage) key = storage_client.key(auth_handler_id) return (await storage.read([key], target_cls=_FlowState)).get(key) + def mock_provider(mocker, exchange_token=None): - instance = mock_instance(mocker, MsalAuth, {"acquire_token_on_behalf_of": exchange_token}) + instance = mock_instance( + mocker, MsalAuth, {"acquire_token_on_behalf_of": exchange_token} + ) mocker.patch.object(MsalConnectionManager, "get_connection", return_value=instance) return instance + class TestEnv(FlowStateFixtures): def setup_method(self): self.TurnContext = testing_TurnContext @@ -90,7 +99,7 @@ def setup_method(self): @pytest.fixture def context(self, mocker): return self.TurnContext(mocker) - + @pytest.fixture def storage(self): return MemoryStorage(STORAGE_DATA.get_init_data()) @@ -102,7 +111,7 @@ def connection_manager(self): @pytest.fixture def auth_handlers(self): return TEST_AUTH_DATA().auth_handlers - + @pytest.fixture def auth_handler_settings(self): return AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][ @@ -112,29 +121,37 @@ def auth_handler_settings(self): @pytest.fixture def user_authorization(self, connection_manager, storage, auth_handler_settings): return MyUserAuthorization( - storage, connection_manager, auth_handler_settings=auth_handler_settings, auth_handler_id=DEFAULTS.auth_handler_id + storage, + connection_manager, + auth_handler_settings=auth_handler_settings, + auth_handler_id=DEFAULTS.auth_handler_id, ) - + @pytest.fixture def exchangeable_token(self): jwt.encode({"aud": "exchange_audience"}, "secret", algorithm="HS256") - @pytest.fixture(params=[ - [None, ["scope1", "scope2"]], - [[], ["scope1", "scope2"]], - [["scope1"], ["scope1"]], - ]) + @pytest.fixture( + params=[ + [None, ["scope1", "scope2"]], + [[], ["scope1", "scope2"]], + [["scope1"], ["scope1"]], + ] + ) def scope_set(self, request): return request.param - - @pytest.fixture(params=[ - ["AGENTIC", "AGENTIC"], - [None, DEFAULTS.obo_connection_name], - ["", DEFAULTS.obo_connection_name], - ]) + + @pytest.fixture( + params=[ + ["AGENTIC", "AGENTIC"], + [None, DEFAULTS.obo_connection_name], + ["", DEFAULTS.obo_connection_name], + ] + ) def connection_set(self, request): return request.param + class TestUserAuthorization(TestEnv): # TODO -> test init @@ -147,70 +164,97 @@ class TestUserAuthorization(TestEnv): _FlowResponse( token_response=TokenResponse(token=make_jwt()), flow_state=_FlowState( - tag=_FlowStateTag.COMPLETE, auth_handler_id=DEFAULTS.auth_handler_id + tag=_FlowStateTag.COMPLETE, + auth_handler_id=DEFAULTS.auth_handler_id, ), ), - True, "wow", - _SignInResponse(token_response=TokenResponse(token="wow"), tag=_FlowStateTag.COMPLETE) + True, + "wow", + _SignInResponse( + token_response=TokenResponse(token="wow"), + tag=_FlowStateTag.COMPLETE, + ), ], [ _FlowResponse( token_response=TokenResponse(token=make_jwt(aud=None)), flow_state=_FlowState( - tag=_FlowStateTag.COMPLETE, auth_handler_id=DEFAULTS.auth_handler_id + tag=_FlowStateTag.COMPLETE, + auth_handler_id=DEFAULTS.auth_handler_id, ), ), - False, "wow", - _SignInResponse(token_response=TokenResponse(token=make_jwt(aud=None)), tag=_FlowStateTag.COMPLETE) + False, + "wow", + _SignInResponse( + token_response=TokenResponse(token=make_jwt(aud=None)), + tag=_FlowStateTag.COMPLETE, + ), ], [ _FlowResponse( - token_response=TokenResponse(token=make_jwt(token="some_value", aud="other")), + token_response=TokenResponse( + token=make_jwt(token="some_value", aud="other") + ), flow_state=_FlowState( - tag=_FlowStateTag.COMPLETE, auth_handler_id=DEFAULTS.auth_handler_id + tag=_FlowStateTag.COMPLETE, + auth_handler_id=DEFAULTS.auth_handler_id, + ), + ), + False, + DEFAULTS.token, + _SignInResponse( + token_response=TokenResponse( + token=make_jwt("some_value", aud="other") ), + tag=_FlowStateTag.COMPLETE, ), - False, DEFAULTS.token, - _SignInResponse(token_response=TokenResponse(token=make_jwt("some_value", aud="other")), tag=_FlowStateTag.COMPLETE) ], [ _FlowResponse( token_response=TokenResponse(token=make_jwt(token="some_value")), flow_state=_FlowState( - tag=_FlowStateTag.COMPLETE, auth_handler_id=DEFAULTS.auth_handler_id + tag=_FlowStateTag.COMPLETE, + auth_handler_id=DEFAULTS.auth_handler_id, ), ), - True, None, - _SignInResponse(tag=_FlowStateTag.FAILURE) + True, + None, + _SignInResponse(tag=_FlowStateTag.FAILURE), ], [ _FlowResponse( flow_state=_FlowState( - tag=_FlowStateTag.BEGIN, auth_handler_id=DEFAULTS.auth_handler_id + tag=_FlowStateTag.BEGIN, + auth_handler_id=DEFAULTS.auth_handler_id, ), ), - False, None, - _SignInResponse(tag=_FlowStateTag.BEGIN) + False, + None, + _SignInResponse(tag=_FlowStateTag.BEGIN), ], [ _FlowResponse( flow_state=_FlowState( - tag=_FlowStateTag.CONTINUE, auth_handler_id=DEFAULTS.auth_handler_id + tag=_FlowStateTag.CONTINUE, + auth_handler_id=DEFAULTS.auth_handler_id, ), ), - False, None, - _SignInResponse(tag=_FlowStateTag.CONTINUE) + False, + None, + _SignInResponse(tag=_FlowStateTag.CONTINUE), ], [ _FlowResponse( flow_state=_FlowState( - tag=_FlowStateTag.FAILURE, auth_handler_id=DEFAULTS.auth_handler_id + tag=_FlowStateTag.FAILURE, + auth_handler_id=DEFAULTS.auth_handler_id, ), ), - False, None, - _SignInResponse(tag=_FlowStateTag.FAILURE) + False, + None, + _SignInResponse(tag=_FlowStateTag.FAILURE), ], - ] + ], ) async def test_sign_in( self, @@ -223,68 +267,66 @@ async def test_sign_in( token_exchange_response, expected_response, scope_set, - connection_set + connection_set, ): request_scopes, expected_scopes = scope_set request_connection, expected_connection = connection_set mock_class_OAuthFlow(mocker, begin_or_continue_flow_return=flow_response) provider = mock_provider(mocker, exchange_token=token_exchange_response) - sign_in_response = await user_authorization._sign_in(context, request_connection, request_scopes) + sign_in_response = await user_authorization._sign_in( + context, request_connection, request_scopes + ) assert sign_in_response.token_response == expected_response.token_response assert sign_in_response.tag == expected_response.tag - + state = await read_state(storage, auth_handler_id=DEFAULTS.auth_handler_id) assert flow_state_eq(state, flow_response.flow_state) if exchange_attempted: - MsalConnectionManager.get_connection.assert_called_once_with(expected_connection) + MsalConnectionManager.get_connection.assert_called_once_with( + expected_connection + ) provider.acquire_token_on_behalf_of.assert_called_once_with( - scopes=expected_scopes, user_assertion=flow_response.token_response.token + scopes=expected_scopes, + user_assertion=flow_response.token_response.token, ) @pytest.mark.asyncio async def test_sign_out_individual( - self, - mocker, - storage, - user_authorization, - context + self, mocker, storage, user_authorization, context ): mock_class_OAuthFlow(mocker) await user_authorization._sign_out(context) - assert await read_state(storage, auth_handler_id=DEFAULTS.auth_handler_id) is None + assert ( + await read_state(storage, auth_handler_id=DEFAULTS.auth_handler_id) is None + ) _OAuthFlow.sign_out.assert_called_once() @pytest.mark.asyncio @pytest.mark.parametrize( "get_user_token_return, exchange_attempted, token_exchange_response, expected_response", [ - [ - TokenResponse(token=make_jwt()), - True, "wow", - TokenResponse(token="wow") - ], + [TokenResponse(token=make_jwt()), True, "wow", TokenResponse(token="wow")], [ TokenResponse(token=make_jwt(aud=None)), - False, "wow", - TokenResponse(token=make_jwt(aud=None)) + False, + "wow", + TokenResponse(token=make_jwt(aud=None)), ], [ TokenResponse(token=make_jwt(token="some_value", aud="other")), - False, DEFAULTS.token, - TokenResponse(token=make_jwt("some_value", aud="other")) + False, + DEFAULTS.token, + TokenResponse(token=make_jwt("some_value", aud="other")), ], [ TokenResponse(token=make_jwt(token="some_value")), - True, None, - TokenResponse() - ], - [ + True, + None, TokenResponse(), - False, None, - TokenResponse() ], - ] + [TokenResponse(), False, None, TokenResponse()], + ], ) async def test_get_refreshed_token( self, @@ -297,15 +339,19 @@ async def test_get_refreshed_token( token_exchange_response, expected_response, scope_set, - connection_set + connection_set, ): request_scopes, expected_scopes = scope_set request_connection, expected_connection = connection_set mock_class_OAuthFlow(mocker, get_user_token_return=get_user_token_return) provider = mock_provider(mocker, exchange_token=token_exchange_response) - state_before = await read_state(storage, auth_handler_id=DEFAULTS.auth_handler_id) - token_response = await user_authorization.get_refreshed_token(context, request_connection, request_scopes) + state_before = await read_state( + storage, auth_handler_id=DEFAULTS.auth_handler_id + ) + token_response = await user_authorization.get_refreshed_token( + context, request_connection, request_scopes + ) assert token_response == expected_response state = await read_state(storage, auth_handler_id=DEFAULTS.auth_handler_id) @@ -313,7 +359,9 @@ async def test_get_refreshed_token( if state: assert flow_state_eq(state, state_before) if exchange_attempted: - MsalConnectionManager.get_connection.assert_called_once_with(expected_connection) + MsalConnectionManager.get_connection.assert_called_once_with( + expected_connection + ) provider.acquire_token_on_behalf_of.assert_called_once_with( scopes=expected_scopes, user_assertion=get_user_token_return.token - ) \ No newline at end of file + ) diff --git a/tests/hosting_core/app/oauth/test_auth_handler.py b/tests/hosting_core/app/oauth/test_auth_handler.py index d79a6d07..ccaf15ec 100644 --- a/tests/hosting_core/app/oauth/test_auth_handler.py +++ b/tests/hosting_core/app/oauth/test_auth_handler.py @@ -15,7 +15,7 @@ def auth_setting(self): return ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][ DEFAULTS.auth_handler_id ]["SETTINGS"] - + @pytest.fixture def agentic_auth_setting(self): return AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][ @@ -33,13 +33,15 @@ def test_init(self, auth_setting): ) def test_init_agentic(self, agentic_auth_setting): - auth_handler = AuthHandler(DEFAULTS.agentic_auth_handler_id, **agentic_auth_setting) + auth_handler = AuthHandler( + DEFAULTS.agentic_auth_handler_id, **agentic_auth_setting + ) assert auth_handler.name == DEFAULTS.agentic_auth_handler_id assert auth_handler.title == DEFAULTS.agentic_auth_handler_title assert auth_handler.text == DEFAULTS.agentic_auth_handler_text assert auth_handler.obo_connection_name == DEFAULTS.agentic_obo_connection_name - assert auth_handler.scopes == [ "user.Read", "Mail.Read" ] + assert auth_handler.scopes == ["user.Read", "Mail.Read"] assert ( - auth_handler.abs_oauth_connection_name == DEFAULTS.agentic_abs_oauth_connection_name + auth_handler.abs_oauth_connection_name + == DEFAULTS.agentic_abs_oauth_connection_name ) - diff --git a/tests/hosting_core/app/oauth/test_authorization.py b/tests/hosting_core/app/oauth/test_authorization.py index 1ac250c2..d3905fdf 100644 --- a/tests/hosting_core/app/oauth/test_authorization.py +++ b/tests/hosting_core/app/oauth/test_authorization.py @@ -12,7 +12,7 @@ _SignInState, _UserAuthorization, Authorization, - AgenticUserAuthorization + AgenticUserAuthorization, ) from microsoft_agents.hosting.core._oauth import _FlowStateTag @@ -21,7 +21,7 @@ AuthHandler, Storage, MemoryStorage, - TurnContext + TurnContext, ) from tests._common.storage.utils import StorageBaseline @@ -52,31 +52,50 @@ ENV_DICT = TEST_ENV_DICT() AGENTIC_ENV_DICT = TEST_AGENTIC_ENV_DICT() + def make_jwt(token: str = DEFAULTS.token, aud="api://default"): if aud: return jwt.encode({"aud": aud}, token, algorithm="HS256") else: return jwt.encode({}, token, algorithm="HS256") + def mock_variants(mocker, sign_in_return=None, get_refreshed_token_return=None): - mock_class_UserAuthorization(mocker, sign_in_return=sign_in_return, get_refreshed_token_return=get_refreshed_token_return) - mock_class_AgenticUserAuthorization(mocker, sign_in_return=sign_in_return, get_refreshed_token_return=get_refreshed_token_return) + mock_class_UserAuthorization( + mocker, + sign_in_return=sign_in_return, + get_refreshed_token_return=get_refreshed_token_return, + ) + mock_class_AgenticUserAuthorization( + mocker, + sign_in_return=sign_in_return, + get_refreshed_token_return=get_refreshed_token_return, + ) + def sign_in_state_eq(a: Optional[_SignInState], b: Optional[_SignInState]) -> bool: if a is None and b is None: return True if a is None or b is None: return False - return a.active_handler_id == b.active_handler_id and a.continuation_activity == b.continuation_activity + return ( + a.active_handler_id == b.active_handler_id + and a.continuation_activity == b.continuation_activity + ) + def create_turn_state(context, token_cache: dict): d = {**context.turn_state} - d.update({ - Authorization._cache_key(context, k): TokenResponse(token=v) for k, v in token_cache.items() - }) + d.update( + { + Authorization._cache_key(context, k): TokenResponse(token=v) + for k, v in token_cache.items() + } + ) return d + def copy_sign_in_state(state: _SignInState) -> _SignInState: return _SignInState( active_handler_id=state.active_handler_id, @@ -135,7 +154,9 @@ class TestAuthorizationSetup(TestEnv): def test_init_user_auth(self, connection_manager, storage, env_dict): auth = Authorization(storage, connection_manager, **env_dict) assert auth._resolve_handler(DEFAULTS.auth_handler_id) is not None - assert isinstance(auth._resolve_handler(DEFAULTS.auth_handler_id), _UserAuthorization) + assert isinstance( + auth._resolve_handler(DEFAULTS.auth_handler_id), _UserAuthorization + ) def test_init_agentic_auth_not_configured(self, connection_manager, storage): auth = Authorization(storage, connection_manager, **ENV_DICT) @@ -145,7 +166,10 @@ def test_init_agentic_auth_not_configured(self, connection_manager, storage): def test_init_agentic_auth(self, connection_manager, storage): auth = Authorization(storage, connection_manager, **AGENTIC_ENV_DICT) assert auth._resolve_handler(DEFAULTS.agentic_auth_handler_id) is not None - assert isinstance(auth._resolve_handler(DEFAULTS.agentic_auth_handler_id), AgenticUserAuthorization) + assert isinstance( + auth._resolve_handler(DEFAULTS.agentic_auth_handler_id), + AgenticUserAuthorization, + ) @pytest.mark.parametrize( "auth_handler_id", [DEFAULTS.auth_handler_id, DEFAULTS.agentic_auth_handler_id] @@ -172,26 +196,45 @@ class TestAuthorizationUsage(TestEnv): @pytest.mark.parametrize( "initial_turn_state, final_turn_state, initial_sign_in_state, auth_handler_id", [ - [{DEFAULTS.auth_handler_id: DEFAULTS.token}, {}, None, DEFAULTS.auth_handler_id], [ - {DEFAULTS.auth_handler_id: DEFAULTS.token}, {}, - _SignInState(active_handler_id="some_value"), DEFAULTS.auth_handler_id + {DEFAULTS.auth_handler_id: DEFAULTS.token}, + {}, + None, + DEFAULTS.auth_handler_id, + ], + [ + {DEFAULTS.auth_handler_id: DEFAULTS.token}, + {}, + _SignInState(active_handler_id="some_value"), + DEFAULTS.auth_handler_id, ], [ {DEFAULTS.agentic_auth_handler_id: DEFAULTS.token}, {DEFAULTS.agentic_auth_handler_id: DEFAULTS.token}, - None, DEFAULTS.auth_handler_id + None, + DEFAULTS.auth_handler_id, ], [ - {DEFAULTS.agentic_auth_handler_id: DEFAULTS.token, DEFAULTS.auth_handler_id: "value"}, + { + DEFAULTS.agentic_auth_handler_id: DEFAULTS.token, + DEFAULTS.auth_handler_id: "value", + }, {DEFAULTS.auth_handler_id: "value"}, - _SignInState(active_handler_id="some_val"), DEFAULTS.agentic_auth_handler_id + _SignInState(active_handler_id="some_val"), + DEFAULTS.agentic_auth_handler_id, ], - ] + ], ) async def test_sign_out( - self, mocker, storage, authorization, context, - initial_turn_state, final_turn_state, initial_sign_in_state, auth_handler_id + self, + mocker, + storage, + authorization, + context, + initial_turn_state, + final_turn_state, + initial_sign_in_state, + auth_handler_id, ): # setup mock_variants(mocker) @@ -226,7 +269,10 @@ async def test_sign_out( ], [ {DEFAULTS.auth_handler_id: "old_token"}, - {DEFAULTS.agentic_auth_handler_id: "valid_token", DEFAULTS.auth_handler_id: "old_token"}, + { + DEFAULTS.agentic_auth_handler_id: "valid_token", + DEFAULTS.auth_handler_id: "old_token", + }, None, DEFAULTS.agentic_auth_handler_id, _SignInState(active_handler_id=DEFAULTS.agentic_auth_handler_id), @@ -261,8 +307,14 @@ async def test_sign_out( ), ], [ - {DEFAULTS.agentic_auth_handler_id: "old_token", DEFAULTS.auth_handler_id: "old_token"}, - {DEFAULTS.agentic_auth_handler_id: "valid_token", DEFAULTS.auth_handler_id: "old_token"}, + { + DEFAULTS.agentic_auth_handler_id: "old_token", + DEFAULTS.auth_handler_id: "old_token", + }, + { + DEFAULTS.agentic_auth_handler_id: "valid_token", + DEFAULTS.auth_handler_id: "old_token", + }, DEFAULTS.agentic_auth_handler_id, DEFAULTS.agentic_auth_handler_id, _SignInState(active_handler_id=DEFAULTS.agentic_auth_handler_id), @@ -273,8 +325,14 @@ async def test_sign_out( ), ], [ - {DEFAULTS.agentic_auth_handler_id: "old_token", DEFAULTS.auth_handler_id: "old_token"}, - {DEFAULTS.agentic_auth_handler_id: "old_token", DEFAULTS.auth_handler_id: "old_token"}, + { + DEFAULTS.agentic_auth_handler_id: "old_token", + DEFAULTS.auth_handler_id: "old_token", + }, + { + DEFAULTS.agentic_auth_handler_id: "old_token", + DEFAULTS.auth_handler_id: "old_token", + }, DEFAULTS.agentic_auth_handler_id, DEFAULTS.agentic_auth_handler_id, _SignInState(active_handler_id=DEFAULTS.agentic_auth_handler_id), @@ -285,8 +343,14 @@ async def test_sign_out( ), ], [ - {DEFAULTS.agentic_auth_handler_id: "old_token", DEFAULTS.auth_handler_id: "old_token"}, - {DEFAULTS.agentic_auth_handler_id: "old_token", DEFAULTS.auth_handler_id: "old_token"}, + { + DEFAULTS.agentic_auth_handler_id: "old_token", + DEFAULTS.auth_handler_id: "old_token", + }, + { + DEFAULTS.agentic_auth_handler_id: "old_token", + DEFAULTS.auth_handler_id: "old_token", + }, None, DEFAULTS.auth_handler_id, None, @@ -296,11 +360,21 @@ async def test_sign_out( tag=_FlowStateTag.FAILURE, ), ], - ] + ], ) async def test_start_or_continue_sign_in_complete_or_failure( - self, mocker, storage, authorization, context, - initial_cache, final_cache, auth_handler_id, expected_auth_handler_id, initial_sign_in_state, final_sign_in_state, sign_in_response + self, + mocker, + storage, + authorization, + context, + initial_cache, + final_cache, + auth_handler_id, + expected_auth_handler_id, + initial_sign_in_state, + final_sign_in_state, + sign_in_response, ): # setup mock_variants(mocker, sign_in_return=sign_in_response) @@ -310,7 +384,7 @@ async def test_start_or_continue_sign_in_complete_or_failure( await authorization._delete_sign_in_state(context) else: await authorization._save_sign_in_state(context, initial_sign_in_state) - + # test res = await authorization._start_or_continue_sign_in( @@ -321,7 +395,9 @@ async def test_start_or_continue_sign_in_complete_or_failure( assert res.tag == sign_in_response.tag assert res.token_response == sign_in_response.token_response - authorization._resolve_handler(expected_auth_handler_id)._sign_in.assert_called_once_with(context) + authorization._resolve_handler( + expected_auth_handler_id + )._sign_in.assert_called_once_with(context) assert (await authorization._load_sign_in_state(context)) is None assert context.turn_state == expected_turn_state @@ -363,23 +439,29 @@ def pending_tag(self, request): DEFAULTS.auth_handler_id, _SignInState(active_handler_id=DEFAULTS.auth_handler_id), ], - ] + ], ) async def test_start_or_continue_sign_in_pending( - self, mocker, storage, authorization, context, - initial_cache, auth_handler_id, expected_auth_handler_id, initial_sign_in_state, pending_tag + self, + mocker, + storage, + authorization, + context, + initial_cache, + auth_handler_id, + expected_auth_handler_id, + initial_sign_in_state, + pending_tag, ): # setup - mock_variants(mocker, sign_in_return=_SignInResponse( - tag=pending_tag - )) + mock_variants(mocker, sign_in_return=_SignInResponse(tag=pending_tag)) expected_turn_state = create_turn_state(context, initial_cache) context.turn_state = expected_turn_state if not initial_sign_in_state: await authorization._delete_sign_in_state(context) else: await authorization._save_sign_in_state(context, initial_sign_in_state) - + # test res = await authorization._start_or_continue_sign_in( @@ -390,7 +472,9 @@ async def test_start_or_continue_sign_in_pending( assert res.tag == pending_tag assert not res.token_response - authorization._resolve_handler(expected_auth_handler_id)._sign_in.assert_called_once_with(context) + authorization._resolve_handler( + expected_auth_handler_id + )._sign_in.assert_called_once_with(context) final_sign_in_state = await authorization._load_sign_in_state(context) assert final_sign_in_state.continuation_activity == context.activity assert final_sign_in_state.active_handler_id == expected_auth_handler_id @@ -400,41 +484,53 @@ async def test_start_or_continue_sign_in_pending( @pytest.mark.parametrize( "initial_state, initial_cache, handler_id, expected_handler_id, refresh_token, expected", [ - [ # no cached token + [ # no cached token _SignInState(active_handler_id="value"), {DEFAULTS.auth_handler_id: "token"}, DEFAULTS.agentic_auth_handler_id, DEFAULTS.agentic_auth_handler_id, TokenResponse(), - TokenResponse() + TokenResponse(), ], - [ # no cached token and default handler id resolution + [ # no cached token and default handler id resolution _SignInState(active_handler_id="value"), {DEFAULTS.agentic_auth_handler_id: "token"}, "", DEFAULTS.auth_handler_id, TokenResponse(), - TokenResponse() + TokenResponse(), ], - [ # no cached token pt.2 + [ # no cached token pt.2 _SignInState(active_handler_id=DEFAULTS.auth_handler_id), {DEFAULTS.agentic_auth_handler_id: "token"}, DEFAULTS.auth_handler_id, DEFAULTS.auth_handler_id, TokenResponse(), - TokenResponse() + TokenResponse(), ], - [ # refreshed, new token + [ # refreshed, new token _SignInState(active_handler_id="value"), {DEFAULTS.agentic_auth_handler_id: make_jwt()}, DEFAULTS.agentic_auth_handler_id, DEFAULTS.agentic_auth_handler_id, TokenResponse(token=DEFAULTS.token), - TokenResponse(token=DEFAULTS.token) + TokenResponse(token=DEFAULTS.token), ], - ] + ], ) - async def test_get_token(self, mocker, authorization, context, storage, initial_state, initial_cache, handler_id, expected_handler_id, refresh_token, expected): + async def test_get_token( + self, + mocker, + authorization, + context, + storage, + initial_state, + initial_cache, + handler_id, + expected_handler_id, + refresh_token, + expected, + ): # setup mock_variants(mocker, get_refreshed_token_return=refresh_token) expected_turn_state = create_turn_state(context, initial_cache) @@ -449,11 +545,9 @@ async def test_get_token(self, mocker, authorization, context, storage, initial_ assert res == expected if handler_id and refresh_token: - authorization._resolve_handler(expected_handler_id).get_refreshed_token.assert_called_once_with( - context, - None, - None - ) + authorization._resolve_handler( + expected_handler_id + ).get_refreshed_token.assert_called_once_with(context, None, None) final_state = await authorization._load_sign_in_state(context) assert sign_in_state_eq(initial_state, final_state) @@ -463,37 +557,48 @@ async def test_get_token(self, mocker, authorization, context, storage, initial_ @pytest.mark.parametrize( "initial_state, initial_cache, handler_id, refreshed, refresh_token", [ - [ # no cached token + [ # no cached token None, - { DEFAULTS.auth_handler_id: "token" }, + {DEFAULTS.auth_handler_id: "token"}, DEFAULTS.agentic_auth_handler_id, False, TokenResponse(), ], - [ # no cached token and default handler id resolution + [ # no cached token and default handler id resolution None, {DEFAULTS.agentic_auth_handler_id: "token"}, "", False, TokenResponse(), ], - [ # no cached token pt.2 + [ # no cached token pt.2 _SignInState(active_handler_id=DEFAULTS.auth_handler_id), {DEFAULTS.agentic_auth_handler_id: "token"}, DEFAULTS.auth_handler_id, True, TokenResponse(), ], - [ # refreshed, new token + [ # refreshed, new token _SignInState(active_handler_id=DEFAULTS.auth_handler_id), {DEFAULTS.agentic_auth_handler_id: DEFAULTS.token}, DEFAULTS.agentic_auth_handler_id, True, TokenResponse(token=DEFAULTS.token), ], - ] + ], ) - async def test_exchange_token(self, mocker, authorization, context, storage, initial_state, initial_cache, handler_id, refreshed, refresh_token): + async def test_exchange_token( + self, + mocker, + authorization, + context, + storage, + initial_state, + initial_cache, + handler_id, + refreshed, + refresh_token, + ): # setup mock_variants(mocker, get_refreshed_token_return=refresh_token) expected_turn_state = create_turn_state(context, initial_cache) @@ -503,22 +608,26 @@ async def test_exchange_token(self, mocker, authorization, context, storage, ini else: await authorization._save_sign_in_state(context, initial_state) - res = await authorization.exchange_token(context, auth_handler_id=handler_id, exchange_connection="some_connection", scopes=["scope1", "scope2"]) + res = await authorization.exchange_token( + context, + auth_handler_id=handler_id, + exchange_connection="some_connection", + scopes=["scope1", "scope2"], + ) assert res == refresh_token final_state = await authorization._load_sign_in_state(context) assert sign_in_state_eq(initial_state, final_state) if handler_id and refresh_token: - authorization._resolve_handler(handler_id).get_refreshed_token.assert_called_once_with( - context, - "some_connection", - ["scope1", "scope2"] + authorization._resolve_handler( + handler_id + ).get_refreshed_token.assert_called_once_with( + context, "some_connection", ["scope1", "scope2"] ) final_state = await authorization._load_sign_in_state(context) assert sign_in_state_eq(initial_state, final_state) assert context.turn_state == expected_turn_state - @pytest.mark.asyncio async def test_on_turn_auth_intercept_no_intercept( @@ -557,7 +666,8 @@ async def test_on_turn_auth_intercept_with_intercept_incomplete( expected_cache = create_turn_state(context, initial_cache) context.turn_state = expected_cache - initial_state = _SignInState(active_handler_id=auth_handler_id, + initial_state = _SignInState( + active_handler_id=auth_handler_id, continuation_activity=Activity( type=ActivityTypes.message, text="old activity" ), @@ -583,7 +693,9 @@ async def test_on_turn_auth_intercept_with_intercept_complete( ): mock_class_Authorization( mocker, - start_or_continue_sign_in_return=_SignInResponse(tag=_FlowStateTag.COMPLETE), + start_or_continue_sign_in_return=_SignInResponse( + tag=_FlowStateTag.COMPLETE + ), ) initial_cache = {"some_handler": "old_token"} @@ -591,8 +703,8 @@ async def test_on_turn_auth_intercept_with_intercept_complete( context.turn_state = expected_cache old_activity = Activity(type=ActivityTypes.message, text="old activity") - initial_state = _SignInState(active_handler_id=auth_handler_id, - continuation_activity=old_activity + initial_state = _SignInState( + active_handler_id=auth_handler_id, continuation_activity=old_activity ) await authorization._save_sign_in_state( context, copy_sign_in_state(initial_state) @@ -607,4 +719,4 @@ async def test_on_turn_auth_intercept_with_intercept_complete( final_state = await authorization._load_sign_in_state(context) assert sign_in_state_eq(final_state, initial_state) - assert context.turn_state == expected_cache \ No newline at end of file + assert context.turn_state == expected_cache diff --git a/tests/hosting_core/app/oauth/test_sign_in_response.py b/tests/hosting_core/app/oauth/test_sign_in_response.py index a062b60f..30c52fd8 100644 --- a/tests/hosting_core/app/oauth/test_sign_in_response.py +++ b/tests/hosting_core/app/oauth/test_sign_in_response.py @@ -1,6 +1,7 @@ from microsoft_agents.hosting.core.app.oauth import _SignInResponse from microsoft_agents.hosting.core._oauth import _FlowStateTag + def test_sign_in_response_sign_in_complete(): assert _SignInResponse(tag=_FlowStateTag.BEGIN).sign_in_complete() == False assert _SignInResponse(tag=_FlowStateTag.CONTINUE).sign_in_complete() == False diff --git a/tests/hosting_core/app/test_agent_application.py b/tests/hosting_core/app/test_agent_application.py index 0516841f..b5bad921 100644 --- a/tests/hosting_core/app/test_agent_application.py +++ b/tests/hosting_core/app/test_agent_application.py @@ -18,17 +18,16 @@ # @pytest.fixture # def options(self): # return ApplicationOptions() - + # @pytest.fixture # def storage(self): # return MemoryStorage() - + # @pytest.fixture # def connection_manager(self): # return MsalConnectionManager() - # class TestAgentApplication: -# pass \ No newline at end of file +# pass diff --git a/tests/hosting_core/test_turn_context.py b/tests/hosting_core/test_turn_context.py index aad00067..263b856f 100644 --- a/tests/hosting_core/test_turn_context.py +++ b/tests/hosting_core/test_turn_context.py @@ -420,4 +420,4 @@ async def aux_func( await context.send_trace_activity( "name-text", "value-text", "valueType-text", "label-text" ) - assert called \ No newline at end of file + assert called From fe197cbacc1b58e4fffbb1c5e14dd172516f292a Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 1 Oct 2025 16:08:49 -0700 Subject: [PATCH 34/36] Fixing test case --- .../adapters/testing_adapter.py | 2 +- .../test_transcript_logger_middleware.py | 50 ++++--- .../storage/test_transcript_store_memory.py | 141 +++++++++++------- 3 files changed, 121 insertions(+), 72 deletions(-) diff --git a/tests/_common/testing_objects/adapters/testing_adapter.py b/tests/_common/testing_objects/adapters/testing_adapter.py index f753f78b..38021bc4 100644 --- a/tests/_common/testing_objects/adapters/testing_adapter.py +++ b/tests/_common/testing_objects/adapters/testing_adapter.py @@ -497,7 +497,7 @@ def create_turn_context( turn_context = TurnContext(self, activity) turn_context.services["UserTokenClient"] = self._user_token_client - turn_context.identity = identity or self.claims_identity + turn_context._identity = identity or self.claims_identity return turn_context diff --git a/tests/hosting_core/storage/test_transcript_logger_middleware.py b/tests/hosting_core/storage/test_transcript_logger_middleware.py index d980e3c7..f63db030 100644 --- a/tests/hosting_core/storage/test_transcript_logger_middleware.py +++ b/tests/hosting_core/storage/test_transcript_logger_middleware.py @@ -6,11 +6,21 @@ from microsoft_agents.activity import Activity, ActivityEventNames, ActivityTypes from microsoft_agents.hosting.core.authorization.claims_identity import ClaimsIdentity from microsoft_agents.hosting.core.middleware_set import TurnContext -from microsoft_agents.hosting.core.storage.transcript_logger import ConsoleTranscriptLogger, FileTranscriptLogger, TranscriptLoggerMiddleware -from microsoft_agents.hosting.core.storage.transcript_memory_store import TranscriptMemoryStore +from microsoft_agents.hosting.core.storage.transcript_logger import ( + ConsoleTranscriptLogger, + FileTranscriptLogger, + TranscriptLoggerMiddleware, +) +from microsoft_agents.hosting.core.storage.transcript_memory_store import ( + TranscriptMemoryStore, +) import pytest -from tests._common.testing_objects.adapters.testing_adapter import AgentCallbackHandler, TestingAdapter +from tests._common.testing_objects.adapters.testing_adapter import ( + AgentCallbackHandler, + TestingAdapter, +) + @pytest.mark.asyncio async def test_should_round_trip_via_middleware(): @@ -18,23 +28,23 @@ async def test_should_round_trip_via_middleware(): conversation_id = "id.1" transcript_middleware = TranscriptLoggerMiddleware(transcript_store) channelName = "Channel1" - + adapter = TestingAdapter(channelName) - adapter.use(transcript_middleware) + adapter.use(transcript_middleware) id = ClaimsIdentity({}, True) async def callback(tc): print("process callback") a1 = adapter.make_activity("some random text") - a1.conversation.id = conversation_id # Make sure the conversation ID is set + a1.conversation.id = conversation_id # Make sure the conversation ID is set await adapter.process_activity(id, a1, callback) - + transcriptAndContinuationToken = await transcript_store.get_transcript_activities( channelName, conversation_id ) - + transcript = transcriptAndContinuationToken[0] continuationToken = transcriptAndContinuationToken[1] @@ -44,12 +54,13 @@ async def callback(tc): assert transcript[0].text == a1.text assert continuationToken is None + @pytest.mark.asyncio async def test_should_write_to_file(): fileName = "test_transcript.log" - if os.path.exists(fileName): # Check if the file exists - os.remove(fileName) # Delete the file + if os.path.exists(fileName): # Check if the file exists + os.remove(fileName) # Delete the file assert not os.path.exists(fileName), "file already exists." @@ -57,9 +68,9 @@ async def test_should_write_to_file(): conversation_id = "id.1" transcript_middleware = TranscriptLoggerMiddleware(file_store) channelName = "Channel1" - + adapter = TestingAdapter(channelName) - adapter.use(transcript_middleware) + adapter.use(transcript_middleware) id = ClaimsIdentity({}, True) async def callback(tc): @@ -67,8 +78,8 @@ async def callback(tc): textInActivity = "some random text" a1 = adapter.make_activity(textInActivity) - a1.conversation.id = conversation_id # Make sure the conversation ID is set - + a1.conversation.id = conversation_id # Make sure the conversation ID is set + # This round-trips out to the File logger which does the actual write await adapter.process_activity(id, a1, callback) @@ -77,6 +88,7 @@ async def callback(tc): assert os.path.isfile(fileName), "file is not a file." assert os.path.getsize(fileName) > 0, "file is empty" + @pytest.mark.asyncio async def test_should_write_to_console(): @@ -84,9 +96,9 @@ async def test_should_write_to_console(): conversation_id = "id.1" transcript_middleware = TranscriptLoggerMiddleware(store) channelName = "Channel1" - + adapter = TestingAdapter(channelName) - adapter.use(transcript_middleware) + adapter.use(transcript_middleware) id = ClaimsIdentity({}, True) async def callback(tc): @@ -94,9 +106,9 @@ async def callback(tc): textInActivity = "some random text" a1 = adapter.make_activity(textInActivity) - a1.conversation.id = conversation_id # Make sure the conversation ID is set - + a1.conversation.id = conversation_id # Make sure the conversation ID is set + # This round-trips out to the console logger which does the actual write await adapter.process_activity(id, a1, callback) - #check the console by hand. \ No newline at end of file + # check the console by hand. diff --git a/tests/hosting_core/storage/test_transcript_store_memory.py b/tests/hosting_core/storage/test_transcript_store_memory.py index 2733eb85..7d11e752 100644 --- a/tests/hosting_core/storage/test_transcript_store_memory.py +++ b/tests/hosting_core/storage/test_transcript_store_memory.py @@ -3,31 +3,39 @@ from datetime import datetime, timezone import pytest -from microsoft_agents.hosting.core.storage.transcript_memory_store import TranscriptMemoryStore +from microsoft_agents.hosting.core.storage.transcript_memory_store import ( + TranscriptMemoryStore, +) from microsoft_agents.activity import Activity, ConversationAccount + @pytest.mark.asyncio async def test_get_transcript_empty(): store = TranscriptMemoryStore() - transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1") + transcriptAndContinuationToken = await store.get_transcript_activities( + "Channel 1", "Conversation 1" + ) transcript = transcriptAndContinuationToken[0] continuationToken = transcriptAndContinuationToken[1] assert transcript == [] assert continuationToken is None + @pytest.mark.asyncio async def test_log_activity_add_one_activity(): store = TranscriptMemoryStore() activity = Activity.create_message_activity() activity.text = "Activity 1" activity.channel_id = "Channel 1" - activity.conversation = ConversationAccount( id="Conversation 1" ) - - # Add one activity and make sure it's there and comes back + activity.conversation = ConversationAccount(id="Conversation 1") + + # Add one activity and make sure it's there and comes back await store.log_activity(activity) # Ask for the activity we just added - transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1") + transcriptAndContinuationToken = await store.get_transcript_activities( + "Channel 1", "Conversation 1" + ) transcript = transcriptAndContinuationToken[0] continuationToken = transcriptAndContinuationToken[1] @@ -38,37 +46,44 @@ async def test_log_activity_add_one_activity(): assert continuationToken is None # Ask for a channel that doesn't exist and make sure we get nothing - transcriptAndContinuationToken = await store.get_transcript_activities("Invalid", "Conversation 1") + transcriptAndContinuationToken = await store.get_transcript_activities( + "Invalid", "Conversation 1" + ) transcript = transcriptAndContinuationToken[0] continuationToken = transcriptAndContinuationToken[1] - assert transcript == [] + assert transcript == [] assert continuationToken is None # Ask for a ConversationID that doesn't exist and make sure we get nothing - transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "INVALID") + transcriptAndContinuationToken = await store.get_transcript_activities( + "Channel 1", "INVALID" + ) transcript = transcriptAndContinuationToken[0] continuationToken = transcriptAndContinuationToken[1] - assert transcript == [] + assert transcript == [] assert continuationToken is None + @pytest.mark.asyncio async def test_log_activity_add_two_activity_same_conversation(): store = TranscriptMemoryStore() activity1 = Activity.create_message_activity() activity1.text = "Activity 1" activity1.channel_id = "Channel 1" - activity1.conversation = ConversationAccount( id="Conversation 1" ) + activity1.conversation = ConversationAccount(id="Conversation 1") activity2 = Activity.create_message_activity() activity2.text = "Activity 2" activity2.channel_id = "Channel 1" - activity2.conversation = ConversationAccount( id="Conversation 1" ) + activity2.conversation = ConversationAccount(id="Conversation 1") await store.log_activity(activity1) - await store.log_activity(activity2) + await store.log_activity(activity2) # Ask for the activity we just added - transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1") + transcriptAndContinuationToken = await store.get_transcript_activities( + "Channel 1", "Conversation 1" + ) transcript = transcriptAndContinuationToken[0] continuationToken = transcriptAndContinuationToken[1] @@ -83,50 +98,57 @@ async def test_log_activity_add_two_activity_same_conversation(): assert continuationToken is None + @pytest.mark.asyncio async def test_log_activity_add_two_activity_same_conversation(): store = TranscriptMemoryStore() activity1 = Activity.create_message_activity() activity1.text = "Activity 1" activity1.channel_id = "Channel 1" - activity1.conversation = ConversationAccount( id="Conversation 1" ) - activity1.timestamp = datetime(2000, 1, 1, 12, 0, 0 , tzinfo=timezone.utc) + activity1.conversation = ConversationAccount(id="Conversation 1") + activity1.timestamp = datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc) activity2 = Activity.create_message_activity() activity2.text = "Activity 2" activity2.channel_id = "Channel 1" - activity2.conversation = ConversationAccount( id="Conversation 1" ) - activity2.timestamp = datetime(2010, 1, 1, 12, 0, 1 , tzinfo=timezone.utc) + activity2.conversation = ConversationAccount(id="Conversation 1") + activity2.timestamp = datetime(2010, 1, 1, 12, 0, 1, tzinfo=timezone.utc) activity3 = Activity.create_message_activity() activity3.text = "Activity 2" activity3.channel_id = "Channel 1" - activity3.conversation = ConversationAccount( id="Conversation 1" ) - activity3.timestamp = datetime(2020, 1, 1, 12, 0, 1 , tzinfo=timezone.utc) + activity3.conversation = ConversationAccount(id="Conversation 1") + activity3.timestamp = datetime(2020, 1, 1, 12, 0, 1, tzinfo=timezone.utc) - await store.log_activity(activity1) # 2000 - await store.log_activity(activity2) # 2010 - await store.log_activity(activity3) # 2020 + await store.log_activity(activity1) # 2000 + await store.log_activity(activity2) # 2010 + await store.log_activity(activity3) # 2020 # Ask for the activities we just added - date1 = datetime(1999, 1, 1, 12, 0, 0 , tzinfo=timezone.utc) - date2 = datetime(2009, 1, 1, 12, 0, 0 , tzinfo=timezone.utc) - date3 = datetime(2019, 1, 1, 12, 0, 0 , tzinfo=timezone.utc) + date1 = datetime(1999, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + date2 = datetime(2009, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + date3 = datetime(2019, 1, 1, 12, 0, 0, tzinfo=timezone.utc) # ask for everything after 1999. Should get all 3 activities - transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1", None, date1) + transcriptAndContinuationToken = await store.get_transcript_activities( + "Channel 1", "Conversation 1", None, date1 + ) transcript = transcriptAndContinuationToken[0] continuationToken = transcriptAndContinuationToken[1] assert len(transcript) == 3 # ask for everything after 2009. Should get 2 activities - the 2010 and 2020 activities - transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1", None, date2) + transcriptAndContinuationToken = await store.get_transcript_activities( + "Channel 1", "Conversation 1", None, date2 + ) transcript = transcriptAndContinuationToken[0] continuationToken = transcriptAndContinuationToken[1] assert len(transcript) == 2 # ask for everything after 2019. Should only get the 2020 activity - transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1", None, date3) + transcriptAndContinuationToken = await store.get_transcript_activities( + "Channel 1", "Conversation 1", None, date3 + ) transcript = transcriptAndContinuationToken[0] continuationToken = transcriptAndContinuationToken[1] assert len(transcript) == 1 @@ -138,18 +160,20 @@ async def test_log_activity_add_two_activity_two_conversation(): activity1 = Activity.create_message_activity() activity1.text = "Activity 1 Channel 1 Conversation 1" activity1.channel_id = "Channel 1" - activity1.conversation = ConversationAccount( id="Conversation 1" ) + activity1.conversation = ConversationAccount(id="Conversation 1") activity2 = Activity.create_message_activity() activity2.text = "Activity 1 Channel 1 Conversation 2" activity2.channel_id = "Channel 1" - activity2.conversation = ConversationAccount( id="Conversation 2" ) + activity2.conversation = ConversationAccount(id="Conversation 2") await store.log_activity(activity1) - await store.log_activity(activity2) + await store.log_activity(activity2) # Ask for the activity we just added - transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1") + transcriptAndContinuationToken = await store.get_transcript_activities( + "Channel 1", "Conversation 1" + ) transcript = transcriptAndContinuationToken[0] continuationToken = transcriptAndContinuationToken[1] @@ -160,7 +184,9 @@ async def test_log_activity_add_two_activity_two_conversation(): assert continuationToken is None # Now grab Conversation 2 - transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 2") + transcriptAndContinuationToken = await store.get_transcript_activities( + "Channel 1", "Conversation 2" + ) transcript = transcriptAndContinuationToken[0] continuationToken = transcriptAndContinuationToken[1] @@ -170,19 +196,22 @@ async def test_log_activity_add_two_activity_two_conversation(): assert transcript[0].text == activity2.text assert continuationToken is None + @pytest.mark.asyncio async def test_delete_one_transcript(): store = TranscriptMemoryStore() activity = Activity.create_message_activity() activity.text = "Activity 1" activity.channel_id = "Channel 1" - activity.conversation = ConversationAccount( id="Conversation 1" ) - - # Add one activity and make sure it's there and comes back + activity.conversation = ConversationAccount(id="Conversation 1") + + # Add one activity and make sure it's there and comes back await store.log_activity(activity) # Ask for the activity we just added - transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1") + transcriptAndContinuationToken = await store.get_transcript_activities( + "Channel 1", "Conversation 1" + ) transcript = transcriptAndContinuationToken[0] continuationToken = transcriptAndContinuationToken[1] @@ -190,25 +219,28 @@ async def test_delete_one_transcript(): # Now delete the transcript await store.delete_transcript("Channel 1", "Conversation 1") - transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1") + transcriptAndContinuationToken = await store.get_transcript_activities( + "Channel 1", "Conversation 1" + ) transcript = transcriptAndContinuationToken[0] assert len(transcript) == 0 + @pytest.mark.asyncio async def test_delete_one_transcript_of_two(): store = TranscriptMemoryStore() - + activity = Activity.create_message_activity() activity.text = "Activity 1" activity.channel_id = "Channel 1" - activity.conversation = ConversationAccount( id="Conversation 1" ) + activity.conversation = ConversationAccount(id="Conversation 1") activity2 = Activity.create_message_activity() activity2.text = "Activity 2" activity2.channel_id = "Channel 2" - activity2.conversation = ConversationAccount( id="Conversation 1" ) + activity2.conversation = ConversationAccount(id="Conversation 1") - # Add one activity and make sure it's there and comes back + # Add one activity and make sure it's there and comes back await store.log_activity(activity) await store.log_activity(activity2) @@ -218,28 +250,33 @@ async def test_delete_one_transcript_of_two(): await store.delete_transcript("Channel 1", "Conversation 1") # Make sure the one we deleted is gone - transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1") + transcriptAndContinuationToken = await store.get_transcript_activities( + "Channel 1", "Conversation 1" + ) transcript = transcriptAndContinuationToken[0] assert len(transcript) == 0 # Make sure the other one is still there - transcriptAndContinuationToken = await store.get_transcript_activities("Channel 2", "Conversation 1") + transcriptAndContinuationToken = await store.get_transcript_activities( + "Channel 2", "Conversation 1" + ) transcript = transcriptAndContinuationToken[0] assert len(transcript) == 1 + @pytest.mark.asyncio async def test_list_transcripts(): store = TranscriptMemoryStore() - + activity = Activity.create_message_activity() activity.text = "Activity 1" activity.channel_id = "Channel 1" - activity.conversation = ConversationAccount( id="Conversation 1" ) + activity.conversation = ConversationAccount(id="Conversation 1") activity2 = Activity.create_message_activity() activity2.text = "Activity 2" activity2.channel_id = "Channel 2" - activity2.conversation = ConversationAccount( id="Conversation 1" ) + activity2.conversation = ConversationAccount(id="Conversation 1") # Make sure a list on an empty store returns an empty set transcriptAndContinuationToken = await store.list_transcripts("Should Be Empty") @@ -251,7 +288,7 @@ async def test_list_transcripts(): # Add one activity so we can go searching await store.log_activity(activity) - transcriptAndContinuationToken = await store.list_transcripts("Channel 1") + transcriptAndContinuationToken = await store.list_transcripts("Channel 1") transcript = transcriptAndContinuationToken[0] continuationToken = transcriptAndContinuationToken[1] assert len(transcript) == 1 @@ -261,15 +298,15 @@ async def test_list_transcripts(): await store.log_activity(activity2) # Check again for "Transcript 1" which is on channel 1 - transcriptAndContinuationToken = await store.list_transcripts("Channel 1") + transcriptAndContinuationToken = await store.list_transcripts("Channel 1") transcript = transcriptAndContinuationToken[0] continuationToken = transcriptAndContinuationToken[1] assert len(transcript) == 1 assert continuationToken is None # Check for "Transcript 2" which is on channel 2 - transcriptAndContinuationToken = await store.list_transcripts("Channel 2") + transcriptAndContinuationToken = await store.list_transcripts("Channel 2") transcript = transcriptAndContinuationToken[0] continuationToken = transcriptAndContinuationToken[1] assert len(transcript) == 1 - assert continuationToken is None \ No newline at end of file + assert continuationToken is None From 1f2ce31bde6892062fd63f1578e214b3e3830405 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 2 Oct 2025 09:07:29 -0700 Subject: [PATCH 35/36] Removing unneeded subchannel constants --- .../microsoft_agents/activity/channels.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py index d8184b80..d6172a43 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py @@ -12,15 +12,6 @@ class Channels(str, Enum): """Agents channel.""" agents = "agents" - agents_email_sub_channel = "email" - agents_excel_sub_channel = "excel" - agents_word_sub_channel = "word" - agents_power_point_sub_channel = "powerpoint" - - agents_email = "agents:email" - agents_excel = "agents:excel" - agents_word = "agents:word" - agents_power_point = "agents:powerpoint" console = "console" """Console channel.""" From 8883dd4c35f91ae891d7cfbe9c8d32c93e811c8b Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 2 Oct 2025 11:15:04 -0700 Subject: [PATCH 36/36] Logging blueprint id only if debugging --- .../authentication/msal/msal_auth.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 839aacd8..abeb718c 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -24,6 +24,18 @@ logger = logging.getLogger(__name__) +# this is deferred because jwt.decode is expensive and we don't want to do it unless we +# have logging.DEBUG enabled +class _DeferredLogOfBlueprintId: + def __init__(self, jwt_token: str): + self.jwt_token = jwt_token + + def __str__(self): + payload = jwt.decode(self.jwt_token, options={"verify_signature": False}) + agentic_blueprint_id = payload.get("xms_par_app_azp") + return f"Agentic blueprint id: {agentic_blueprint_id}" + + class MsalAuth(AccessTokenProviderBase): _client_credential_cache = None @@ -291,9 +303,7 @@ async def get_agentic_instance_token( ) raise ValueError(f"Failed to acquire token. {str(agentic_instance_token)}") - payload = jwt.decode(token, options={"verify_signature": False}) - agentic_blueprint_id = payload.get("xms_par_app_azp") - logger.debug("Agentic blueprint id: %s", agentic_blueprint_id) + logger.debug(_DeferredLogOfBlueprintId(token)) return agentic_instance_token["access_token"], agent_token_result