From ca07a830f4cf8ebf5a3c41e84651ceb4f4edd8ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Brand=C3=A3o?= Date: Thu, 18 Sep 2025 09:15:35 -0700 Subject: [PATCH 01/67] Drafting extension starter sample --- test_samples/extension-starter/README.md | 0 .../extension-starter/requirements.txt | 0 test_samples/extension-starter/sample/main.py | 15 ++++ .../extension-starter/src/__init__.py | 0 .../src/extension/__init__.py | 0 .../src/extension/custom_extension_result.py | 6 ++ .../src/extension/extension.py | 10 +++ .../src/extension/get_channel_data.py | 2 + .../src/extension/messaging_extension.py | 56 +++++++++++++++ .../src/extension/mock_client.py | 1 + .../extension-starter/src/extension/mocks.py | 44 ++++++++++++ .../src/extension/my_channel_data.py | 17 +++++ .../extension-starter/src/sample/agent.py | 69 +++++++++++++++++++ .../src/sample/extension_agent.py | 20 ++++++ .../extension-starter/src/sample/main.py | 7 ++ 15 files changed, 247 insertions(+) create mode 100644 test_samples/extension-starter/README.md create mode 100644 test_samples/extension-starter/requirements.txt create mode 100644 test_samples/extension-starter/sample/main.py create mode 100644 test_samples/extension-starter/src/__init__.py create mode 100644 test_samples/extension-starter/src/extension/__init__.py create mode 100644 test_samples/extension-starter/src/extension/custom_extension_result.py create mode 100644 test_samples/extension-starter/src/extension/extension.py create mode 100644 test_samples/extension-starter/src/extension/get_channel_data.py create mode 100644 test_samples/extension-starter/src/extension/messaging_extension.py create mode 100644 test_samples/extension-starter/src/extension/mock_client.py create mode 100644 test_samples/extension-starter/src/extension/mocks.py create mode 100644 test_samples/extension-starter/src/extension/my_channel_data.py create mode 100644 test_samples/extension-starter/src/sample/agent.py create mode 100644 test_samples/extension-starter/src/sample/extension_agent.py create mode 100644 test_samples/extension-starter/src/sample/main.py diff --git a/test_samples/extension-starter/README.md b/test_samples/extension-starter/README.md new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/extension-starter/requirements.txt b/test_samples/extension-starter/requirements.txt new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/extension-starter/sample/main.py b/test_samples/extension-starter/sample/main.py new file mode 100644 index 00000000..fe5821fb --- /dev/null +++ b/test_samples/extension-starter/sample/main.py @@ -0,0 +1,15 @@ +from microsoft_agents.hosting.core import ( + AgentApplication, + MemoryStorage, + TurnContext, + TurnState, + Authorization +) + +from src.extension import Extension + +AUTHORIZATION = Authorization() +MEMORY = MemoryStorage() +APP = AgentApplication() + +EXTENSION = Extension(app=APP) \ No newline at end of file diff --git a/test_samples/extension-starter/src/__init__.py b/test_samples/extension-starter/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/extension-starter/src/extension/__init__.py b/test_samples/extension-starter/src/extension/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/extension-starter/src/extension/custom_extension_result.py b/test_samples/extension-starter/src/extension/custom_extension_result.py new file mode 100644 index 00000000..2d087cd3 --- /dev/null +++ b/test_samples/extension-starter/src/extension/custom_extension_result.py @@ -0,0 +1,6 @@ +from microsoft_agents.activity import AgentsModel + +class CustomExtensionResult(AgentsModel): + + def __init__(self): + pass \ No newline at end of file diff --git a/test_samples/extension-starter/src/extension/extension.py b/test_samples/extension-starter/src/extension/extension.py new file mode 100644 index 00000000..f7e9410c --- /dev/null +++ b/test_samples/extension-starter/src/extension/extension.py @@ -0,0 +1,10 @@ +from microsoft_agents.hosting.core import ( + AgentApplication, +) + +class ExtensionAgent: + + def __init__(self, app: AgentApplication): + self.app = app + + def \ No newline at end of file diff --git a/test_samples/extension-starter/src/extension/get_channel_data.py b/test_samples/extension-starter/src/extension/get_channel_data.py new file mode 100644 index 00000000..f1232a71 --- /dev/null +++ b/test_samples/extension-starter/src/extension/get_channel_data.py @@ -0,0 +1,2 @@ +def get_channel_data(context): + return context.activity.channel_data \ No newline at end of file diff --git a/test_samples/extension-starter/src/extension/messaging_extension.py b/test_samples/extension-starter/src/extension/messaging_extension.py new file mode 100644 index 00000000..723fe584 --- /dev/null +++ b/test_samples/extension-starter/src/extension/messaging_extension.py @@ -0,0 +1,56 @@ +from microsoft_agents.activity import ( + Activity, + AgentsModel +) + +from microsoft_agents.hosting.core import ( + TurnState, + TurnContext +) + +class ExtensionQuery(AgentsModel): + pass + +class ExtensionResult(AgentsModel): + pass + +TState = TypeVar("TState", bound=TurnState) +RouteQueryHandler = TypeVar("RouteQueryHandler", + bound=Awaitable[[TurnContext, TState, query: ExtensionQuery], ExtensionResult]) + +def create_route_selector(route_type: str) -> Awaitable[[TurnContext], bool]: + + async def route_selector(context: TurnContext) -> bool: + return context.activity.type == ActivityTypes.message and \ + context.activity.channel_id == MY_CHANNEL and \ + context.activity.name == route_type + + return route_selector + +class MessageExtension(Generic[TState]): + + def __init__(self, app: AgentApplication): + self._app = app + + def on_query(self, handler: RouteQueryHandler[TState]): + + route_selector = create_route_selector("query") + + async def route_handler(context: TurnContext, state: TState): + message_extension_query = MessageExtensionQuery.model_validate(context.activity.value) + result = await handler(context, state, message_extension_query) + + self._app.add_route(route_selector, route_handler, True) + + def on_invoke_custom_event(self, handler: RouteQueryHandler[TState]): + + route_selector = create_route_selector("invokeCustomEvent") + + async def route_handler(context: TurnContext, state: TState): + message_extension_query = MessageExtensionQuery.model_validate(context.activity.value) + result = await handler(context, state, message_extension_query) + response = Activity(type=ActivityTypes.invoke_response, value=InvokeResponse( + status=200, + body=result + )) + await context.send_activity(response) \ No newline at end of file diff --git a/test_samples/extension-starter/src/extension/mock_client.py b/test_samples/extension-starter/src/extension/mock_client.py new file mode 100644 index 00000000..8da03602 --- /dev/null +++ b/test_samples/extension-starter/src/extension/mock_client.py @@ -0,0 +1 @@ +class MockClient \ No newline at end of file diff --git a/test_samples/extension-starter/src/extension/mocks.py b/test_samples/extension-starter/src/extension/mocks.py new file mode 100644 index 00000000..d6e88690 --- /dev/null +++ b/test_samples/extension-starter/src/extension/mocks.py @@ -0,0 +1,44 @@ +""" +If you are looking for more mocking capabilities for the SDK's core components, +consider taking a look at the /tests/_common directory under the Python SDK's root. +""" + +class MockSimpleAdapter(ChannelAdapter): + + def __init__(self): + super().__init__() + + async def send_activities(self, context, activities) -> List[ResourceResponse]: + responses = [] + assert context is not None + assert activities is not None + assert isinstance(activities, list) + assert activities + for idx, activity in enumerate(activities): # pylint: disable=unused-variable + assert isinstance(activity, Activity) + assert activity.type == "message" or activity.type == ActivityTypes.trace + responses.append(ResourceResponse(id="5678")) + return responses + + async def update_activity(self, context, activity): + assert context is not None + assert activity is not None + assert activity.id is not None + return ResourceResponse(id=activity.id) + + async def delete_activity(self, context, reference): + assert context is not None + assert reference is not None + assert reference.activity_id == ACTIVITY.id + +class MockClient: + + def __init__(self, adapter: ChannelAdapter, on_activity: Awaitable[Activity, None] = None): + self._adapater = adapter + self._on_activity = None + + def on_activity(self, handler: Awaitable[Activity, None]): + self._on_activity = handler + + + \ No newline at end of file diff --git a/test_samples/extension-starter/src/extension/my_channel_data.py b/test_samples/extension-starter/src/extension/my_channel_data.py new file mode 100644 index 00000000..ce3527e6 --- /dev/null +++ b/test_samples/extension-starter/src/extension/my_channel_data.py @@ -0,0 +1,17 @@ +from typing import Optional + +from microsoft_agents.activity import AgentsModel + +class MyChannelData(AgentsModel): + + user_id: Optional[str] = None + custom_field: Optional[str] = None + +def get_my_channel_data(context): + + data = MyChannelData( + user_id=context.activity.from_property.id, + custom_field=context.activity.channel_data.get("custom_field") + ) + + return data \ No newline at end of file diff --git a/test_samples/extension-starter/src/sample/agent.py b/test_samples/extension-starter/src/sample/agent.py new file mode 100644 index 00000000..275ebd3e --- /dev/null +++ b/test_samples/extension-starter/src/sample/agent.py @@ -0,0 +1,69 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from dotenv import load_dotenv +from aiohttp.web import Application, Request, Response, run_app + +from microsoft_agents.hosting.core import RestChannelServiceClientFactory +from microsoft_agents.hosting.aiohttp import CloudAdapter, jwt_authorization_middleware +from microsoft_agents.hosting.core.authorization import ( + Connections, + AccessTokenProviderBase, + ClaimsIdentity, +) +from microsoft_agents.authentication.msal import MsalAuth +from openai import AsyncAzureOpenAI + +from weather_agent import WeatherAgent +from config import DefaultConfig + +load_dotenv() + +CONFIG = DefaultConfig() +AUTH_PROVIDER = MsalAuth(DefaultConfig()) + + +class DefaultConnection(Connections): + def get_default_connection(self) -> AccessTokenProviderBase: + pass + + def get_token_provider( + self, claims_identity: ClaimsIdentity, service_url: str + ) -> AccessTokenProviderBase: + return AUTH_PROVIDER + + def get_connection(self, connection_name: str) -> AccessTokenProviderBase: + pass + + +CHANNEL_CLIENT_FACTORY = RestChannelServiceClientFactory(CONFIG, DefaultConnection()) + +# Create adapter. +ADAPTER = CloudAdapter(CHANNEL_CLIENT_FACTORY) + +# gets the API Key from environment variable AZURE_OPENAI_API_KEY +CLIENT = AsyncAzureOpenAI( + api_version=CONFIG.AZURE_OPENAI_API_VERSION, + azure_endpoint=CONFIG.AZURE_OPENAI_ENDPOINT, +) + +# Create the Agent +AGENT = WeatherAgent(client=CLIENT) + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + adapter: CloudAdapter = req.app["adapter"] + return await adapter.process(req, AGENT) + + +APP = Application(middlewares=[jwt_authorization_middleware]) +APP.router.add_post("/api/messages", messages) +APP["agent_configuration"] = CONFIG +APP["adapter"] = ADAPTER + +if __name__ == "__main__": + try: + run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error \ No newline at end of file diff --git a/test_samples/extension-starter/src/sample/extension_agent.py b/test_samples/extension-starter/src/sample/extension_agent.py new file mode 100644 index 00000000..ad416b6d --- /dev/null +++ b/test_samples/extension-starter/src/sample/extension_agent.py @@ -0,0 +1,20 @@ +from microsoft_agents.hosting.core import ( + AgentApplication, + MemoryStorage, + TurnContext, + TurnState +) + +from src.extension import ExtensionAgent + +APP = AgentApplication() + +EXT = ExtensionAgent(APP) + +@EXT.on_custom_event +async def custom_event(context: TurnContext, state: TurnState): + await context.send_activity(f"Custom event triggered {context.activity.type}/{context.activity.name}") + +@EXT.on_message_reaction_added +async def reaction_added(context: TurnContext, state: TurnState, reaction: str): + await context.send_activity(f"Reaction added: {reaction}") \ No newline at end of file diff --git a/test_samples/extension-starter/src/sample/main.py b/test_samples/extension-starter/src/sample/main.py new file mode 100644 index 00000000..1c5606ce --- /dev/null +++ b/test_samples/extension-starter/src/sample/main.py @@ -0,0 +1,7 @@ +from extension_agent import APP, ext + +class MockClient: + pass + +if __name__ == "__main__": + pass \ No newline at end of file From 1f85836b8ecb721346a42b92d3d7008df09e3898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Brand=C3=A3o?= Date: Thu, 18 Sep 2025 09:34:59 -0700 Subject: [PATCH 02/67] Completed basic outline --- test_samples/extension-starter/src/extension/extension.py | 7 ++++++- .../src/extension/messaging_extension.py | 6 ------ .../extension/{custom_extension_result.py => models.py} | 8 +++++++- .../src/extension/my_connector_client.py | 8 ++++++++ .../extension-starter/src/{extension => sample}/mocks.py | 0 5 files changed, 21 insertions(+), 8 deletions(-) rename test_samples/extension-starter/src/extension/{custom_extension_result.py => models.py} (53%) create mode 100644 test_samples/extension-starter/src/extension/my_connector_client.py rename test_samples/extension-starter/src/{extension => sample}/mocks.py (100%) diff --git a/test_samples/extension-starter/src/extension/extension.py b/test_samples/extension-starter/src/extension/extension.py index f7e9410c..68288468 100644 --- a/test_samples/extension-starter/src/extension/extension.py +++ b/test_samples/extension-starter/src/extension/extension.py @@ -2,9 +2,14 @@ AgentApplication, ) +from src.extension.my_connector_client import MyConnectorClient + class ExtensionAgent: def __init__(self, app: AgentApplication): self.app = app - def \ No newline at end of file + @staticmethod + def get_rest_client(self, context: TurnContext) -> MyConnectorClient: + connector_client = context.turn_state.get("connector_client") + return MyConnectorClient(connector_client) \ No newline at end of file diff --git a/test_samples/extension-starter/src/extension/messaging_extension.py b/test_samples/extension-starter/src/extension/messaging_extension.py index 723fe584..131e8fac 100644 --- a/test_samples/extension-starter/src/extension/messaging_extension.py +++ b/test_samples/extension-starter/src/extension/messaging_extension.py @@ -8,12 +8,6 @@ TurnContext ) -class ExtensionQuery(AgentsModel): - pass - -class ExtensionResult(AgentsModel): - pass - TState = TypeVar("TState", bound=TurnState) RouteQueryHandler = TypeVar("RouteQueryHandler", bound=Awaitable[[TurnContext, TState, query: ExtensionQuery], ExtensionResult]) diff --git a/test_samples/extension-starter/src/extension/custom_extension_result.py b/test_samples/extension-starter/src/extension/models.py similarity index 53% rename from test_samples/extension-starter/src/extension/custom_extension_result.py rename to test_samples/extension-starter/src/extension/models.py index 2d087cd3..176fc53a 100644 --- a/test_samples/extension-starter/src/extension/custom_extension_result.py +++ b/test_samples/extension-starter/src/extension/models.py @@ -3,4 +3,10 @@ class CustomExtensionResult(AgentsModel): def __init__(self): - pass \ No newline at end of file + pass + +class ExtensionQuery(AgentsModel): + pass + +class ExtensionResult(AgentsModel): + pass \ No newline at end of file diff --git a/test_samples/extension-starter/src/extension/my_connector_client.py b/test_samples/extension-starter/src/extension/my_connector_client.py new file mode 100644 index 00000000..65b9db75 --- /dev/null +++ b/test_samples/extension-starter/src/extension/my_connector_client.py @@ -0,0 +1,8 @@ +from microsoft_agents.hosting.core import ( + ConnectorClient +) + +class MyConnectorClient(ConnectorClient): + + def __init__(self, base_url: str, **kwargs): + super().__init__(base_url, **kwargs) \ No newline at end of file diff --git a/test_samples/extension-starter/src/extension/mocks.py b/test_samples/extension-starter/src/sample/mocks.py similarity index 100% rename from test_samples/extension-starter/src/extension/mocks.py rename to test_samples/extension-starter/src/sample/mocks.py From 00839d3dc1ed85dbeaea59fee57dbcf5322cf950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Brand=C3=A3o?= Date: Thu, 18 Sep 2025 10:55:00 -0700 Subject: [PATCH 03/67] doc_samples directory created --- doc_samples/README.md | 3 +++ .../extension-starter/README.md | 0 .../extension-starter/requirements.txt | 0 .../extension-starter/src/__init__.py | 0 .../extension-starter/src/extension/__init__.py | 0 .../extension-starter/src/extension/extension.py | 1 + .../src/extension/get_channel_data.py | 0 .../src/extension/messaging_extension.py | 0 .../src/extension/mock_client.py | 0 .../extension-starter/src/extension/models.py | 0 .../src/extension/my_channel_data.py | 0 .../src/extension/my_connector_client.py | 0 .../extension-starter/src/sample/agent.py | 0 .../src/sample/extension_agent.py | 0 .../extension-starter/src/sample/main.py | 0 .../extension-starter/src/sample/mocks.py | 0 test_samples/extension-starter/sample/main.py | 15 --------------- 17 files changed, 4 insertions(+), 15 deletions(-) create mode 100644 doc_samples/README.md rename {test_samples => doc_samples}/extension-starter/README.md (100%) rename {test_samples => doc_samples}/extension-starter/requirements.txt (100%) rename {test_samples => doc_samples}/extension-starter/src/__init__.py (100%) rename {test_samples => doc_samples}/extension-starter/src/extension/__init__.py (100%) rename {test_samples => doc_samples}/extension-starter/src/extension/extension.py (96%) rename {test_samples => doc_samples}/extension-starter/src/extension/get_channel_data.py (100%) rename {test_samples => doc_samples}/extension-starter/src/extension/messaging_extension.py (100%) rename {test_samples => doc_samples}/extension-starter/src/extension/mock_client.py (100%) rename {test_samples => doc_samples}/extension-starter/src/extension/models.py (100%) rename {test_samples => doc_samples}/extension-starter/src/extension/my_channel_data.py (100%) rename {test_samples => doc_samples}/extension-starter/src/extension/my_connector_client.py (100%) rename {test_samples => doc_samples}/extension-starter/src/sample/agent.py (100%) rename {test_samples => doc_samples}/extension-starter/src/sample/extension_agent.py (100%) rename {test_samples => doc_samples}/extension-starter/src/sample/main.py (100%) rename {test_samples => doc_samples}/extension-starter/src/sample/mocks.py (100%) delete mode 100644 test_samples/extension-starter/sample/main.py diff --git a/doc_samples/README.md b/doc_samples/README.md new file mode 100644 index 00000000..fd85d0ef --- /dev/null +++ b/doc_samples/README.md @@ -0,0 +1,3 @@ +# Doc Samples + +These samples are a bit more specific to development and are meant to highlight usage patterns of specific SDK features. Included are also samples for developing extensions on top of the SDK. \ No newline at end of file diff --git a/test_samples/extension-starter/README.md b/doc_samples/extension-starter/README.md similarity index 100% rename from test_samples/extension-starter/README.md rename to doc_samples/extension-starter/README.md diff --git a/test_samples/extension-starter/requirements.txt b/doc_samples/extension-starter/requirements.txt similarity index 100% rename from test_samples/extension-starter/requirements.txt rename to doc_samples/extension-starter/requirements.txt diff --git a/test_samples/extension-starter/src/__init__.py b/doc_samples/extension-starter/src/__init__.py similarity index 100% rename from test_samples/extension-starter/src/__init__.py rename to doc_samples/extension-starter/src/__init__.py diff --git a/test_samples/extension-starter/src/extension/__init__.py b/doc_samples/extension-starter/src/extension/__init__.py similarity index 100% rename from test_samples/extension-starter/src/extension/__init__.py rename to doc_samples/extension-starter/src/extension/__init__.py diff --git a/test_samples/extension-starter/src/extension/extension.py b/doc_samples/extension-starter/src/extension/extension.py similarity index 96% rename from test_samples/extension-starter/src/extension/extension.py rename to doc_samples/extension-starter/src/extension/extension.py index 68288468..797a987c 100644 --- a/test_samples/extension-starter/src/extension/extension.py +++ b/doc_samples/extension-starter/src/extension/extension.py @@ -1,5 +1,6 @@ from microsoft_agents.hosting.core import ( AgentApplication, + TurnContext ) from src.extension.my_connector_client import MyConnectorClient diff --git a/test_samples/extension-starter/src/extension/get_channel_data.py b/doc_samples/extension-starter/src/extension/get_channel_data.py similarity index 100% rename from test_samples/extension-starter/src/extension/get_channel_data.py rename to doc_samples/extension-starter/src/extension/get_channel_data.py diff --git a/test_samples/extension-starter/src/extension/messaging_extension.py b/doc_samples/extension-starter/src/extension/messaging_extension.py similarity index 100% rename from test_samples/extension-starter/src/extension/messaging_extension.py rename to doc_samples/extension-starter/src/extension/messaging_extension.py diff --git a/test_samples/extension-starter/src/extension/mock_client.py b/doc_samples/extension-starter/src/extension/mock_client.py similarity index 100% rename from test_samples/extension-starter/src/extension/mock_client.py rename to doc_samples/extension-starter/src/extension/mock_client.py diff --git a/test_samples/extension-starter/src/extension/models.py b/doc_samples/extension-starter/src/extension/models.py similarity index 100% rename from test_samples/extension-starter/src/extension/models.py rename to doc_samples/extension-starter/src/extension/models.py diff --git a/test_samples/extension-starter/src/extension/my_channel_data.py b/doc_samples/extension-starter/src/extension/my_channel_data.py similarity index 100% rename from test_samples/extension-starter/src/extension/my_channel_data.py rename to doc_samples/extension-starter/src/extension/my_channel_data.py diff --git a/test_samples/extension-starter/src/extension/my_connector_client.py b/doc_samples/extension-starter/src/extension/my_connector_client.py similarity index 100% rename from test_samples/extension-starter/src/extension/my_connector_client.py rename to doc_samples/extension-starter/src/extension/my_connector_client.py diff --git a/test_samples/extension-starter/src/sample/agent.py b/doc_samples/extension-starter/src/sample/agent.py similarity index 100% rename from test_samples/extension-starter/src/sample/agent.py rename to doc_samples/extension-starter/src/sample/agent.py diff --git a/test_samples/extension-starter/src/sample/extension_agent.py b/doc_samples/extension-starter/src/sample/extension_agent.py similarity index 100% rename from test_samples/extension-starter/src/sample/extension_agent.py rename to doc_samples/extension-starter/src/sample/extension_agent.py diff --git a/test_samples/extension-starter/src/sample/main.py b/doc_samples/extension-starter/src/sample/main.py similarity index 100% rename from test_samples/extension-starter/src/sample/main.py rename to doc_samples/extension-starter/src/sample/main.py diff --git a/test_samples/extension-starter/src/sample/mocks.py b/doc_samples/extension-starter/src/sample/mocks.py similarity index 100% rename from test_samples/extension-starter/src/sample/mocks.py rename to doc_samples/extension-starter/src/sample/mocks.py diff --git a/test_samples/extension-starter/sample/main.py b/test_samples/extension-starter/sample/main.py deleted file mode 100644 index fe5821fb..00000000 --- a/test_samples/extension-starter/sample/main.py +++ /dev/null @@ -1,15 +0,0 @@ -from microsoft_agents.hosting.core import ( - AgentApplication, - MemoryStorage, - TurnContext, - TurnState, - Authorization -) - -from src.extension import Extension - -AUTHORIZATION = Authorization() -MEMORY = MemoryStorage() -APP = AgentApplication() - -EXTENSION = Extension(app=APP) \ No newline at end of file From 37e7f70f9eed1a0e92edd2684548b4de4f12c702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Brand=C3=A3o?= Date: Thu, 18 Sep 2025 14:59:13 -0700 Subject: [PATCH 04/67] Enhancing app routing --- .../src/extension/custom_event_data.py | 15 ++++ .../src/extension/custom_event_result.py | 6 ++ .../src/extension/custom_event_types.py | 5 ++ .../src/extension/extension.py | 77 +++++++++++++++++-- .../src/extension/get_channel_data.py | 2 - .../src/extension/messaging_extension.py | 50 ------------ .../src/extension/mock_client.py | 1 - .../extension-starter/src/extension/models.py | 12 --- .../src/extension/my_channel_data.py | 17 ---- .../src/extension/my_connector_client.py | 8 -- .../src/sample/extension_agent.py | 8 +- .../extension-starter/src/sample/main.py | 18 ++++- .../extension-starter/src/sample/mocks.py | 41 ++++++---- .../microsoft_agents/activity/_model_utils.py | 1 - .../activity/_type_aliases.py | 1 - .../hosting/core/app/__init__.py | 3 +- .../hosting/core/app/agent_application.py | 31 +++++--- .../hosting/core/app/route.py | 32 -------- .../hosting/core/app/routes/__init__.py | 9 +++ .../hosting/core/app/routes/route.py | 36 +++++++++ .../hosting/core/app/routes/route_list.py | 45 +++++++++++ .../hosting/core/app/routes/route_rank.py | 11 +++ .../hosting/core/app/type_defs.py | 10 +++ .../microsoft-agents-hosting-core/setup.py | 1 + 24 files changed, 278 insertions(+), 162 deletions(-) create mode 100644 doc_samples/extension-starter/src/extension/custom_event_data.py create mode 100644 doc_samples/extension-starter/src/extension/custom_event_result.py create mode 100644 doc_samples/extension-starter/src/extension/custom_event_types.py delete mode 100644 doc_samples/extension-starter/src/extension/get_channel_data.py delete mode 100644 doc_samples/extension-starter/src/extension/messaging_extension.py delete mode 100644 doc_samples/extension-starter/src/extension/mock_client.py delete mode 100644 doc_samples/extension-starter/src/extension/models.py delete mode 100644 doc_samples/extension-starter/src/extension/my_channel_data.py delete mode 100644 doc_samples/extension-starter/src/extension/my_connector_client.py delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/route.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/__init__.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_rank.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/type_defs.py diff --git a/doc_samples/extension-starter/src/extension/custom_event_data.py b/doc_samples/extension-starter/src/extension/custom_event_data.py new file mode 100644 index 00000000..b2acbf06 --- /dev/null +++ b/doc_samples/extension-starter/src/extension/custom_event_data.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from typing import Optional +from microsoft_agents.activity import AgentsModel + +class CustomEventData(AgentsModel): + user_id: Optional[str] = None + field: Optional[str] = None + + @staticmethod + def from_context(context) -> CustomEventData: + return CustomEventData( + user_id=context.activity.from_property.id, + field=context.activity.channel_data.get("field") + ) \ No newline at end of file diff --git a/doc_samples/extension-starter/src/extension/custom_event_result.py b/doc_samples/extension-starter/src/extension/custom_event_result.py new file mode 100644 index 00000000..d5fddfa3 --- /dev/null +++ b/doc_samples/extension-starter/src/extension/custom_event_result.py @@ -0,0 +1,6 @@ +from typing import Optional +from microsoft_agents.activity import AgentsModel + +class CustomEventResult(AgentsModel): + user_id: Optional[str] = None + field: Optional[str] = None \ No newline at end of file diff --git a/doc_samples/extension-starter/src/extension/custom_event_types.py b/doc_samples/extension-starter/src/extension/custom_event_types.py new file mode 100644 index 00000000..b002f075 --- /dev/null +++ b/doc_samples/extension-starter/src/extension/custom_event_types.py @@ -0,0 +1,5 @@ +from strenum import StrEnum + +class CustomEventTypes(StrEnum): + CUSTOM_EVENT = "customEvent" + OTHER_CUSTOM_EVENT = "otherCustomEvent" \ No newline at end of file diff --git a/doc_samples/extension-starter/src/extension/extension.py b/doc_samples/extension-starter/src/extension/extension.py index 797a987c..ce856aa3 100644 --- a/doc_samples/extension-starter/src/extension/extension.py +++ b/doc_samples/extension-starter/src/extension/extension.py @@ -1,16 +1,79 @@ +from venv import create + +from typing import ( + Awaitable, + Generic, + TypeVar +) + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + InvokeResponse +) from microsoft_agents.hosting.core import ( AgentApplication, - TurnContext + TurnContext, + TurnState, ) -from src.extension.my_connector_client import MyConnectorClient +from src.extension.custom_event_data import CustomEventData +from src.extension.custom_event_result import CustomEventResult +from src.extension.custom_event_types import CustomEventTypes + +TState = TypeVar("TState", bound=TurnState) +CustomRouteHandler = TypeVar("CustomRouteHandler", + bound=Awaitable[[TurnContext, TState, CustomEventData], CustomEventResult]) + +def create_route_selector(event_name: str) -> Awaitable[[TurnContext], bool]: + + async def route_selector(context: TurnContext) -> bool: + return context.activity.type == ActivityTypes.message and \ + context.activity.channel_id == MY_CHANNEL and \ + context.activity.name == f"invoke/{event_name}" -class ExtensionAgent: + return route_selector + +class ExtensionAgent(Generic[TState]): def __init__(self, app: AgentApplication): self.app = app - @staticmethod - def get_rest_client(self, context: TurnContext) -> MyConnectorClient: - connector_client = context.turn_state.get("connector_client") - return MyConnectorClient(connector_client) \ No newline at end of file + def on_invoke_custom_event(self, handler: RouteQueryHandler[TState]): + route_selector = create_route_selector(CustomEventTypes.CUSTOM_EVENT) + async def route_handler(context: TurnContext, state: TState): + custom_event_data = CustomEventData.from_context(context) + result = await handler(context, state, custom_event_data) + if not result: + result = CustomEventResult() + + response = Activity(type=ActivityTypes.invoke_response, value=InvokeResponse( + status=200, + body=result + )) + await context.send_activity(response) + self.app.add_route(route_selector, route_handler, True) + + def on_invoke_other_custom_event(self, handler: RouteHandler[TState]): + route_selector = create_route_selector(CustomEventTypes.OTHER_CUSTOM_EVENT) + async def route_handler(context: TurnContext, state: TState): + await handler(context, state) + response = Activity(type=ActivityTypes.invoke_response, value=InvokeResponse( + status=200, + body={} + )) + await context.send_activity(response) + self.app.add_route(route_selector, route_handler, True) + + def on_message_reaction_added(self, handler: Awaitable[[TurnContext, TState, str], None]): + + async def route_selector(context: TurnContext) -> bool: + return context.activity.type == ActivityTypes.message and \ + context.activity.name == "reactionAdded" + + async def route_handler(context: TurnContext, state: TState): + reactions_added = context.activity.reactions_added + for reaction in context.activity.values: + await handler(context, state, reaction.type) + + self.app.add_route(route_selector, route_handler) \ No newline at end of file diff --git a/doc_samples/extension-starter/src/extension/get_channel_data.py b/doc_samples/extension-starter/src/extension/get_channel_data.py deleted file mode 100644 index f1232a71..00000000 --- a/doc_samples/extension-starter/src/extension/get_channel_data.py +++ /dev/null @@ -1,2 +0,0 @@ -def get_channel_data(context): - return context.activity.channel_data \ No newline at end of file diff --git a/doc_samples/extension-starter/src/extension/messaging_extension.py b/doc_samples/extension-starter/src/extension/messaging_extension.py deleted file mode 100644 index 131e8fac..00000000 --- a/doc_samples/extension-starter/src/extension/messaging_extension.py +++ /dev/null @@ -1,50 +0,0 @@ -from microsoft_agents.activity import ( - Activity, - AgentsModel -) - -from microsoft_agents.hosting.core import ( - TurnState, - TurnContext -) - -TState = TypeVar("TState", bound=TurnState) -RouteQueryHandler = TypeVar("RouteQueryHandler", - bound=Awaitable[[TurnContext, TState, query: ExtensionQuery], ExtensionResult]) - -def create_route_selector(route_type: str) -> Awaitable[[TurnContext], bool]: - - async def route_selector(context: TurnContext) -> bool: - return context.activity.type == ActivityTypes.message and \ - context.activity.channel_id == MY_CHANNEL and \ - context.activity.name == route_type - - return route_selector - -class MessageExtension(Generic[TState]): - - def __init__(self, app: AgentApplication): - self._app = app - - def on_query(self, handler: RouteQueryHandler[TState]): - - route_selector = create_route_selector("query") - - async def route_handler(context: TurnContext, state: TState): - message_extension_query = MessageExtensionQuery.model_validate(context.activity.value) - result = await handler(context, state, message_extension_query) - - self._app.add_route(route_selector, route_handler, True) - - def on_invoke_custom_event(self, handler: RouteQueryHandler[TState]): - - route_selector = create_route_selector("invokeCustomEvent") - - async def route_handler(context: TurnContext, state: TState): - message_extension_query = MessageExtensionQuery.model_validate(context.activity.value) - result = await handler(context, state, message_extension_query) - response = Activity(type=ActivityTypes.invoke_response, value=InvokeResponse( - status=200, - body=result - )) - await context.send_activity(response) \ No newline at end of file diff --git a/doc_samples/extension-starter/src/extension/mock_client.py b/doc_samples/extension-starter/src/extension/mock_client.py deleted file mode 100644 index 8da03602..00000000 --- a/doc_samples/extension-starter/src/extension/mock_client.py +++ /dev/null @@ -1 +0,0 @@ -class MockClient \ No newline at end of file diff --git a/doc_samples/extension-starter/src/extension/models.py b/doc_samples/extension-starter/src/extension/models.py deleted file mode 100644 index 176fc53a..00000000 --- a/doc_samples/extension-starter/src/extension/models.py +++ /dev/null @@ -1,12 +0,0 @@ -from microsoft_agents.activity import AgentsModel - -class CustomExtensionResult(AgentsModel): - - def __init__(self): - pass - -class ExtensionQuery(AgentsModel): - pass - -class ExtensionResult(AgentsModel): - pass \ No newline at end of file diff --git a/doc_samples/extension-starter/src/extension/my_channel_data.py b/doc_samples/extension-starter/src/extension/my_channel_data.py deleted file mode 100644 index ce3527e6..00000000 --- a/doc_samples/extension-starter/src/extension/my_channel_data.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import Optional - -from microsoft_agents.activity import AgentsModel - -class MyChannelData(AgentsModel): - - user_id: Optional[str] = None - custom_field: Optional[str] = None - -def get_my_channel_data(context): - - data = MyChannelData( - user_id=context.activity.from_property.id, - custom_field=context.activity.channel_data.get("custom_field") - ) - - return data \ No newline at end of file diff --git a/doc_samples/extension-starter/src/extension/my_connector_client.py b/doc_samples/extension-starter/src/extension/my_connector_client.py deleted file mode 100644 index 65b9db75..00000000 --- a/doc_samples/extension-starter/src/extension/my_connector_client.py +++ /dev/null @@ -1,8 +0,0 @@ -from microsoft_agents.hosting.core import ( - ConnectorClient -) - -class MyConnectorClient(ConnectorClient): - - def __init__(self, base_url: str, **kwargs): - super().__init__(base_url, **kwargs) \ No newline at end of file diff --git a/doc_samples/extension-starter/src/sample/extension_agent.py b/doc_samples/extension-starter/src/sample/extension_agent.py index ad416b6d..d86d8623 100644 --- a/doc_samples/extension-starter/src/sample/extension_agent.py +++ b/doc_samples/extension-starter/src/sample/extension_agent.py @@ -11,8 +11,12 @@ EXT = ExtensionAgent(APP) -@EXT.on_custom_event -async def custom_event(context: TurnContext, state: TurnState): +@EXT.on_invoke_custom_event +async def invoke_custom_event(context: TurnContext, state: TurnState, data: CustomEventData): + await context.send_activity(f"Custom event triggered {context.activity.type}/{context.activity.name}") + +@EXT.on_invoke_other_custom_event +async def invoke_other_custom_event(context: TurnContext, state: TurnState): await context.send_activity(f"Custom event triggered {context.activity.type}/{context.activity.name}") @EXT.on_message_reaction_added diff --git a/doc_samples/extension-starter/src/sample/main.py b/doc_samples/extension-starter/src/sample/main.py index 1c5606ce..8e53ef34 100644 --- a/doc_samples/extension-starter/src/sample/main.py +++ b/doc_samples/extension-starter/src/sample/main.py @@ -1,7 +1,17 @@ -from extension_agent import APP, ext +# this will mock HTTP requests +import mocks -class MockClient: - pass +from microsoft_agents.activity import Activity +from microsoft_agents.hosting.core import ChannelAdapter + +from extension_agent import APP, ext, MockAdapter + +def main(): + + while True: + input(">>> Press Enter to send an activity...") + await MockAdapter.send_activity() + print("Activity sent.") if __name__ == "__main__": - pass \ No newline at end of file + main() \ No newline at end of file diff --git a/doc_samples/extension-starter/src/sample/mocks.py b/doc_samples/extension-starter/src/sample/mocks.py index d6e88690..da070588 100644 --- a/doc_samples/extension-starter/src/sample/mocks.py +++ b/doc_samples/extension-starter/src/sample/mocks.py @@ -3,10 +3,35 @@ consider taking a look at the /tests/_common directory under the Python SDK's root. """ +from typing import Protocol + +from microsoft_agents.hosting.core import ( + ChannelAdapter +) + class MockSimpleAdapter(ChannelAdapter): - def __init__(self): + def __init__(self, app: AgentApplication, client: MockClient): super().__init__() + self._app = None + self._client = None + + def run(self, app: AgentApplication, client: MockClient): + self._app = app + self._client = client + # Simulate receiving an activity + activity = Activity( + type="message", + id="1234", + timestamp=datetime.utcnow(), + service_url="https://service.url/", + channel_id="mock_channel", + from_property=ChannelAccount(id="user1", name="User One"), + recipient=ChannelAccount(id="bot", name="Bot"), + conversation=ConversationAccount(id="conv1"), + text="Hello, Bot!" + ) + self._client.on_activity(activity, self) async def send_activities(self, context, activities) -> List[ResourceResponse]: responses = [] @@ -29,16 +54,4 @@ async def update_activity(self, context, activity): async def delete_activity(self, context, reference): assert context is not None assert reference is not None - assert reference.activity_id == ACTIVITY.id - -class MockClient: - - def __init__(self, adapter: ChannelAdapter, on_activity: Awaitable[Activity, None] = None): - self._adapater = adapter - self._on_activity = None - - def on_activity(self, handler: Awaitable[Activity, None]): - self._on_activity = handler - - - \ No newline at end of file + assert reference.activity_id == ACTIVITY.id \ No newline at end of file diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/_model_utils.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/_model_utils.py index 025722a5..63b82b3b 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/_model_utils.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/_model_utils.py @@ -3,7 +3,6 @@ from .agents_model import AgentsModel - class ModelFieldHelper(ABC): """Base class for model field processing prior to initialization of an AgentsModel""" diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/_type_aliases.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/_type_aliases.py index a792b195..b0952c5e 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/_type_aliases.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/_type_aliases.py @@ -1,5 +1,4 @@ from typing import Annotated from pydantic import StringConstraints - NonEmptyString = Annotated[str, StringConstraints(min_length=1)] 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..626ea44d 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 @@ -10,8 +10,9 @@ from .app_options import ApplicationOptions from .input_file import InputFile, InputFileDownloader from .query import Query -from .route import Route, RouteHandler +from .route import RouteList, Rouete, RouteRank from .typing_indicator import TypingIndicator +from .type_defs import RouteHandler, RouteSelector, StateT # Auth from .oauth import ( 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..20f70a3b 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 @@ -56,9 +56,11 @@ from .oauth import Authorization from .typing_indicator import TypingIndicator +from .type_defs import StateT, RouteHandler, RouteSelector +from .routes import RouteList, Route, RouteRank + logger = logging.getLogger(__name__) -StateT = TypeVar("StateT", bound=TurnState) IN_SIGN_IN_KEY = "__InSignInFlow__" @@ -82,7 +84,7 @@ class AgentApplication(Agent, Generic[StateT]): _auth: Optional[Authorization] = None _internal_before_turn: List[Callable[[TurnContext, StateT], Awaitable[bool]]] = [] _internal_after_turn: List[Callable[[TurnContext, StateT], Awaitable[bool]]] = [] - _routes: List[Route[StateT]] = [] + _routes: RouteList[StateT] = RouteList[StateT]() _error: Optional[Callable[[TurnContext, Exception], Awaitable[None]]] = None _turn_state_factory: Optional[Callable[[TurnContext], StateT]] = None @@ -98,7 +100,7 @@ def __init__( Creates a new AgentApplication instance. """ self.typing = TypingIndicator() - self._routes = [] + self._routes = RouteList[StateT]() configuration = kwargs @@ -215,6 +217,16 @@ def options(self) -> ApplicationOptions: The application's configured options. """ return self._options + + def add_route( + self, + selector: RouteSelector, + handler: RouteHandler[StateT], + is_invoke: bool = False, + rank: RouteRank = RouteRank.DEFAULT, + auth_handlers: Optional[List[str]] = None + ) -> None: + self._routes.add_route(selector, handler, is_invoke, rank, auth_handlers) def activity( self, @@ -245,7 +257,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering activity handler for route handler {func.__name__} with type: {activity_type} with auth handlers: {auth_handlers}" ) - self._routes.append( + self.add_route( Route[StateT](__selector, func, auth_handlers=auth_handlers) ) return func @@ -288,7 +300,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message handler for route handler {func.__name__} with select: {select} with auth handlers: {auth_handlers}" ) - self._routes.append( + self.add_route( Route[StateT](__selector, func, auth_handlers=auth_handlers) ) return func @@ -342,7 +354,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering conversation update handler for route handler {func.__name__} with type: {type} with auth handlers: {auth_handlers}" ) - self._routes.append( + self.add_route( Route[StateT](__selector, func, auth_handlers=auth_handlers) ) return func @@ -388,7 +400,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message reaction handler for route handler {func.__name__} with type: {type} with auth handlers: {auth_handlers}" ) - self._routes.append( + self.add_route( Route[StateT](__selector, func, auth_handlers=auth_handlers) ) return func @@ -447,7 +459,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message update handler for route handler {func.__name__} with type: {type} with auth handlers: {auth_handlers}" ) - self._routes.append( + self.add_route( Route[StateT](__selector, func, auth_handlers=auth_handlers) ) return func @@ -495,10 +507,9 @@ async def __handler(context: TurnContext, state: StateT): f"Registering handoff handler for route handler {func.__name__} with auth handlers: {auth_handlers}" ) - self._routes.append( + self.add_route( Route[StateT](__selector, __handler, True, auth_handlers) ) - self._routes = sorted(self._routes, key=lambda route: not route.is_invoke) return func return __call diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/route.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/route.py deleted file mode 100644 index df85c5a9..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/route.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" - -from __future__ import annotations - -from typing import Awaitable, Callable, Generic, List, TypeVar - -from microsoft_agents.hosting.core import TurnContext -from .state import TurnState - -StateT = TypeVar("StateT", bound=TurnState) -RouteHandler = Callable[[TurnContext, StateT], Awaitable[None]] - - -class Route(Generic[StateT]): - selector: Callable[[TurnContext], bool] - handler: RouteHandler[StateT] - is_invoke: bool - - def __init__( - self, - selector: Callable[[TurnContext], bool], - handler: RouteHandler, - is_invoke: bool = False, - auth_handlers: List[str] = None, - ) -> None: - self.selector = selector - self.handler = handler - self.is_invoke = is_invoke - self.auth_handlers = auth_handlers or [] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/__init__.py new file mode 100644 index 00000000..cb2933fd --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/__init__.py @@ -0,0 +1,9 @@ +from .route_list import RouteList +from .route import Route +from .route_rank import RouteRank + +__all__ = [ + "RouteList", + "Route", + "RouteRank", +] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py new file mode 100644 index 00000000..8743cf00 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py @@ -0,0 +1,36 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from typing import Generic, List, Optional + +from ..type_defs import RouteHandler, RouteSelector, StateT +from .route_rank import RouteRank + +class Route(Generic[StateT]): + selector: RouteSelector + handler: RouteHandler[StateT] + is_invoke: bool + rank: RouteRank + auth_handlers: list[str] + + def __init__( + self, + selector: RouteSelector, + handler: RouteHandler[StateT], + is_invoke: bool = False, + rank: RouteRank = RouteRank.DEFAULT, + auth_handlers: Optional[List[str]] = None, + ) -> None: + self.selector = selector + self.handler = handler + self.is_invoke = is_invoke + self.rank = rank + self.auth_handlers = auth_handlers or [] + + def __lt__(self, other: Route) -> bool: + return self.is_invoke < other.is_invoke or \ + (self.is_invoke == other.is_invoke and self.rank < other.rank) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py new file mode 100644 index 00000000..1a88eaff --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py @@ -0,0 +1,45 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +import heapq +from typing import Generic, Optional + +from ..type_defs import RouteSelector, RouteHandler, StateT +from .route import Route +from .route_rank import RouteRank + +class RouteList(Generic[StateT]): + _routes: list[Route[StateT]] + + def __init__( + self, + ) -> None: + # a min-heap where lower "values" indicate higher priority + self._routes = [] + + def add_route( + self, + route_selector: RouteSelector, + route_handler: RouteHandler[StateT], + is_invoke: bool = False, + rank: RouteRank = RouteRank.DEFAULT, + auth_handlers: Optional[list[str]] = None + ) -> None: + """Add a route to the priority queue.""" + route = Route( + selector=route_selector, + handler=route_handler, + is_invoke=is_invoke, + rank=rank, + auth_handlers=auth_handlers or [] + ) + + heapq.heappush(self._routes, route) + + def get_routes(self) -> list[Route[StateT]]: + """Get all routes in priority order.""" + return self._routes \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_rank.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_rank.py new file mode 100644 index 00000000..76ff4dd9 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_rank.py @@ -0,0 +1,11 @@ +from enum import IntEnum + +MAX_RANK = 2**32 - 1 # Python ints don't have a max value, LOL + +class RouteRank(IntEnum): + + """Defines the rank of a route. Lower values indicate higher priority.""" + + FIRST = 0 + DEFAULT = MAX_RANK // 2 + LAST = MAX_RANK \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/type_defs.py new file mode 100644 index 00000000..1099053b --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/type_defs.py @@ -0,0 +1,10 @@ +from typing import Callable, TypeVar, Awaitable, Protocol + +from microsoft_agents.hosting.core import TurnContext, TurnState + +StateT = TypeVar("StateT", bound=TurnState) +RouteSelector = Callable[[TurnContext], bool] + +class RouteHandler(Protocol[StateT]): + def __call__(self, context: TurnContext, state: StateT) -> Awaitable[None]: + ... \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/setup.py b/libraries/microsoft-agents-hosting-core/setup.py index e7604116..f5474585 100644 --- a/libraries/microsoft-agents-hosting-core/setup.py +++ b/libraries/microsoft-agents-hosting-core/setup.py @@ -11,5 +11,6 @@ "isodate>=0.6.1", "azure-core>=1.30.0", "python-dotenv>=1.1.1", + "strenum" ], ) From 14de9d5699e76e0be4268d57653d7802cb432efc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Brand=C3=A3o?= Date: Thu, 18 Sep 2025 15:23:36 -0700 Subject: [PATCH 05/67] Cleaned up new route logic and its usage in the extension-starter sample --- .../extension-starter/requirements.txt | 1 + .../src/extension/custom_event_data.py | 15 ---- .../src/extension/custom_event_result.py | 6 -- .../src/extension/custom_event_types.py | 5 -- .../src/extension/extension.py | 47 +++++++------ .../extension-starter/src/extension/models.py | 32 +++++++++ .../extension-starter/src/sample/agent.py | 69 ------------------- .../extension-starter/src/sample/app.py | 30 ++++++++ .../src/sample/extension_agent.py | 19 +++-- .../extension-starter/src/sample/main.py | 5 +- .../microsoft_agents/hosting/core/__init__.py | 1 + .../hosting/core/app/agent_application.py | 38 +++++----- .../hosting/core/app/routes/route.py | 4 +- .../hosting/core/app/routes/route_list.py | 9 ++- .../hosting/core/app/type_defs.py | 2 +- 15 files changed, 135 insertions(+), 148 deletions(-) delete mode 100644 doc_samples/extension-starter/src/extension/custom_event_data.py delete mode 100644 doc_samples/extension-starter/src/extension/custom_event_result.py delete mode 100644 doc_samples/extension-starter/src/extension/custom_event_types.py create mode 100644 doc_samples/extension-starter/src/extension/models.py delete mode 100644 doc_samples/extension-starter/src/sample/agent.py create mode 100644 doc_samples/extension-starter/src/sample/app.py diff --git a/doc_samples/extension-starter/requirements.txt b/doc_samples/extension-starter/requirements.txt index e69de29b..97502ab4 100644 --- a/doc_samples/extension-starter/requirements.txt +++ b/doc_samples/extension-starter/requirements.txt @@ -0,0 +1 @@ +strenum \ No newline at end of file diff --git a/doc_samples/extension-starter/src/extension/custom_event_data.py b/doc_samples/extension-starter/src/extension/custom_event_data.py deleted file mode 100644 index b2acbf06..00000000 --- a/doc_samples/extension-starter/src/extension/custom_event_data.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -from typing import Optional -from microsoft_agents.activity import AgentsModel - -class CustomEventData(AgentsModel): - user_id: Optional[str] = None - field: Optional[str] = None - - @staticmethod - def from_context(context) -> CustomEventData: - return CustomEventData( - user_id=context.activity.from_property.id, - field=context.activity.channel_data.get("field") - ) \ No newline at end of file diff --git a/doc_samples/extension-starter/src/extension/custom_event_result.py b/doc_samples/extension-starter/src/extension/custom_event_result.py deleted file mode 100644 index d5fddfa3..00000000 --- a/doc_samples/extension-starter/src/extension/custom_event_result.py +++ /dev/null @@ -1,6 +0,0 @@ -from typing import Optional -from microsoft_agents.activity import AgentsModel - -class CustomEventResult(AgentsModel): - user_id: Optional[str] = None - field: Optional[str] = None \ No newline at end of file diff --git a/doc_samples/extension-starter/src/extension/custom_event_types.py b/doc_samples/extension-starter/src/extension/custom_event_types.py deleted file mode 100644 index b002f075..00000000 --- a/doc_samples/extension-starter/src/extension/custom_event_types.py +++ /dev/null @@ -1,5 +0,0 @@ -from strenum import StrEnum - -class CustomEventTypes(StrEnum): - CUSTOM_EVENT = "customEvent" - OTHER_CUSTOM_EVENT = "otherCustomEvent" \ No newline at end of file diff --git a/doc_samples/extension-starter/src/extension/extension.py b/doc_samples/extension-starter/src/extension/extension.py index ce856aa3..96484209 100644 --- a/doc_samples/extension-starter/src/extension/extension.py +++ b/doc_samples/extension-starter/src/extension/extension.py @@ -1,9 +1,9 @@ -from venv import create - +import logging from typing import ( Awaitable, + Callable, Generic, - TypeVar + TypeVar, ) from microsoft_agents.activity import ( @@ -15,17 +15,16 @@ AgentApplication, TurnContext, TurnState, + RouteSelector ) -from src.extension.custom_event_data import CustomEventData -from src.extension.custom_event_result import CustomEventResult -from src.extension.custom_event_types import CustomEventTypes +logger = logging.getLogger(__name__) + +MY_CHANNEL = "mychannel" -TState = TypeVar("TState", bound=TurnState) -CustomRouteHandler = TypeVar("CustomRouteHandler", - bound=Awaitable[[TurnContext, TState, CustomEventData], CustomEventResult]) +from .models import CustomEventData, CustomEventResult, CustomEventTypes, CustomRouteHandler -def create_route_selector(event_name: str) -> Awaitable[[TurnContext], bool]: +def create_route_selector(event_name: str) -> RouteSelector: async def route_selector(context: TurnContext) -> bool: return context.activity.type == ActivityTypes.message and \ @@ -34,14 +33,15 @@ async def route_selector(context: TurnContext) -> bool: return route_selector -class ExtensionAgent(Generic[TState]): +class ExtensionAgent(Generic[StateT]): + app: AgentApplication[StateT] - def __init__(self, app: AgentApplication): + def __init__(self, app: AgentApplication[StateT]): self.app = app - def on_invoke_custom_event(self, handler: RouteQueryHandler[TState]): + def on_invoke_custom_event(self, handler: CustomRouteHandler[StateT]): route_selector = create_route_selector(CustomEventTypes.CUSTOM_EVENT) - async def route_handler(context: TurnContext, state: TState): + async def route_handler(context: TurnContext, state: StateT): custom_event_data = CustomEventData.from_context(context) result = await handler(context, state, custom_event_data) if not result: @@ -52,28 +52,33 @@ async def route_handler(context: TurnContext, state: TState): body=result )) await context.send_activity(response) - self.app.add_route(route_selector, route_handler, True) + logger.debug("Registering route for custom event") + self.app.add_route(route_selector, route_handler, is_invoke=True) - def on_invoke_other_custom_event(self, handler: RouteHandler[TState]): + def on_invoke_other_custom_event(self, handler: RouteHandler[StateT]): route_selector = create_route_selector(CustomEventTypes.OTHER_CUSTOM_EVENT) - async def route_handler(context: TurnContext, state: TState): + async def route_handler(context: TurnContext, state: StateT): await handler(context, state) response = Activity(type=ActivityTypes.invoke_response, value=InvokeResponse( status=200, body={} )) await context.send_activity(response) - self.app.add_route(route_selector, route_handler, True) + logger.debug("Registering route for other custom event") + self.app.add_route(route_selector, route_handler, is_invoke=True) - def on_message_reaction_added(self, handler: Awaitable[[TurnContext, TState, str], None]): + # Callable that takes in three arguments (TurnContext, StateT, str) and returns Awaitable[None] + # Awaitable indicates that the function is asynchronous and returns a coroutine + def on_message_reaction_added(self, handler: Callable[[TurnContext, StateT, str], Awaitable[None]]): async def route_selector(context: TurnContext) -> bool: return context.activity.type == ActivityTypes.message and \ context.activity.name == "reactionAdded" - async def route_handler(context: TurnContext, state: TState): + async def route_handler(context: TurnContext, state: StateT): reactions_added = context.activity.reactions_added - for reaction in context.activity.values: + for reaction in context.activity.value: await handler(context, state, reaction.type) + logger.debug("Registering route for message reaction added") self.app.add_route(route_selector, route_handler) \ No newline at end of file diff --git a/doc_samples/extension-starter/src/extension/models.py b/doc_samples/extension-starter/src/extension/models.py new file mode 100644 index 00000000..7ec0815a --- /dev/null +++ b/doc_samples/extension-starter/src/extension/models.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Optional, Protocol, TypeVar +from microsoft_agents.activity import AgentsModel +from microsoft_agents.hosting.core import TurnContext, TurnState + +from strenum import StrEnum + +StateT = TypeVar("StateT", bound=TurnState) + +class CustomRouteHandler(Protocol(StateT)): + async def __call__(self, context: TurnContext, state: StateT, event_data: CustomEventData) -> CustomEventResult: + ... + +class CustomEventTypes(StrEnum): + CUSTOM_EVENT = "customEvent" + OTHER_CUSTOM_EVENT = "otherCustomEvent" + +class CustomEventData(AgentsModel): + user_id: Optional[str] = None + field: Optional[str] = None + + @staticmethod + def from_context(context) -> CustomEventData: + return CustomEventData( + user_id=context.activity.from_property.id, + field=context.activity.channel_data.get("field") + ) + +class CustomEventResult(AgentsModel): + user_id: Optional[str] = None + field: Optional[str] = None \ No newline at end of file diff --git a/doc_samples/extension-starter/src/sample/agent.py b/doc_samples/extension-starter/src/sample/agent.py deleted file mode 100644 index 275ebd3e..00000000 --- a/doc_samples/extension-starter/src/sample/agent.py +++ /dev/null @@ -1,69 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from dotenv import load_dotenv -from aiohttp.web import Application, Request, Response, run_app - -from microsoft_agents.hosting.core import RestChannelServiceClientFactory -from microsoft_agents.hosting.aiohttp import CloudAdapter, jwt_authorization_middleware -from microsoft_agents.hosting.core.authorization import ( - Connections, - AccessTokenProviderBase, - ClaimsIdentity, -) -from microsoft_agents.authentication.msal import MsalAuth -from openai import AsyncAzureOpenAI - -from weather_agent import WeatherAgent -from config import DefaultConfig - -load_dotenv() - -CONFIG = DefaultConfig() -AUTH_PROVIDER = MsalAuth(DefaultConfig()) - - -class DefaultConnection(Connections): - def get_default_connection(self) -> AccessTokenProviderBase: - pass - - def get_token_provider( - self, claims_identity: ClaimsIdentity, service_url: str - ) -> AccessTokenProviderBase: - return AUTH_PROVIDER - - def get_connection(self, connection_name: str) -> AccessTokenProviderBase: - pass - - -CHANNEL_CLIENT_FACTORY = RestChannelServiceClientFactory(CONFIG, DefaultConnection()) - -# Create adapter. -ADAPTER = CloudAdapter(CHANNEL_CLIENT_FACTORY) - -# gets the API Key from environment variable AZURE_OPENAI_API_KEY -CLIENT = AsyncAzureOpenAI( - api_version=CONFIG.AZURE_OPENAI_API_VERSION, - azure_endpoint=CONFIG.AZURE_OPENAI_ENDPOINT, -) - -# Create the Agent -AGENT = WeatherAgent(client=CLIENT) - - -# Listen for incoming requests on /api/messages -async def messages(req: Request) -> Response: - adapter: CloudAdapter = req.app["adapter"] - return await adapter.process(req, AGENT) - - -APP = Application(middlewares=[jwt_authorization_middleware]) -APP.router.add_post("/api/messages", messages) -APP["agent_configuration"] = CONFIG -APP["adapter"] = ADAPTER - -if __name__ == "__main__": - try: - run_app(APP, host="localhost", port=CONFIG.PORT) - except Exception as error: - raise error \ No newline at end of file diff --git a/doc_samples/extension-starter/src/sample/app.py b/doc_samples/extension-starter/src/sample/app.py new file mode 100644 index 00000000..fe7a45bd --- /dev/null +++ b/doc_samples/extension-starter/src/sample/app.py @@ -0,0 +1,30 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +import re +from dotenv import load_dotenv + +from microsoft_agents.hosting.core import ( + Authorization, + MemoryStorage, + AgentApplication, + TurnState, + MemoryStorage, +) +from microsoft_agents.activity import load_configuration_from_env +from microsoft_agents.authentication.msal import MsalConnectionManager +from src.sample.mocks import MockAdapter + +# Load configuration from environment +load_dotenv() +agents_sdk_config = load_configuration_from_env(os.environ) + +# Create storage and connection manager +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = MockAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +APP = AgentApplication[TurnState]( + storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config +) \ No newline at end of file diff --git a/doc_samples/extension-starter/src/sample/extension_agent.py b/doc_samples/extension-starter/src/sample/extension_agent.py index d86d8623..a81bf264 100644 --- a/doc_samples/extension-starter/src/sample/extension_agent.py +++ b/doc_samples/extension-starter/src/sample/extension_agent.py @@ -1,19 +1,26 @@ from microsoft_agents.hosting.core import ( - AgentApplication, - MemoryStorage, TurnContext, TurnState ) -from src.extension import ExtensionAgent +from src.extension import ( + ExtensionAgent, + CustomEventData, + CustomEventResult, +) -APP = AgentApplication() +from src.app import APP +from src.extension import ExtensionAgent -EXT = ExtensionAgent(APP) +EXT = ExtensionAgent[TurnState](APP) @EXT.on_invoke_custom_event -async def invoke_custom_event(context: TurnContext, state: TurnState, data: CustomEventData): +async def invoke_custom_event(context: TurnContext, state: TurnState, data: CustomEventData) -> CustomEventResult: await context.send_activity(f"Custom event triggered {context.activity.type}/{context.activity.name}") + return CustomEventResult( + user_id=data.user_id, + field=data.field + ) @EXT.on_invoke_other_custom_event async def invoke_other_custom_event(context: TurnContext, state: TurnState): diff --git a/doc_samples/extension-starter/src/sample/main.py b/doc_samples/extension-starter/src/sample/main.py index 8e53ef34..d92d91e7 100644 --- a/doc_samples/extension-starter/src/sample/main.py +++ b/doc_samples/extension-starter/src/sample/main.py @@ -1,10 +1,13 @@ # this will mock HTTP requests -import mocks +import logging from microsoft_agents.activity import Activity from microsoft_agents.hosting.core import ChannelAdapter from extension_agent import APP, ext, MockAdapter + +logger = logging.getLogger("src.extension.extension") +print(logger) def main(): 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..0e158f76 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 @@ -11,6 +11,7 @@ from .turn_context import TurnContext # Application Style +from .app.type_defs import RouteHandler, RouteSelector, StateT from .app.agent_application import AgentApplication from .app.app_error import ApplicationError from .app.app_options import ApplicationOptions 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 20f70a3b..3e536612 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 @@ -13,9 +13,7 @@ Any, Awaitable, Callable, - Dict, Generic, - List, Optional, Pattern, TypeVar, @@ -56,14 +54,14 @@ from .oauth import Authorization from .typing_indicator import TypingIndicator -from .type_defs import StateT, RouteHandler, RouteSelector +from .type_defs import RouteHandler, RouteSelector from .routes import RouteList, Route, RouteRank logger = logging.getLogger(__name__) IN_SIGN_IN_KEY = "__InSignInFlow__" - +StateT = TypeVar("StateT", bound=TurnState) class AgentApplication(Agent, Generic[StateT]): """ AgentApplication class for routing and processing incoming requests. @@ -82,8 +80,8 @@ class AgentApplication(Agent, Generic[StateT]): _options: ApplicationOptions _adapter: Optional[ChannelServiceAdapter] = None _auth: Optional[Authorization] = None - _internal_before_turn: List[Callable[[TurnContext, StateT], Awaitable[bool]]] = [] - _internal_after_turn: List[Callable[[TurnContext, StateT], Awaitable[bool]]] = [] + _internal_before_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] = [] + _internal_after_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] = [] _routes: RouteList[StateT] = RouteList[StateT]() _error: Optional[Callable[[TurnContext, Exception], Awaitable[None]]] = None _turn_state_factory: Optional[Callable[[TurnContext], StateT]] = None @@ -224,15 +222,15 @@ def add_route( handler: RouteHandler[StateT], is_invoke: bool = False, rank: RouteRank = RouteRank.DEFAULT, - auth_handlers: Optional[List[str]] = None + auth_handlers: Optional[list[str]] = None ) -> None: self._routes.add_route(selector, handler, is_invoke, rank, auth_handlers) def activity( self, - activity_type: Union[str, ActivityTypes, List[Union[str, ActivityTypes]]], + activity_type: Union[str, ActivityTypes, list[Union[str, ActivityTypes]]], *, - auth_handlers: Optional[List[str]] = None, + auth_handlers: Optional[list[str]] = None, ) -> Callable[[RouteHandler[StateT]], RouteHandler[StateT]]: """ Registers a new activity event listener. This method can be used as either @@ -266,9 +264,9 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: def message( self, - select: Union[str, Pattern[str], List[Union[str, Pattern[str]]]], + select: Union[str, Pattern[str], list[Union[str, Pattern[str]]]], *, - auth_handlers: Optional[List[str]] = None, + auth_handlers: Optional[list[str]] = None, ) -> Callable[[RouteHandler[StateT]], RouteHandler[StateT]]: """ Registers a new message activity event listener. This method can be used as either @@ -311,7 +309,7 @@ def conversation_update( self, type: ConversationUpdateTypes, *, - auth_handlers: Optional[List[str]] = None, + auth_handlers: Optional[list[str]] = None, ) -> Callable[[RouteHandler[StateT]], RouteHandler[StateT]]: """ Registers a new message activity event listener. This method can be used as either @@ -335,12 +333,12 @@ def __selector(context: TurnContext): return False if type == "membersAdded": - if isinstance(context.activity.members_added, List): + if isinstance(context.activity.members_added, list): return len(context.activity.members_added) > 0 return False if type == "membersRemoved": - if isinstance(context.activity.members_removed, List): + if isinstance(context.activity.members_removed, list): return len(context.activity.members_removed) > 0 return False @@ -362,7 +360,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: return __call def message_reaction( - self, type: MessageReactionTypes, *, auth_handlers: Optional[List[str]] = None + self, type: MessageReactionTypes, *, auth_handlers: Optional[list[str]] = None ) -> Callable[[RouteHandler[StateT]], RouteHandler[StateT]]: """ Registers a new message activity event listener. This method can be used as either @@ -385,12 +383,12 @@ def __selector(context: TurnContext): return False if type == "reactionsAdded": - if isinstance(context.activity.reactions_added, List): + if isinstance(context.activity.reactions_added, list): return len(context.activity.reactions_added) > 0 return False if type == "reactionsRemoved": - if isinstance(context.activity.reactions_removed, List): + if isinstance(context.activity.reactions_removed, list): return len(context.activity.reactions_removed) > 0 return False @@ -408,7 +406,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: return __call def message_update( - self, type: MessageUpdateTypes, *, auth_handlers: Optional[List[str]] = None + self, type: MessageUpdateTypes, *, auth_handlers: Optional[list[str]] = None ) -> Callable[[RouteHandler[StateT]], RouteHandler[StateT]]: """ Registers a new message activity event listener. This method can be used as either @@ -466,7 +464,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]], ]: @@ -765,7 +763,7 @@ def _remove_mentions(self, context: TurnContext): context.activity.text = context.remove_recipient_mention(context.activity) @staticmethod - def parse_env_vars_configuration(vars: Dict[str, Any]) -> dict: + def parse_env_vars_configuration(vars: dict[str, Any]) -> dict: """ Parses environment variables and returns a dictionary with the relevant configuration. """ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py index 8743cf00..9e7c96da 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import Generic, List, Optional +from typing import Generic, Optional from ..type_defs import RouteHandler, RouteSelector, StateT from .route_rank import RouteRank @@ -23,7 +23,7 @@ def __init__( handler: RouteHandler[StateT], is_invoke: bool = False, rank: RouteRank = RouteRank.DEFAULT, - auth_handlers: Optional[List[str]] = None, + auth_handlers: Optional[list[str]] = None, ) -> None: self.selector = selector self.handler = handler diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py index 1a88eaff..bc53a01e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py @@ -5,13 +5,18 @@ from __future__ import annotations +from ast import TypeVar import heapq -from typing import Generic, Optional +from typing import Generic, Optional, TypeVar -from ..type_defs import RouteSelector, RouteHandler, StateT +from microsoft_agents.hosting.core import TurnState + +from ..type_defs import RouteSelector, RouteHandler from .route import Route from .route_rank import RouteRank +StateT = TypeVar("StateT", bound=TurnState) + class RouteList(Generic[StateT]): _routes: list[Route[StateT]] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/type_defs.py index 1099053b..2489f2fa 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/type_defs.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/type_defs.py @@ -2,9 +2,9 @@ from microsoft_agents.hosting.core import TurnContext, TurnState -StateT = TypeVar("StateT", bound=TurnState) RouteSelector = Callable[[TurnContext], bool] +StateT = TypeVar("StateT", bound=TurnState) class RouteHandler(Protocol[StateT]): def __call__(self, context: TurnContext, state: StateT) -> Awaitable[None]: ... \ No newline at end of file From ba30c8299ecd87e62582b803ba133237e9434c15 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 23 Sep 2025 14:53:02 -0700 Subject: [PATCH 06/67] 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 07/67] 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 08/67] 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 09/67] 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 c4a83819be6964a149a764e66da25d10f140a503 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Sep 2025 08:52:26 -0700 Subject: [PATCH 10/67] Adding improved route handling tests --- .../hosting/core/app/routes/route_list.py | 1 - tests/hosting_core/app/routes/__init__.py | 0 tests/hosting_core/app/routes/test_route.py | 83 +++++++++++++++++++ .../app/routes/test_route_list.py | 10 +++ .../app/test_agent_application.py | 0 5 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 tests/hosting_core/app/routes/__init__.py create mode 100644 tests/hosting_core/app/routes/test_route.py create mode 100644 tests/hosting_core/app/routes/test_route_list.py create mode 100644 tests/hosting_core/app/test_agent_application.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py index bc53a01e..d2c2907c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py @@ -5,7 +5,6 @@ from __future__ import annotations -from ast import TypeVar import heapq from typing import Generic, Optional, TypeVar diff --git a/tests/hosting_core/app/routes/__init__.py b/tests/hosting_core/app/routes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/app/routes/test_route.py b/tests/hosting_core/app/routes/test_route.py new file mode 100644 index 00000000..66a05c7b --- /dev/null +++ b/tests/hosting_core/app/routes/test_route.py @@ -0,0 +1,83 @@ +import pytest + +from microsoft_agents.hosting.core import ( + TurnContext, + RouteSelector, + RouteHandler, + Route, + RouteRank, + StateT, + TurnState, +) + +from microsoft_agents.hosting.core.app.type_defs import RouteHandler, RouteSelector, StateT + +def selector(context: TurnContext) -> bool: + return True + +async def handler(context: TurnContext, state: TurnState) -> None: + pass + +class TestRoute: + + def test_init(self): + + route = Route( + selector=selector, + handler=handler, + is_invoke=True, + rank=RouteRank.HIGH, + auth_handlers=["auth1", "auth2"] + ) + + assert route.selector == self.selector + assert route.handler == self.handler + assert route.is_invoke is True + assert route.rank == RouteRank.HIGH + assert route.auth_handlers == ["auth1", "auth2"] + + def test_init_defaults(self): + + route = Route( + selector=selector, + handler=handler + ) + + assert route.selector == selector + assert route.handler == handler + assert route.is_invoke is False + assert route.rank == RouteRank.DEFAULT + assert route.auth_handlers == [] + + @pytest.fixture(params=[None, [], ["authA1", "authA2"], ["github"]]) + def auth_handlers_a(self, request): + return request.param + + @pytest.fixture(params=[None, [], ["authB1", "authB2"], ["github"]]) + def auth_handlers_b(self, request): + return request.param + + @pytest.mark.parametrize( + "is_invoke_a, rank_a, is_invoke_b, rank_b, expected_result", + [ + [False, RouteRank.DEFAULT, False, RouteRank.DEFAULT, False], + [False, RouteRank.DEFAULT, False, RouteRank.LAST, True], + [False, RouteRank.LAST, False, RouteRank.DEFAULT, False], + [False, RouteRank.DEFAULT, True, RouteRank.DEFAULT, True], + [True, RouteRank.DEFAULT, False, RouteRank.DEFAULT, True], + [True, RouteRank.DEFAULT, True, RouteRank.DEFAULT, False], + [True, RouteRank.LAST, True, RouteRank.DEFAULT, False], + [True, RouteRank.DEFAULT, True, RouteRank.LAST, True], + [False, RouteRank.FIRST, True, RouteRank.DEFAULT, True], + [True, RouteRank.DEFAULT, False, RouteRank.LAST, True], + [False, RouteRank.LAST, True, RouteRank.FIRST, False], + [True, RouteRank.FIRST, False, RouteRank.LAST, True], + [False, RouteRank.FIRST, False, RouteRank.LAST, True], + [True, RouteRank.FIRST, True, RouteRank.LAST, True], + ]) + def test_lt(self, is_invoke_a, rank_a, is_invoke_b, rank_b, expected_result, auth_handlers_a, auth_handlers_b): + + route_a = Route(selector, handler, is_invoke=is_invoke_a, rank=rank_a, auth_handlers=auth_handlers_a) + route_b = Route(selector, handler, is_invoke=is_invoke_b, rank=rank_b, auth_handlers=auth_handlers_b) + + assert (route_a < route_b) == expected_result \ No newline at end of file diff --git a/tests/hosting_core/app/routes/test_route_list.py b/tests/hosting_core/app/routes/test_route_list.py new file mode 100644 index 00000000..8a1f4ca1 --- /dev/null +++ b/tests/hosting_core/app/routes/test_route_list.py @@ -0,0 +1,10 @@ +from microsoft_agents.hosting.core import ( + RouteList, + Route, + RouteRank +) + +class TestRouteList + + def helper(self): + pass \ No newline at end of file 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..e69de29b From 5568b6e41af033ea79640d92d67cda695badf66a Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Sep 2025 09:14:25 -0700 Subject: [PATCH 11/67] Adding RouteList tests --- .../app/routes/test_route_list.py | 53 +++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/tests/hosting_core/app/routes/test_route_list.py b/tests/hosting_core/app/routes/test_route_list.py index 8a1f4ca1..f4397c3d 100644 --- a/tests/hosting_core/app/routes/test_route_list.py +++ b/tests/hosting_core/app/routes/test_route_list.py @@ -1,10 +1,57 @@ from microsoft_agents.hosting.core import ( + TurnContext, + TurnState, RouteList, Route, RouteRank ) -class TestRouteList +def selector(context: TurnContext) -> bool: + return True - def helper(self): - pass \ No newline at end of file +async def handler(context: TurnContext, state: TurnState) -> None: + pass + +class TestRouteList: + + def assert_priority_invariant(self, route_list: RouteList): + + # check priority invariant + routes = route_list.get_routes() + for i in range(1, len(routes)): + assert not routes[i] < routes[i - 1] + + def has_contents(self, route_list: RouteList, should_contain: list[Route]): + for route in should_contain: + for existing in route_list.get_routes(): + if existing == route: + break + else: + return False + return True + + def test_route_list_init(self): + route_list = RouteList() + assert route_list.get_routes() == [] + + def test_route_list_add_and_order(self): + + route_list = RouteList() + + all_routes = [ + (selector, handler, is_invoke=False, rank=RouteRank.DEFAULT, auth_handlers=["a"]), + (selector, handler, is_invoke=True, rank=RouteRank.LAST, auth_handlers=["a"]), + (selector, handler, is_invoke=False, rank=RouteRank.FIRST), + (selector, handler, is_invoke=True), + (selector, handler), + (selector, handler, is_invoke=True, rank=RouteRank.DEFAULT, auth_handlers=["slack"]), + (selector, handler, is_invoke=False, rank=RouteRank.FIRST, auth_handlers=["a", "b"]), + (selector, handler, is_invoke=True, rank=RouteRank.DEFAULT, auth_handlers=["c"]), + ] + added_routes = [] + + for i, route in enumerate(all_routes): + added_routes.append(Route(*route)) + route_list.add_route(*route) + self.assert_priority_invariant(route_list) + assert self.has_contents(route_list, added_routes) \ No newline at end of file From b57b7bf155bd58a5f96567f50e19ced04af69496 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Sep 2025 11:19:06 -0700 Subject: [PATCH 12/67] Adding __iter__ to RouteList and fixing AgentApplication usage of RouteList --- .../hosting/core/app/agent_application.py | 8 ++++---- .../hosting/core/app/routes/route_list.py | 8 ++++++-- 2 files changed, 10 insertions(+), 6 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 3e536612..474f6a0a 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 @@ -54,7 +54,7 @@ from .oauth import Authorization from .typing_indicator import TypingIndicator -from .type_defs import RouteHandler, RouteSelector +from .type_defs import RouteHandler, RouteSelector, StateT from .routes import RouteList, Route, RouteRank logger = logging.getLogger(__name__) @@ -98,7 +98,7 @@ def __init__( Creates a new AgentApplication instance. """ self.typing = TypingIndicator() - self._routes = RouteList[StateT]() + self._route_list = RouteList[StateT]() configuration = kwargs @@ -224,7 +224,7 @@ def add_route( rank: RouteRank = RouteRank.DEFAULT, auth_handlers: Optional[list[str]] = None ) -> None: - self._routes.add_route(selector, handler, is_invoke, rank, auth_handlers) + self._route_list.add_route(selector, handler, is_invoke, rank, auth_handlers) def activity( self, @@ -838,7 +838,7 @@ async def _run_after_turn_middleware(self, context: TurnContext, state: StateT): return True async def _on_activity(self, context: TurnContext, state: StateT): - for route in self._routes: + for route in self._route_list: if route.selector(context): if not route.auth_handlers: await route.handler(context, state) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py index d2c2907c..872c5927 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py @@ -44,6 +44,10 @@ def add_route( heapq.heappush(self._routes, route) - def get_routes(self) -> list[Route[StateT]]: + @property + def routes(self) -> list[Route[StateT]]: """Get all routes in priority order.""" - return self._routes \ No newline at end of file + return self._routes + + def __iter__(self): + return iter(self._routes) \ No newline at end of file From b06af401e7466ab741bf65d9514125deba8a5f75 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 24 Sep 2025 15:13:13 -0700 Subject: [PATCH 13/67] 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 14/67] 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 15/67] 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 16/67] 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 17/67] 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 18/67] 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 19/67] 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 20/67] 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 23efb16921705928d8f0f6ededca6454b066c0ba Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 25 Sep 2025 15:21:21 -0700 Subject: [PATCH 21/67] Extension starter sample --- .../src/extension/extension.py | 61 +++++++++++-------- .../extension-starter/src/extension/models.py | 15 +++-- .../extension-starter/src/sample/app.py | 2 +- .../src/sample/extension_agent.py | 25 ++++---- .../extension-starter/src/sample/main.py | 20 ------ .../extension-starter/src/sample/mocks.py | 57 ----------------- .../microsoft_agents/activity/_model_utils.py | 1 + .../hosting/core/app/agent_application.py | 31 ++++------ .../hosting/core/app/routes/__init__.py | 2 +- .../hosting/core/app/routes/route.py | 15 ++++- .../hosting/core/app/routes/route_list.py | 11 ++-- .../hosting/core/app/routes/route_rank.py | 6 +- .../hosting/core/app/type_defs.py | 5 +- .../microsoft-agents-hosting-core/setup.py | 2 +- tests/hosting_core/app/routes/test_route.py | 52 ++++++++++++---- 15 files changed, 140 insertions(+), 165 deletions(-) delete mode 100644 doc_samples/extension-starter/src/sample/main.py delete mode 100644 doc_samples/extension-starter/src/sample/mocks.py diff --git a/doc_samples/extension-starter/src/extension/extension.py b/doc_samples/extension-starter/src/extension/extension.py index 96484209..a5e1ecb7 100644 --- a/doc_samples/extension-starter/src/extension/extension.py +++ b/doc_samples/extension-starter/src/extension/extension.py @@ -6,32 +6,37 @@ TypeVar, ) -from microsoft_agents.activity import ( - Activity, - ActivityTypes, - InvokeResponse -) +from microsoft_agents.activity import Activity, ActivityTypes, InvokeResponse from microsoft_agents.hosting.core import ( AgentApplication, TurnContext, TurnState, - RouteSelector + RouteSelector, ) logger = logging.getLogger(__name__) MY_CHANNEL = "mychannel" -from .models import CustomEventData, CustomEventResult, CustomEventTypes, CustomRouteHandler +from .models import ( + CustomEventData, + CustomEventResult, + CustomEventTypes, + CustomRouteHandler, +) + def create_route_selector(event_name: str) -> RouteSelector: async def route_selector(context: TurnContext) -> bool: - return context.activity.type == ActivityTypes.message and \ - context.activity.channel_id == MY_CHANNEL and \ - context.activity.name == f"invoke/{event_name}" + return ( + context.activity.type == ActivityTypes.message + and context.activity.channel_id == MY_CHANNEL + and context.activity.name == f"invoke/{event_name}" + ) + + return route_selector - return route_selector class ExtensionAgent(Generic[StateT]): app: AgentApplication[StateT] @@ -41,44 +46,52 @@ def __init__(self, app: AgentApplication[StateT]): def on_invoke_custom_event(self, handler: CustomRouteHandler[StateT]): route_selector = create_route_selector(CustomEventTypes.CUSTOM_EVENT) + async def route_handler(context: TurnContext, state: StateT): custom_event_data = CustomEventData.from_context(context) result = await handler(context, state, custom_event_data) if not result: result = CustomEventResult() - response = Activity(type=ActivityTypes.invoke_response, value=InvokeResponse( - status=200, - body=result - )) + response = Activity( + type=ActivityTypes.invoke_response, + value=InvokeResponse(status=200, body=result), + ) await context.send_activity(response) + logger.debug("Registering route for custom event") self.app.add_route(route_selector, route_handler, is_invoke=True) def on_invoke_other_custom_event(self, handler: RouteHandler[StateT]): route_selector = create_route_selector(CustomEventTypes.OTHER_CUSTOM_EVENT) + async def route_handler(context: TurnContext, state: StateT): await handler(context, state) - response = Activity(type=ActivityTypes.invoke_response, value=InvokeResponse( - status=200, - body={} - )) + response = Activity( + type=ActivityTypes.invoke_response, + value=InvokeResponse(status=200, body={}), + ) await context.send_activity(response) + logger.debug("Registering route for other custom event") self.app.add_route(route_selector, route_handler, is_invoke=True) # Callable that takes in three arguments (TurnContext, StateT, str) and returns Awaitable[None] # Awaitable indicates that the function is asynchronous and returns a coroutine - def on_message_reaction_added(self, handler: Callable[[TurnContext, StateT, str], Awaitable[None]]): + def on_message_reaction_added( + self, handler: Callable[[TurnContext, StateT, str], Awaitable[None]] + ): async def route_selector(context: TurnContext) -> bool: - return context.activity.type == ActivityTypes.message and \ - context.activity.name == "reactionAdded" - + return ( + context.activity.type == ActivityTypes.message + and context.activity.name == "reactionAdded" + ) + async def route_handler(context: TurnContext, state: StateT): reactions_added = context.activity.reactions_added for reaction in context.activity.value: await handler(context, state, reaction.type) logger.debug("Registering route for message reaction added") - self.app.add_route(route_selector, route_handler) \ No newline at end of file + self.app.add_route(route_selector, route_handler) diff --git a/doc_samples/extension-starter/src/extension/models.py b/doc_samples/extension-starter/src/extension/models.py index 7ec0815a..e5b4a829 100644 --- a/doc_samples/extension-starter/src/extension/models.py +++ b/doc_samples/extension-starter/src/extension/models.py @@ -8,14 +8,18 @@ StateT = TypeVar("StateT", bound=TurnState) + class CustomRouteHandler(Protocol(StateT)): - async def __call__(self, context: TurnContext, state: StateT, event_data: CustomEventData) -> CustomEventResult: - ... + async def __call__( + self, context: TurnContext, state: StateT, event_data: CustomEventData + ) -> CustomEventResult: ... + class CustomEventTypes(StrEnum): CUSTOM_EVENT = "customEvent" OTHER_CUSTOM_EVENT = "otherCustomEvent" + class CustomEventData(AgentsModel): user_id: Optional[str] = None field: Optional[str] = None @@ -24,9 +28,10 @@ class CustomEventData(AgentsModel): def from_context(context) -> CustomEventData: return CustomEventData( user_id=context.activity.from_property.id, - field=context.activity.channel_data.get("field") + field=context.activity.channel_data.get("field"), ) - + + class CustomEventResult(AgentsModel): user_id: Optional[str] = None - field: Optional[str] = None \ No newline at end of file + field: Optional[str] = None diff --git a/doc_samples/extension-starter/src/sample/app.py b/doc_samples/extension-starter/src/sample/app.py index fe7a45bd..1c7b6a2a 100644 --- a/doc_samples/extension-starter/src/sample/app.py +++ b/doc_samples/extension-starter/src/sample/app.py @@ -27,4 +27,4 @@ AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) APP = AgentApplication[TurnState]( storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config -) \ No newline at end of file +) diff --git a/doc_samples/extension-starter/src/sample/extension_agent.py b/doc_samples/extension-starter/src/sample/extension_agent.py index a81bf264..85f2e983 100644 --- a/doc_samples/extension-starter/src/sample/extension_agent.py +++ b/doc_samples/extension-starter/src/sample/extension_agent.py @@ -1,7 +1,4 @@ -from microsoft_agents.hosting.core import ( - TurnContext, - TurnState -) +from microsoft_agents.hosting.core import TurnContext, TurnState from src.extension import ( ExtensionAgent, @@ -14,18 +11,24 @@ EXT = ExtensionAgent[TurnState](APP) + @EXT.on_invoke_custom_event -async def invoke_custom_event(context: TurnContext, state: TurnState, data: CustomEventData) -> CustomEventResult: - await context.send_activity(f"Custom event triggered {context.activity.type}/{context.activity.name}") - return CustomEventResult( - user_id=data.user_id, - field=data.field +async def invoke_custom_event( + context: TurnContext, state: TurnState, data: CustomEventData +) -> CustomEventResult: + await context.send_activity( + f"Custom event triggered {context.activity.type}/{context.activity.name}" ) + return CustomEventResult(user_id=data.user_id, field=data.field) + @EXT.on_invoke_other_custom_event async def invoke_other_custom_event(context: TurnContext, state: TurnState): - await context.send_activity(f"Custom event triggered {context.activity.type}/{context.activity.name}") + await context.send_activity( + f"Custom event triggered {context.activity.type}/{context.activity.name}" + ) + @EXT.on_message_reaction_added async def reaction_added(context: TurnContext, state: TurnState, reaction: str): - await context.send_activity(f"Reaction added: {reaction}") \ No newline at end of file + await context.send_activity(f"Reaction added: {reaction}") diff --git a/doc_samples/extension-starter/src/sample/main.py b/doc_samples/extension-starter/src/sample/main.py deleted file mode 100644 index d92d91e7..00000000 --- a/doc_samples/extension-starter/src/sample/main.py +++ /dev/null @@ -1,20 +0,0 @@ -# this will mock HTTP requests -import logging - -from microsoft_agents.activity import Activity -from microsoft_agents.hosting.core import ChannelAdapter - -from extension_agent import APP, ext, MockAdapter - -logger = logging.getLogger("src.extension.extension") -print(logger) - -def main(): - - while True: - input(">>> Press Enter to send an activity...") - await MockAdapter.send_activity() - print("Activity sent.") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/doc_samples/extension-starter/src/sample/mocks.py b/doc_samples/extension-starter/src/sample/mocks.py deleted file mode 100644 index da070588..00000000 --- a/doc_samples/extension-starter/src/sample/mocks.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -If you are looking for more mocking capabilities for the SDK's core components, -consider taking a look at the /tests/_common directory under the Python SDK's root. -""" - -from typing import Protocol - -from microsoft_agents.hosting.core import ( - ChannelAdapter -) - -class MockSimpleAdapter(ChannelAdapter): - - def __init__(self, app: AgentApplication, client: MockClient): - super().__init__() - self._app = None - self._client = None - - def run(self, app: AgentApplication, client: MockClient): - self._app = app - self._client = client - # Simulate receiving an activity - activity = Activity( - type="message", - id="1234", - timestamp=datetime.utcnow(), - service_url="https://service.url/", - channel_id="mock_channel", - from_property=ChannelAccount(id="user1", name="User One"), - recipient=ChannelAccount(id="bot", name="Bot"), - conversation=ConversationAccount(id="conv1"), - text="Hello, Bot!" - ) - self._client.on_activity(activity, self) - - async def send_activities(self, context, activities) -> List[ResourceResponse]: - responses = [] - assert context is not None - assert activities is not None - assert isinstance(activities, list) - assert activities - for idx, activity in enumerate(activities): # pylint: disable=unused-variable - assert isinstance(activity, Activity) - assert activity.type == "message" or activity.type == ActivityTypes.trace - responses.append(ResourceResponse(id="5678")) - return responses - - async def update_activity(self, context, activity): - assert context is not None - assert activity is not None - assert activity.id is not None - return ResourceResponse(id=activity.id) - - async def delete_activity(self, context, reference): - assert context is not None - assert reference is not None - assert reference.activity_id == ACTIVITY.id \ No newline at end of file diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/_model_utils.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/_model_utils.py index 63b82b3b..025722a5 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/_model_utils.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/_model_utils.py @@ -3,6 +3,7 @@ from .agents_model import AgentsModel + class ModelFieldHelper(ABC): """Base class for model field processing prior to initialization of an AgentsModel""" 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 474f6a0a..3b916eb6 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 @@ -62,6 +62,8 @@ IN_SIGN_IN_KEY = "__InSignInFlow__" StateT = TypeVar("StateT", bound=TurnState) + + class AgentApplication(Agent, Generic[StateT]): """ AgentApplication class for routing and processing incoming requests. @@ -215,15 +217,16 @@ def options(self) -> ApplicationOptions: The application's configured options. """ return self._options - + def add_route( self, selector: RouteSelector, handler: RouteHandler[StateT], is_invoke: bool = False, rank: RouteRank = RouteRank.DEFAULT, - auth_handlers: Optional[list[str]] = None + auth_handlers: Optional[list[str]] = None, ) -> None: + """Adds a new route to the application.""" self._route_list.add_route(selector, handler, is_invoke, rank, auth_handlers) def activity( @@ -255,9 +258,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering activity handler for route handler {func.__name__} with type: {activity_type} with auth handlers: {auth_handlers}" ) - self.add_route( - Route[StateT](__selector, func, auth_handlers=auth_handlers) - ) + self.add_route(__selector, func, auth_handlers=auth_handlers) return func return __call @@ -298,9 +299,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message handler for route handler {func.__name__} with select: {select} with auth handlers: {auth_handlers}" ) - self.add_route( - Route[StateT](__selector, func, auth_handlers=auth_handlers) - ) + self.add_route(Route[StateT](__selector, func, auth_handlers=auth_handlers)) return func return __call @@ -352,9 +351,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering conversation update handler for route handler {func.__name__} with type: {type} with auth handlers: {auth_handlers}" ) - self.add_route( - Route[StateT](__selector, func, auth_handlers=auth_handlers) - ) + self.add_route(Route[StateT](__selector, func, auth_handlers=auth_handlers)) return func return __call @@ -398,9 +395,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message reaction handler for route handler {func.__name__} with type: {type} with auth handlers: {auth_handlers}" ) - self.add_route( - Route[StateT](__selector, func, auth_handlers=auth_handlers) - ) + self.add_route(Route[StateT](__selector, func, auth_handlers=auth_handlers)) return func return __call @@ -457,9 +452,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message update handler for route handler {func.__name__} with type: {type} with auth handlers: {auth_handlers}" ) - self.add_route( - Route[StateT](__selector, func, auth_handlers=auth_handlers) - ) + self.add_route(Route[StateT](__selector, func, auth_handlers=auth_handlers)) return func return __call @@ -505,9 +498,7 @@ async def __handler(context: TurnContext, state: StateT): f"Registering handoff handler for route handler {func.__name__} with auth handlers: {auth_handlers}" ) - self.add_route( - Route[StateT](__selector, __handler, True, auth_handlers) - ) + self.add_route(Route[StateT](__selector, __handler, True, auth_handlers)) return func return __call diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/__init__.py index cb2933fd..52522e09 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/__init__.py @@ -6,4 +6,4 @@ "RouteList", "Route", "RouteRank", -] \ No newline at end of file +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py index 9e7c96da..509e25c7 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py @@ -7,9 +7,19 @@ from typing import Generic, Optional +from ...turn_context import TurnContext from ..type_defs import RouteHandler, RouteSelector, StateT from .route_rank import RouteRank + +def agentic_selector(selector: RouteSelector) -> RouteSelector: + def wrapped_selector(context: TurnContext) -> bool: + # TODO + return selector(context) + + return wrapped_selector + + class Route(Generic[StateT]): selector: RouteSelector handler: RouteHandler[StateT] @@ -32,5 +42,6 @@ def __init__( self.auth_handlers = auth_handlers or [] def __lt__(self, other: Route) -> bool: - return self.is_invoke < other.is_invoke or \ - (self.is_invoke == other.is_invoke and self.rank < other.rank) \ No newline at end of file + return self.is_invoke < other.is_invoke or ( + self.is_invoke == other.is_invoke and self.rank < other.rank + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py index 872c5927..dcc999c1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py @@ -16,9 +16,10 @@ StateT = TypeVar("StateT", bound=TurnState) + class RouteList(Generic[StateT]): _routes: list[Route[StateT]] - + def __init__( self, ) -> None: @@ -31,7 +32,7 @@ def add_route( route_handler: RouteHandler[StateT], is_invoke: bool = False, rank: RouteRank = RouteRank.DEFAULT, - auth_handlers: Optional[list[str]] = None + auth_handlers: Optional[list[str]] = None, ) -> None: """Add a route to the priority queue.""" route = Route( @@ -39,7 +40,7 @@ def add_route( handler=route_handler, is_invoke=is_invoke, rank=rank, - auth_handlers=auth_handlers or [] + auth_handlers=auth_handlers or [], ) heapq.heappush(self._routes, route) @@ -48,6 +49,6 @@ def add_route( def routes(self) -> list[Route[StateT]]: """Get all routes in priority order.""" return self._routes - + def __iter__(self): - return iter(self._routes) \ No newline at end of file + return iter(self._routes) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_rank.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_rank.py index 76ff4dd9..1b0f79db 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_rank.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_rank.py @@ -1,11 +1,11 @@ from enum import IntEnum -MAX_RANK = 2**32 - 1 # Python ints don't have a max value, LOL +MAX_RANK = 2**32 - 1 # Python ints don't have a max value, LOL -class RouteRank(IntEnum): +class RouteRank(IntEnum): """Defines the rank of a route. Lower values indicate higher priority.""" FIRST = 0 DEFAULT = MAX_RANK // 2 - LAST = MAX_RANK \ No newline at end of file + LAST = MAX_RANK diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/type_defs.py index 2489f2fa..a623ff1e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/type_defs.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/type_defs.py @@ -5,6 +5,7 @@ RouteSelector = Callable[[TurnContext], bool] StateT = TypeVar("StateT", bound=TurnState) + + class RouteHandler(Protocol[StateT]): - def __call__(self, context: TurnContext, state: StateT) -> Awaitable[None]: - ... \ No newline at end of file + def __call__(self, context: TurnContext, state: StateT) -> Awaitable[None]: ... diff --git a/libraries/microsoft-agents-hosting-core/setup.py b/libraries/microsoft-agents-hosting-core/setup.py index f5474585..536a1425 100644 --- a/libraries/microsoft-agents-hosting-core/setup.py +++ b/libraries/microsoft-agents-hosting-core/setup.py @@ -11,6 +11,6 @@ "isodate>=0.6.1", "azure-core>=1.30.0", "python-dotenv>=1.1.1", - "strenum" + "strenum", ], ) diff --git a/tests/hosting_core/app/routes/test_route.py b/tests/hosting_core/app/routes/test_route.py index 66a05c7b..8158cabc 100644 --- a/tests/hosting_core/app/routes/test_route.py +++ b/tests/hosting_core/app/routes/test_route.py @@ -10,14 +10,21 @@ TurnState, ) -from microsoft_agents.hosting.core.app.type_defs import RouteHandler, RouteSelector, StateT +from microsoft_agents.hosting.core.app.type_defs import ( + RouteHandler, + RouteSelector, + StateT, +) + def selector(context: TurnContext) -> bool: return True + async def handler(context: TurnContext, state: TurnState) -> None: pass + class TestRoute: def test_init(self): @@ -27,7 +34,7 @@ def test_init(self): handler=handler, is_invoke=True, rank=RouteRank.HIGH, - auth_handlers=["auth1", "auth2"] + auth_handlers=["auth1", "auth2"], ) assert route.selector == self.selector @@ -38,10 +45,7 @@ def test_init(self): def test_init_defaults(self): - route = Route( - selector=selector, - handler=handler - ) + route = Route(selector=selector, handler=handler) assert route.selector == selector assert route.handler == handler @@ -52,7 +56,7 @@ def test_init_defaults(self): @pytest.fixture(params=[None, [], ["authA1", "authA2"], ["github"]]) def auth_handlers_a(self, request): return request.param - + @pytest.fixture(params=[None, [], ["authB1", "authB2"], ["github"]]) def auth_handlers_b(self, request): return request.param @@ -74,10 +78,32 @@ def auth_handlers_b(self, request): [True, RouteRank.FIRST, False, RouteRank.LAST, True], [False, RouteRank.FIRST, False, RouteRank.LAST, True], [True, RouteRank.FIRST, True, RouteRank.LAST, True], - ]) - def test_lt(self, is_invoke_a, rank_a, is_invoke_b, rank_b, expected_result, auth_handlers_a, auth_handlers_b): - - route_a = Route(selector, handler, is_invoke=is_invoke_a, rank=rank_a, auth_handlers=auth_handlers_a) - route_b = Route(selector, handler, is_invoke=is_invoke_b, rank=rank_b, auth_handlers=auth_handlers_b) + ], + ) + def test_lt( + self, + is_invoke_a, + rank_a, + is_invoke_b, + rank_b, + expected_result, + auth_handlers_a, + auth_handlers_b, + ): + + route_a = Route( + selector, + handler, + is_invoke=is_invoke_a, + rank=rank_a, + auth_handlers=auth_handlers_a, + ) + route_b = Route( + selector, + handler, + is_invoke=is_invoke_b, + rank=rank_b, + auth_handlers=auth_handlers_b, + ) - assert (route_a < route_b) == expected_result \ No newline at end of file + assert (route_a < route_b) == expected_result From 193508d6dfb83d6c21dbe243453c32c8960cc8f4 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 26 Sep 2025 07:39:19 -0700 Subject: [PATCH 22/67] agentic_selector decorator --- .../hosting/core/app/agent_application.py | 9 +++++++-- .../microsoft_agents/hosting/core/app/routes/__init__.py | 3 ++- 2 files changed, 9 insertions(+), 3 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 3b916eb6..314ecc80 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 @@ -43,7 +43,7 @@ from .app_options import ApplicationOptions # from .auth import AuthManager, OAuth, OAuthOptions -from .route import Route, RouteHandler +from .route import Route, RouteHandler, agentic_selector from .state import TurnState from ..channel_service_adapter import ChannelServiceAdapter from ..oauth import ( @@ -223,10 +223,13 @@ def add_route( selector: RouteSelector, handler: RouteHandler[StateT], is_invoke: bool = False, + is_agentic: bool = False, rank: RouteRank = RouteRank.DEFAULT, auth_handlers: Optional[list[str]] = None, ) -> None: """Adds a new route to the application.""" + if is_agentic: + selector = agentic_selector(selector) self._route_list.add_route(selector, handler, is_invoke, rank, auth_handlers) def activity( @@ -457,7 +460,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/routes/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/__init__.py index 52522e09..05273126 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/__init__.py @@ -1,9 +1,10 @@ from .route_list import RouteList -from .route import Route +from .route import Route, agentic_selector from .route_rank import RouteRank __all__ = [ "RouteList", "Route", "RouteRank", + "agentic_selector", ] From 537b5c8de274c74afc6820fddfd422926d2f9e30 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 26 Sep 2025 09:22:24 -0700 Subject: [PATCH 23/67] Route helpers --- .../hosting/core/app/agent_application.py | 22 +++++++++++-------- .../hosting/core/app/routes/route.py | 3 +-- 2 files changed, 14 insertions(+), 11 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 314ecc80..47732b03 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 @@ -230,13 +230,14 @@ def add_route( """Adds a new route to the application.""" if is_agentic: selector = agentic_selector(selector) - self._route_list.add_route(selector, handler, is_invoke, rank, auth_handlers) + self._route_list.add_route(selector, handler, is_invoke=is_invoke, rank=rank, auth_handlers=auth_handlers) def activity( self, activity_type: Union[str, ActivityTypes, list[Union[str, ActivityTypes]]], *, auth_handlers: Optional[list[str]] = None, + **kwargs ) -> Callable[[RouteHandler[StateT]], RouteHandler[StateT]]: """ Registers a new activity event listener. This method can be used as either @@ -261,7 +262,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering activity handler for route handler {func.__name__} with type: {activity_type} with auth handlers: {auth_handlers}" ) - self.add_route(__selector, func, auth_handlers=auth_handlers) + self.add_route(__selector, func, auth_handlers=auth_handlers, **kwargs) return func return __call @@ -271,6 +272,7 @@ def message( select: Union[str, Pattern[str], list[Union[str, Pattern[str]]]], *, auth_handlers: Optional[list[str]] = None, + **kwargs ) -> Callable[[RouteHandler[StateT]], RouteHandler[StateT]]: """ Registers a new message activity event listener. This method can be used as either @@ -302,7 +304,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message handler for route handler {func.__name__} with select: {select} with auth handlers: {auth_handlers}" ) - self.add_route(Route[StateT](__selector, func, auth_handlers=auth_handlers)) + self.add_route(Route[StateT](__selector, func, auth_handlers=auth_handlers, **kwargs)) return func return __call @@ -312,6 +314,7 @@ def conversation_update( type: ConversationUpdateTypes, *, auth_handlers: Optional[list[str]] = None, + **kwargs ) -> Callable[[RouteHandler[StateT]], RouteHandler[StateT]]: """ Registers a new message activity event listener. This method can be used as either @@ -354,13 +357,13 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering conversation update handler for route handler {func.__name__} with type: {type} with auth handlers: {auth_handlers}" ) - self.add_route(Route[StateT](__selector, func, auth_handlers=auth_handlers)) + self.add_route(Route[StateT](__selector, func, auth_handlers=auth_handlers, **kwargs)) return func return __call def message_reaction( - self, type: MessageReactionTypes, *, auth_handlers: Optional[list[str]] = None + self, type: MessageReactionTypes, *, auth_handlers: Optional[list[str]] = None, **kwargs ) -> Callable[[RouteHandler[StateT]], RouteHandler[StateT]]: """ Registers a new message activity event listener. This method can be used as either @@ -398,13 +401,13 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message reaction handler for route handler {func.__name__} with type: {type} with auth handlers: {auth_handlers}" ) - self.add_route(Route[StateT](__selector, func, auth_handlers=auth_handlers)) + self.add_route(Route[StateT](__selector, func, auth_handlers=auth_handlers, **kwargs)) return func return __call def message_update( - self, type: MessageUpdateTypes, *, auth_handlers: Optional[list[str]] = None + self, type: MessageUpdateTypes, *, auth_handlers: Optional[list[str]] = None, **kwargs ) -> Callable[[RouteHandler[StateT]], RouteHandler[StateT]]: """ Registers a new message activity event listener. This method can be used as either @@ -455,7 +458,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message update handler for route handler {func.__name__} with type: {type} with auth handlers: {auth_handlers}" ) - self.add_route(Route[StateT](__selector, func, auth_handlers=auth_handlers)) + self.add_route(Route[StateT](__selector, func, auth_handlers=auth_handlers), **kwargs) return func return __call @@ -465,6 +468,7 @@ def handoff( ) -> Callable[ [Callable[[TurnContext, StateT, str], Awaitable[None]]], Callable[[TurnContext, StateT, str], Awaitable[None]], + **kwargs ]: """ Registers a handler to handoff conversations from one copilot to another. @@ -503,7 +507,7 @@ async def __handler(context: TurnContext, state: StateT): f"Registering handoff handler for route handler {func.__name__} with auth handlers: {auth_handlers}" ) - self.add_route(Route[StateT](__selector, __handler, True, auth_handlers)) + self.add_route(Route[StateT](__selector, __handler, True, auth_handlers), **kwargs) return func return __call diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py index 509e25c7..d110d5cd 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import Generic, Optional +from typing import Callable, Generic, Optional from ...turn_context import TurnContext from ..type_defs import RouteHandler, RouteSelector, StateT @@ -19,7 +19,6 @@ def wrapped_selector(context: TurnContext) -> bool: return wrapped_selector - class Route(Generic[StateT]): selector: RouteSelector handler: RouteHandler[StateT] From 265643528cc3da295f8724c3fdfc5742da8ac777 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 26 Sep 2025 11:07:48 -0700 Subject: [PATCH 24/67] 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 25/67] 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 26/67] 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 27/67] 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 28/67] 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 29/67] 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 30/67] 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 31/67] 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 32/67] 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 33/67] 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 34/67] 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 35/67] 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 36/67] 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 37/67] 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 38/67] 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 39/67] 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 40/67] 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 41/67] 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 42/67] 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 43/67] 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 44/67] 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 45/67] 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 46/67] 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 68db989f6c141690899d4330adfdaf09d70d59af Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 2 Oct 2025 09:58:27 -0700 Subject: [PATCH 47/67] Tweaks to imports --- .../microsoft_agents/hosting/core/__init__.py | 4 ++-- .../microsoft_agents/hosting/core/app/__init__.py | 2 +- .../hosting/core/app/{routes => _routes}/__init__.py | 0 .../hosting/core/app/{routes => _routes}/route.py | 6 +++--- .../core/app/{routes => _routes}/route_list.py | 2 +- .../core/app/{routes => _routes}/route_rank.py | 0 .../hosting/core/app/{type_defs.py => _type_defs.py} | 1 - .../hosting/core/app/agent_application.py | 12 +++++------- 8 files changed, 12 insertions(+), 15 deletions(-) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{routes => _routes}/__init__.py (100%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{routes => _routes}/route.py (86%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{routes => _routes}/route_list.py (96%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{routes => _routes}/route_rank.py (100%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/{type_defs.py => _type_defs.py} (99%) 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 c3f6dee2..195dedc0 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 @@ -11,13 +11,13 @@ from .turn_context import TurnContext # Application Style -from .app.type_defs import RouteHandler, RouteSelector, StateT +from .app._type_defs import RouteHandler, RouteSelector, StateT from .app.agent_application import AgentApplication from .app.app_error import ApplicationError from .app.app_options import ApplicationOptions from .app.input_file import InputFile, InputFileDownloader from .app.query import Query -from .app.route import Route, RouteHandler +from .app._routes import Route, RouteList, RouteRank from .app.typing_indicator import TypingIndicator # App Auth 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 01daa2f8..ae4a12f0 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 @@ -12,7 +12,7 @@ from .query import Query from .route import RouteList, Rouete, RouteRank from .typing_indicator import TypingIndicator -from .type_defs import RouteHandler, RouteSelector, StateT +from ._type_defs import RouteHandler, RouteSelector, StateT # Auth from .oauth import ( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/__init__.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/__init__.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/__init__.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route.py similarity index 86% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route.py index d110d5cd..72fcab14 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route.py @@ -5,14 +5,14 @@ from __future__ import annotations -from typing import Callable, Generic, Optional +from typing import Generic, Optional from ...turn_context import TurnContext -from ..type_defs import RouteHandler, RouteSelector, StateT +from .._type_defs import RouteHandler, RouteSelector, StateT from .route_rank import RouteRank -def agentic_selector(selector: RouteSelector) -> RouteSelector: +def _agentic_selector(selector: RouteSelector) -> RouteSelector: def wrapped_selector(context: TurnContext) -> bool: # TODO return selector(context) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_list.py similarity index 96% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_list.py index dcc999c1..f366e427 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_list.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_list.py @@ -10,7 +10,7 @@ from microsoft_agents.hosting.core import TurnState -from ..type_defs import RouteSelector, RouteHandler +from .._type_defs import RouteSelector, RouteHandler from .route import Route from .route_rank import RouteRank diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_rank.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_rank.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/routes/route_rank.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_rank.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py similarity index 99% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/type_defs.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py index a623ff1e..3a7f379f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/type_defs.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py @@ -6,6 +6,5 @@ StateT = TypeVar("StateT", bound=TurnState) - class RouteHandler(Protocol[StateT]): def __call__(self, context: TurnContext, state: StateT) -> Awaitable[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 bba544fb..87c9bd21 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 @@ -36,22 +36,20 @@ from .app_error import ApplicationError from .app_options import ApplicationOptions -from .route import Route, RouteHandler +from ._routes import Route, _agentic_selector from .state import TurnState from ..channel_service_adapter import ChannelServiceAdapter from .oauth import Authorization from .typing_indicator import TypingIndicator -from .type_defs import RouteHandler, RouteSelector, StateT -from .routes import RouteList, Route, RouteRank +from ._type_defs import RouteHandler, RouteSelector +from ._routes import RouteList, Route, RouteRank logger = logging.getLogger(__name__) IN_SIGN_IN_KEY = "__InSignInFlow__" StateT = TypeVar("StateT", bound=TurnState) - - class AgentApplication(Agent, Generic[StateT]): """ AgentApplication class for routing and processing incoming requests. @@ -72,7 +70,7 @@ class AgentApplication(Agent, Generic[StateT]): _auth: Optional[Authorization] = None _internal_before_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] = [] _internal_after_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] = [] - _routes: RouteList[StateT] = RouteList[StateT]() + _route_list: RouteList[StateT] = RouteList[StateT]() _error: Optional[Callable[[TurnContext, Exception], Awaitable[None]]] = None _turn_state_factory: Optional[Callable[[TurnContext], StateT]] = None @@ -217,7 +215,7 @@ def add_route( ) -> None: """Adds a new route to the application.""" if is_agentic: - selector = agentic_selector(selector) + selector = _agentic_selector(selector) self._route_list.add_route(selector, handler, is_invoke=is_invoke, rank=rank, auth_handlers=auth_handlers) def activity( From d8ebb191acb69aaa93d7ad3b6ffe5c5d191bc9e3 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 2 Oct 2025 09:59:06 -0700 Subject: [PATCH 48/67] Fixing test import --- tests/hosting_core/app/routes/test_route.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/hosting_core/app/routes/test_route.py b/tests/hosting_core/app/routes/test_route.py index 8158cabc..48b9afe7 100644 --- a/tests/hosting_core/app/routes/test_route.py +++ b/tests/hosting_core/app/routes/test_route.py @@ -10,7 +10,7 @@ TurnState, ) -from microsoft_agents.hosting.core.app.type_defs import ( +from microsoft_agents.hosting.core.app._type_defs import ( RouteHandler, RouteSelector, StateT, From fb4e5837c0f55c2b676758366f670c92a98389d3 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 2 Oct 2025 14:53:45 -0700 Subject: [PATCH 49/67] Fixed improved route handling tests --- .../authentication/msal/msal_auth.py | 6 - .../microsoft_agents/hosting/core/__init__.py | 2 +- .../hosting/core/app/__init__.py | 2 +- .../hosting/core/app/_routes/__init__.py | 10 +- .../hosting/core/app/_routes/_route.py | 69 +++++++ .../_routes/{route_list.py => _route_list.py} | 16 +- .../hosting/core/app/_routes/route.py | 46 ----- .../hosting/core/app/_type_defs.py | 3 +- .../hosting/core/app/agent_application.py | 30 ++-- tests/_common/fixtures/roles.py | 10 ++ .../app/{oauth => _oauth}/__init__.py | 0 .../app/{oauth => _oauth}/_common.py | 0 .../app/{oauth => _oauth}/_env.py | 0 .../{oauth => _oauth}/_handlers/__init__.py | 0 .../test_agentic_user_authorization.py | 0 .../_handlers/test_user_authorization.py | 0 .../{oauth => _oauth}/test_auth_handler.py | 0 .../{oauth => _oauth}/test_authorization.py | 0 .../test_sign_in_response.py | 0 .../app/{routes => _routes}/__init__.py | 0 .../app/_routes/test_agentic_selector.py | 82 +++++++++ tests/hosting_core/app/_routes/test_route.py | 170 ++++++++++++++++++ .../app/_routes/test_route_list.py | 66 +++++++ tests/hosting_core/app/routes/test_route.py | 109 ----------- .../app/routes/test_route_list.py | 57 ------ 25 files changed, 424 insertions(+), 254 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/{route_list.py => _route_list.py} (77%) delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route.py create mode 100644 tests/_common/fixtures/roles.py rename tests/hosting_core/app/{oauth => _oauth}/__init__.py (100%) rename tests/hosting_core/app/{oauth => _oauth}/_common.py (100%) rename tests/hosting_core/app/{oauth => _oauth}/_env.py (100%) rename tests/hosting_core/app/{oauth => _oauth}/_handlers/__init__.py (100%) rename tests/hosting_core/app/{oauth => _oauth}/_handlers/test_agentic_user_authorization.py (100%) rename tests/hosting_core/app/{oauth => _oauth}/_handlers/test_user_authorization.py (100%) rename tests/hosting_core/app/{oauth => _oauth}/test_auth_handler.py (100%) rename tests/hosting_core/app/{oauth => _oauth}/test_authorization.py (100%) rename tests/hosting_core/app/{oauth => _oauth}/test_sign_in_response.py (100%) rename tests/hosting_core/app/{routes => _routes}/__init__.py (100%) create mode 100644 tests/hosting_core/app/_routes/test_agentic_selector.py create mode 100644 tests/hosting_core/app/_routes/test_route.py create mode 100644 tests/hosting_core/app/_routes/test_route_list.py delete mode 100644 tests/hosting_core/app/routes/test_route.py delete mode 100644 tests/hosting_core/app/routes/test_route_list.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 7d97789f..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 @@ -303,13 +303,7 @@ async def get_agentic_instance_token( ) raise ValueError(f"Failed to acquire token. {str(agentic_instance_token)}") -<<<<<<< HEAD - 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)) ->>>>>>> 4ea2c5701c4e0ab1dc797d5b86bf611265f851db return agentic_instance_token["access_token"], agent_token_result 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 195dedc0..90d6f0ec 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 @@ -17,7 +17,7 @@ from .app.app_options import ApplicationOptions from .app.input_file import InputFile, InputFileDownloader from .app.query import Query -from .app._routes import Route, RouteList, RouteRank +from .app._routes import _Route, _RouteList, RouteRank from .app.typing_indicator import TypingIndicator # App Auth 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 ae4a12f0..2751bf41 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 @@ -10,7 +10,7 @@ from .app_options import ApplicationOptions from .input_file import InputFile, InputFileDownloader from .query import Query -from .route import RouteList, Rouete, RouteRank +from ._routes import _RouteList, _Route, RouteRank from .typing_indicator import TypingIndicator from ._type_defs import RouteHandler, RouteSelector, StateT diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/__init__.py index 05273126..26e46712 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/__init__.py @@ -1,10 +1,10 @@ -from .route_list import RouteList -from .route import Route, agentic_selector +from ._route_list import _RouteList +from ._route import _Route, _agentic_selector from .route_rank import RouteRank __all__ = [ - "RouteList", - "Route", + "_RouteList", + "_Route", "RouteRank", - "agentic_selector", + "_agentic_selector", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py new file mode 100644 index 00000000..776d34d6 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py @@ -0,0 +1,69 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from typing import Generic, Optional, TypeVar + +from ...turn_context import TurnContext +from .._type_defs import RouteHandler, RouteSelector +from ..state.turn_state import TurnState +from .route_rank import RouteRank + +def _agentic_selector(selector: RouteSelector) -> RouteSelector: + def wrapped_selector(context: TurnContext) -> bool: + return context.activity.is_agentic_request() and selector(context) + return wrapped_selector + +StateT = TypeVar("StateT", bound=TurnState) + +class _Route(Generic[StateT]): + selector: RouteSelector + handler: RouteHandler[StateT] + _is_invoke: bool + _rank: RouteRank + auth_handlers: list[str] + _is_agentic: bool + + def __init__( + self, + selector: RouteSelector, + handler: RouteHandler[StateT], + is_invoke: bool = False, + rank: RouteRank = RouteRank.DEFAULT, + auth_handlers: Optional[list[str]] = None, + is_agentic: bool = False, + **kwargs, + ) -> None: + self.selector = selector + self.handler = handler + self._is_invoke = is_invoke + self._rank = rank + self._is_agentic = is_agentic + self.auth_handlers = auth_handlers or [] + + @property + def is_invoke(self) -> bool: + return self._is_invoke + + @property + def rank(self) -> RouteRank: + return self._rank + + @property + def is_agentic(self) -> bool: + return self._is_agentic + + @property + def ordering(self) -> list[int]: + return [ + 0 if self._is_agentic else 1, + 0 if self._is_invoke else 1, + self._rank.value + ] + + def __lt__(self, other: _Route) -> bool: + # list ordering is a lexicographic comparison + return self.ordering < other.ordering diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_list.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py similarity index 77% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_list.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py index f366e427..1720e0b7 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_list.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py @@ -8,17 +8,16 @@ import heapq from typing import Generic, Optional, TypeVar -from microsoft_agents.hosting.core import TurnState +from ..state.turn_state import TurnState from .._type_defs import RouteSelector, RouteHandler -from .route import Route +from ._route import _Route from .route_rank import RouteRank StateT = TypeVar("StateT", bound=TurnState) - -class RouteList(Generic[StateT]): - _routes: list[Route[StateT]] +class _RouteList(Generic[StateT]): + _routes: list[_Route[StateT]] def __init__( self, @@ -35,7 +34,7 @@ def add_route( auth_handlers: Optional[list[str]] = None, ) -> None: """Add a route to the priority queue.""" - route = Route( + route = _Route( selector=route_selector, handler=route_handler, is_invoke=is_invoke, @@ -45,10 +44,5 @@ def add_route( heapq.heappush(self._routes, route) - @property - def routes(self) -> list[Route[StateT]]: - """Get all routes in priority order.""" - return self._routes - def __iter__(self): return iter(self._routes) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route.py deleted file mode 100644 index 72fcab14..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" - -from __future__ import annotations - -from typing import Generic, Optional - -from ...turn_context import TurnContext -from .._type_defs import RouteHandler, RouteSelector, StateT -from .route_rank import RouteRank - - -def _agentic_selector(selector: RouteSelector) -> RouteSelector: - def wrapped_selector(context: TurnContext) -> bool: - # TODO - return selector(context) - - return wrapped_selector - -class Route(Generic[StateT]): - selector: RouteSelector - handler: RouteHandler[StateT] - is_invoke: bool - rank: RouteRank - auth_handlers: list[str] - - def __init__( - self, - selector: RouteSelector, - handler: RouteHandler[StateT], - is_invoke: bool = False, - rank: RouteRank = RouteRank.DEFAULT, - auth_handlers: Optional[list[str]] = None, - ) -> None: - self.selector = selector - self.handler = handler - self.is_invoke = is_invoke - self.rank = rank - self.auth_handlers = auth_handlers or [] - - def __lt__(self, other: Route) -> bool: - return self.is_invoke < other.is_invoke or ( - self.is_invoke == other.is_invoke and self.rank < other.rank - ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py index 3a7f379f..6a4bb632 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py @@ -1,6 +1,7 @@ from typing import Callable, TypeVar, Awaitable, Protocol -from microsoft_agents.hosting.core import TurnContext, TurnState +from ..turn_context import TurnContext +from .state import TurnState RouteSelector = Callable[[TurnContext], bool] 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 dc3ff5b5..6d47bece 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 @@ -36,19 +36,16 @@ from .app_error import ApplicationError from .app_options import ApplicationOptions -from .route import Route, RouteHandler from .state import TurnState from ..channel_service_adapter import ChannelServiceAdapter from .oauth import Authorization from .typing_indicator import TypingIndicator from ._type_defs import RouteHandler, RouteSelector -from ._routes import RouteList, Route, RouteRank +from ._routes import _RouteList, _Route, RouteRank, _agentic_selector logger = logging.getLogger(__name__) -IN_SIGN_IN_KEY = "__InSignInFlow__" - StateT = TypeVar("StateT", bound=TurnState) class AgentApplication(Agent, Generic[StateT]): """ @@ -70,23 +67,23 @@ class AgentApplication(Agent, Generic[StateT]): _auth: Optional[Authorization] = None _internal_before_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] = [] _internal_after_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] = [] - _route_list: RouteList[StateT] = RouteList[StateT]() + _route_list: _RouteList[StateT] = _RouteList[StateT]() _error: Optional[Callable[[TurnContext, Exception], Awaitable[None]]] = None _turn_state_factory: Optional[Callable[[TurnContext], StateT]] = None def __init__( self, - options: ApplicationOptions = None, + options: Optional[ApplicationOptions] = None, *, - connection_manager: Connections = None, - authorization: Authorization = None, + connection_manager: Optional[Connections] = None, + authorization: Optional[Authorization] = None, **kwargs, ) -> None: """ Creates a new AgentApplication instance. """ self.typing = TypingIndicator() - self._route_list = RouteList[StateT]() + self._route_list = _RouteList[StateT]() configuration = kwargs @@ -290,7 +287,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message handler for route handler {func.__name__} with select: {select} with auth handlers: {auth_handlers}" ) - self.add_route(Route[StateT](__selector, func, auth_handlers=auth_handlers, **kwargs)) + self.add_route(_Route[StateT](__selector, func, auth_handlers=auth_handlers, **kwargs)) return func return __call @@ -343,7 +340,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering conversation update handler for route handler {func.__name__} with type: {type} with auth handlers: {auth_handlers}" ) - self.add_route(Route[StateT](__selector, func, auth_handlers=auth_handlers, **kwargs)) + self.add_route(_Route[StateT](__selector, func, auth_handlers=auth_handlers, **kwargs)) return func return __call @@ -387,7 +384,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message reaction handler for route handler {func.__name__} with type: {type} with auth handlers: {auth_handlers}" ) - self.add_route(Route[StateT](__selector, func, auth_handlers=auth_handlers, **kwargs)) + self.add_route(_Route[StateT](__selector, func, auth_handlers=auth_handlers, **kwargs)) return func return __call @@ -444,17 +441,16 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message update handler for route handler {func.__name__} with type: {type} with auth handlers: {auth_handlers}" ) - self.add_route(Route[StateT](__selector, func, auth_handlers=auth_handlers), **kwargs) + self.add_route(_Route[StateT](__selector, func, auth_handlers=auth_handlers), **kwargs) return func return __call def handoff( - self, *, auth_handlers: Optional[list[str]] = None + self, *, auth_handlers: Optional[list[str]] = None, **kwargs ) -> Callable[ [Callable[[TurnContext, StateT, str], Awaitable[None]]], - Callable[[TurnContext, StateT, str], Awaitable[None]], - **kwargs + Callable[[TurnContext, StateT, str], Awaitable[None]] ]: """ Registers a handler to handoff conversations from one copilot to another. @@ -493,7 +489,7 @@ async def __handler(context: TurnContext, state: StateT): f"Registering handoff handler for route handler {func.__name__} with auth handlers: {auth_handlers}" ) - self.add_route(Route[StateT](__selector, __handler, True, auth_handlers), **kwargs) + self.add_route(_Route[StateT](__selector, __handler, True, auth_handlers), **kwargs) return func return __call diff --git a/tests/_common/fixtures/roles.py b/tests/_common/fixtures/roles.py new file mode 100644 index 00000000..239b1762 --- /dev/null +++ b/tests/_common/fixtures/roles.py @@ -0,0 +1,10 @@ +import pytest +from microsoft_agents.activity import RoleTypes + +@pytest.fixture(params=[RoleTypes.user, RoleTypes.skill, RoleTypes.agent]) +def non_agentic_role( request): + return request.param + +@pytest.fixture(params=[RoleTypes.agentic_user, RoleTypes.agentic_identity]) +def agentic_role(request): + return request.param \ No newline at end of file diff --git a/tests/hosting_core/app/oauth/__init__.py b/tests/hosting_core/app/_oauth/__init__.py similarity index 100% rename from tests/hosting_core/app/oauth/__init__.py rename to tests/hosting_core/app/_oauth/__init__.py diff --git a/tests/hosting_core/app/oauth/_common.py b/tests/hosting_core/app/_oauth/_common.py similarity index 100% rename from tests/hosting_core/app/oauth/_common.py rename to tests/hosting_core/app/_oauth/_common.py diff --git a/tests/hosting_core/app/oauth/_env.py b/tests/hosting_core/app/_oauth/_env.py similarity index 100% rename from tests/hosting_core/app/oauth/_env.py rename to tests/hosting_core/app/_oauth/_env.py diff --git a/tests/hosting_core/app/oauth/_handlers/__init__.py b/tests/hosting_core/app/_oauth/_handlers/__init__.py similarity index 100% rename from tests/hosting_core/app/oauth/_handlers/__init__.py rename to tests/hosting_core/app/_oauth/_handlers/__init__.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 similarity index 100% rename from tests/hosting_core/app/oauth/_handlers/test_agentic_user_authorization.py rename to tests/hosting_core/app/_oauth/_handlers/test_agentic_user_authorization.py diff --git a/tests/hosting_core/app/oauth/_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/oauth/_handlers/test_user_authorization.py rename to tests/hosting_core/app/_oauth/_handlers/test_user_authorization.py diff --git a/tests/hosting_core/app/oauth/test_auth_handler.py b/tests/hosting_core/app/_oauth/test_auth_handler.py similarity index 100% rename from tests/hosting_core/app/oauth/test_auth_handler.py rename to tests/hosting_core/app/_oauth/test_auth_handler.py diff --git a/tests/hosting_core/app/oauth/test_authorization.py b/tests/hosting_core/app/_oauth/test_authorization.py similarity index 100% rename from tests/hosting_core/app/oauth/test_authorization.py rename to tests/hosting_core/app/_oauth/test_authorization.py diff --git a/tests/hosting_core/app/oauth/test_sign_in_response.py b/tests/hosting_core/app/_oauth/test_sign_in_response.py similarity index 100% rename from tests/hosting_core/app/oauth/test_sign_in_response.py rename to tests/hosting_core/app/_oauth/test_sign_in_response.py diff --git a/tests/hosting_core/app/routes/__init__.py b/tests/hosting_core/app/_routes/__init__.py similarity index 100% rename from tests/hosting_core/app/routes/__init__.py rename to tests/hosting_core/app/_routes/__init__.py diff --git a/tests/hosting_core/app/_routes/test_agentic_selector.py b/tests/hosting_core/app/_routes/test_agentic_selector.py new file mode 100644 index 00000000..30e36d30 --- /dev/null +++ b/tests/hosting_core/app/_routes/test_agentic_selector.py @@ -0,0 +1,82 @@ +import pytest + +from microsoft_agents.activity import Activity, ChannelAccount, RoleTypes + +from microsoft_agents.hosting.core import RouteSelector, TurnContext +from microsoft_agents.hosting.core.app._routes import _Route, _agentic_selector + +from tests._common.fixtures.roles import agentic_role, non_agentic_role + +def message_selector(context) -> bool: + return context.activity.type == "message" + +def invoke_selector(context) -> bool: + return context.activity.type == "invoke" + +def create_text_selector(includes: str) -> RouteSelector: + def text_selector(context) -> bool: + return context.activity.type == "message" and includes in context.activity.text + return text_selector + +hello_text_selector = create_text_selector("hello") +bye_text_selector = create_text_selector("bye") + +do_select = { + message_selector: Activity(type="message", text="hello world"), + invoke_selector: Activity(type="invoke"), + hello_text_selector: Activity(type="message", text="hello there"), + bye_text_selector: Activity(type="message", text="goodbye"), +} + +do_not_select = { + message_selector: Activity(type="invoke"), + invoke_selector: Activity(type="message"), + hello_text_selector: Activity(type="message", text="goodbye"), + bye_text_selector: Activity(type="message", text="hello there"), +} + +@pytest.fixture(params=[message_selector, invoke_selector, hello_text_selector, bye_text_selector]) +def selector(request) -> RouteSelector: + return request.param + +def test_agentic_selector_does_not_select_with_non_agentic_request(mocker, selector, non_agentic_role): + channel_account = ChannelAccount(role=non_agentic_role) + + selecting_activity = do_select[selector].model_copy() + non_selecting_activity = do_not_select[selector].model_copy() + + selecting_context = mocker.Mock(spec=TurnContext) + selecting_context.activity = selecting_activity + non_selecting_context = mocker.Mock(spec=TurnContext) + non_selecting_context.activity = non_selecting_activity + + assert selector(selecting_context) + assert not selector(non_selecting_context) + + new_selector = _agentic_selector(selector) + + selecting_activity.recipient = channel_account + + assert not new_selector(selecting_context) + assert not new_selector(non_selecting_context) + +def test_agentic_selector_selects_with_agentic_request(mocker, selector, agentic_role): + channel_account = ChannelAccount(role=agentic_role) + + selecting_activity = do_select[selector].model_copy() + non_selecting_activity = do_not_select[selector].model_copy() + + selecting_context = mocker.Mock(spec=TurnContext) + selecting_context.activity = selecting_activity + non_selecting_context = mocker.Mock(spec=TurnContext) + non_selecting_context.activity = non_selecting_activity + + assert selector(selecting_context) + assert not selector(non_selecting_context) + + new_selector = _agentic_selector(selector) + + selecting_activity.recipient = channel_account + + assert new_selector(selecting_context) + assert not new_selector(non_selecting_context) \ No newline at end of file diff --git a/tests/hosting_core/app/_routes/test_route.py b/tests/hosting_core/app/_routes/test_route.py new file mode 100644 index 00000000..acc697f6 --- /dev/null +++ b/tests/hosting_core/app/_routes/test_route.py @@ -0,0 +1,170 @@ +import pytest + +from microsoft_agents.hosting.core import ( + TurnContext, + _Route, + RouteRank, + TurnState, +) + +from microsoft_agents.hosting.core.app._type_defs import ( + RouteHandler, + RouteSelector, + StateT, +) + + +def selector(context: TurnContext) -> bool: + return True + + +async def handler(context: TurnContext, state: TurnState) -> None: + pass + + +class TestRoute: + + def test_init(self): + + route = _Route( + selector=selector, + handler=handler, + is_invoke=True, + rank=RouteRank.LAST, + auth_handlers=["auth1", "auth2"], + ) + + assert route.selector == selector + assert route.handler == handler + assert route.is_invoke is True + assert route.rank == RouteRank.LAST + assert route.auth_handlers == ["auth1", "auth2"] + + def test_init_defaults(self): + + route = _Route(selector=selector, handler=handler) + + assert route.selector == selector + assert route.handler == handler + assert route.is_invoke is False + assert route.rank == RouteRank.DEFAULT + assert route.auth_handlers == [] + + def test_ordering(self): + + route_a = _Route( + selector=selector, + handler=handler, + is_invoke=True, + rank=RouteRank.FIRST, + auth_handlers=["auth1"], + ) + route_b = _Route( + selector=selector, + handler=handler, + is_invoke=False, + rank=RouteRank.LAST, + auth_handlers=["auth2"], + is_agentic=True + ) + route_c = _Route( + selector=selector, + handler=handler, + is_invoke=False, + rank=RouteRank.DEFAULT, + auth_handlers=["auth2"], + is_agentic=False + ) + + assert route_a.ordering == [1, 0, RouteRank.FIRST] + assert route_b.ordering == [0, 1, RouteRank.LAST] + assert route_c.ordering == [1, 1, RouteRank.DEFAULT] + + @pytest.fixture(params=[None, [], ["authA1", "authA2"], ["github"]]) + def auth_handlers_a(self, request): + return request.param + + @pytest.fixture(params=[None, [], ["authB1", "authB2"], ["github"]]) + def auth_handlers_b(self, request): + return request.param + + + @pytest.mark.parametrize( + "is_invoke_a, rank_a, is_agentic_a, is_invoke_b, rank_b, is_agentic_b, expected_result", + [ + # Same agentic status (both False) + [False, RouteRank.DEFAULT, False, False, RouteRank.DEFAULT, False, False], # [1,1,DEFAULT] vs [1,1,DEFAULT] + [False, RouteRank.DEFAULT, False, False, RouteRank.LAST, False, True], # [1,1,DEFAULT] vs [1,1,LAST] + [False, RouteRank.LAST, False, False, RouteRank.DEFAULT, False, False], # [1,1,LAST] vs [1,1,DEFAULT] + [False, RouteRank.DEFAULT, False, True, RouteRank.DEFAULT, False, False], # [1,1,DEFAULT] vs [1,0,DEFAULT] + [True, RouteRank.DEFAULT, False, False, RouteRank.DEFAULT, False, True], # [1,0,DEFAULT] vs [1,1,DEFAULT] + [True, RouteRank.DEFAULT, False, True, RouteRank.DEFAULT, False, False], # [1,0,DEFAULT] vs [1,0,DEFAULT] + [True, RouteRank.LAST, False, True, RouteRank.DEFAULT, False, False], # [1,0,LAST] vs [1,0,DEFAULT] + [True, RouteRank.DEFAULT, False, True, RouteRank.LAST, False, True], # [1,0,DEFAULT] vs [1,0,LAST] + [False, RouteRank.FIRST, False, True, RouteRank.DEFAULT, False, False], # [1,1,FIRST] vs [1,0,DEFAULT] + [True, RouteRank.DEFAULT, False, False, RouteRank.LAST, False, True], # [1,0,DEFAULT] vs [1,1,LAST] + [False, RouteRank.LAST, False, True, RouteRank.FIRST, False, False], # [1,1,LAST] vs [1,0,FIRST] + [True, RouteRank.FIRST, False, False, RouteRank.LAST, False, True], # [1,0,FIRST] vs [1,1,LAST] + [False, RouteRank.FIRST, False, False, RouteRank.LAST, False, True], # [1,1,FIRST] vs [1,1,LAST] + [True, RouteRank.FIRST, False, True, RouteRank.LAST, False, True], # [1,0,FIRST] vs [1,0,LAST] + + # Same agentic status (both True) + [False, RouteRank.DEFAULT, True, False, RouteRank.DEFAULT, True, False], # [0,1,DEFAULT] vs [0,1,DEFAULT] + [False, RouteRank.DEFAULT, True, False, RouteRank.LAST, True, True], # [0,1,DEFAULT] vs [0,1,LAST] + [False, RouteRank.LAST, True, False, RouteRank.DEFAULT, True, False], # [0,1,LAST] vs [0,1,DEFAULT] + [False, RouteRank.DEFAULT, True, True, RouteRank.DEFAULT, True, False], # [0,1,DEFAULT] vs [0,0,DEFAULT] + [True, RouteRank.DEFAULT, True, False, RouteRank.DEFAULT, True, True], # [0,0,DEFAULT] vs [0,1,DEFAULT] + [True, RouteRank.DEFAULT, True, True, RouteRank.DEFAULT, True, False], # [0,0,DEFAULT] vs [0,0,DEFAULT] + [True, RouteRank.LAST, True, True, RouteRank.DEFAULT, True, False], # [0,0,LAST] vs [0,0,DEFAULT] + [True, RouteRank.DEFAULT, True, True, RouteRank.LAST, True, True], # [0,0,DEFAULT] vs [0,0,LAST] + [False, RouteRank.FIRST, True, True, RouteRank.DEFAULT, True, False], # [0,1,FIRST] vs [0,0,DEFAULT] + [True, RouteRank.DEFAULT, True, False, RouteRank.LAST, True, True], # [0,0,DEFAULT] vs [0,1,LAST] + [False, RouteRank.LAST, True, True, RouteRank.FIRST, True, False], # [0,1,LAST] vs [0,0,FIRST] + [True, RouteRank.FIRST, True, False, RouteRank.LAST, True, True], # [0,0,FIRST] vs [0,1,LAST] + [False, RouteRank.FIRST, True, False, RouteRank.LAST, True, True], # [0,1,FIRST] vs [0,1,LAST] + [True, RouteRank.FIRST, True, True, RouteRank.LAST, True, True], # [0,0,FIRST] vs [0,0,LAST] + + # Different agentic status - agentic (True) has higher priority than non-agentic (False) + [False, RouteRank.DEFAULT, True, False, RouteRank.DEFAULT, False, True], # [0,1,DEFAULT] vs [1,1,DEFAULT] + [False, RouteRank.DEFAULT, False, False, RouteRank.DEFAULT, True, False], # [1,1,DEFAULT] vs [0,1,DEFAULT] + [True, RouteRank.DEFAULT, True, True, RouteRank.DEFAULT, False, True], # [0,0,DEFAULT] vs [1,0,DEFAULT] + [True, RouteRank.DEFAULT, False, True, RouteRank.DEFAULT, True, False], # [1,0,DEFAULT] vs [0,0,DEFAULT] + [False, RouteRank.LAST, True, False, RouteRank.FIRST, False, True], # [0,1,LAST] vs [1,1,FIRST] + [False, RouteRank.FIRST, False, False, RouteRank.LAST, True, False], # [1,1,FIRST] vs [0,1,LAST] + [True, RouteRank.LAST, True, True, RouteRank.FIRST, False, True], # [0,0,LAST] vs [1,0,FIRST] + [True, RouteRank.FIRST, False, True, RouteRank.LAST, True, False], # [1,0,FIRST] vs [0,0,LAST] + [False, RouteRank.LAST, True, True, RouteRank.FIRST, False, True], # [0,1,LAST] vs [1,0,FIRST] + [True, RouteRank.LAST, False, False, RouteRank.FIRST, True, False], # [1,0,LAST] vs [0,1,FIRST] + ], + ) + def test_lt_with_agentic( + self, + is_invoke_a, + rank_a, + is_agentic_a, + is_invoke_b, + rank_b, + is_agentic_b, + expected_result, + auth_handlers_a, + auth_handlers_b, + ): + + route_a = _Route( + selector, + handler, + is_invoke=is_invoke_a, + rank=rank_a, + is_agentic=is_agentic_a, + auth_handlers=auth_handlers_a, + ) + route_b = _Route( + selector, + handler, + is_invoke=is_invoke_b, + rank=rank_b, + is_agentic=is_agentic_b, + auth_handlers=auth_handlers_b, + ) + + assert (route_a < route_b) == expected_result \ No newline at end of file diff --git a/tests/hosting_core/app/_routes/test_route_list.py b/tests/hosting_core/app/_routes/test_route_list.py new file mode 100644 index 00000000..3d84acdf --- /dev/null +++ b/tests/hosting_core/app/_routes/test_route_list.py @@ -0,0 +1,66 @@ +from microsoft_agents.hosting.core import ( + TurnContext, + TurnState, + _RouteList, + _Route, + RouteRank +) + +def selector(context: TurnContext) -> bool: + return True + +async def handler(context: TurnContext, state: TurnState) -> None: + pass + +class Test_RouteList: + + def assert_priority_invariant(self, route_list: _RouteList): + + # check priority invariant + routes = list(route_list) + for i in range(1, len(routes)): + assert not routes[i] < routes[i - 1] + + def has_contents(self, route_list: _RouteList, should_contain: list[_Route]): + for route in should_contain: + for existing in list(route_list): + if existing == route: + break + else: + return False + return True + + def test_route_list_init(self): + route_list = _RouteList() + assert list(route_list) == [] + + def test_route_list_add_and_order(self): + + route_list = _RouteList() + + all_routes = [ + (selector, handler, False, RouteRank.DEFAULT, ["a"]), + (selector, handler, True, RouteRank.LAST, ["a"]), + (selector, handler, False, RouteRank.FIRST), + (selector, handler, True), + (selector, handler), + (selector, handler, True, RouteRank.DEFAULT, ["slack"]), + (selector, handler, False, RouteRank.FIRST, ["a", "b"]), + (selector, handler, True, RouteRank.DEFAULT, ["c"]), + ] + all_routes = [ + { + "selector": route[0], + "handler": route[1], + "is_invoke": route[2], + "rank": route[3], + "auth_handlers": route[4] if len(route) > 4 else None + } for route in all_routes + ] + added_routes = [] + + for i, route in enumerate(all_routes): + added_routes.append(_Route(**route)) + route_list.add_route(**route) + self.assert_priority_invariant(route_list) + assert self.has_contents(route_list, added_routes) \ No newline at end of file diff --git a/tests/hosting_core/app/routes/test_route.py b/tests/hosting_core/app/routes/test_route.py deleted file mode 100644 index 48b9afe7..00000000 --- a/tests/hosting_core/app/routes/test_route.py +++ /dev/null @@ -1,109 +0,0 @@ -import pytest - -from microsoft_agents.hosting.core import ( - TurnContext, - RouteSelector, - RouteHandler, - Route, - RouteRank, - StateT, - TurnState, -) - -from microsoft_agents.hosting.core.app._type_defs import ( - RouteHandler, - RouteSelector, - StateT, -) - - -def selector(context: TurnContext) -> bool: - return True - - -async def handler(context: TurnContext, state: TurnState) -> None: - pass - - -class TestRoute: - - def test_init(self): - - route = Route( - selector=selector, - handler=handler, - is_invoke=True, - rank=RouteRank.HIGH, - auth_handlers=["auth1", "auth2"], - ) - - assert route.selector == self.selector - assert route.handler == self.handler - assert route.is_invoke is True - assert route.rank == RouteRank.HIGH - assert route.auth_handlers == ["auth1", "auth2"] - - def test_init_defaults(self): - - route = Route(selector=selector, handler=handler) - - assert route.selector == selector - assert route.handler == handler - assert route.is_invoke is False - assert route.rank == RouteRank.DEFAULT - assert route.auth_handlers == [] - - @pytest.fixture(params=[None, [], ["authA1", "authA2"], ["github"]]) - def auth_handlers_a(self, request): - return request.param - - @pytest.fixture(params=[None, [], ["authB1", "authB2"], ["github"]]) - def auth_handlers_b(self, request): - return request.param - - @pytest.mark.parametrize( - "is_invoke_a, rank_a, is_invoke_b, rank_b, expected_result", - [ - [False, RouteRank.DEFAULT, False, RouteRank.DEFAULT, False], - [False, RouteRank.DEFAULT, False, RouteRank.LAST, True], - [False, RouteRank.LAST, False, RouteRank.DEFAULT, False], - [False, RouteRank.DEFAULT, True, RouteRank.DEFAULT, True], - [True, RouteRank.DEFAULT, False, RouteRank.DEFAULT, True], - [True, RouteRank.DEFAULT, True, RouteRank.DEFAULT, False], - [True, RouteRank.LAST, True, RouteRank.DEFAULT, False], - [True, RouteRank.DEFAULT, True, RouteRank.LAST, True], - [False, RouteRank.FIRST, True, RouteRank.DEFAULT, True], - [True, RouteRank.DEFAULT, False, RouteRank.LAST, True], - [False, RouteRank.LAST, True, RouteRank.FIRST, False], - [True, RouteRank.FIRST, False, RouteRank.LAST, True], - [False, RouteRank.FIRST, False, RouteRank.LAST, True], - [True, RouteRank.FIRST, True, RouteRank.LAST, True], - ], - ) - def test_lt( - self, - is_invoke_a, - rank_a, - is_invoke_b, - rank_b, - expected_result, - auth_handlers_a, - auth_handlers_b, - ): - - route_a = Route( - selector, - handler, - is_invoke=is_invoke_a, - rank=rank_a, - auth_handlers=auth_handlers_a, - ) - route_b = Route( - selector, - handler, - is_invoke=is_invoke_b, - rank=rank_b, - auth_handlers=auth_handlers_b, - ) - - assert (route_a < route_b) == expected_result diff --git a/tests/hosting_core/app/routes/test_route_list.py b/tests/hosting_core/app/routes/test_route_list.py deleted file mode 100644 index f4397c3d..00000000 --- a/tests/hosting_core/app/routes/test_route_list.py +++ /dev/null @@ -1,57 +0,0 @@ -from microsoft_agents.hosting.core import ( - TurnContext, - TurnState, - RouteList, - Route, - RouteRank -) - -def selector(context: TurnContext) -> bool: - return True - -async def handler(context: TurnContext, state: TurnState) -> None: - pass - -class TestRouteList: - - def assert_priority_invariant(self, route_list: RouteList): - - # check priority invariant - routes = route_list.get_routes() - for i in range(1, len(routes)): - assert not routes[i] < routes[i - 1] - - def has_contents(self, route_list: RouteList, should_contain: list[Route]): - for route in should_contain: - for existing in route_list.get_routes(): - if existing == route: - break - else: - return False - return True - - def test_route_list_init(self): - route_list = RouteList() - assert route_list.get_routes() == [] - - def test_route_list_add_and_order(self): - - route_list = RouteList() - - all_routes = [ - (selector, handler, is_invoke=False, rank=RouteRank.DEFAULT, auth_handlers=["a"]), - (selector, handler, is_invoke=True, rank=RouteRank.LAST, auth_handlers=["a"]), - (selector, handler, is_invoke=False, rank=RouteRank.FIRST), - (selector, handler, is_invoke=True), - (selector, handler), - (selector, handler, is_invoke=True, rank=RouteRank.DEFAULT, auth_handlers=["slack"]), - (selector, handler, is_invoke=False, rank=RouteRank.FIRST, auth_handlers=["a", "b"]), - (selector, handler, is_invoke=True, rank=RouteRank.DEFAULT, auth_handlers=["c"]), - ] - added_routes = [] - - for i, route in enumerate(all_routes): - added_routes.append(Route(*route)) - route_list.add_route(*route) - self.assert_priority_invariant(route_list) - assert self.has_contents(route_list, added_routes) \ No newline at end of file From fe900ae24340b71f98a017e3de30fbb5e8d6fe56 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 2 Oct 2025 15:23:44 -0700 Subject: [PATCH 50/67] Adding to extension starter sample --- test_samples/extensions/doc_samples/README.md | 3 + .../extensions/extension-starter/README.md | 0 .../extensions/extension-starter/env.TEMPLATE | 3 + .../extension-starter/requirements.txt | 1 + .../extension-starter/src/__init__.py | 0 .../src/extension/__init__.py | 4 + .../src/extension/extension.py | 124 ++++++ .../extension-starter/src/extension/models.py | 37 ++ .../extension-starter/src/sample/__init__.py | 0 .../extension-starter/src/sample/app.py | 37 ++ .../src/sample/extension_agent.py | 42 ++ .../extension-starter/src/sample/main.py | 19 + .../src/sample/start_server.py | 32 ++ .../extensions/quickstart-extension/README.md | 0 .../quickstart-extension/env.TEMPLATE | 0 .../quickstart-extension/infra/README.md | 29 ++ .../infra/bicep/app.bicep | 20 + .../infra/bicep/bicepconfig.json | 9 + .../infra/bicep/bot.bicep | 36 ++ .../quickstart-extension/infra/bot/color.png | Bin 0 -> 1893 bytes .../infra/bot/outline.png | Bin 0 -> 755 bytes .../quickstart-extension/infra/decompose.ps1 | 38 ++ .../infra/gen_teams_manifest.ps1 | 80 ++++ .../quickstart-extension/infra/prov_app.ps1 | 0 .../quickstart-extension/infra/prov_bot.ps1 | 0 .../quickstart-extension/infra/provision.ps1 | 0 .../quickstart-extension/requirements.txt | 1 + .../quickstart-extension/src/__init__.py | 0 .../src/extension/__init__.py | 0 .../src/extension/extension.py | 97 +++++ .../src/extension/models.py | 37 ++ .../src/sample/__init__.py | 0 .../quickstart-extension/src/sample/app.py | 30 ++ .../src/sample/extension_agent.py | 34 ++ .../src/sample/start_server.py | 32 ++ tests/_common/fixtures/roles.py | 6 +- .../app/_routes/test_agentic_selector.py | 27 +- tests/hosting_core/app/_routes/test_route.py | 389 ++++++++++++++++-- .../app/_routes/test_route_list.py | 16 +- 39 files changed, 1125 insertions(+), 58 deletions(-) create mode 100644 test_samples/extensions/doc_samples/README.md create mode 100644 test_samples/extensions/extension-starter/README.md create mode 100644 test_samples/extensions/extension-starter/env.TEMPLATE create mode 100644 test_samples/extensions/extension-starter/requirements.txt create mode 100644 test_samples/extensions/extension-starter/src/__init__.py create mode 100644 test_samples/extensions/extension-starter/src/extension/__init__.py create mode 100644 test_samples/extensions/extension-starter/src/extension/extension.py create mode 100644 test_samples/extensions/extension-starter/src/extension/models.py create mode 100644 test_samples/extensions/extension-starter/src/sample/__init__.py create mode 100644 test_samples/extensions/extension-starter/src/sample/app.py create mode 100644 test_samples/extensions/extension-starter/src/sample/extension_agent.py create mode 100644 test_samples/extensions/extension-starter/src/sample/main.py create mode 100644 test_samples/extensions/extension-starter/src/sample/start_server.py create mode 100644 test_samples/extensions/quickstart-extension/README.md create mode 100644 test_samples/extensions/quickstart-extension/env.TEMPLATE create mode 100644 test_samples/extensions/quickstart-extension/infra/README.md create mode 100644 test_samples/extensions/quickstart-extension/infra/bicep/app.bicep create mode 100644 test_samples/extensions/quickstart-extension/infra/bicep/bicepconfig.json create mode 100644 test_samples/extensions/quickstart-extension/infra/bicep/bot.bicep create mode 100644 test_samples/extensions/quickstart-extension/infra/bot/color.png create mode 100644 test_samples/extensions/quickstart-extension/infra/bot/outline.png create mode 100644 test_samples/extensions/quickstart-extension/infra/decompose.ps1 create mode 100644 test_samples/extensions/quickstart-extension/infra/gen_teams_manifest.ps1 create mode 100644 test_samples/extensions/quickstart-extension/infra/prov_app.ps1 create mode 100644 test_samples/extensions/quickstart-extension/infra/prov_bot.ps1 create mode 100644 test_samples/extensions/quickstart-extension/infra/provision.ps1 create mode 100644 test_samples/extensions/quickstart-extension/requirements.txt create mode 100644 test_samples/extensions/quickstart-extension/src/__init__.py create mode 100644 test_samples/extensions/quickstart-extension/src/extension/__init__.py create mode 100644 test_samples/extensions/quickstart-extension/src/extension/extension.py create mode 100644 test_samples/extensions/quickstart-extension/src/extension/models.py create mode 100644 test_samples/extensions/quickstart-extension/src/sample/__init__.py create mode 100644 test_samples/extensions/quickstart-extension/src/sample/app.py create mode 100644 test_samples/extensions/quickstart-extension/src/sample/extension_agent.py create mode 100644 test_samples/extensions/quickstart-extension/src/sample/start_server.py diff --git a/test_samples/extensions/doc_samples/README.md b/test_samples/extensions/doc_samples/README.md new file mode 100644 index 00000000..fd85d0ef --- /dev/null +++ b/test_samples/extensions/doc_samples/README.md @@ -0,0 +1,3 @@ +# Doc Samples + +These samples are a bit more specific to development and are meant to highlight usage patterns of specific SDK features. Included are also samples for developing extensions on top of the SDK. \ No newline at end of file diff --git a/test_samples/extensions/extension-starter/README.md b/test_samples/extensions/extension-starter/README.md new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/extensions/extension-starter/env.TEMPLATE b/test_samples/extensions/extension-starter/env.TEMPLATE new file mode 100644 index 00000000..187ec681 --- /dev/null +++ b/test_samples/extensions/extension-starter/env.TEMPLATE @@ -0,0 +1,3 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= \ No newline at end of file diff --git a/test_samples/extensions/extension-starter/requirements.txt b/test_samples/extensions/extension-starter/requirements.txt new file mode 100644 index 00000000..97502ab4 --- /dev/null +++ b/test_samples/extensions/extension-starter/requirements.txt @@ -0,0 +1 @@ +strenum \ No newline at end of file diff --git a/test_samples/extensions/extension-starter/src/__init__.py b/test_samples/extensions/extension-starter/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/extensions/extension-starter/src/extension/__init__.py b/test_samples/extensions/extension-starter/src/extension/__init__.py new file mode 100644 index 00000000..ea28b37b --- /dev/null +++ b/test_samples/extensions/extension-starter/src/extension/__init__.py @@ -0,0 +1,4 @@ +from .extension import ExtensionAgent +from .models import CustomEventData, CustomEventResult + +__all__ = ["ExtensionAgent", "CustomEventData", "CustomEventResult"] diff --git a/test_samples/extensions/extension-starter/src/extension/extension.py b/test_samples/extensions/extension-starter/src/extension/extension.py new file mode 100644 index 00000000..7b0f9aa9 --- /dev/null +++ b/test_samples/extensions/extension-starter/src/extension/extension.py @@ -0,0 +1,124 @@ +import logging +from msvcrt import kbhit +from typing import Awaitable, Callable, Generic, TypeVar, Iterable, cast + +from microsoft_agents.activity import Activity, ActivityTypes, InvokeResponse +from microsoft_agents.hosting.core import ( + AgentApplication, + TurnContext, + TurnState, + RouteSelector, + RouteHandler, +) + +from .models import ( + CustomEventData, + CustomEventResult, + CustomEventTypes, + CustomRouteHandler, +) + +logger = logging.getLogger(__name__) + +MY_CHANNEL = "mychannel" + + +def create_route_selector(event_name: str) -> RouteSelector: + + def route_selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.message + and context.activity.channel_id == MY_CHANNEL + and context.activity.name == f"invoke/{event_name}" + ) + + return route_selector + + +StateT = TypeVar("StateT", bound=TurnState) + + +class ExtensionAgent(Generic[StateT]): + app: AgentApplication[StateT] + + def __init__(self, app: AgentApplication[StateT]): + self.app = app + + def _on_message_has_hello_event( + self, + handler: Callable[[TurnContext, StateT, CustomEventData], Awaitable[None]], + **kwargs, + ): + def route_selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.message + and "hello" in context.activity.text.lower() + ) + + async def route_handler(context: TurnContext, state: StateT): + custom_event_data = CustomEventData.from_context(context) + await handler(context, state, custom_event_data) + + logger.debug("Registering route for message has hello event") + self.app.add_route(route_selector, route_handler, **kwargs) + + def on_message_has_hello_event( + self, handler: Callable[[TurnContext, StateT, CustomEventData], Awaitable[None]] + ): + self._on_message_has_hello_event(handler, is_agentic=False) + + def on_agentic_message_has_hello_event( + self, handler: Callable[[TurnContext, StateT, CustomEventData], Awaitable[None]] + ): + self._on_message_has_hello_event(handler, is_agentic=True) + + def on_invoke_custom_event(self, handler: CustomRouteHandler[StateT]): + route_selector = create_route_selector(CustomEventTypes.CUSTOM_EVENT) + + async def route_handler(context: TurnContext, state: StateT): + custom_event_data = CustomEventData.from_context(context) + result = await handler(context, state, custom_event_data) + if not result: + result = CustomEventResult() + + response = Activity( + type=ActivityTypes.invoke_response, + value=InvokeResponse(status=200, body=result), + ) + await context.send_activity(response) + + logger.debug("Registering route for custom event") + self.app.add_route(route_selector, route_handler, is_invoke=True) + + def on_invoke_other_custom_event(self, handler: RouteHandler[StateT]): + route_selector = create_route_selector(CustomEventTypes.OTHER_CUSTOM_EVENT) + + async def route_handler(context: TurnContext, state: StateT): + await handler(context, state) + response = Activity( + type=ActivityTypes.invoke_response, + value=InvokeResponse(status=200, body={}), + ) + await context.send_activity(response) + + logger.debug("Registering route for other custom event") + self.app.add_route(route_selector, route_handler, is_invoke=True) + + # Callable that takes in three arguments (TurnContext, StateT, str) and returns Awaitable[None] + # Awaitable indicates that the function is asynchronous and returns a coroutine + def on_message_reaction_added( + self, handler: Callable[[TurnContext, StateT, str], Awaitable[None]] + ): + + def route_selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.message + and context.activity.name == "reactionAdded" + ) + + async def route_handler(context: TurnContext, state: StateT): + for reaction in cast(Iterable, context.activity.value): + await handler(context, state, reaction.type) + + logger.debug("Registering route for message reaction added") + self.app.add_route(route_selector, route_handler) diff --git a/test_samples/extensions/extension-starter/src/extension/models.py b/test_samples/extensions/extension-starter/src/extension/models.py new file mode 100644 index 00000000..233a09ee --- /dev/null +++ b/test_samples/extensions/extension-starter/src/extension/models.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Optional, Protocol, TypeVar +from microsoft_agents.activity import AgentsModel +from microsoft_agents.hosting.core import TurnContext, TurnState + +from strenum import StrEnum + +StateT = TypeVar("StateT", bound=TurnState) + + +class CustomRouteHandler(Protocol[StateT]): + async def __call__( + self, context: TurnContext, state: StateT, event_data: CustomEventData + ) -> CustomEventResult: ... + + +class CustomEventTypes(StrEnum): + CUSTOM_EVENT = "customEvent" + OTHER_CUSTOM_EVENT = "otherCustomEvent" + + +class CustomEventData(AgentsModel): + user_id: Optional[str] = None + field: Optional[str] = None + + @staticmethod + def from_context(context) -> CustomEventData: + return CustomEventData( + user_id=context.activity.from_property.id, + field=context.activity.channel_data.get("field"), + ) + + +class CustomEventResult(AgentsModel): + user_id: Optional[str] = None + field: Optional[str] = None diff --git a/test_samples/extensions/extension-starter/src/sample/__init__.py b/test_samples/extensions/extension-starter/src/sample/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/extensions/extension-starter/src/sample/app.py b/test_samples/extensions/extension-starter/src/sample/app.py new file mode 100644 index 00000000..04539190 --- /dev/null +++ b/test_samples/extensions/extension-starter/src/sample/app.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +import re +from dotenv import load_dotenv + +from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core import ( + Authorization, + MemoryStorage, + AgentApplication, + TurnState, + MemoryStorage, + RouteRank, + TurnContext, +) +from microsoft_agents.activity import load_configuration_from_env +from microsoft_agents.authentication.msal import MsalConnectionManager + +# Load configuration from environment +load_dotenv() +agents_sdk_config = load_configuration_from_env(os.environ) + +# Create storage and connection manager +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +APP = AgentApplication[TurnState]( + storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config +) + + +@APP.activity("message", rank=RouteRank.LAST) +async def on_message(context: TurnContext, state: TurnState): + await context.send_activity(f"Echo: {context.activity.text}") diff --git a/test_samples/extensions/extension-starter/src/sample/extension_agent.py b/test_samples/extensions/extension-starter/src/sample/extension_agent.py new file mode 100644 index 00000000..c315704a --- /dev/null +++ b/test_samples/extensions/extension-starter/src/sample/extension_agent.py @@ -0,0 +1,42 @@ +from microsoft_agents.hosting.core import TurnContext, TurnState + +from src.extension import ( + ExtensionAgent, + CustomEventData, + CustomEventResult, +) + +from .app import APP + +EXT = ExtensionAgent[TurnState](APP) + + +@EXT.on_message_has_hello_event +async def message_has_hello_event( + context: TurnContext, state: TurnState, data: CustomEventData +): + await context.send_activity( + f"Hello event detected! User ID: {data.user_id}, Field: {data.field}" + ) + + +@EXT.on_invoke_custom_event +async def invoke_custom_event( + context: TurnContext, state: TurnState, data: CustomEventData +) -> CustomEventResult: + await context.send_activity( + f"Custom event triggered {context.activity.type}/{context.activity.name}" + ) + return CustomEventResult(user_id=data.user_id, field=data.field) + + +@EXT.on_invoke_other_custom_event +async def invoke_other_custom_event(context: TurnContext, state: TurnState): + await context.send_activity( + f"Custom event triggered {context.activity.type}/{context.activity.name}" + ) + + +@EXT.on_message_reaction_added +async def reaction_added(context: TurnContext, state: TurnState, reaction: str): + await context.send_activity(f"Reaction added: {reaction}") diff --git a/test_samples/extensions/extension-starter/src/sample/main.py b/test_samples/extensions/extension-starter/src/sample/main.py new file mode 100644 index 00000000..d253252f --- /dev/null +++ b/test_samples/extensions/extension-starter/src/sample/main.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# enable logging for Microsoft Agents library +# for more information, see README.md for Quickstart Agent +import logging + +ms_agents_logger = logging.getLogger("microsoft_agents") +ms_agents_logger.addHandler(logging.StreamHandler()) +ms_agents_logger.setLevel(logging.INFO) + +from .app import CONNECTION_MANAGER +from .extension_agent import APP +from .start_server import start_server + +start_server( + agent_application=APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), +) diff --git a/test_samples/extensions/extension-starter/src/sample/start_server.py b/test_samples/extensions/extension-starter/src/sample/start_server.py new file mode 100644 index 00000000..d76b619e --- /dev/null +++ b/test_samples/extensions/extension-starter/src/sample/start_server.py @@ -0,0 +1,32 @@ +from os import environ +from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration +from microsoft_agents.hosting.aiohttp import ( + start_agent_process, + jwt_authorization_middleware, + CloudAdapter, +) +from aiohttp.web import Request, Response, Application, run_app + + +def start_server( + agent_application: AgentApplication, auth_configuration: AgentAuthConfiguration +): + async def entry_point(req: Request) -> Response: + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + return await start_agent_process( + req, + agent, + adapter, + ) + + APP = Application(middlewares=[jwt_authorization_middleware]) + APP.router.add_post("/api/messages", entry_point) + APP["agent_configuration"] = auth_configuration + APP["agent_app"] = agent_application + APP["adapter"] = agent_application.adapter + + try: + run_app(APP, host="localhost", port=environ.get("PORT", 3978)) + except Exception as error: + raise error diff --git a/test_samples/extensions/quickstart-extension/README.md b/test_samples/extensions/quickstart-extension/README.md new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/extensions/quickstart-extension/env.TEMPLATE b/test_samples/extensions/quickstart-extension/env.TEMPLATE new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/extensions/quickstart-extension/infra/README.md b/test_samples/extensions/quickstart-extension/infra/README.md new file mode 100644 index 00000000..8e25450e --- /dev/null +++ b/test_samples/extensions/quickstart-extension/infra/README.md @@ -0,0 +1,29 @@ +## Scripts + +- `decompose.ps1` + - This command will configuration details for the Azure Bot Service resource, including its Connections and Channels. If an application ID is provided, the script will also print the configuration of the App Registration. + - Usage: +```bash + ./decompose.ps1 -g RESOURCE_GROUP -n BOT_NAME -APP_ID OPTIONAL_APP_ID +``` + + +- `gen_teams_manifest.ps1` + - This command will create the file `./bot/manifest.json`, allowing you to zip the contents of the `./bot` directory and import the Agent into teams. + - Usage: +``` + ./gen_teams_manifest.ps1 -APP_ID APP_ID +``` + + + +## Directories + +- `bicep`: common bicep scripts used by the samples provisioning scripts + +- `samples` + - `quickstart`: provisioning script for the Quickstart sample + - `auto-signin`: provisioning scripts for the Auto Sign-In sample + - `obo-authorization`: provisioning scripts for the OBO Authorization sample + +- `bot`: Destination of `manifest.json` file created by `gen_teams_manifest.ps1`. The resulting contents can be used to deploy an Agent to Teams. \ No newline at end of file diff --git a/test_samples/extensions/quickstart-extension/infra/bicep/app.bicep b/test_samples/extensions/quickstart-extension/infra/bicep/app.bicep new file mode 100644 index 00000000..5aec0227 --- /dev/null +++ b/test_samples/extensions/quickstart-extension/infra/bicep/app.bicep @@ -0,0 +1,20 @@ +extension microsoftGraphV1 + +param botName string + +resource app 'Microsoft.Graph/applications@v1.0' = { + displayName: '${botName}-app' + uniqueName: '${botName}-app' + signInAudience: 'AzureADMyOrg' + owners: { + relationships: [ deployer().objectId ] + } +} + +resource servicePrincipal 'Microsoft.Graph/servicePrincipals@v1.0' = { + appId: app.appId + accountEnabled: true + servicePrincipalType: 'Application' +} + +output appId string = app.appId diff --git a/test_samples/extensions/quickstart-extension/infra/bicep/bicepconfig.json b/test_samples/extensions/quickstart-extension/infra/bicep/bicepconfig.json new file mode 100644 index 00000000..36370e02 --- /dev/null +++ b/test_samples/extensions/quickstart-extension/infra/bicep/bicepconfig.json @@ -0,0 +1,9 @@ +{ + "experimentalFeaturesEnabled": { + "extensibility": true + }, + // specify an alias for the version of the v1.0 dynamic types package you want to use + "extensions": { + "microsoftGraphV1": "br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:0.2.0-preview" + } +} \ No newline at end of file diff --git a/test_samples/extensions/quickstart-extension/infra/bicep/bot.bicep b/test_samples/extensions/quickstart-extension/infra/bicep/bot.bicep new file mode 100644 index 00000000..31bed5f8 --- /dev/null +++ b/test_samples/extensions/quickstart-extension/infra/bicep/bot.bicep @@ -0,0 +1,36 @@ +@description('The name of the Azure Bot resource.') +param botName string + +@description('The ID for an existing App Registration') +param appId string + +@description('The endpoint for the bot service.') +param endpoint string + +@description('The location for the bot service.') +param location string + +resource azureBot 'microsoft.botService/botServices@2023-09-15-preview' = { + name: botName + location: location + kind: 'azurebot' + properties: { + displayName: botName + msaAppId: appId + endpoint: endpoint + msaAppType: 'SingleTenant' + msaAppTenantId: tenant().tenantId + // schemaTransformationVersion: '1.3' + } +} + +resource msteams 'microsoft.botService/botServices/channels@2023-09-15-preview' = { + parent: azureBot + location: location + name: 'MsTeamsChannel' + properties: { + channelName: 'MsTeamsChannel' + } +} + +output appId string = azureBot.id diff --git a/test_samples/extensions/quickstart-extension/infra/bot/color.png b/test_samples/extensions/quickstart-extension/infra/bot/color.png new file mode 100644 index 0000000000000000000000000000000000000000..6bbd4f9831162d78117f54283932a0124166689b GIT binary patch literal 1893 zcmd5-Sx}Q#6#f$oRG6?Y3PP|B5|9B~hyg-?06`%L1X0;RK=wgW2!s?_RX|J;hegmL z1i@gVVRKfFWa+z9lf&1^LW=9@qO1= zMM&l}50*rPg)!hgx`=FyA{4I)3D6Lm4EfbTO|D;pBSu_y&%rtfPtTx9S5f)9B@=;5 z22a!ZM3^ppp<6}e;rfdB{&1+*THtdQhp4YvQ`^YMI`o{|c}7ZS7sIW-Tixgt?aj15 zK5kbYA9h-^rbJ>dYv-~9aOXV7Vg(9HhutpSW zY*zzzF4H8m)lxBOeq{BUW^hZ!k=_MY3nnBx&yWp=W}&bp8yXx;NUQW=QbcI>a^hs0 zM5T6{tkuHNYgJlb7;L|gCFFKM6z|Du>&fFXtT|ihd?`2 zbQxF#9#N*jS@1Vp%NV>(^5F}QlGIL4Saq;}^?saafVA%J@z$|WnN&lj! zXvh>y5W`}JQB#+1dOIEUcwy%I_Bu?f+~FU{d)Rf(R8It0ru?gUVWZC%*NHh*q`YM8uQnbq8etqf^hX%$-v*bxRchn7p4+FQf4Pf8#A2?nl_vqj%|pn zh;b9r-dq|F+!9K?O~YQ6KGq|t>uu;pmu*EX*Hw-qDaUweJnWsmG`DV@d+vN9@5XJR#CZd)=QlByO~x{d(x)V|BKz3WW_&3J`t z%kyd6;ApmTp}puk-Wzu89(56!;MV!u!G(^|0eJUnxkT|yhc*e7(xij`CYDjf0o1aa%& z3gHS#okQ(^c^%J$4oY6{$L(uN<%{xZ$`wI4E92&8eNSY&5Y}oA&05jyIj{PuD_PKknttCpC3ifOr&Pd48Py zD<-6ceCsioNzOhm46Crjue!oag&u<&U(j3s&kuCrwauDb=H;>GFgbKJ_!=QMqNmGk Ir?A{V02K%`jsO4v literal 0 HcmV?d00001 diff --git a/test_samples/extensions/quickstart-extension/infra/bot/outline.png b/test_samples/extensions/quickstart-extension/infra/bot/outline.png new file mode 100644 index 0000000000000000000000000000000000000000..80c85b2ce6b423bfd007c90538e89695d2fbb0e5 GIT binary patch literal 755 zcmVPx#1ZP1_K>z@;j|==^1poj532;bRa{vGf5&!@T5&_cPe*6Fc0*FaOK~z{ry_d~S z6hRb)hY%qdOc2zSD;Bh#R?-;6zdl9Q^s_tv?$e!6?6^*`u#ySLz^hs~nX=`6zoxCfuw+zeQCo1{my z0rZb)+IEj0N=DMJAv>UdNYh*a0!g32zwipK*xVdg@f3;A;RT$wxjB$@#>vGQo9l&y zS?OFB@h!khb0I6n9R6-FN!z;)zreW4eqIt6pnjh(O=)bx>c^xB)|PmdsHpS*ZpOPX zK+8yH!}7%-R*%>TyoO7Rv$nM|rD3g8SbkRsnuouks-LoXWJp6=)||IF9|R@0TPv`6 zWOx_ik>u|HffMi!LGgC?6cGL9gv|-^{Pbf1@oCANuRtZ=hq_e4H%+!X`j18RZBCHq zmxi>UAnpCE`7!XEHh_Mu1<(f2SB}A>mC?=sPpaz(Diu(81fxP4(z=l>K7z7KIEn2g zT(oHb^1&C1wJ8=*ehDK8%6tqNuQJva{(`UIV|dq8|26#Mm{m_p7f}2dMx-aGZ?g(` z0JoJOg0WVWd=mu5?-lE5xq7)Mn}A*0{04r8`Ud=jpWs{g$YBYFE`Y$wuVVZ<&smsz zrqXZ$L%oXi;VAkTDe-MjNq|ko^u$WTS|^OF{Wc(YgWv%AVf#{ypJ6>t={vFpRd`SQ zjY`njbZ9vUp4J4I^@Ai_I<+wT$e?`}vH-`VDUEep!+%+s)Q0_A8s8IIt)7e>?6IKd z=~xk4y}uVFi0}_5+@-JNhwzC*#tX0NREY)pAK^p0#}9It002ovPDHLkV1mXeUwr@o literal 0 HcmV?d00001 diff --git a/test_samples/extensions/quickstart-extension/infra/decompose.ps1 b/test_samples/extensions/quickstart-extension/infra/decompose.ps1 new file mode 100644 index 00000000..0a9c9c71 --- /dev/null +++ b/test_samples/extensions/quickstart-extension/infra/decompose.ps1 @@ -0,0 +1,38 @@ +# References for ARM and Graph resource types +# https://learn.microsoft.com/en-us/azure/templates/microsoft.botservice/botservices?pivots=deployment-language-bicep +# https://learn.microsoft.com/en-us/graph/templates/bicep/reference/applications?view=graph-bicep-1.0 + +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)] + [Alias('g')] + [string]$RESOURCE_GROUP, + + [Parameter(Mandatory=$true)] + [Alias('n')] + [string]$BOT_NAME, + + [string]$APP_ID='' +) + +if ($APP_ID -ne '') { + Write-Output 'Showing App Registration Details:\n' + az ad app show --id $APP_ID + Write-Output '\nAssociated federated-credential list:' + az ad app federated-credential list --id $APP_ID +} + +$CHANNEL_NAME_LIST = @('msteams', 'webchat', 'directline') + +# Azure Bot Service Channels +Write-Output 'Showing configured channels (from a non-exhaustive list)' +foreach($channel_name in $CHANNEL_NAME_LIST) { + Write-Output "Channel: $channel_name" + az bot $CHANNEL_NAME show -n $BOT_NAME -g $RESOURCE_GROUP + Write-Output '\n' +} + +# Azure Bot Service Connections +Write-Output 'Showing connections' +az bot authsetting list -n $BOT_NAME -g $RESOURCE_GROUP +Write-Output '\n' \ No newline at end of file diff --git a/test_samples/extensions/quickstart-extension/infra/gen_teams_manifest.ps1 b/test_samples/extensions/quickstart-extension/infra/gen_teams_manifest.ps1 new file mode 100644 index 00000000..447ceea7 --- /dev/null +++ b/test_samples/extensions/quickstart-extension/infra/gen_teams_manifest.ps1 @@ -0,0 +1,80 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)] + [string]$APP_ID +) + +# Define JSON content as a string +$jsonContent = @' +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v/MicrosoftTeams.schema.json", + "version": "1.0.0", + "manifestVersion": "", + "id": "${APP_ID}", + "name": { + "short": "Testing Teams SSO Auth", + "full": "Testing Teams Single Sign On Sample" + }, + "developer": { + "name": "Microsoft", + "mpnId": "", + "websiteUrl": "https://example.azurewebsites.net", + "privacyUrl": "https://example.azurewebsites.net/privacy", + "termsOfUseUrl": "https://example.azurewebsites.net/termsofuse" + }, + "description": { + "short": "1Test Teams SSO Auth", + "full": "1This is a bot for testing Single Sign on for Teams" + }, + "icons": { + "outline": "outline.png", + "color": "color.png" + }, + "accentColor": "#FFFFFF", + "staticTabs": [ + { + "entityId": "conversations", + "name": "Chat", + "scopes": [ + "personal" + ] + }, + { + "entityId": "about", + "name": "", + "scopes": [ + "personal" + ] + } + ], + "bots": [ + { + "botId": "${APP_ID}", + "scopes": [ + "personal", + "team", + "groupchat" + ], + "isNotificationOnly": false, + "supportsCalling": false, + "supportsVideo": false, + "supportsFiles": false + } + ], + "validDomains": [ + "token.botframework.com", + "ngrok.io" + ], + "webApplicationInfo": { + "id": "${APP_ID}", + "resource": "api://botid-${APP_ID}/access_as_user" + } +} +'@ + + +$jsonContent = $jsonContent.Replace('${APP_ID}', $APP_ID) + +Write-Output 'Saving generated JSON content to bot/manifest.json...' +# Save the JSON content to a file +$jsonContent | Set-Content -Path "bot/manifest.json" diff --git a/test_samples/extensions/quickstart-extension/infra/prov_app.ps1 b/test_samples/extensions/quickstart-extension/infra/prov_app.ps1 new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/extensions/quickstart-extension/infra/prov_bot.ps1 b/test_samples/extensions/quickstart-extension/infra/prov_bot.ps1 new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/extensions/quickstart-extension/infra/provision.ps1 b/test_samples/extensions/quickstart-extension/infra/provision.ps1 new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/extensions/quickstart-extension/requirements.txt b/test_samples/extensions/quickstart-extension/requirements.txt new file mode 100644 index 00000000..97502ab4 --- /dev/null +++ b/test_samples/extensions/quickstart-extension/requirements.txt @@ -0,0 +1 @@ +strenum \ No newline at end of file diff --git a/test_samples/extensions/quickstart-extension/src/__init__.py b/test_samples/extensions/quickstart-extension/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/extensions/quickstart-extension/src/extension/__init__.py b/test_samples/extensions/quickstart-extension/src/extension/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/extensions/quickstart-extension/src/extension/extension.py b/test_samples/extensions/quickstart-extension/src/extension/extension.py new file mode 100644 index 00000000..a5e1ecb7 --- /dev/null +++ b/test_samples/extensions/quickstart-extension/src/extension/extension.py @@ -0,0 +1,97 @@ +import logging +from typing import ( + Awaitable, + Callable, + Generic, + TypeVar, +) + +from microsoft_agents.activity import Activity, ActivityTypes, InvokeResponse +from microsoft_agents.hosting.core import ( + AgentApplication, + TurnContext, + TurnState, + RouteSelector, +) + +logger = logging.getLogger(__name__) + +MY_CHANNEL = "mychannel" + +from .models import ( + CustomEventData, + CustomEventResult, + CustomEventTypes, + CustomRouteHandler, +) + + +def create_route_selector(event_name: str) -> RouteSelector: + + async def route_selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.message + and context.activity.channel_id == MY_CHANNEL + and context.activity.name == f"invoke/{event_name}" + ) + + return route_selector + + +class ExtensionAgent(Generic[StateT]): + app: AgentApplication[StateT] + + def __init__(self, app: AgentApplication[StateT]): + self.app = app + + def on_invoke_custom_event(self, handler: CustomRouteHandler[StateT]): + route_selector = create_route_selector(CustomEventTypes.CUSTOM_EVENT) + + async def route_handler(context: TurnContext, state: StateT): + custom_event_data = CustomEventData.from_context(context) + result = await handler(context, state, custom_event_data) + if not result: + result = CustomEventResult() + + response = Activity( + type=ActivityTypes.invoke_response, + value=InvokeResponse(status=200, body=result), + ) + await context.send_activity(response) + + logger.debug("Registering route for custom event") + self.app.add_route(route_selector, route_handler, is_invoke=True) + + def on_invoke_other_custom_event(self, handler: RouteHandler[StateT]): + route_selector = create_route_selector(CustomEventTypes.OTHER_CUSTOM_EVENT) + + async def route_handler(context: TurnContext, state: StateT): + await handler(context, state) + response = Activity( + type=ActivityTypes.invoke_response, + value=InvokeResponse(status=200, body={}), + ) + await context.send_activity(response) + + logger.debug("Registering route for other custom event") + self.app.add_route(route_selector, route_handler, is_invoke=True) + + # Callable that takes in three arguments (TurnContext, StateT, str) and returns Awaitable[None] + # Awaitable indicates that the function is asynchronous and returns a coroutine + def on_message_reaction_added( + self, handler: Callable[[TurnContext, StateT, str], Awaitable[None]] + ): + + async def route_selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.message + and context.activity.name == "reactionAdded" + ) + + async def route_handler(context: TurnContext, state: StateT): + reactions_added = context.activity.reactions_added + for reaction in context.activity.value: + await handler(context, state, reaction.type) + + logger.debug("Registering route for message reaction added") + self.app.add_route(route_selector, route_handler) diff --git a/test_samples/extensions/quickstart-extension/src/extension/models.py b/test_samples/extensions/quickstart-extension/src/extension/models.py new file mode 100644 index 00000000..e5b4a829 --- /dev/null +++ b/test_samples/extensions/quickstart-extension/src/extension/models.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Optional, Protocol, TypeVar +from microsoft_agents.activity import AgentsModel +from microsoft_agents.hosting.core import TurnContext, TurnState + +from strenum import StrEnum + +StateT = TypeVar("StateT", bound=TurnState) + + +class CustomRouteHandler(Protocol(StateT)): + async def __call__( + self, context: TurnContext, state: StateT, event_data: CustomEventData + ) -> CustomEventResult: ... + + +class CustomEventTypes(StrEnum): + CUSTOM_EVENT = "customEvent" + OTHER_CUSTOM_EVENT = "otherCustomEvent" + + +class CustomEventData(AgentsModel): + user_id: Optional[str] = None + field: Optional[str] = None + + @staticmethod + def from_context(context) -> CustomEventData: + return CustomEventData( + user_id=context.activity.from_property.id, + field=context.activity.channel_data.get("field"), + ) + + +class CustomEventResult(AgentsModel): + user_id: Optional[str] = None + field: Optional[str] = None diff --git a/test_samples/extensions/quickstart-extension/src/sample/__init__.py b/test_samples/extensions/quickstart-extension/src/sample/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/extensions/quickstart-extension/src/sample/app.py b/test_samples/extensions/quickstart-extension/src/sample/app.py new file mode 100644 index 00000000..1c7b6a2a --- /dev/null +++ b/test_samples/extensions/quickstart-extension/src/sample/app.py @@ -0,0 +1,30 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +import re +from dotenv import load_dotenv + +from microsoft_agents.hosting.core import ( + Authorization, + MemoryStorage, + AgentApplication, + TurnState, + MemoryStorage, +) +from microsoft_agents.activity import load_configuration_from_env +from microsoft_agents.authentication.msal import MsalConnectionManager +from src.sample.mocks import MockAdapter + +# Load configuration from environment +load_dotenv() +agents_sdk_config = load_configuration_from_env(os.environ) + +# Create storage and connection manager +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = MockAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +APP = AgentApplication[TurnState]( + storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config +) diff --git a/test_samples/extensions/quickstart-extension/src/sample/extension_agent.py b/test_samples/extensions/quickstart-extension/src/sample/extension_agent.py new file mode 100644 index 00000000..85f2e983 --- /dev/null +++ b/test_samples/extensions/quickstart-extension/src/sample/extension_agent.py @@ -0,0 +1,34 @@ +from microsoft_agents.hosting.core import TurnContext, TurnState + +from src.extension import ( + ExtensionAgent, + CustomEventData, + CustomEventResult, +) + +from src.app import APP +from src.extension import ExtensionAgent + +EXT = ExtensionAgent[TurnState](APP) + + +@EXT.on_invoke_custom_event +async def invoke_custom_event( + context: TurnContext, state: TurnState, data: CustomEventData +) -> CustomEventResult: + await context.send_activity( + f"Custom event triggered {context.activity.type}/{context.activity.name}" + ) + return CustomEventResult(user_id=data.user_id, field=data.field) + + +@EXT.on_invoke_other_custom_event +async def invoke_other_custom_event(context: TurnContext, state: TurnState): + await context.send_activity( + f"Custom event triggered {context.activity.type}/{context.activity.name}" + ) + + +@EXT.on_message_reaction_added +async def reaction_added(context: TurnContext, state: TurnState, reaction: str): + await context.send_activity(f"Reaction added: {reaction}") diff --git a/test_samples/extensions/quickstart-extension/src/sample/start_server.py b/test_samples/extensions/quickstart-extension/src/sample/start_server.py new file mode 100644 index 00000000..d76b619e --- /dev/null +++ b/test_samples/extensions/quickstart-extension/src/sample/start_server.py @@ -0,0 +1,32 @@ +from os import environ +from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration +from microsoft_agents.hosting.aiohttp import ( + start_agent_process, + jwt_authorization_middleware, + CloudAdapter, +) +from aiohttp.web import Request, Response, Application, run_app + + +def start_server( + agent_application: AgentApplication, auth_configuration: AgentAuthConfiguration +): + async def entry_point(req: Request) -> Response: + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + return await start_agent_process( + req, + agent, + adapter, + ) + + APP = Application(middlewares=[jwt_authorization_middleware]) + APP.router.add_post("/api/messages", entry_point) + APP["agent_configuration"] = auth_configuration + APP["agent_app"] = agent_application + APP["adapter"] = agent_application.adapter + + try: + run_app(APP, host="localhost", port=environ.get("PORT", 3978)) + except Exception as error: + raise error diff --git a/tests/_common/fixtures/roles.py b/tests/_common/fixtures/roles.py index 239b1762..44cab400 100644 --- a/tests/_common/fixtures/roles.py +++ b/tests/_common/fixtures/roles.py @@ -1,10 +1,12 @@ import pytest from microsoft_agents.activity import RoleTypes + @pytest.fixture(params=[RoleTypes.user, RoleTypes.skill, RoleTypes.agent]) -def non_agentic_role( request): +def non_agentic_role(request): return request.param + @pytest.fixture(params=[RoleTypes.agentic_user, RoleTypes.agentic_identity]) def agentic_role(request): - return request.param \ No newline at end of file + return request.param diff --git a/tests/hosting_core/app/_routes/test_agentic_selector.py b/tests/hosting_core/app/_routes/test_agentic_selector.py index 30e36d30..69b651fa 100644 --- a/tests/hosting_core/app/_routes/test_agentic_selector.py +++ b/tests/hosting_core/app/_routes/test_agentic_selector.py @@ -1,23 +1,31 @@ import pytest -from microsoft_agents.activity import Activity, ChannelAccount, RoleTypes +from microsoft_agents.activity import Activity, ChannelAccount from microsoft_agents.hosting.core import RouteSelector, TurnContext -from microsoft_agents.hosting.core.app._routes import _Route, _agentic_selector +from microsoft_agents.hosting.core.app._routes import _agentic_selector + +from tests._common.fixtures.roles import ( + agentic_role, + non_agentic_role, +) # required for fixtures -from tests._common.fixtures.roles import agentic_role, non_agentic_role def message_selector(context) -> bool: return context.activity.type == "message" + def invoke_selector(context) -> bool: return context.activity.type == "invoke" + def create_text_selector(includes: str) -> RouteSelector: def text_selector(context) -> bool: return context.activity.type == "message" and includes in context.activity.text + return text_selector + hello_text_selector = create_text_selector("hello") bye_text_selector = create_text_selector("bye") @@ -35,11 +43,17 @@ def text_selector(context) -> bool: bye_text_selector: Activity(type="message", text="hello there"), } -@pytest.fixture(params=[message_selector, invoke_selector, hello_text_selector, bye_text_selector]) + +@pytest.fixture( + params=[message_selector, invoke_selector, hello_text_selector, bye_text_selector] +) def selector(request) -> RouteSelector: return request.param -def test_agentic_selector_does_not_select_with_non_agentic_request(mocker, selector, non_agentic_role): + +def test_agentic_selector_does_not_select_with_non_agentic_request( + mocker, selector, non_agentic_role +): channel_account = ChannelAccount(role=non_agentic_role) selecting_activity = do_select[selector].model_copy() @@ -60,6 +74,7 @@ def test_agentic_selector_does_not_select_with_non_agentic_request(mocker, selec assert not new_selector(selecting_context) assert not new_selector(non_selecting_context) + def test_agentic_selector_selects_with_agentic_request(mocker, selector, agentic_role): channel_account = ChannelAccount(role=agentic_role) @@ -79,4 +94,4 @@ def test_agentic_selector_selects_with_agentic_request(mocker, selector, agentic selecting_activity.recipient = channel_account assert new_selector(selecting_context) - assert not new_selector(non_selecting_context) \ No newline at end of file + assert not new_selector(non_selecting_context) diff --git a/tests/hosting_core/app/_routes/test_route.py b/tests/hosting_core/app/_routes/test_route.py index acc697f6..68546fa6 100644 --- a/tests/hosting_core/app/_routes/test_route.py +++ b/tests/hosting_core/app/_routes/test_route.py @@ -65,7 +65,7 @@ def test_ordering(self): is_invoke=False, rank=RouteRank.LAST, auth_handlers=["auth2"], - is_agentic=True + is_agentic=True, ) route_c = _Route( selector=selector, @@ -73,7 +73,7 @@ def test_ordering(self): is_invoke=False, rank=RouteRank.DEFAULT, auth_handlers=["auth2"], - is_agentic=False + is_agentic=False, ) assert route_a.ordering == [1, 0, RouteRank.FIRST] @@ -88,53 +88,354 @@ def auth_handlers_a(self, request): def auth_handlers_b(self, request): return request.param - @pytest.mark.parametrize( "is_invoke_a, rank_a, is_agentic_a, is_invoke_b, rank_b, is_agentic_b, expected_result", [ # Same agentic status (both False) - [False, RouteRank.DEFAULT, False, False, RouteRank.DEFAULT, False, False], # [1,1,DEFAULT] vs [1,1,DEFAULT] - [False, RouteRank.DEFAULT, False, False, RouteRank.LAST, False, True], # [1,1,DEFAULT] vs [1,1,LAST] - [False, RouteRank.LAST, False, False, RouteRank.DEFAULT, False, False], # [1,1,LAST] vs [1,1,DEFAULT] - [False, RouteRank.DEFAULT, False, True, RouteRank.DEFAULT, False, False], # [1,1,DEFAULT] vs [1,0,DEFAULT] - [True, RouteRank.DEFAULT, False, False, RouteRank.DEFAULT, False, True], # [1,0,DEFAULT] vs [1,1,DEFAULT] - [True, RouteRank.DEFAULT, False, True, RouteRank.DEFAULT, False, False], # [1,0,DEFAULT] vs [1,0,DEFAULT] - [True, RouteRank.LAST, False, True, RouteRank.DEFAULT, False, False], # [1,0,LAST] vs [1,0,DEFAULT] - [True, RouteRank.DEFAULT, False, True, RouteRank.LAST, False, True], # [1,0,DEFAULT] vs [1,0,LAST] - [False, RouteRank.FIRST, False, True, RouteRank.DEFAULT, False, False], # [1,1,FIRST] vs [1,0,DEFAULT] - [True, RouteRank.DEFAULT, False, False, RouteRank.LAST, False, True], # [1,0,DEFAULT] vs [1,1,LAST] - [False, RouteRank.LAST, False, True, RouteRank.FIRST, False, False], # [1,1,LAST] vs [1,0,FIRST] - [True, RouteRank.FIRST, False, False, RouteRank.LAST, False, True], # [1,0,FIRST] vs [1,1,LAST] - [False, RouteRank.FIRST, False, False, RouteRank.LAST, False, True], # [1,1,FIRST] vs [1,1,LAST] - [True, RouteRank.FIRST, False, True, RouteRank.LAST, False, True], # [1,0,FIRST] vs [1,0,LAST] - + [ + False, + RouteRank.DEFAULT, + False, + False, + RouteRank.DEFAULT, + False, + False, + ], # [1,1,DEFAULT] vs [1,1,DEFAULT] + [ + False, + RouteRank.DEFAULT, + False, + False, + RouteRank.LAST, + False, + True, + ], # [1,1,DEFAULT] vs [1,1,LAST] + [ + False, + RouteRank.LAST, + False, + False, + RouteRank.DEFAULT, + False, + False, + ], # [1,1,LAST] vs [1,1,DEFAULT] + [ + False, + RouteRank.DEFAULT, + False, + True, + RouteRank.DEFAULT, + False, + False, + ], # [1,1,DEFAULT] vs [1,0,DEFAULT] + [ + True, + RouteRank.DEFAULT, + False, + False, + RouteRank.DEFAULT, + False, + True, + ], # [1,0,DEFAULT] vs [1,1,DEFAULT] + [ + True, + RouteRank.DEFAULT, + False, + True, + RouteRank.DEFAULT, + False, + False, + ], # [1,0,DEFAULT] vs [1,0,DEFAULT] + [ + True, + RouteRank.LAST, + False, + True, + RouteRank.DEFAULT, + False, + False, + ], # [1,0,LAST] vs [1,0,DEFAULT] + [ + True, + RouteRank.DEFAULT, + False, + True, + RouteRank.LAST, + False, + True, + ], # [1,0,DEFAULT] vs [1,0,LAST] + [ + False, + RouteRank.FIRST, + False, + True, + RouteRank.DEFAULT, + False, + False, + ], # [1,1,FIRST] vs [1,0,DEFAULT] + [ + True, + RouteRank.DEFAULT, + False, + False, + RouteRank.LAST, + False, + True, + ], # [1,0,DEFAULT] vs [1,1,LAST] + [ + False, + RouteRank.LAST, + False, + True, + RouteRank.FIRST, + False, + False, + ], # [1,1,LAST] vs [1,0,FIRST] + [ + True, + RouteRank.FIRST, + False, + False, + RouteRank.LAST, + False, + True, + ], # [1,0,FIRST] vs [1,1,LAST] + [ + False, + RouteRank.FIRST, + False, + False, + RouteRank.LAST, + False, + True, + ], # [1,1,FIRST] vs [1,1,LAST] + [ + True, + RouteRank.FIRST, + False, + True, + RouteRank.LAST, + False, + True, + ], # [1,0,FIRST] vs [1,0,LAST] # Same agentic status (both True) - [False, RouteRank.DEFAULT, True, False, RouteRank.DEFAULT, True, False], # [0,1,DEFAULT] vs [0,1,DEFAULT] - [False, RouteRank.DEFAULT, True, False, RouteRank.LAST, True, True], # [0,1,DEFAULT] vs [0,1,LAST] - [False, RouteRank.LAST, True, False, RouteRank.DEFAULT, True, False], # [0,1,LAST] vs [0,1,DEFAULT] - [False, RouteRank.DEFAULT, True, True, RouteRank.DEFAULT, True, False], # [0,1,DEFAULT] vs [0,0,DEFAULT] - [True, RouteRank.DEFAULT, True, False, RouteRank.DEFAULT, True, True], # [0,0,DEFAULT] vs [0,1,DEFAULT] - [True, RouteRank.DEFAULT, True, True, RouteRank.DEFAULT, True, False], # [0,0,DEFAULT] vs [0,0,DEFAULT] - [True, RouteRank.LAST, True, True, RouteRank.DEFAULT, True, False], # [0,0,LAST] vs [0,0,DEFAULT] - [True, RouteRank.DEFAULT, True, True, RouteRank.LAST, True, True], # [0,0,DEFAULT] vs [0,0,LAST] - [False, RouteRank.FIRST, True, True, RouteRank.DEFAULT, True, False], # [0,1,FIRST] vs [0,0,DEFAULT] - [True, RouteRank.DEFAULT, True, False, RouteRank.LAST, True, True], # [0,0,DEFAULT] vs [0,1,LAST] - [False, RouteRank.LAST, True, True, RouteRank.FIRST, True, False], # [0,1,LAST] vs [0,0,FIRST] - [True, RouteRank.FIRST, True, False, RouteRank.LAST, True, True], # [0,0,FIRST] vs [0,1,LAST] - [False, RouteRank.FIRST, True, False, RouteRank.LAST, True, True], # [0,1,FIRST] vs [0,1,LAST] - [True, RouteRank.FIRST, True, True, RouteRank.LAST, True, True], # [0,0,FIRST] vs [0,0,LAST] - + [ + False, + RouteRank.DEFAULT, + True, + False, + RouteRank.DEFAULT, + True, + False, + ], # [0,1,DEFAULT] vs [0,1,DEFAULT] + [ + False, + RouteRank.DEFAULT, + True, + False, + RouteRank.LAST, + True, + True, + ], # [0,1,DEFAULT] vs [0,1,LAST] + [ + False, + RouteRank.LAST, + True, + False, + RouteRank.DEFAULT, + True, + False, + ], # [0,1,LAST] vs [0,1,DEFAULT] + [ + False, + RouteRank.DEFAULT, + True, + True, + RouteRank.DEFAULT, + True, + False, + ], # [0,1,DEFAULT] vs [0,0,DEFAULT] + [ + True, + RouteRank.DEFAULT, + True, + False, + RouteRank.DEFAULT, + True, + True, + ], # [0,0,DEFAULT] vs [0,1,DEFAULT] + [ + True, + RouteRank.DEFAULT, + True, + True, + RouteRank.DEFAULT, + True, + False, + ], # [0,0,DEFAULT] vs [0,0,DEFAULT] + [ + True, + RouteRank.LAST, + True, + True, + RouteRank.DEFAULT, + True, + False, + ], # [0,0,LAST] vs [0,0,DEFAULT] + [ + True, + RouteRank.DEFAULT, + True, + True, + RouteRank.LAST, + True, + True, + ], # [0,0,DEFAULT] vs [0,0,LAST] + [ + False, + RouteRank.FIRST, + True, + True, + RouteRank.DEFAULT, + True, + False, + ], # [0,1,FIRST] vs [0,0,DEFAULT] + [ + True, + RouteRank.DEFAULT, + True, + False, + RouteRank.LAST, + True, + True, + ], # [0,0,DEFAULT] vs [0,1,LAST] + [ + False, + RouteRank.LAST, + True, + True, + RouteRank.FIRST, + True, + False, + ], # [0,1,LAST] vs [0,0,FIRST] + [ + True, + RouteRank.FIRST, + True, + False, + RouteRank.LAST, + True, + True, + ], # [0,0,FIRST] vs [0,1,LAST] + [ + False, + RouteRank.FIRST, + True, + False, + RouteRank.LAST, + True, + True, + ], # [0,1,FIRST] vs [0,1,LAST] + [ + True, + RouteRank.FIRST, + True, + True, + RouteRank.LAST, + True, + True, + ], # [0,0,FIRST] vs [0,0,LAST] # Different agentic status - agentic (True) has higher priority than non-agentic (False) - [False, RouteRank.DEFAULT, True, False, RouteRank.DEFAULT, False, True], # [0,1,DEFAULT] vs [1,1,DEFAULT] - [False, RouteRank.DEFAULT, False, False, RouteRank.DEFAULT, True, False], # [1,1,DEFAULT] vs [0,1,DEFAULT] - [True, RouteRank.DEFAULT, True, True, RouteRank.DEFAULT, False, True], # [0,0,DEFAULT] vs [1,0,DEFAULT] - [True, RouteRank.DEFAULT, False, True, RouteRank.DEFAULT, True, False], # [1,0,DEFAULT] vs [0,0,DEFAULT] - [False, RouteRank.LAST, True, False, RouteRank.FIRST, False, True], # [0,1,LAST] vs [1,1,FIRST] - [False, RouteRank.FIRST, False, False, RouteRank.LAST, True, False], # [1,1,FIRST] vs [0,1,LAST] - [True, RouteRank.LAST, True, True, RouteRank.FIRST, False, True], # [0,0,LAST] vs [1,0,FIRST] - [True, RouteRank.FIRST, False, True, RouteRank.LAST, True, False], # [1,0,FIRST] vs [0,0,LAST] - [False, RouteRank.LAST, True, True, RouteRank.FIRST, False, True], # [0,1,LAST] vs [1,0,FIRST] - [True, RouteRank.LAST, False, False, RouteRank.FIRST, True, False], # [1,0,LAST] vs [0,1,FIRST] + [ + False, + RouteRank.DEFAULT, + True, + False, + RouteRank.DEFAULT, + False, + True, + ], # [0,1,DEFAULT] vs [1,1,DEFAULT] + [ + False, + RouteRank.DEFAULT, + False, + False, + RouteRank.DEFAULT, + True, + False, + ], # [1,1,DEFAULT] vs [0,1,DEFAULT] + [ + True, + RouteRank.DEFAULT, + True, + True, + RouteRank.DEFAULT, + False, + True, + ], # [0,0,DEFAULT] vs [1,0,DEFAULT] + [ + True, + RouteRank.DEFAULT, + False, + True, + RouteRank.DEFAULT, + True, + False, + ], # [1,0,DEFAULT] vs [0,0,DEFAULT] + [ + False, + RouteRank.LAST, + True, + False, + RouteRank.FIRST, + False, + True, + ], # [0,1,LAST] vs [1,1,FIRST] + [ + False, + RouteRank.FIRST, + False, + False, + RouteRank.LAST, + True, + False, + ], # [1,1,FIRST] vs [0,1,LAST] + [ + True, + RouteRank.LAST, + True, + True, + RouteRank.FIRST, + False, + True, + ], # [0,0,LAST] vs [1,0,FIRST] + [ + True, + RouteRank.FIRST, + False, + True, + RouteRank.LAST, + True, + False, + ], # [1,0,FIRST] vs [0,0,LAST] + [ + False, + RouteRank.LAST, + True, + True, + RouteRank.FIRST, + False, + True, + ], # [0,1,LAST] vs [1,0,FIRST] + [ + True, + RouteRank.LAST, + False, + False, + RouteRank.FIRST, + True, + False, + ], # [1,0,LAST] vs [0,1,FIRST] ], ) def test_lt_with_agentic( @@ -167,4 +468,4 @@ def test_lt_with_agentic( auth_handlers=auth_handlers_b, ) - assert (route_a < route_b) == expected_result \ No newline at end of file + assert (route_a < route_b) == expected_result diff --git a/tests/hosting_core/app/_routes/test_route_list.py b/tests/hosting_core/app/_routes/test_route_list.py index 3d84acdf..ebce7536 100644 --- a/tests/hosting_core/app/_routes/test_route_list.py +++ b/tests/hosting_core/app/_routes/test_route_list.py @@ -3,24 +3,27 @@ TurnState, _RouteList, _Route, - RouteRank + RouteRank, ) + def selector(context: TurnContext) -> bool: return True + async def handler(context: TurnContext, state: TurnState) -> None: pass + class Test_RouteList: def assert_priority_invariant(self, route_list: _RouteList): - + # check priority invariant routes = list(route_list) for i in range(1, len(routes)): assert not routes[i] < routes[i - 1] - + def has_contents(self, route_list: _RouteList, should_contain: list[_Route]): for route in should_contain: for existing in list(route_list): @@ -54,8 +57,9 @@ def test_route_list_add_and_order(self): "handler": route[1], "is_invoke": route[2], "rank": route[3], - "auth_handlers": route[4] if len(route) > 4 else None - } for route in all_routes + "auth_handlers": route[4] if len(route) > 4 else None, + } + for route in all_routes ] added_routes = [] @@ -63,4 +67,4 @@ def test_route_list_add_and_order(self): added_routes.append(_Route(**route)) route_list.add_route(**route) self.assert_priority_invariant(route_list) - assert self.has_contents(route_list, added_routes) \ No newline at end of file + assert self.has_contents(route_list, added_routes) From 3b3ca1c55fe8ef3cbf5acc8e8b3cb8565371588d Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 6 Oct 2025 09:25:06 -0700 Subject: [PATCH 51/67] Reorganizing sample code --- .../hosting/core/app/_routes/_route.py | 16 +-- .../extensions/{doc_samples => }/README.md | 0 .../src/extension/extension.py | 80 +++++++-------- .../src/sample/extension_agent.py | 14 ++- .../extensions/quickstart-extension/README.md | 0 .../quickstart-extension/env.TEMPLATE | 0 .../quickstart-extension/infra/README.md | 29 ------ .../infra/bicep/app.bicep | 20 ---- .../infra/bicep/bicepconfig.json | 9 -- .../infra/bicep/bot.bicep | 36 ------- .../quickstart-extension/infra/bot/color.png | Bin 1893 -> 0 bytes .../infra/bot/outline.png | Bin 755 -> 0 bytes .../quickstart-extension/infra/decompose.ps1 | 38 ------- .../infra/gen_teams_manifest.ps1 | 80 --------------- .../quickstart-extension/infra/prov_app.ps1 | 0 .../quickstart-extension/infra/prov_bot.ps1 | 0 .../quickstart-extension/infra/provision.ps1 | 0 .../quickstart-extension/requirements.txt | 1 - .../quickstart-extension/src/__init__.py | 0 .../src/extension/__init__.py | 0 .../src/extension/extension.py | 97 ------------------ .../src/extension/models.py | 37 ------- .../src/sample/__init__.py | 0 .../quickstart-extension/src/sample/app.py | 30 ------ .../src/sample/extension_agent.py | 34 ------ .../src/sample/start_server.py | 32 ------ 26 files changed, 55 insertions(+), 498 deletions(-) rename test_samples/extensions/{doc_samples => }/README.md (100%) delete mode 100644 test_samples/extensions/quickstart-extension/README.md delete mode 100644 test_samples/extensions/quickstart-extension/env.TEMPLATE delete mode 100644 test_samples/extensions/quickstart-extension/infra/README.md delete mode 100644 test_samples/extensions/quickstart-extension/infra/bicep/app.bicep delete mode 100644 test_samples/extensions/quickstart-extension/infra/bicep/bicepconfig.json delete mode 100644 test_samples/extensions/quickstart-extension/infra/bicep/bot.bicep delete mode 100644 test_samples/extensions/quickstart-extension/infra/bot/color.png delete mode 100644 test_samples/extensions/quickstart-extension/infra/bot/outline.png delete mode 100644 test_samples/extensions/quickstart-extension/infra/decompose.ps1 delete mode 100644 test_samples/extensions/quickstart-extension/infra/gen_teams_manifest.ps1 delete mode 100644 test_samples/extensions/quickstart-extension/infra/prov_app.ps1 delete mode 100644 test_samples/extensions/quickstart-extension/infra/prov_bot.ps1 delete mode 100644 test_samples/extensions/quickstart-extension/infra/provision.ps1 delete mode 100644 test_samples/extensions/quickstart-extension/requirements.txt delete mode 100644 test_samples/extensions/quickstart-extension/src/__init__.py delete mode 100644 test_samples/extensions/quickstart-extension/src/extension/__init__.py delete mode 100644 test_samples/extensions/quickstart-extension/src/extension/extension.py delete mode 100644 test_samples/extensions/quickstart-extension/src/extension/models.py delete mode 100644 test_samples/extensions/quickstart-extension/src/sample/__init__.py delete mode 100644 test_samples/extensions/quickstart-extension/src/sample/app.py delete mode 100644 test_samples/extensions/quickstart-extension/src/sample/extension_agent.py delete mode 100644 test_samples/extensions/quickstart-extension/src/sample/start_server.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py index 776d34d6..e0628177 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py @@ -12,13 +12,17 @@ from ..state.turn_state import TurnState from .route_rank import RouteRank + def _agentic_selector(selector: RouteSelector) -> RouteSelector: def wrapped_selector(context: TurnContext) -> bool: return context.activity.is_agentic_request() and selector(context) + return wrapped_selector + StateT = TypeVar("StateT", bound=TurnState) + class _Route(Generic[StateT]): selector: RouteSelector handler: RouteHandler[StateT] @@ -47,22 +51,22 @@ def __init__( @property def is_invoke(self) -> bool: return self._is_invoke - + @property def rank(self) -> RouteRank: return self._rank - + @property def is_agentic(self) -> bool: return self._is_agentic - + @property def ordering(self) -> list[int]: - return [ + return [ 0 if self._is_agentic else 1, 0 if self._is_invoke else 1, - self._rank.value - ] + self._rank.value, + ] def __lt__(self, other: _Route) -> bool: # list ordering is a lexicographic comparison diff --git a/test_samples/extensions/doc_samples/README.md b/test_samples/extensions/README.md similarity index 100% rename from test_samples/extensions/doc_samples/README.md rename to test_samples/extensions/README.md diff --git a/test_samples/extensions/extension-starter/src/extension/extension.py b/test_samples/extensions/extension-starter/src/extension/extension.py index 7b0f9aa9..b05f96cc 100644 --- a/test_samples/extensions/extension-starter/src/extension/extension.py +++ b/test_samples/extensions/extension-starter/src/extension/extension.py @@ -1,13 +1,11 @@ import logging -from msvcrt import kbhit -from typing import Awaitable, Callable, Generic, TypeVar, Iterable, cast +from typing import Awaitable, Callable, Generic, TypeVar from microsoft_agents.activity import Activity, ActivityTypes, InvokeResponse from microsoft_agents.hosting.core import ( AgentApplication, TurnContext, TurnState, - RouteSelector, RouteHandler, ) @@ -23,18 +21,6 @@ MY_CHANNEL = "mychannel" -def create_route_selector(event_name: str) -> RouteSelector: - - def route_selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.message - and context.activity.channel_id == MY_CHANNEL - and context.activity.name == f"invoke/{event_name}" - ) - - return route_selector - - StateT = TypeVar("StateT", bound=TurnState) @@ -44,6 +30,9 @@ class ExtensionAgent(Generic[StateT]): def __init__(self, app: AgentApplication[StateT]): self.app = app + # defining event decorators with custom selectors + # allowing event decorators to accept **kwargs and passing + # **kwargs to app.add_route is recommended def _on_message_has_hello_event( self, handler: Callable[[TurnContext, StateT, CustomEventData], Awaitable[None]], @@ -63,17 +52,27 @@ async def route_handler(context: TurnContext, state: StateT): self.app.add_route(route_selector, route_handler, **kwargs) def on_message_has_hello_event( - self, handler: Callable[[TurnContext, StateT, CustomEventData], Awaitable[None]] + self, + handler: Callable[[TurnContext, StateT, CustomEventData], Awaitable[None]], + **kwargs, ): - self._on_message_has_hello_event(handler, is_agentic=False) + self._on_message_has_hello_event(handler, is_agentic=False, **kwargs) def on_agentic_message_has_hello_event( - self, handler: Callable[[TurnContext, StateT, CustomEventData], Awaitable[None]] + self, + handler: Callable[[TurnContext, StateT, CustomEventData], Awaitable[None]], + **kwargs, ): - self._on_message_has_hello_event(handler, is_agentic=True) + self._on_message_has_hello_event(handler, is_agentic=True, **kwargs) - def on_invoke_custom_event(self, handler: CustomRouteHandler[StateT]): - route_selector = create_route_selector(CustomEventTypes.CUSTOM_EVENT) + # events that are handled with custom payloads + def on_invoke_custom_event(self, handler: CustomRouteHandler[StateT], **kwargs): + def route_selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.message + and context.activity.channel_id == MY_CHANNEL + and context.activity.name == f"invoke/{CustomEventTypes.CUSTOM_EVENT}" + ) async def route_handler(context: TurnContext, state: StateT): custom_event_data = CustomEventData.from_context(context) @@ -81,6 +80,8 @@ async def route_handler(context: TurnContext, state: StateT): if not result: result = CustomEventResult() + # send an invoke response back to the caller + # invokes must send back an invoke response response = Activity( type=ActivityTypes.invoke_response, value=InvokeResponse(status=200, body=result), @@ -88,13 +89,23 @@ async def route_handler(context: TurnContext, state: StateT): await context.send_activity(response) logger.debug("Registering route for custom event") - self.app.add_route(route_selector, route_handler, is_invoke=True) + self.app.add_route(route_selector, route_handler, is_invoke=True, **kwargs) - def on_invoke_other_custom_event(self, handler: RouteHandler[StateT]): - route_selector = create_route_selector(CustomEventTypes.OTHER_CUSTOM_EVENT) + # event that does not require a custom payload + def on_invoke_other_custom_event(self, handler: RouteHandler[StateT], **kwargs): + def route_selector(context: TurnContext) -> bool: + return ( + context.activity.type == ActivityTypes.message + and context.activity.channel_id == MY_CHANNEL + and context.activity.name + == f"invoke/{CustomEventTypes.OTHER_CUSTOM_EVENT}" + ) async def route_handler(context: TurnContext, state: StateT): await handler(context, state) + + # send an invoke response back to the caller + # invokes must send back an invoke response response = Activity( type=ActivityTypes.invoke_response, value=InvokeResponse(status=200, body={}), @@ -102,23 +113,4 @@ async def route_handler(context: TurnContext, state: StateT): await context.send_activity(response) logger.debug("Registering route for other custom event") - self.app.add_route(route_selector, route_handler, is_invoke=True) - - # Callable that takes in three arguments (TurnContext, StateT, str) and returns Awaitable[None] - # Awaitable indicates that the function is asynchronous and returns a coroutine - def on_message_reaction_added( - self, handler: Callable[[TurnContext, StateT, str], Awaitable[None]] - ): - - def route_selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.message - and context.activity.name == "reactionAdded" - ) - - async def route_handler(context: TurnContext, state: StateT): - for reaction in cast(Iterable, context.activity.value): - await handler(context, state, reaction.type) - - logger.debug("Registering route for message reaction added") - self.app.add_route(route_selector, route_handler) + self.app.add_route(route_selector, route_handler, is_invoke=True, **kwargs) diff --git a/test_samples/extensions/extension-starter/src/sample/extension_agent.py b/test_samples/extensions/extension-starter/src/sample/extension_agent.py index c315704a..f7f04831 100644 --- a/test_samples/extensions/extension-starter/src/sample/extension_agent.py +++ b/test_samples/extensions/extension-starter/src/sample/extension_agent.py @@ -11,6 +11,15 @@ EXT = ExtensionAgent[TurnState](APP) +@EXT.on_message_has_hello_event +async def message_has_hello_event( + context: TurnContext, state: TurnState, data: CustomEventData +): + await context.send_activity( + f"Hello event detected! User ID: {data.user_id}, Field: {data.field}" + ) + + @EXT.on_message_has_hello_event async def message_has_hello_event( context: TurnContext, state: TurnState, data: CustomEventData @@ -35,8 +44,3 @@ async def invoke_other_custom_event(context: TurnContext, state: TurnState): await context.send_activity( f"Custom event triggered {context.activity.type}/{context.activity.name}" ) - - -@EXT.on_message_reaction_added -async def reaction_added(context: TurnContext, state: TurnState, reaction: str): - await context.send_activity(f"Reaction added: {reaction}") diff --git a/test_samples/extensions/quickstart-extension/README.md b/test_samples/extensions/quickstart-extension/README.md deleted file mode 100644 index e69de29b..00000000 diff --git a/test_samples/extensions/quickstart-extension/env.TEMPLATE b/test_samples/extensions/quickstart-extension/env.TEMPLATE deleted file mode 100644 index e69de29b..00000000 diff --git a/test_samples/extensions/quickstart-extension/infra/README.md b/test_samples/extensions/quickstart-extension/infra/README.md deleted file mode 100644 index 8e25450e..00000000 --- a/test_samples/extensions/quickstart-extension/infra/README.md +++ /dev/null @@ -1,29 +0,0 @@ -## Scripts - -- `decompose.ps1` - - This command will configuration details for the Azure Bot Service resource, including its Connections and Channels. If an application ID is provided, the script will also print the configuration of the App Registration. - - Usage: -```bash - ./decompose.ps1 -g RESOURCE_GROUP -n BOT_NAME -APP_ID OPTIONAL_APP_ID -``` - - -- `gen_teams_manifest.ps1` - - This command will create the file `./bot/manifest.json`, allowing you to zip the contents of the `./bot` directory and import the Agent into teams. - - Usage: -``` - ./gen_teams_manifest.ps1 -APP_ID APP_ID -``` - - - -## Directories - -- `bicep`: common bicep scripts used by the samples provisioning scripts - -- `samples` - - `quickstart`: provisioning script for the Quickstart sample - - `auto-signin`: provisioning scripts for the Auto Sign-In sample - - `obo-authorization`: provisioning scripts for the OBO Authorization sample - -- `bot`: Destination of `manifest.json` file created by `gen_teams_manifest.ps1`. The resulting contents can be used to deploy an Agent to Teams. \ No newline at end of file diff --git a/test_samples/extensions/quickstart-extension/infra/bicep/app.bicep b/test_samples/extensions/quickstart-extension/infra/bicep/app.bicep deleted file mode 100644 index 5aec0227..00000000 --- a/test_samples/extensions/quickstart-extension/infra/bicep/app.bicep +++ /dev/null @@ -1,20 +0,0 @@ -extension microsoftGraphV1 - -param botName string - -resource app 'Microsoft.Graph/applications@v1.0' = { - displayName: '${botName}-app' - uniqueName: '${botName}-app' - signInAudience: 'AzureADMyOrg' - owners: { - relationships: [ deployer().objectId ] - } -} - -resource servicePrincipal 'Microsoft.Graph/servicePrincipals@v1.0' = { - appId: app.appId - accountEnabled: true - servicePrincipalType: 'Application' -} - -output appId string = app.appId diff --git a/test_samples/extensions/quickstart-extension/infra/bicep/bicepconfig.json b/test_samples/extensions/quickstart-extension/infra/bicep/bicepconfig.json deleted file mode 100644 index 36370e02..00000000 --- a/test_samples/extensions/quickstart-extension/infra/bicep/bicepconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "experimentalFeaturesEnabled": { - "extensibility": true - }, - // specify an alias for the version of the v1.0 dynamic types package you want to use - "extensions": { - "microsoftGraphV1": "br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:0.2.0-preview" - } -} \ No newline at end of file diff --git a/test_samples/extensions/quickstart-extension/infra/bicep/bot.bicep b/test_samples/extensions/quickstart-extension/infra/bicep/bot.bicep deleted file mode 100644 index 31bed5f8..00000000 --- a/test_samples/extensions/quickstart-extension/infra/bicep/bot.bicep +++ /dev/null @@ -1,36 +0,0 @@ -@description('The name of the Azure Bot resource.') -param botName string - -@description('The ID for an existing App Registration') -param appId string - -@description('The endpoint for the bot service.') -param endpoint string - -@description('The location for the bot service.') -param location string - -resource azureBot 'microsoft.botService/botServices@2023-09-15-preview' = { - name: botName - location: location - kind: 'azurebot' - properties: { - displayName: botName - msaAppId: appId - endpoint: endpoint - msaAppType: 'SingleTenant' - msaAppTenantId: tenant().tenantId - // schemaTransformationVersion: '1.3' - } -} - -resource msteams 'microsoft.botService/botServices/channels@2023-09-15-preview' = { - parent: azureBot - location: location - name: 'MsTeamsChannel' - properties: { - channelName: 'MsTeamsChannel' - } -} - -output appId string = azureBot.id diff --git a/test_samples/extensions/quickstart-extension/infra/bot/color.png b/test_samples/extensions/quickstart-extension/infra/bot/color.png deleted file mode 100644 index 6bbd4f9831162d78117f54283932a0124166689b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1893 zcmd5-Sx}Q#6#f$oRG6?Y3PP|B5|9B~hyg-?06`%L1X0;RK=wgW2!s?_RX|J;hegmL z1i@gVVRKfFWa+z9lf&1^LW=9@qO1= zMM&l}50*rPg)!hgx`=FyA{4I)3D6Lm4EfbTO|D;pBSu_y&%rtfPtTx9S5f)9B@=;5 z22a!ZM3^ppp<6}e;rfdB{&1+*THtdQhp4YvQ`^YMI`o{|c}7ZS7sIW-Tixgt?aj15 zK5kbYA9h-^rbJ>dYv-~9aOXV7Vg(9HhutpSW zY*zzzF4H8m)lxBOeq{BUW^hZ!k=_MY3nnBx&yWp=W}&bp8yXx;NUQW=QbcI>a^hs0 zM5T6{tkuHNYgJlb7;L|gCFFKM6z|Du>&fFXtT|ihd?`2 zbQxF#9#N*jS@1Vp%NV>(^5F}QlGIL4Saq;}^?saafVA%J@z$|WnN&lj! zXvh>y5W`}JQB#+1dOIEUcwy%I_Bu?f+~FU{d)Rf(R8It0ru?gUVWZC%*NHh*q`YM8uQnbq8etqf^hX%$-v*bxRchn7p4+FQf4Pf8#A2?nl_vqj%|pn zh;b9r-dq|F+!9K?O~YQ6KGq|t>uu;pmu*EX*Hw-qDaUweJnWsmG`DV@d+vN9@5XJR#CZd)=QlByO~x{d(x)V|BKz3WW_&3J`t z%kyd6;ApmTp}puk-Wzu89(56!;MV!u!G(^|0eJUnxkT|yhc*e7(xij`CYDjf0o1aa%& z3gHS#okQ(^c^%J$4oY6{$L(uN<%{xZ$`wI4E92&8eNSY&5Y}oA&05jyIj{PuD_PKknttCpC3ifOr&Pd48Py zD<-6ceCsioNzOhm46Crjue!oag&u<&U(j3s&kuCrwauDb=H;>GFgbKJ_!=QMqNmGk Ir?A{V02K%`jsO4v diff --git a/test_samples/extensions/quickstart-extension/infra/bot/outline.png b/test_samples/extensions/quickstart-extension/infra/bot/outline.png deleted file mode 100644 index 80c85b2ce6b423bfd007c90538e89695d2fbb0e5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 755 zcmVPx#1ZP1_K>z@;j|==^1poj532;bRa{vGf5&!@T5&_cPe*6Fc0*FaOK~z{ry_d~S z6hRb)hY%qdOc2zSD;Bh#R?-;6zdl9Q^s_tv?$e!6?6^*`u#ySLz^hs~nX=`6zoxCfuw+zeQCo1{my z0rZb)+IEj0N=DMJAv>UdNYh*a0!g32zwipK*xVdg@f3;A;RT$wxjB$@#>vGQo9l&y zS?OFB@h!khb0I6n9R6-FN!z;)zreW4eqIt6pnjh(O=)bx>c^xB)|PmdsHpS*ZpOPX zK+8yH!}7%-R*%>TyoO7Rv$nM|rD3g8SbkRsnuouks-LoXWJp6=)||IF9|R@0TPv`6 zWOx_ik>u|HffMi!LGgC?6cGL9gv|-^{Pbf1@oCANuRtZ=hq_e4H%+!X`j18RZBCHq zmxi>UAnpCE`7!XEHh_Mu1<(f2SB}A>mC?=sPpaz(Diu(81fxP4(z=l>K7z7KIEn2g zT(oHb^1&C1wJ8=*ehDK8%6tqNuQJva{(`UIV|dq8|26#Mm{m_p7f}2dMx-aGZ?g(` z0JoJOg0WVWd=mu5?-lE5xq7)Mn}A*0{04r8`Ud=jpWs{g$YBYFE`Y$wuVVZ<&smsz zrqXZ$L%oXi;VAkTDe-MjNq|ko^u$WTS|^OF{Wc(YgWv%AVf#{ypJ6>t={vFpRd`SQ zjY`njbZ9vUp4J4I^@Ai_I<+wT$e?`}vH-`VDUEep!+%+s)Q0_A8s8IIt)7e>?6IKd z=~xk4y}uVFi0}_5+@-JNhwzC*#tX0NREY)pAK^p0#}9It002ovPDHLkV1mXeUwr@o diff --git a/test_samples/extensions/quickstart-extension/infra/decompose.ps1 b/test_samples/extensions/quickstart-extension/infra/decompose.ps1 deleted file mode 100644 index 0a9c9c71..00000000 --- a/test_samples/extensions/quickstart-extension/infra/decompose.ps1 +++ /dev/null @@ -1,38 +0,0 @@ -# References for ARM and Graph resource types -# https://learn.microsoft.com/en-us/azure/templates/microsoft.botservice/botservices?pivots=deployment-language-bicep -# https://learn.microsoft.com/en-us/graph/templates/bicep/reference/applications?view=graph-bicep-1.0 - -[CmdletBinding()] -param( - [Parameter(Mandatory=$true)] - [Alias('g')] - [string]$RESOURCE_GROUP, - - [Parameter(Mandatory=$true)] - [Alias('n')] - [string]$BOT_NAME, - - [string]$APP_ID='' -) - -if ($APP_ID -ne '') { - Write-Output 'Showing App Registration Details:\n' - az ad app show --id $APP_ID - Write-Output '\nAssociated federated-credential list:' - az ad app federated-credential list --id $APP_ID -} - -$CHANNEL_NAME_LIST = @('msteams', 'webchat', 'directline') - -# Azure Bot Service Channels -Write-Output 'Showing configured channels (from a non-exhaustive list)' -foreach($channel_name in $CHANNEL_NAME_LIST) { - Write-Output "Channel: $channel_name" - az bot $CHANNEL_NAME show -n $BOT_NAME -g $RESOURCE_GROUP - Write-Output '\n' -} - -# Azure Bot Service Connections -Write-Output 'Showing connections' -az bot authsetting list -n $BOT_NAME -g $RESOURCE_GROUP -Write-Output '\n' \ No newline at end of file diff --git a/test_samples/extensions/quickstart-extension/infra/gen_teams_manifest.ps1 b/test_samples/extensions/quickstart-extension/infra/gen_teams_manifest.ps1 deleted file mode 100644 index 447ceea7..00000000 --- a/test_samples/extensions/quickstart-extension/infra/gen_teams_manifest.ps1 +++ /dev/null @@ -1,80 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory=$true)] - [string]$APP_ID -) - -# Define JSON content as a string -$jsonContent = @' -{ - "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v/MicrosoftTeams.schema.json", - "version": "1.0.0", - "manifestVersion": "", - "id": "${APP_ID}", - "name": { - "short": "Testing Teams SSO Auth", - "full": "Testing Teams Single Sign On Sample" - }, - "developer": { - "name": "Microsoft", - "mpnId": "", - "websiteUrl": "https://example.azurewebsites.net", - "privacyUrl": "https://example.azurewebsites.net/privacy", - "termsOfUseUrl": "https://example.azurewebsites.net/termsofuse" - }, - "description": { - "short": "1Test Teams SSO Auth", - "full": "1This is a bot for testing Single Sign on for Teams" - }, - "icons": { - "outline": "outline.png", - "color": "color.png" - }, - "accentColor": "#FFFFFF", - "staticTabs": [ - { - "entityId": "conversations", - "name": "Chat", - "scopes": [ - "personal" - ] - }, - { - "entityId": "about", - "name": "", - "scopes": [ - "personal" - ] - } - ], - "bots": [ - { - "botId": "${APP_ID}", - "scopes": [ - "personal", - "team", - "groupchat" - ], - "isNotificationOnly": false, - "supportsCalling": false, - "supportsVideo": false, - "supportsFiles": false - } - ], - "validDomains": [ - "token.botframework.com", - "ngrok.io" - ], - "webApplicationInfo": { - "id": "${APP_ID}", - "resource": "api://botid-${APP_ID}/access_as_user" - } -} -'@ - - -$jsonContent = $jsonContent.Replace('${APP_ID}', $APP_ID) - -Write-Output 'Saving generated JSON content to bot/manifest.json...' -# Save the JSON content to a file -$jsonContent | Set-Content -Path "bot/manifest.json" diff --git a/test_samples/extensions/quickstart-extension/infra/prov_app.ps1 b/test_samples/extensions/quickstart-extension/infra/prov_app.ps1 deleted file mode 100644 index e69de29b..00000000 diff --git a/test_samples/extensions/quickstart-extension/infra/prov_bot.ps1 b/test_samples/extensions/quickstart-extension/infra/prov_bot.ps1 deleted file mode 100644 index e69de29b..00000000 diff --git a/test_samples/extensions/quickstart-extension/infra/provision.ps1 b/test_samples/extensions/quickstart-extension/infra/provision.ps1 deleted file mode 100644 index e69de29b..00000000 diff --git a/test_samples/extensions/quickstart-extension/requirements.txt b/test_samples/extensions/quickstart-extension/requirements.txt deleted file mode 100644 index 97502ab4..00000000 --- a/test_samples/extensions/quickstart-extension/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -strenum \ No newline at end of file diff --git a/test_samples/extensions/quickstart-extension/src/__init__.py b/test_samples/extensions/quickstart-extension/src/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/test_samples/extensions/quickstart-extension/src/extension/__init__.py b/test_samples/extensions/quickstart-extension/src/extension/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/test_samples/extensions/quickstart-extension/src/extension/extension.py b/test_samples/extensions/quickstart-extension/src/extension/extension.py deleted file mode 100644 index a5e1ecb7..00000000 --- a/test_samples/extensions/quickstart-extension/src/extension/extension.py +++ /dev/null @@ -1,97 +0,0 @@ -import logging -from typing import ( - Awaitable, - Callable, - Generic, - TypeVar, -) - -from microsoft_agents.activity import Activity, ActivityTypes, InvokeResponse -from microsoft_agents.hosting.core import ( - AgentApplication, - TurnContext, - TurnState, - RouteSelector, -) - -logger = logging.getLogger(__name__) - -MY_CHANNEL = "mychannel" - -from .models import ( - CustomEventData, - CustomEventResult, - CustomEventTypes, - CustomRouteHandler, -) - - -def create_route_selector(event_name: str) -> RouteSelector: - - async def route_selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.message - and context.activity.channel_id == MY_CHANNEL - and context.activity.name == f"invoke/{event_name}" - ) - - return route_selector - - -class ExtensionAgent(Generic[StateT]): - app: AgentApplication[StateT] - - def __init__(self, app: AgentApplication[StateT]): - self.app = app - - def on_invoke_custom_event(self, handler: CustomRouteHandler[StateT]): - route_selector = create_route_selector(CustomEventTypes.CUSTOM_EVENT) - - async def route_handler(context: TurnContext, state: StateT): - custom_event_data = CustomEventData.from_context(context) - result = await handler(context, state, custom_event_data) - if not result: - result = CustomEventResult() - - response = Activity( - type=ActivityTypes.invoke_response, - value=InvokeResponse(status=200, body=result), - ) - await context.send_activity(response) - - logger.debug("Registering route for custom event") - self.app.add_route(route_selector, route_handler, is_invoke=True) - - def on_invoke_other_custom_event(self, handler: RouteHandler[StateT]): - route_selector = create_route_selector(CustomEventTypes.OTHER_CUSTOM_EVENT) - - async def route_handler(context: TurnContext, state: StateT): - await handler(context, state) - response = Activity( - type=ActivityTypes.invoke_response, - value=InvokeResponse(status=200, body={}), - ) - await context.send_activity(response) - - logger.debug("Registering route for other custom event") - self.app.add_route(route_selector, route_handler, is_invoke=True) - - # Callable that takes in three arguments (TurnContext, StateT, str) and returns Awaitable[None] - # Awaitable indicates that the function is asynchronous and returns a coroutine - def on_message_reaction_added( - self, handler: Callable[[TurnContext, StateT, str], Awaitable[None]] - ): - - async def route_selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.message - and context.activity.name == "reactionAdded" - ) - - async def route_handler(context: TurnContext, state: StateT): - reactions_added = context.activity.reactions_added - for reaction in context.activity.value: - await handler(context, state, reaction.type) - - logger.debug("Registering route for message reaction added") - self.app.add_route(route_selector, route_handler) diff --git a/test_samples/extensions/quickstart-extension/src/extension/models.py b/test_samples/extensions/quickstart-extension/src/extension/models.py deleted file mode 100644 index e5b4a829..00000000 --- a/test_samples/extensions/quickstart-extension/src/extension/models.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -from typing import Optional, Protocol, TypeVar -from microsoft_agents.activity import AgentsModel -from microsoft_agents.hosting.core import TurnContext, TurnState - -from strenum import StrEnum - -StateT = TypeVar("StateT", bound=TurnState) - - -class CustomRouteHandler(Protocol(StateT)): - async def __call__( - self, context: TurnContext, state: StateT, event_data: CustomEventData - ) -> CustomEventResult: ... - - -class CustomEventTypes(StrEnum): - CUSTOM_EVENT = "customEvent" - OTHER_CUSTOM_EVENT = "otherCustomEvent" - - -class CustomEventData(AgentsModel): - user_id: Optional[str] = None - field: Optional[str] = None - - @staticmethod - def from_context(context) -> CustomEventData: - return CustomEventData( - user_id=context.activity.from_property.id, - field=context.activity.channel_data.get("field"), - ) - - -class CustomEventResult(AgentsModel): - user_id: Optional[str] = None - field: Optional[str] = None diff --git a/test_samples/extensions/quickstart-extension/src/sample/__init__.py b/test_samples/extensions/quickstart-extension/src/sample/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/test_samples/extensions/quickstart-extension/src/sample/app.py b/test_samples/extensions/quickstart-extension/src/sample/app.py deleted file mode 100644 index 1c7b6a2a..00000000 --- a/test_samples/extensions/quickstart-extension/src/sample/app.py +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import os -import re -from dotenv import load_dotenv - -from microsoft_agents.hosting.core import ( - Authorization, - MemoryStorage, - AgentApplication, - TurnState, - MemoryStorage, -) -from microsoft_agents.activity import load_configuration_from_env -from microsoft_agents.authentication.msal import MsalConnectionManager -from src.sample.mocks import MockAdapter - -# Load configuration from environment -load_dotenv() -agents_sdk_config = load_configuration_from_env(os.environ) - -# Create storage and connection manager -STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) -ADAPTER = MockAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) -APP = AgentApplication[TurnState]( - storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config -) diff --git a/test_samples/extensions/quickstart-extension/src/sample/extension_agent.py b/test_samples/extensions/quickstart-extension/src/sample/extension_agent.py deleted file mode 100644 index 85f2e983..00000000 --- a/test_samples/extensions/quickstart-extension/src/sample/extension_agent.py +++ /dev/null @@ -1,34 +0,0 @@ -from microsoft_agents.hosting.core import TurnContext, TurnState - -from src.extension import ( - ExtensionAgent, - CustomEventData, - CustomEventResult, -) - -from src.app import APP -from src.extension import ExtensionAgent - -EXT = ExtensionAgent[TurnState](APP) - - -@EXT.on_invoke_custom_event -async def invoke_custom_event( - context: TurnContext, state: TurnState, data: CustomEventData -) -> CustomEventResult: - await context.send_activity( - f"Custom event triggered {context.activity.type}/{context.activity.name}" - ) - return CustomEventResult(user_id=data.user_id, field=data.field) - - -@EXT.on_invoke_other_custom_event -async def invoke_other_custom_event(context: TurnContext, state: TurnState): - await context.send_activity( - f"Custom event triggered {context.activity.type}/{context.activity.name}" - ) - - -@EXT.on_message_reaction_added -async def reaction_added(context: TurnContext, state: TurnState, reaction: str): - await context.send_activity(f"Reaction added: {reaction}") diff --git a/test_samples/extensions/quickstart-extension/src/sample/start_server.py b/test_samples/extensions/quickstart-extension/src/sample/start_server.py deleted file mode 100644 index d76b619e..00000000 --- a/test_samples/extensions/quickstart-extension/src/sample/start_server.py +++ /dev/null @@ -1,32 +0,0 @@ -from os import environ -from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration -from microsoft_agents.hosting.aiohttp import ( - start_agent_process, - jwt_authorization_middleware, - CloudAdapter, -) -from aiohttp.web import Request, Response, Application, run_app - - -def start_server( - agent_application: AgentApplication, auth_configuration: AgentAuthConfiguration -): - async def entry_point(req: Request) -> Response: - agent: AgentApplication = req.app["agent_app"] - adapter: CloudAdapter = req.app["adapter"] - return await start_agent_process( - req, - agent, - adapter, - ) - - APP = Application(middlewares=[jwt_authorization_middleware]) - APP.router.add_post("/api/messages", entry_point) - APP["agent_configuration"] = auth_configuration - APP["agent_app"] = agent_application - APP["adapter"] = agent_application.adapter - - try: - run_app(APP, host="localhost", port=environ.get("PORT", 3978)) - except Exception as error: - raise error From 02e173cfa22345b57df5803e7480395ed27591b2 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 6 Oct 2025 11:55:45 -0700 Subject: [PATCH 52/67] Change to add_route for RouteList --- .../hosting/core/app/_routes/_route_list.py | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py index 1720e0b7..a3bde440 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py @@ -25,23 +25,7 @@ def __init__( # a min-heap where lower "values" indicate higher priority self._routes = [] - def add_route( - self, - route_selector: RouteSelector, - route_handler: RouteHandler[StateT], - is_invoke: bool = False, - rank: RouteRank = RouteRank.DEFAULT, - auth_handlers: Optional[list[str]] = None, - ) -> None: - """Add a route to the priority queue.""" - route = _Route( - selector=route_selector, - handler=route_handler, - is_invoke=is_invoke, - rank=rank, - auth_handlers=auth_handlers or [], - ) - + def add_route(self, route: _Route[StateT]) -> None: heapq.heappush(self._routes, route) def __iter__(self): From c5ad7f89e30db24e6466261a5ee8c3256c49fec7 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 6 Oct 2025 12:25:39 -0700 Subject: [PATCH 53/67] Adding add_route docstring --- doc_samples/README.md | 3 - doc_samples/extension-starter/README.md | 0 .../extension-starter/requirements.txt | 1 - doc_samples/extension-starter/src/__init__.py | 0 .../src/extension/__init__.py | 0 .../src/extension/extension.py | 97 ------------------- .../extension-starter/src/extension/models.py | 37 ------- .../extension-starter/src/sample/app.py | 30 ------ .../src/sample/extension_agent.py | 34 ------- .../hosting/core/app/_routes/_route_list.py | 1 + .../hosting/core/app/agent_application.py | 25 ++++- 11 files changed, 24 insertions(+), 204 deletions(-) delete mode 100644 doc_samples/README.md delete mode 100644 doc_samples/extension-starter/README.md delete mode 100644 doc_samples/extension-starter/requirements.txt delete mode 100644 doc_samples/extension-starter/src/__init__.py delete mode 100644 doc_samples/extension-starter/src/extension/__init__.py delete mode 100644 doc_samples/extension-starter/src/extension/extension.py delete mode 100644 doc_samples/extension-starter/src/extension/models.py delete mode 100644 doc_samples/extension-starter/src/sample/app.py delete mode 100644 doc_samples/extension-starter/src/sample/extension_agent.py diff --git a/doc_samples/README.md b/doc_samples/README.md deleted file mode 100644 index fd85d0ef..00000000 --- a/doc_samples/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Doc Samples - -These samples are a bit more specific to development and are meant to highlight usage patterns of specific SDK features. Included are also samples for developing extensions on top of the SDK. \ No newline at end of file diff --git a/doc_samples/extension-starter/README.md b/doc_samples/extension-starter/README.md deleted file mode 100644 index e69de29b..00000000 diff --git a/doc_samples/extension-starter/requirements.txt b/doc_samples/extension-starter/requirements.txt deleted file mode 100644 index 97502ab4..00000000 --- a/doc_samples/extension-starter/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -strenum \ No newline at end of file diff --git a/doc_samples/extension-starter/src/__init__.py b/doc_samples/extension-starter/src/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/doc_samples/extension-starter/src/extension/__init__.py b/doc_samples/extension-starter/src/extension/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/doc_samples/extension-starter/src/extension/extension.py b/doc_samples/extension-starter/src/extension/extension.py deleted file mode 100644 index a5e1ecb7..00000000 --- a/doc_samples/extension-starter/src/extension/extension.py +++ /dev/null @@ -1,97 +0,0 @@ -import logging -from typing import ( - Awaitable, - Callable, - Generic, - TypeVar, -) - -from microsoft_agents.activity import Activity, ActivityTypes, InvokeResponse -from microsoft_agents.hosting.core import ( - AgentApplication, - TurnContext, - TurnState, - RouteSelector, -) - -logger = logging.getLogger(__name__) - -MY_CHANNEL = "mychannel" - -from .models import ( - CustomEventData, - CustomEventResult, - CustomEventTypes, - CustomRouteHandler, -) - - -def create_route_selector(event_name: str) -> RouteSelector: - - async def route_selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.message - and context.activity.channel_id == MY_CHANNEL - and context.activity.name == f"invoke/{event_name}" - ) - - return route_selector - - -class ExtensionAgent(Generic[StateT]): - app: AgentApplication[StateT] - - def __init__(self, app: AgentApplication[StateT]): - self.app = app - - def on_invoke_custom_event(self, handler: CustomRouteHandler[StateT]): - route_selector = create_route_selector(CustomEventTypes.CUSTOM_EVENT) - - async def route_handler(context: TurnContext, state: StateT): - custom_event_data = CustomEventData.from_context(context) - result = await handler(context, state, custom_event_data) - if not result: - result = CustomEventResult() - - response = Activity( - type=ActivityTypes.invoke_response, - value=InvokeResponse(status=200, body=result), - ) - await context.send_activity(response) - - logger.debug("Registering route for custom event") - self.app.add_route(route_selector, route_handler, is_invoke=True) - - def on_invoke_other_custom_event(self, handler: RouteHandler[StateT]): - route_selector = create_route_selector(CustomEventTypes.OTHER_CUSTOM_EVENT) - - async def route_handler(context: TurnContext, state: StateT): - await handler(context, state) - response = Activity( - type=ActivityTypes.invoke_response, - value=InvokeResponse(status=200, body={}), - ) - await context.send_activity(response) - - logger.debug("Registering route for other custom event") - self.app.add_route(route_selector, route_handler, is_invoke=True) - - # Callable that takes in three arguments (TurnContext, StateT, str) and returns Awaitable[None] - # Awaitable indicates that the function is asynchronous and returns a coroutine - def on_message_reaction_added( - self, handler: Callable[[TurnContext, StateT, str], Awaitable[None]] - ): - - async def route_selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.message - and context.activity.name == "reactionAdded" - ) - - async def route_handler(context: TurnContext, state: StateT): - reactions_added = context.activity.reactions_added - for reaction in context.activity.value: - await handler(context, state, reaction.type) - - logger.debug("Registering route for message reaction added") - self.app.add_route(route_selector, route_handler) diff --git a/doc_samples/extension-starter/src/extension/models.py b/doc_samples/extension-starter/src/extension/models.py deleted file mode 100644 index e5b4a829..00000000 --- a/doc_samples/extension-starter/src/extension/models.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -from typing import Optional, Protocol, TypeVar -from microsoft_agents.activity import AgentsModel -from microsoft_agents.hosting.core import TurnContext, TurnState - -from strenum import StrEnum - -StateT = TypeVar("StateT", bound=TurnState) - - -class CustomRouteHandler(Protocol(StateT)): - async def __call__( - self, context: TurnContext, state: StateT, event_data: CustomEventData - ) -> CustomEventResult: ... - - -class CustomEventTypes(StrEnum): - CUSTOM_EVENT = "customEvent" - OTHER_CUSTOM_EVENT = "otherCustomEvent" - - -class CustomEventData(AgentsModel): - user_id: Optional[str] = None - field: Optional[str] = None - - @staticmethod - def from_context(context) -> CustomEventData: - return CustomEventData( - user_id=context.activity.from_property.id, - field=context.activity.channel_data.get("field"), - ) - - -class CustomEventResult(AgentsModel): - user_id: Optional[str] = None - field: Optional[str] = None diff --git a/doc_samples/extension-starter/src/sample/app.py b/doc_samples/extension-starter/src/sample/app.py deleted file mode 100644 index 1c7b6a2a..00000000 --- a/doc_samples/extension-starter/src/sample/app.py +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import os -import re -from dotenv import load_dotenv - -from microsoft_agents.hosting.core import ( - Authorization, - MemoryStorage, - AgentApplication, - TurnState, - MemoryStorage, -) -from microsoft_agents.activity import load_configuration_from_env -from microsoft_agents.authentication.msal import MsalConnectionManager -from src.sample.mocks import MockAdapter - -# Load configuration from environment -load_dotenv() -agents_sdk_config = load_configuration_from_env(os.environ) - -# Create storage and connection manager -STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) -ADAPTER = MockAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) -APP = AgentApplication[TurnState]( - storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config -) diff --git a/doc_samples/extension-starter/src/sample/extension_agent.py b/doc_samples/extension-starter/src/sample/extension_agent.py deleted file mode 100644 index 85f2e983..00000000 --- a/doc_samples/extension-starter/src/sample/extension_agent.py +++ /dev/null @@ -1,34 +0,0 @@ -from microsoft_agents.hosting.core import TurnContext, TurnState - -from src.extension import ( - ExtensionAgent, - CustomEventData, - CustomEventResult, -) - -from src.app import APP -from src.extension import ExtensionAgent - -EXT = ExtensionAgent[TurnState](APP) - - -@EXT.on_invoke_custom_event -async def invoke_custom_event( - context: TurnContext, state: TurnState, data: CustomEventData -) -> CustomEventResult: - await context.send_activity( - f"Custom event triggered {context.activity.type}/{context.activity.name}" - ) - return CustomEventResult(user_id=data.user_id, field=data.field) - - -@EXT.on_invoke_other_custom_event -async def invoke_other_custom_event(context: TurnContext, state: TurnState): - await context.send_activity( - f"Custom event triggered {context.activity.type}/{context.activity.name}" - ) - - -@EXT.on_message_reaction_added -async def reaction_added(context: TurnContext, state: TurnState, reaction: str): - await context.send_activity(f"Reaction added: {reaction}") diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py index a3bde440..cb886799 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py @@ -26,6 +26,7 @@ def __init__( self._routes = [] def add_route(self, route: _Route[StateT]) -> None: + """Adds a route to the list.""" heapq.heappush(self._routes, route) def __iter__(self): 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 a837d921..9009fe23 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 @@ -210,10 +210,31 @@ def add_route( rank: RouteRank = RouteRank.DEFAULT, auth_handlers: Optional[list[str]] = None, ) -> None: - """Adds a new route to the application.""" + """Adds a new route to the application. + + Routes are ordered by: is_agentic, is_invoke, rank (lower is higher priority), in that order. + + :param selector: A function that takes a TurnContext and returns a boolean indicating whether the route should be selected. + :type selector: RouteSelector + :param handler: A function that takes a TurnContext and a TurnState and returns an Awaitable. + :type handler: RouteHandler[StateT] + :param is_invoke: Whether the route is for an invoke activity, defaults to False + :type is_invoke: bool, optional + :param is_agentic: Whether the route is for an agentic request, defaults to False. For agentic requests + the selector will include a new check for `context.activity.is_agentic_request()`. + :type is_agentic: bool, optional + :param rank: The rank of the route, defaults to RouteRank.DEFAULT + :type rank: RouteRank, optional + :param auth_handlers: A list of authentication handler IDs to use for this route, defaults to None + :type auth_handlers: Optional[list[str]], optional + :raises ApplicationError: If the selector or handler are not valid. + """ if is_agentic: selector = _agentic_selector(selector) - self._route_list.add_route(selector, handler, is_invoke=is_invoke, rank=rank, auth_handlers=auth_handlers) + route = _Route[StateT]( + selector, handler, is_invoke, rank, auth_handlers, is_agentic + ) + self._route_list.add_route(route) def activity( self, From 6162523d9e22fc23deae178c8242dcf96a4e705c Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 6 Oct 2025 13:04:25 -0700 Subject: [PATCH 54/67] Fixing RouteList definition and iteration --- .../hosting/core/app/_routes/_route.py | 3 +- .../hosting/core/app/_routes/_route_list.py | 10 +++--- .../hosting/core/app/_routes/route_rank.py | 2 +- .../hosting/core/app/_type_defs.py | 1 - .../app/_routes/test_route_list.py | 36 ++++++++++++------- 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py index e0628177..635df65a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py @@ -62,6 +62,7 @@ def is_agentic(self) -> bool: @property def ordering(self) -> list[int]: + """Lower "values" indicate higher priority.""" return [ 0 if self._is_agentic else 1, 0 if self._is_invoke else 1, @@ -69,5 +70,5 @@ def ordering(self) -> list[int]: ] def __lt__(self, other: _Route) -> bool: - # list ordering is a lexicographic comparison + # built-in list ordering is a lexicographic comparison in Python return self.ordering < other.ordering diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py index cb886799..74e06610 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py @@ -6,13 +6,10 @@ from __future__ import annotations import heapq -from typing import Generic, Optional, TypeVar +from typing import Generic, TypeVar from ..state.turn_state import TurnState - -from .._type_defs import RouteSelector, RouteHandler from ._route import _Route -from .route_rank import RouteRank StateT = TypeVar("StateT", bound=TurnState) @@ -30,4 +27,7 @@ def add_route(self, route: _Route[StateT]) -> None: heapq.heappush(self._routes, route) def __iter__(self): - return iter(self._routes) + # sorted will return a new list, leaving the heap intact + # returning an iterator over the previous list would expose + # internal details + return iter(sorted(self._routes)) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_rank.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_rank.py index 1b0f79db..1c948b91 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_rank.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_rank.py @@ -1,6 +1,6 @@ from enum import IntEnum -MAX_RANK = 2**32 - 1 # Python ints don't have a max value, LOL +MAX_RANK = 2**32 - 1 class RouteRank(IntEnum): diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py index 6a4bb632..3fa400cd 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py @@ -6,6 +6,5 @@ RouteSelector = Callable[[TurnContext], bool] StateT = TypeVar("StateT", bound=TurnState) - class RouteHandler(Protocol[StateT]): def __call__(self, context: TurnContext, state: StateT) -> Awaitable[None]: ... diff --git a/tests/hosting_core/app/_routes/test_route_list.py b/tests/hosting_core/app/_routes/test_route_list.py index ebce7536..ce297f42 100644 --- a/tests/hosting_core/app/_routes/test_route_list.py +++ b/tests/hosting_core/app/_routes/test_route_list.py @@ -14,6 +14,15 @@ def selector(context: TurnContext) -> bool: async def handler(context: TurnContext, state: TurnState) -> None: pass +def route_eq(route1: _Route, route2: _Route) -> bool: + return ( + route1.selector == route2.selector + and route1.handler == route2.handler + and route1.is_invoke == route2.is_invoke + and route1.rank == route2.rank + and route1.auth_handlers == route2.auth_handlers + and route1.is_agentic == route2.is_agentic + ) class Test_RouteList: @@ -27,7 +36,7 @@ def assert_priority_invariant(self, route_list: _RouteList): def has_contents(self, route_list: _RouteList, should_contain: list[_Route]): for route in should_contain: for existing in list(route_list): - if existing == route: + if route_eq(existing, route): break else: return False @@ -42,14 +51,14 @@ def test_route_list_add_and_order(self): route_list = _RouteList() all_routes = [ - (selector, handler, False, RouteRank.DEFAULT, ["a"]), - (selector, handler, True, RouteRank.LAST, ["a"]), - (selector, handler, False, RouteRank.FIRST), - (selector, handler, True), - (selector, handler), - (selector, handler, True, RouteRank.DEFAULT, ["slack"]), - (selector, handler, False, RouteRank.FIRST, ["a", "b"]), - (selector, handler, True, RouteRank.DEFAULT, ["c"]), + (selector, handler, False, RouteRank.DEFAULT, ["a"], False), + (selector, handler, True, RouteRank.LAST, ["a"], False), + (selector, handler, False, RouteRank.FIRST, [], True), + (selector, handler, True, RouteRank.DEFAULT, [], True), + (selector, handler, False, RouteRank.DEFAULT, [], False), + (selector, handler, True, RouteRank.DEFAULT, ["slack"], True), + (selector, handler, False, RouteRank.FIRST, ["a", "b"], False), + (selector, handler, True, RouteRank.DEFAULT, ["c"], True), ] all_routes = [ { @@ -57,14 +66,15 @@ def test_route_list_add_and_order(self): "handler": route[1], "is_invoke": route[2], "rank": route[3], - "auth_handlers": route[4] if len(route) > 4 else None, + "auth_handlers": route[4], + "is_agentic": route[5], } for route in all_routes ] added_routes = [] - for i, route in enumerate(all_routes): - added_routes.append(_Route(**route)) - route_list.add_route(**route) + for i, kwargs in enumerate(all_routes): + added_routes.append(_Route(**kwargs)) + route_list.add_route(_Route(**kwargs)) self.assert_priority_invariant(route_list) assert self.has_contents(route_list, added_routes) From c75d904b158b2656a013ce7d973220c9a0d55aff Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 6 Oct 2025 13:41:32 -0700 Subject: [PATCH 55/67] Formatting src --- .../hosting/core/app/_routes/_route_list.py | 3 +- .../hosting/core/app/_type_defs.py | 2 + .../hosting/core/app/agent_application.py | 44 ++++++++++++++----- .../app/_routes/test_route_list.py | 2 + 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py index 74e06610..ff48269c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py @@ -13,6 +13,7 @@ StateT = TypeVar("StateT", bound=TurnState) + class _RouteList(Generic[StateT]): _routes: list[_Route[StateT]] @@ -30,4 +31,4 @@ def __iter__(self): # sorted will return a new list, leaving the heap intact # returning an iterator over the previous list would expose # internal details - return iter(sorted(self._routes)) \ No newline at end of file + return iter(sorted(self._routes)) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py index 3fa400cd..8a6e08ea 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py @@ -6,5 +6,7 @@ RouteSelector = Callable[[TurnContext], bool] StateT = TypeVar("StateT", bound=TurnState) + + class RouteHandler(Protocol[StateT]): def __call__(self, context: TurnContext, state: StateT) -> Awaitable[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 9009fe23..f6145f55 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 @@ -47,6 +47,8 @@ logger = logging.getLogger(__name__) StateT = TypeVar("StateT", bound=TurnState) + + class AgentApplication(Agent, Generic[StateT]): """ AgentApplication class for routing and processing incoming requests. @@ -213,7 +215,7 @@ def add_route( """Adds a new route to the application. Routes are ordered by: is_agentic, is_invoke, rank (lower is higher priority), in that order. - + :param selector: A function that takes a TurnContext and returns a boolean indicating whether the route should be selected. :type selector: RouteSelector :param handler: A function that takes a TurnContext and a TurnState and returns an Awaitable. @@ -241,7 +243,7 @@ def activity( activity_type: Union[str, ActivityTypes, list[Union[str, ActivityTypes]]], *, auth_handlers: Optional[list[str]] = None, - **kwargs + **kwargs, ) -> Callable[[RouteHandler[StateT]], RouteHandler[StateT]]: """ Registers a new activity event listener. This method can be used as either @@ -276,7 +278,7 @@ def message( select: Union[str, Pattern[str], list[Union[str, Pattern[str]]]], *, auth_handlers: Optional[list[str]] = None, - **kwargs + **kwargs, ) -> Callable[[RouteHandler[StateT]], RouteHandler[StateT]]: """ Registers a new message activity event listener. This method can be used as either @@ -308,7 +310,9 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message handler for route handler {func.__name__} with select: {select} with auth handlers: {auth_handlers}" ) - self.add_route(_Route[StateT](__selector, func, auth_handlers=auth_handlers, **kwargs)) + self.add_route( + _Route[StateT](__selector, func, auth_handlers=auth_handlers, **kwargs) + ) return func return __call @@ -318,7 +322,7 @@ def conversation_update( type: ConversationUpdateTypes, *, auth_handlers: Optional[list[str]] = None, - **kwargs + **kwargs, ) -> Callable[[RouteHandler[StateT]], RouteHandler[StateT]]: """ Registers a new message activity event listener. This method can be used as either @@ -361,13 +365,19 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering conversation update handler for route handler {func.__name__} with type: {type} with auth handlers: {auth_handlers}" ) - self.add_route(_Route[StateT](__selector, func, auth_handlers=auth_handlers, **kwargs)) + self.add_route( + _Route[StateT](__selector, func, auth_handlers=auth_handlers, **kwargs) + ) return func return __call def message_reaction( - self, type: MessageReactionTypes, *, auth_handlers: Optional[list[str]] = None, **kwargs + self, + type: MessageReactionTypes, + *, + auth_handlers: Optional[list[str]] = None, + **kwargs, ) -> Callable[[RouteHandler[StateT]], RouteHandler[StateT]]: """ Registers a new message activity event listener. This method can be used as either @@ -405,13 +415,19 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message reaction handler for route handler {func.__name__} with type: {type} with auth handlers: {auth_handlers}" ) - self.add_route(_Route[StateT](__selector, func, auth_handlers=auth_handlers, **kwargs)) + self.add_route( + _Route[StateT](__selector, func, auth_handlers=auth_handlers, **kwargs) + ) return func return __call def message_update( - self, type: MessageUpdateTypes, *, auth_handlers: Optional[list[str]] = None, **kwargs + self, + type: MessageUpdateTypes, + *, + auth_handlers: Optional[list[str]] = None, + **kwargs, ) -> Callable[[RouteHandler[StateT]], RouteHandler[StateT]]: """ Registers a new message activity event listener. This method can be used as either @@ -462,7 +478,9 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message update handler for route handler {func.__name__} with type: {type} with auth handlers: {auth_handlers}" ) - self.add_route(_Route[StateT](__selector, func, auth_handlers=auth_handlers), **kwargs) + self.add_route( + _Route[StateT](__selector, func, auth_handlers=auth_handlers), **kwargs + ) return func return __call @@ -471,7 +489,7 @@ def handoff( self, *, auth_handlers: Optional[list[str]] = None, **kwargs ) -> Callable[ [Callable[[TurnContext, StateT, str], Awaitable[None]]], - Callable[[TurnContext, StateT, str], Awaitable[None]] + Callable[[TurnContext, StateT, str], Awaitable[None]], ]: """ Registers a handler to handoff conversations from one copilot to another. @@ -510,7 +528,9 @@ async def __handler(context: TurnContext, state: StateT): f"Registering handoff handler for route handler {func.__name__} with auth handlers: {auth_handlers}" ) - self.add_route(_Route[StateT](__selector, __handler, True, auth_handlers), **kwargs) + self.add_route( + _Route[StateT](__selector, __handler, True, auth_handlers), **kwargs + ) return func return __call diff --git a/tests/hosting_core/app/_routes/test_route_list.py b/tests/hosting_core/app/_routes/test_route_list.py index ce297f42..ae50e793 100644 --- a/tests/hosting_core/app/_routes/test_route_list.py +++ b/tests/hosting_core/app/_routes/test_route_list.py @@ -14,6 +14,7 @@ def selector(context: TurnContext) -> bool: async def handler(context: TurnContext, state: TurnState) -> None: pass + def route_eq(route1: _Route, route2: _Route) -> bool: return ( route1.selector == route2.selector @@ -24,6 +25,7 @@ def route_eq(route1: _Route, route2: _Route) -> bool: and route1.is_agentic == route2.is_agentic ) + class Test_RouteList: def assert_priority_invariant(self, route_list: _RouteList): From 30bca993ec292e14111479610744462cf7a45018 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 6 Oct 2025 13:41:58 -0700 Subject: [PATCH 56/67] Redoing sample decorator definitions to take in arguments --- .../src/extension/extension.py | 113 ++++++++---------- .../src/sample/extension_agent.py | 19 +-- 2 files changed, 59 insertions(+), 73 deletions(-) diff --git a/test_samples/extensions/extension-starter/src/extension/extension.py b/test_samples/extensions/extension-starter/src/extension/extension.py index b05f96cc..4b939312 100644 --- a/test_samples/extensions/extension-starter/src/extension/extension.py +++ b/test_samples/extensions/extension-starter/src/extension/extension.py @@ -23,94 +23,77 @@ StateT = TypeVar("StateT", bound=TurnState) - +# This extension defines event decorators with custom selecting/handling logic: class ExtensionAgent(Generic[StateT]): app: AgentApplication[StateT] def __init__(self, app: AgentApplication[StateT]): self.app = app - # defining event decorators with custom selectors - # allowing event decorators to accept **kwargs and passing - # **kwargs to app.add_route is recommended + # Allowing event decorators to accept **kwargs and passing + # **kwargs to app.add_route is recommended. def _on_message_has_hello_event( - self, - handler: Callable[[TurnContext, StateT, CustomEventData], Awaitable[None]], - **kwargs, - ): + self, **kwargs, + ) -> Callable[[CustomRouteHandler[StateT]], RouteHandler[StateT]]: def route_selector(context: TurnContext) -> bool: return ( context.activity.type == ActivityTypes.message and "hello" in context.activity.text.lower() ) - async def route_handler(context: TurnContext, state: StateT): - custom_event_data = CustomEventData.from_context(context) - await handler(context, state, custom_event_data) + def create_handler(handler: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: + async def route_handler(context: TurnContext, state: StateT): + custom_event_data = CustomEventData.from_context(context) + await handler(context, state, custom_event_data) + return route_handler + + def __call(func: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: + logger.debug("Registering route for message has hello event") + handler = create_handler(func) + self.app.add_route(route_selector, handler, **kwargs) + return handler - logger.debug("Registering route for message has hello event") - self.app.add_route(route_selector, route_handler, **kwargs) + return __call def on_message_has_hello_event( - self, - handler: Callable[[TurnContext, StateT, CustomEventData], Awaitable[None]], - **kwargs, - ): - self._on_message_has_hello_event(handler, is_agentic=False, **kwargs) + self, **kwargs, + ) -> Callable[[CustomRouteHandler[StateT]], RouteHandler[StateT]]: + return self._on_message_has_hello_event(is_agentic=False, **kwargs) def on_agentic_message_has_hello_event( - self, - handler: Callable[[TurnContext, StateT, CustomEventData], Awaitable[None]], - **kwargs, - ): - self._on_message_has_hello_event(handler, is_agentic=True, **kwargs) + self, **kwargs, + ) -> Callable[[CustomRouteHandler[StateT]], RouteHandler[StateT]]: + return self._on_message_has_hello_event(is_agentic=True, **kwargs) # events that are handled with custom payloads - def on_invoke_custom_event(self, handler: CustomRouteHandler[StateT], **kwargs): - def route_selector(context: TurnContext) -> bool: - return ( - context.activity.type == ActivityTypes.message - and context.activity.channel_id == MY_CHANNEL - and context.activity.name == f"invoke/{CustomEventTypes.CUSTOM_EVENT}" - ) - - async def route_handler(context: TurnContext, state: StateT): - custom_event_data = CustomEventData.from_context(context) - result = await handler(context, state, custom_event_data) - if not result: - result = CustomEventResult() - - # send an invoke response back to the caller - # invokes must send back an invoke response - response = Activity( - type=ActivityTypes.invoke_response, - value=InvokeResponse(status=200, body=result), - ) - await context.send_activity(response) - - logger.debug("Registering route for custom event") - self.app.add_route(route_selector, route_handler, is_invoke=True, **kwargs) - - # event that does not require a custom payload - def on_invoke_other_custom_event(self, handler: RouteHandler[StateT], **kwargs): + def on_invoke_custom_event(self, custom_event_type: str, **kwargs) -> Callable[[CustomRouteHandler[StateT]], RouteHandler[StateT]]: def route_selector(context: TurnContext) -> bool: return ( context.activity.type == ActivityTypes.message and context.activity.channel_id == MY_CHANNEL - and context.activity.name - == f"invoke/{CustomEventTypes.OTHER_CUSTOM_EVENT}" - ) - - async def route_handler(context: TurnContext, state: StateT): - await handler(context, state) - - # send an invoke response back to the caller - # invokes must send back an invoke response - response = Activity( - type=ActivityTypes.invoke_response, - value=InvokeResponse(status=200, body={}), + and context.activity.name == f"invoke/{custom_event_type}" ) - await context.send_activity(response) - logger.debug("Registering route for other custom event") - self.app.add_route(route_selector, route_handler, is_invoke=True, **kwargs) + def create_handler(handler: CustomRouteHandler) -> RouteHandler[StateT]: + async def route_handler(context: TurnContext, state: StateT): + custom_event_data = CustomEventData.from_context(context) + result = await handler(context, state, custom_event_data) + if not result: + result = CustomEventResult() + + # send an invoke response back to the caller + # invokes must send back an invoke response + response = Activity( + type=ActivityTypes.invoke_response, + value=InvokeResponse(status=200, body=result), + ) + await context.send_activity(response) + return route_handler + + def __call(func: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: + logger.debug("Registering route for custom event") + handler = create_handler(func) + self.app.add_route(route_selector, handler, is_invoke=True, **kwargs) + return handler + + return __call \ No newline at end of file diff --git a/test_samples/extensions/extension-starter/src/sample/extension_agent.py b/test_samples/extensions/extension-starter/src/sample/extension_agent.py index f7f04831..fdda12a3 100644 --- a/test_samples/extensions/extension-starter/src/sample/extension_agent.py +++ b/test_samples/extensions/extension-starter/src/sample/extension_agent.py @@ -1,4 +1,4 @@ -from microsoft_agents.hosting.core import TurnContext, TurnState +from microsoft_agents.hosting.core import TurnContext, TurnState, RouteRank from src.extension import ( ExtensionAgent, @@ -11,7 +11,7 @@ EXT = ExtensionAgent[TurnState](APP) -@EXT.on_message_has_hello_event +@EXT.on_agentic_message_has_hello_event(route_rank=RouteRank.FIRST) async def message_has_hello_event( context: TurnContext, state: TurnState, data: CustomEventData ): @@ -20,7 +20,7 @@ async def message_has_hello_event( ) -@EXT.on_message_has_hello_event +@EXT.on_message_has_hello_event(route_rank=RouteRank.FIRST) async def message_has_hello_event( context: TurnContext, state: TurnState, data: CustomEventData ): @@ -28,8 +28,8 @@ async def message_has_hello_event( f"Hello event detected! User ID: {data.user_id}, Field: {data.field}" ) - -@EXT.on_invoke_custom_event +# specifying the event type via the enum +@EXT.on_invoke_custom_event(CustomEventTypes.CUSTOM_EVENT) async def invoke_custom_event( context: TurnContext, state: TurnState, data: CustomEventData ) -> CustomEventResult: @@ -38,9 +38,12 @@ async def invoke_custom_event( ) return CustomEventResult(user_id=data.user_id, field=data.field) - -@EXT.on_invoke_other_custom_event -async def invoke_other_custom_event(context: TurnContext, state: TurnState): +# specifying the event type via a string +@EXT.on_invoke_custom_event("otherCustomEvent") +async def invoke_custom_event( + context: TurnContext, state: TurnState, data: CustomEventData +) -> CustomEventResult: await context.send_activity( f"Custom event triggered {context.activity.type}/{context.activity.name}" ) + return CustomEventResult(user_id=data.user_id, field=data.field) \ No newline at end of file From c4a29898b1ec34c6e69c5bf5973e48d0c11710d2 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 6 Oct 2025 14:30:50 -0700 Subject: [PATCH 57/67] Finalized extension sample --- .../extensions/extension-starter/README.md | 9 ++++++ .../src/extension/__init__.py | 9 ++++-- .../src/extension/extension.py | 28 ++++++++++++------- .../extension-starter/src/extension/models.py | 10 +++++-- .../src/sample/extension_agent.py | 13 +++++---- 5 files changed, 49 insertions(+), 20 deletions(-) diff --git a/test_samples/extensions/extension-starter/README.md b/test_samples/extensions/extension-starter/README.md index e69de29b..14e29ed2 100644 --- a/test_samples/extensions/extension-starter/README.md +++ b/test_samples/extensions/extension-starter/README.md @@ -0,0 +1,9 @@ +## Extension Starter Sample + +This is just a simple example of how to extend the Agents 365 SDK for Python. This is not meant to be a full outline of how to organize a repo for extension development. + +From this directory, run the sample running the dummy extension code with: + +```bash +python -m src.sample.main +``` \ No newline at end of file diff --git a/test_samples/extensions/extension-starter/src/extension/__init__.py b/test_samples/extensions/extension-starter/src/extension/__init__.py index ea28b37b..cb21c2dd 100644 --- a/test_samples/extensions/extension-starter/src/extension/__init__.py +++ b/test_samples/extensions/extension-starter/src/extension/__init__.py @@ -1,4 +1,9 @@ from .extension import ExtensionAgent -from .models import CustomEventData, CustomEventResult +from .models import CustomEventData, CustomEventResult, CustomEventTypes -__all__ = ["ExtensionAgent", "CustomEventData", "CustomEventResult"] +__all__ = [ + "ExtensionAgent", + "CustomEventData", + "CustomEventResult", + "CustomEventTypes", +] diff --git a/test_samples/extensions/extension-starter/src/extension/extension.py b/test_samples/extensions/extension-starter/src/extension/extension.py index 4b939312..0c0cf7b0 100644 --- a/test_samples/extensions/extension-starter/src/extension/extension.py +++ b/test_samples/extensions/extension-starter/src/extension/extension.py @@ -1,5 +1,5 @@ import logging -from typing import Awaitable, Callable, Generic, TypeVar +from typing import Callable, Generic, TypeVar from microsoft_agents.activity import Activity, ActivityTypes, InvokeResponse from microsoft_agents.hosting.core import ( @@ -12,7 +12,6 @@ from .models import ( CustomEventData, CustomEventResult, - CustomEventTypes, CustomRouteHandler, ) @@ -20,9 +19,9 @@ MY_CHANNEL = "mychannel" - StateT = TypeVar("StateT", bound=TurnState) + # This extension defines event decorators with custom selecting/handling logic: class ExtensionAgent(Generic[StateT]): app: AgentApplication[StateT] @@ -33,7 +32,8 @@ def __init__(self, app: AgentApplication[StateT]): # Allowing event decorators to accept **kwargs and passing # **kwargs to app.add_route is recommended. def _on_message_has_hello_event( - self, **kwargs, + self, + **kwargs, ) -> Callable[[CustomRouteHandler[StateT]], RouteHandler[StateT]]: def route_selector(context: TurnContext) -> bool: return ( @@ -45,6 +45,7 @@ def create_handler(handler: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: async def route_handler(context: TurnContext, state: StateT): custom_event_data = CustomEventData.from_context(context) await handler(context, state, custom_event_data) + return route_handler def __call(func: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: @@ -56,20 +57,24 @@ def __call(func: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: return __call def on_message_has_hello_event( - self, **kwargs, + self, + **kwargs, ) -> Callable[[CustomRouteHandler[StateT]], RouteHandler[StateT]]: return self._on_message_has_hello_event(is_agentic=False, **kwargs) def on_agentic_message_has_hello_event( - self, **kwargs, + self, + **kwargs, ) -> Callable[[CustomRouteHandler[StateT]], RouteHandler[StateT]]: return self._on_message_has_hello_event(is_agentic=True, **kwargs) # events that are handled with custom payloads - def on_invoke_custom_event(self, custom_event_type: str, **kwargs) -> Callable[[CustomRouteHandler[StateT]], RouteHandler[StateT]]: + def on_invoke_custom_event( + self, custom_event_type: str, **kwargs + ) -> Callable[[CustomRouteHandler[StateT]], RouteHandler[StateT]]: def route_selector(context: TurnContext) -> bool: return ( - context.activity.type == ActivityTypes.message + context.activity.type == ActivityTypes.invoke and context.activity.channel_id == MY_CHANNEL and context.activity.name == f"invoke/{custom_event_type}" ) @@ -85,9 +90,12 @@ async def route_handler(context: TurnContext, state: StateT): # invokes must send back an invoke response response = Activity( type=ActivityTypes.invoke_response, - value=InvokeResponse(status=200, body=result), + value=InvokeResponse( + status=200, body=result.model_dump(mode="json") + ), ) await context.send_activity(response) + return route_handler def __call(func: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: @@ -96,4 +104,4 @@ def __call(func: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: self.app.add_route(route_selector, handler, is_invoke=True, **kwargs) return handler - return __call \ No newline at end of file + return __call diff --git a/test_samples/extensions/extension-starter/src/extension/models.py b/test_samples/extensions/extension-starter/src/extension/models.py index 233a09ee..675e6aa5 100644 --- a/test_samples/extensions/extension-starter/src/extension/models.py +++ b/test_samples/extensions/extension-starter/src/extension/models.py @@ -11,8 +11,8 @@ class CustomRouteHandler(Protocol[StateT]): async def __call__( - self, context: TurnContext, state: StateT, event_data: CustomEventData - ) -> CustomEventResult: ... + self, context: TurnContext, state: StateT, data: CustomEventData + ) -> Optional[CustomEventResult]: ... class CustomEventTypes(StrEnum): @@ -28,7 +28,11 @@ class CustomEventData(AgentsModel): def from_context(context) -> CustomEventData: return CustomEventData( user_id=context.activity.from_property.id, - field=context.activity.channel_data.get("field"), + field=( + context.activity.channel_data.get("field") + if context.activity.channel_data + else None + ), ) diff --git a/test_samples/extensions/extension-starter/src/sample/extension_agent.py b/test_samples/extensions/extension-starter/src/sample/extension_agent.py index fdda12a3..f924042e 100644 --- a/test_samples/extensions/extension-starter/src/sample/extension_agent.py +++ b/test_samples/extensions/extension-starter/src/sample/extension_agent.py @@ -4,6 +4,7 @@ ExtensionAgent, CustomEventData, CustomEventResult, + CustomEventTypes, ) from .app import APP @@ -11,8 +12,8 @@ EXT = ExtensionAgent[TurnState](APP) -@EXT.on_agentic_message_has_hello_event(route_rank=RouteRank.FIRST) -async def message_has_hello_event( +@EXT.on_agentic_message_has_hello_event(rank=RouteRank.FIRST) +async def agentic_message_has_hello_event( context: TurnContext, state: TurnState, data: CustomEventData ): await context.send_activity( @@ -20,7 +21,7 @@ async def message_has_hello_event( ) -@EXT.on_message_has_hello_event(route_rank=RouteRank.FIRST) +@EXT.on_message_has_hello_event(rank=RouteRank.FIRST) async def message_has_hello_event( context: TurnContext, state: TurnState, data: CustomEventData ): @@ -28,6 +29,7 @@ async def message_has_hello_event( f"Hello event detected! User ID: {data.user_id}, Field: {data.field}" ) + # specifying the event type via the enum @EXT.on_invoke_custom_event(CustomEventTypes.CUSTOM_EVENT) async def invoke_custom_event( @@ -38,12 +40,13 @@ async def invoke_custom_event( ) return CustomEventResult(user_id=data.user_id, field=data.field) + # specifying the event type via a string @EXT.on_invoke_custom_event("otherCustomEvent") -async def invoke_custom_event( +async def invoke_other_custom_event( context: TurnContext, state: TurnState, data: CustomEventData ) -> CustomEventResult: await context.send_activity( f"Custom event triggered {context.activity.type}/{context.activity.name}" ) - return CustomEventResult(user_id=data.user_id, field=data.field) \ No newline at end of file + return CustomEventResult(user_id=data.user_id, field=data.field) From c84871d85d4391f0d65a1ec62470b08a4ea12ac3 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 6 Oct 2025 14:41:02 -0700 Subject: [PATCH 58/67] Fixed RouteList usage in AgentApplication --- .../hosting/core/app/agent_application.py | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 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 f6145f55..8a853ab8 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 @@ -310,9 +310,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message handler for route handler {func.__name__} with select: {select} with auth handlers: {auth_handlers}" ) - self.add_route( - _Route[StateT](__selector, func, auth_handlers=auth_handlers, **kwargs) - ) + self.add_route(__selector, func, auth_handlers=auth_handlers, **kwargs) return func return __call @@ -365,9 +363,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering conversation update handler for route handler {func.__name__} with type: {type} with auth handlers: {auth_handlers}" ) - self.add_route( - _Route[StateT](__selector, func, auth_handlers=auth_handlers, **kwargs) - ) + self.add_route(__selector, func, auth_handlers=auth_handlers, **kwargs) return func return __call @@ -415,9 +411,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message reaction handler for route handler {func.__name__} with type: {type} with auth handlers: {auth_handlers}" ) - self.add_route( - _Route[StateT](__selector, func, auth_handlers=auth_handlers, **kwargs) - ) + self.add_route(__selector, func, auth_handlers=auth_handlers, **kwargs) return func return __call @@ -478,9 +472,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug( f"Registering message update handler for route handler {func.__name__} with type: {type} with auth handlers: {auth_handlers}" ) - self.add_route( - _Route[StateT](__selector, func, auth_handlers=auth_handlers), **kwargs - ) + self.add_route(__selector, func, auth_handlers=auth_handlers, **kwargs) return func return __call @@ -528,9 +520,7 @@ async def __handler(context: TurnContext, state: StateT): f"Registering handoff handler for route handler {func.__name__} with auth handlers: {auth_handlers}" ) - self.add_route( - _Route[StateT](__selector, __handler, True, auth_handlers), **kwargs - ) + self.add_route(__selector, func, auth_handlers=auth_handlers, **kwargs) return func return __call From 2df8da963dad59321fe161d95d9117dc68d67da2 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 6 Oct 2025 14:48:44 -0700 Subject: [PATCH 59/67] Docstrings to code --- .../src/extension/extension.py | 44 +++++++++++++++++++ .../extension-starter/src/extension/models.py | 11 +++++ 2 files changed, 55 insertions(+) diff --git a/test_samples/extensions/extension-starter/src/extension/extension.py b/test_samples/extensions/extension-starter/src/extension/extension.py index 0c0cf7b0..98d7c61d 100644 --- a/test_samples/extensions/extension-starter/src/extension/extension.py +++ b/test_samples/extensions/extension-starter/src/extension/extension.py @@ -24,9 +24,15 @@ # This extension defines event decorators with custom selecting/handling logic: class ExtensionAgent(Generic[StateT]): + """An extension agent that provides custom event decorators.""" + app: AgentApplication[StateT] def __init__(self, app: AgentApplication[StateT]): + """Initialize the ExtensionAgent with an AgentApplication. + + :param app: The AgentApplication instance to extend. + """ self.app = app # Allowing event decorators to accept **kwargs and passing @@ -35,12 +41,28 @@ def _on_message_has_hello_event( self, **kwargs, ) -> Callable[[CustomRouteHandler[StateT]], RouteHandler[StateT]]: + """Decorator for message activities that contain the word 'hello'. + + This demonstrates a custom event selector and handler that extracts + additional data from the activity and passes it to the handler. + + :param kwargs: Additional keyword arguments to pass to app.add_route. + :return: A decorator that registers the route handler. + + Usage: + @extension_agent.on_message_has_hello_event() + async def handle_hello_event(context: TurnContext, state: StateT, event_data: CustomEventData): + await context.send_activity(f"Hello! You said: {event_data.text}") + """ + + # the function the AgentApplication uses to determine if this route should be called def route_selector(context: TurnContext) -> bool: return ( context.activity.type == ActivityTypes.message and "hello" in context.activity.text.lower() ) + # the function that wraps the user's handler to extract custom data def create_handler(handler: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: async def route_handler(context: TurnContext, state: StateT): custom_event_data = CustomEventData.from_context(context) @@ -48,6 +70,7 @@ async def route_handler(context: TurnContext, state: StateT): return route_handler + # the decorator that registers the route handler with the app def __call(func: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug("Registering route for message has hello event") handler = create_handler(func) @@ -60,18 +83,36 @@ def on_message_has_hello_event( self, **kwargs, ) -> Callable[[CustomRouteHandler[StateT]], RouteHandler[StateT]]: + """Decorator for non-agentic message activities that contain the word 'hello'.""" return self._on_message_has_hello_event(is_agentic=False, **kwargs) def on_agentic_message_has_hello_event( self, **kwargs, ) -> Callable[[CustomRouteHandler[StateT]], RouteHandler[StateT]]: + """Decorator for agentic message activities that contain the word 'hello'.""" return self._on_message_has_hello_event(is_agentic=True, **kwargs) # events that are handled with custom payloads def on_invoke_custom_event( self, custom_event_type: str, **kwargs ) -> Callable[[CustomRouteHandler[StateT]], RouteHandler[StateT]]: + """Decorator for invoke activities with a specific custom event type. + + This demonstrates a custom event selector and handler that extracts + additional data from the activity and passes it to the handler. + + :param custom_event_type: The custom event type to listen for. + :param kwargs: Additional keyword arguments to pass to app.add_route. + :return: A decorator that registers the route handler. + Usage: + @extension_agent.on_invoke_custom_event("my_custom_event") + async def handle_custom_event(context: TurnContext, state: StateT, event_data: CustomEventData): + await context.send_activity(f"Received custom event with data: {event_data.data}") + return CustomEventResult(success=True) + """ + + # the function the AgentApplication uses to determine if this route should be called def route_selector(context: TurnContext) -> bool: return ( context.activity.type == ActivityTypes.invoke @@ -79,6 +120,8 @@ def route_selector(context: TurnContext) -> bool: and context.activity.name == f"invoke/{custom_event_type}" ) + # the function that wraps the user's handler to extract custom data + # it also sends back an invoke response with the handler's result def create_handler(handler: CustomRouteHandler) -> RouteHandler[StateT]: async def route_handler(context: TurnContext, state: StateT): custom_event_data = CustomEventData.from_context(context) @@ -98,6 +141,7 @@ async def route_handler(context: TurnContext, state: StateT): return route_handler + # the decorator that registers the route handler with the app def __call(func: CustomRouteHandler[StateT]) -> RouteHandler[StateT]: logger.debug("Registering route for custom event") handler = create_handler(func) diff --git a/test_samples/extensions/extension-starter/src/extension/models.py b/test_samples/extensions/extension-starter/src/extension/models.py index 675e6aa5..4b6b2a50 100644 --- a/test_samples/extensions/extension-starter/src/extension/models.py +++ b/test_samples/extensions/extension-starter/src/extension/models.py @@ -9,18 +9,27 @@ StateT = TypeVar("StateT", bound=TurnState) +# this is defined as a class to allow for robust generic typing class CustomRouteHandler(Protocol[StateT]): + """A protocol for route handlers that accept custom event data.""" + async def __call__( self, context: TurnContext, state: StateT, data: CustomEventData ) -> Optional[CustomEventResult]: ... class CustomEventTypes(StrEnum): + """Custom event types used in the extension.""" + CUSTOM_EVENT = "customEvent" OTHER_CUSTOM_EVENT = "otherCustomEvent" +# inheriting from AgentsModel allows for easy serialization/deserialization +# using Pydantic features class CustomEventData(AgentsModel): + """Dummy data extracted from the activity for custom events.""" + user_id: Optional[str] = None field: Optional[str] = None @@ -37,5 +46,7 @@ def from_context(context) -> CustomEventData: class CustomEventResult(AgentsModel): + """Dummy result returned by custom event handlers.""" + user_id: Optional[str] = None field: Optional[str] = None From 5b1de272697da21c6f9d39f852c7ea68c5a64d59 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 6 Oct 2025 15:19:29 -0700 Subject: [PATCH 60/67] Added argument validation to add_route --- .../microsoft_agents/hosting/core/app/agent_application.py | 7 +++++++ 1 file changed, 7 insertions(+) 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 8a853ab8..a3a4fa3f 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 @@ -231,6 +231,13 @@ def add_route( :type auth_handlers: Optional[list[str]], optional :raises ApplicationError: If the selector or handler are not valid. """ + if not selector or not handler: + logger.error( + "AgentApplication.add_route(): selector and handler are required.", + stack_info=True, + ) + raise ApplicationError("selector and handler are required.") + if is_agentic: selector = _agentic_selector(selector) route = _Route[StateT]( From be72c69fcd13ea041f6f6abd043e54fbb592ee1b Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 7 Oct 2025 08:00:32 -0700 Subject: [PATCH 61/67] Fixing RouteRank bounds, tests, and typos --- .../hosting/core/app/_routes/__init__.py | 5 +++++ .../hosting/core/app/_routes/_route.py | 10 +++++++--- .../hosting/core/app/_routes/route_rank.py | 12 ++++++++---- .../hosting/core/app/oauth/__init__.py | 5 +++++ .../hosting/core/app/oauth/_handlers/__init__.py | 5 +++++ .../app/oauth/_handlers/_authorization_handler.py | 5 +++++ .../core/app/oauth/_handlers/_user_authorization.py | 6 ++++-- .../oauth/_handlers/agentic_user_authorization.py | 5 +++++ .../hosting/core/app/oauth/_sign_in_response.py | 5 +++++ .../hosting/core/app/oauth/_sign_in_state.py | 5 +++++ .../hosting/core/app/oauth/auth_handler.py | 6 ++++-- .../hosting/core/app/oauth/authorization.py | 5 +++++ test_samples/extensions/extension-starter/README.md | 8 +++++--- tests/hosting_core/app/_routes/test_route.py | 10 +++++----- 14 files changed, 73 insertions(+), 19 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/__init__.py index 26e46712..890960db 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/__init__.py @@ -1,3 +1,8 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + from ._route_list import _RouteList from ._route import _Route, _agentic_selector from .route_rank import RouteRank diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py index 635df65a..44312b38 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py @@ -41,6 +41,10 @@ def __init__( is_agentic: bool = False, **kwargs, ) -> None: + + if rank < 0 or rank > RouteRank.LAST: + raise ValueError("Route rank must be between 0 and RouteRank.LAST (inclusive)") + self.selector = selector self.handler = handler self._is_invoke = is_invoke @@ -61,14 +65,14 @@ def is_agentic(self) -> bool: return self._is_agentic @property - def ordering(self) -> list[int]: + def priority(self) -> list[int]: """Lower "values" indicate higher priority.""" return [ - 0 if self._is_agentic else 1, 0 if self._is_invoke else 1, + 0 if self._is_agentic else 1, self._rank.value, ] def __lt__(self, other: _Route) -> bool: # built-in list ordering is a lexicographic comparison in Python - return self.ordering < other.ordering + return self.priority < other.priority diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_rank.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_rank.py index 1c948b91..9778c352 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_rank.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_rank.py @@ -1,11 +1,15 @@ -from enum import IntEnum +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" -MAX_RANK = 2**32 - 1 +from enum import IntEnum +_MAX_RANK = 2**16 - 1 # 65,535 class RouteRank(IntEnum): """Defines the rank of a route. Lower values indicate higher priority.""" FIRST = 0 - DEFAULT = MAX_RANK // 2 - LAST = MAX_RANK + DEFAULT = _MAX_RANK // 2 + LAST = _MAX_RANK 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 7fe3948d..c2ac1da0 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 @@ -1,3 +1,8 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + from .authorization import Authorization from .auth_handler import AuthHandler from ._sign_in_state import _SignInState 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 05cf6dba..6757119c 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,3 +1,8 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + from .agentic_user_authorization import AgenticUserAuthorization from ._user_authorization import _UserAuthorization from ._authorization_handler import _AuthorizationHandler 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 eba18b5d..66894d76 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 @@ -1,3 +1,8 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + from abc import ABC from typing import Optional import logging 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 1083d240..118bad53 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 @@ -1,5 +1,7 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" from __future__ import annotations import logging 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 133d8145..847aedba 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 @@ -1,3 +1,8 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + import logging from typing import Optional 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 4c2968da..5451ce27 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 @@ -1,3 +1,8 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + from typing import Optional from microsoft_agents.activity import TokenResponse 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 9ade2c80..ddd22d90 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 @@ -1,3 +1,8 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + from __future__ import annotations from typing import Optional 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 8e298107..4d11b212 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 @@ -1,5 +1,7 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" import logging from typing import Optional 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 e105ccf4..f5bd8f56 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 @@ -1,3 +1,8 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + from datetime import datetime import logging from typing import TypeVar, Optional, Callable, Awaitable, Generic, cast diff --git a/test_samples/extensions/extension-starter/README.md b/test_samples/extensions/extension-starter/README.md index 14e29ed2..0336837d 100644 --- a/test_samples/extensions/extension-starter/README.md +++ b/test_samples/extensions/extension-starter/README.md @@ -1,9 +1,11 @@ ## Extension Starter Sample -This is just a simple example of how to extend the Agents 365 SDK for Python. This is not meant to be a full outline of how to organize a repo for extension development. +This is a simple example that extends the Agents 365 SDK for Python. This is not meant to be a full outline of how to organize a repo for extension development. -From this directory, run the sample running the dummy extension code with: +From this directory, you may run a sample that uses the extension capabilities with: ```bash python -m src.sample.main -``` \ No newline at end of file +``` + +To interact with this sample, we recommend the [Microsoft 365 Agents Playground](https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/test-with-toolkit-project?tabs=windows). This well let you mock up the invoke activities, the dummy channel data, and so on. \ No newline at end of file diff --git a/tests/hosting_core/app/_routes/test_route.py b/tests/hosting_core/app/_routes/test_route.py index 68546fa6..d8c16d67 100644 --- a/tests/hosting_core/app/_routes/test_route.py +++ b/tests/hosting_core/app/_routes/test_route.py @@ -50,7 +50,7 @@ def test_init_defaults(self): assert route.rank == RouteRank.DEFAULT assert route.auth_handlers == [] - def test_ordering(self): + def test_priority(self): route_a = _Route( selector=selector, @@ -76,9 +76,9 @@ def test_ordering(self): is_agentic=False, ) - assert route_a.ordering == [1, 0, RouteRank.FIRST] - assert route_b.ordering == [0, 1, RouteRank.LAST] - assert route_c.ordering == [1, 1, RouteRank.DEFAULT] + assert route_a.priority == [0, 1, RouteRank.FIRST] + assert route_b.priority == [1, 0, RouteRank.LAST] + assert route_c.priority == [1, 1, RouteRank.DEFAULT] @pytest.fixture(params=[None, [], ["authA1", "authA2"], ["github"]]) def auth_handlers_a(self, request): @@ -89,7 +89,7 @@ def auth_handlers_b(self, request): return request.param @pytest.mark.parametrize( - "is_invoke_a, rank_a, is_agentic_a, is_invoke_b, rank_b, is_agentic_b, expected_result", + "is_agentic_a, rank_a, is_invoke_a, is_agentic_b, rank_b, is_invoke_b, expected_result", [ # Same agentic status (both False) [ From 9e7fdfa791ff0cdc7bdd5a32d5fec49c30b32326 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 7 Oct 2025 08:17:13 -0700 Subject: [PATCH 62/67] Adding copyright comment --- .../microsoft_agents/hosting/core/_oauth/__init__.py | 3 +++ .../microsoft_agents/hosting/core/app/_routes/__init__.py | 6 ++---- .../microsoft_agents/hosting/core/app/_routes/_route.py | 6 ++---- .../hosting/core/app/_routes/_route_list.py | 6 ++---- .../microsoft_agents/hosting/core/app/_routes/route_rank.py | 6 ++---- 5 files changed, 11 insertions(+), 16 deletions(-) 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 c9b319e6..de88c8b4 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,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from ._flow_state import _FlowState, _FlowStateTag, _FlowErrorTag from ._flow_storage_client import _FlowStorageClient from ._oauth_flow import _OAuthFlow, _FlowResponse diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/__init__.py index 890960db..1429cdfd 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/__init__.py @@ -1,7 +1,5 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. from ._route_list import _RouteList from ._route import _Route, _agentic_selector diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py index 44312b38..9d7b5e10 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py @@ -1,7 +1,5 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. from __future__ import annotations diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py index ff48269c..8b3ec990 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route_list.py @@ -1,7 +1,5 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. from __future__ import annotations diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_rank.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_rank.py index 9778c352..297f6a32 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_rank.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_rank.py @@ -1,7 +1,5 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. from enum import IntEnum From 5cdc2b771cb4c5d95974b796c24c4ad7923fe4ab Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 7 Oct 2025 08:21:18 -0700 Subject: [PATCH 63/67] Adding copyright header to hosting.core modules --- .../microsoft_agents/authentication/msal/msal_auth.py | 3 +++ .../authentication/msal/msal_connection_manager.py | 3 +++ .../microsoft_agents/hosting/core/activity_handler.py | 1 + .../microsoft_agents/hosting/core/app/_type_defs.py | 4 +++- .../microsoft_agents/hosting/core/app/oauth/__init__.py | 7 +++---- .../hosting/core/app/oauth/_sign_in_response.py | 7 +++---- .../core/authorization/access_token_provider_base.py | 3 +++ .../hosting/core/authorization/agent_auth_configuration.py | 3 +++ .../hosting/core/authorization/anonymous_token_provider.py | 3 +++ .../hosting/core/authorization/auth_types.py | 3 +++ .../hosting/core/authorization/connections.py | 3 +++ .../hosting/core/authorization/jwt_token_validator.py | 3 +++ .../hosting/core/channel_api_handler_protocol.py | 3 +++ .../hosting/core/channel_service_client_factory_base.py | 3 +++ .../hosting/core/client/agent_conversation_reference.py | 3 +++ .../hosting/core/client/channel_factory_protocol.py | 3 +++ .../hosting/core/client/channel_host_protocol.py | 3 +++ .../hosting/core/client/channel_info_protocol.py | 3 +++ .../hosting/core/client/channel_protocol.py | 3 +++ .../hosting/core/client/channels_configuration.py | 3 +++ .../hosting/core/client/configuration_channel_host.py | 3 +++ .../hosting/core/client/conversation_constants.py | 3 +++ .../hosting/core/client/conversation_id_factory.py | 3 +++ .../hosting/core/client/conversation_id_factory_options.py | 3 +++ .../core/client/conversation_id_factory_protocol.py | 3 +++ .../hosting/core/client/http_agent_channel.py | 3 +++ .../hosting/core/client/http_agent_channel_factory.py | 3 +++ .../hosting/core/rest_channel_service_client_factory.py | 3 +++ .../hosting/core/storage/error_handling.py | 3 +++ .../hosting/core/storage/memory_storage.py | 3 +++ .../microsoft_agents/hosting/core/storage/storage.py | 3 +++ .../microsoft_agents/hosting/core/storage/store_item.py | 3 +++ 32 files changed, 94 insertions(+), 9 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 abeb718c..2f0d34d9 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,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from __future__ import annotations import logging 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 aea4163a..a16ee180 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,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import re from typing import Dict, List, Optional from microsoft_agents.hosting.core import ( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/activity_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/activity_handler.py index 13a641b0..483cb6e9 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/activity_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/activity_handler.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + from __future__ import annotations from http import HTTPStatus from pydantic import BaseModel diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py index 8a6e08ea..0501e26a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Callable, TypeVar, Awaitable, Protocol from ..turn_context import TurnContext @@ -7,6 +10,5 @@ StateT = TypeVar("StateT", bound=TurnState) - class RouteHandler(Protocol[StateT]): def __call__(self, context: TurnContext, state: StateT) -> Awaitable[None]: ... 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 c2ac1da0..2e12d9cc 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 @@ -1,7 +1,6 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from .authorization import Authorization from .auth_handler import AuthHandler 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 5451ce27..0c756697 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 @@ -1,7 +1,6 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Optional 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 e69647cd..d319c3fd 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,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Protocol, Optional from abc import abstractmethod 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 a6fee937..70049a6e 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 @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Optional from microsoft_agents.hosting.core.authorization.auth_types import AuthTypes 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 6ed36fcf..29c3de8c 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,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Optional from .access_token_provider_base import AccessTokenProviderBase diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/auth_types.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/auth_types.py index 07deff8a..58784ae8 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/auth_types.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/auth_types.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from enum import Enum diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections.py index b5026022..e11103e2 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from abc import abstractmethod from typing import Protocol 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 714199b5..399e101f 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 @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import logging import jwt diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_api_handler_protocol.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_api_handler_protocol.py index 364e679c..82ce7740 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_api_handler_protocol.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_api_handler_protocol.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from abc import abstractmethod from typing import Protocol, Optional diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_client_factory_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_client_factory_base.py index e325653c..faf46646 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_client_factory_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_client_factory_base.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Protocol, Optional from abc import abstractmethod diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/agent_conversation_reference.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/agent_conversation_reference.py index 5fc7c15d..4d135226 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/agent_conversation_reference.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/agent_conversation_reference.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from microsoft_agents.activity import AgentsModel, ConversationReference diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_factory_protocol.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_factory_protocol.py index 24e920a6..a55001cc 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_factory_protocol.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_factory_protocol.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Protocol from microsoft_agents.hosting.core.authorization import AccessTokenProviderBase diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_host_protocol.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_host_protocol.py index ed4940a9..a2990357 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_host_protocol.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_host_protocol.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Protocol from .channel_protocol import ChannelProtocol diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_info_protocol.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_info_protocol.py index 85ddc55e..185c24ef 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_info_protocol.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_info_protocol.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Protocol diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_protocol.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_protocol.py index 73f3189d..a5855af0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_protocol.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_protocol.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Protocol from microsoft_agents.activity import AgentsModel, Activity, InvokeResponse diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channels_configuration.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channels_configuration.py index e6153852..935611a9 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channels_configuration.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channels_configuration.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Protocol from .channel_info_protocol import ChannelInfoProtocol diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/configuration_channel_host.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/configuration_channel_host.py index 478da51a..0c48dbf1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/configuration_channel_host.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/configuration_channel_host.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from copy import copy from microsoft_agents.hosting.core.authorization import Connections diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/conversation_constants.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/conversation_constants.py index 162a0d11..1a741726 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/conversation_constants.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/conversation_constants.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from abc import ABC diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/conversation_id_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/conversation_id_factory.py index 7ba34de2..457f96c9 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/conversation_id_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/conversation_id_factory.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from uuid import uuid4 from functools import partial from typing import Type diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/conversation_id_factory_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/conversation_id_factory_options.py index 034977e7..88357a9f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/conversation_id_factory_options.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/conversation_id_factory_options.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from microsoft_agents.activity import Activity from .channel_info_protocol import ChannelInfoProtocol diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/conversation_id_factory_protocol.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/conversation_id_factory_protocol.py index 845e155a..d4dcaa71 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/conversation_id_factory_protocol.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/conversation_id_factory_protocol.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Protocol from abc import abstractmethod diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/http_agent_channel.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/http_agent_channel.py index cd4fac10..ecc1cbbe 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/http_agent_channel.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/http_agent_channel.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from copy import deepcopy, copy from aiohttp import ClientSession diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/http_agent_channel_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/http_agent_channel_factory.py index d331b0ad..c9187477 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/http_agent_channel_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/http_agent_channel_factory.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from microsoft_agents.hosting.core.authorization import AccessTokenProviderBase from .channel_factory_protocol import ChannelFactoryProtocol 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 10aecc70..9e639480 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,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Optional import logging diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/error_handling.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/error_handling.py index 40a62d75..9f5cb520 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/error_handling.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/error_handling.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from collections.abc import Callable, Awaitable from typing import TypeVar diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py index 320eed37..5f04c631 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from threading import Lock from typing import TypeVar diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py index 4a71d939..6fd56037 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Protocol, TypeVar, Type, Union from abc import ABC, abstractmethod from asyncio import gather diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/store_item.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/store_item.py index a2f7b13d..7a334905 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/store_item.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/store_item.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from abc import ABC from typing import Protocol, runtime_checkable From 8892222ce644075ac5101167c6bd862ce33cda93 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 7 Oct 2025 08:31:37 -0700 Subject: [PATCH 64/67] Removing strenum dependency --- .../hosting/core/app/_routes/_route.py | 29 ++++++++--- .../hosting/core/app/_routes/route_rank.py | 1 + .../hosting/core/app/_type_defs.py | 1 + .../microsoft-agents-hosting-core/setup.py | 1 - tests/hosting_core/app/_routes/test_route.py | 28 ++++++++++ .../storage/test_transcript_store_memory.py | 51 +++++++------------ 6 files changed, 68 insertions(+), 43 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py index 9d7b5e10..dfdd9719 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/_route.py @@ -25,7 +25,7 @@ class _Route(Generic[StateT]): selector: RouteSelector handler: RouteHandler[StateT] _is_invoke: bool - _rank: RouteRank + _rank: int auth_handlers: list[str] _is_agentic: bool @@ -34,19 +34,21 @@ def __init__( selector: RouteSelector, handler: RouteHandler[StateT], is_invoke: bool = False, - rank: RouteRank = RouteRank.DEFAULT, + rank: int = RouteRank.DEFAULT, auth_handlers: Optional[list[str]] = None, is_agentic: bool = False, **kwargs, ) -> None: - + if rank < 0 or rank > RouteRank.LAST: - raise ValueError("Route rank must be between 0 and RouteRank.LAST (inclusive)") + raise ValueError( + "Route rank must be between 0 and RouteRank.LAST (inclusive)" + ) self.selector = selector self.handler = handler self._is_invoke = is_invoke - self._rank = rank + self._rank = int(rank) # conversion from RouteRank IntEnum if necessary self._is_agentic = is_agentic self.auth_handlers = auth_handlers or [] @@ -55,7 +57,7 @@ def is_invoke(self) -> bool: return self._is_invoke @property - def rank(self) -> RouteRank: + def rank(self) -> int: return self._rank @property @@ -64,11 +66,22 @@ def is_agentic(self) -> bool: @property def priority(self) -> list[int]: - """Lower "values" indicate higher priority.""" + """Lower "values" indicate higher priority. + + Priority is determined by: + 1. Whether the route is for an invoke activity (0) or not (1). + 2. Whether the route is agentic (0) or not (1). + 3. The rank of the route (lower numbers indicate higher priority). + + In that order. If both are invokes, the agentic one has higher priority. + If both are agentic and invokes, then the rank determines priority. + + priority is represented as a list of three integers for easy lexicographic comparison. + """ return [ 0 if self._is_invoke else 1, 0 if self._is_agentic else 1, - self._rank.value, + self._rank, ] def __lt__(self, other: _Route) -> bool: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_rank.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_rank.py index 297f6a32..48c43880 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_rank.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_routes/route_rank.py @@ -5,6 +5,7 @@ _MAX_RANK = 2**16 - 1 # 65,535 + class RouteRank(IntEnum): """Defines the rank of a route. Lower values indicate higher priority.""" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py index 0501e26a..f5ceb61a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/_type_defs.py @@ -10,5 +10,6 @@ StateT = TypeVar("StateT", bound=TurnState) + class RouteHandler(Protocol[StateT]): def __call__(self, context: TurnContext, state: StateT) -> Awaitable[None]: ... diff --git a/libraries/microsoft-agents-hosting-core/setup.py b/libraries/microsoft-agents-hosting-core/setup.py index 536a1425..e7604116 100644 --- a/libraries/microsoft-agents-hosting-core/setup.py +++ b/libraries/microsoft-agents-hosting-core/setup.py @@ -11,6 +11,5 @@ "isodate>=0.6.1", "azure-core>=1.30.0", "python-dotenv>=1.1.1", - "strenum", ], ) diff --git a/tests/hosting_core/app/_routes/test_route.py b/tests/hosting_core/app/_routes/test_route.py index d8c16d67..1473b135 100644 --- a/tests/hosting_core/app/_routes/test_route.py +++ b/tests/hosting_core/app/_routes/test_route.py @@ -50,6 +50,25 @@ def test_init_defaults(self): assert route.rank == RouteRank.DEFAULT assert route.auth_handlers == [] + @pytest.mark.parametrize( + "rank, is_error", + [ + [RouteRank.FIRST, False], + [RouteRank.LAST, False], + [RouteRank.DEFAULT, False], + [1, False], + [-1, True], + [RouteRank.LAST + 1, True], + ], + ) + def test_init_error(self, rank, is_error): + if is_error: + with pytest.raises(ValueError): + _Route(selector=selector, handler=handler, rank=rank) + else: + route = _Route(selector=selector, handler=handler, rank=rank) + assert route.rank == rank + def test_priority(self): route_a = _Route( @@ -75,10 +94,19 @@ def test_priority(self): auth_handlers=["auth2"], is_agentic=False, ) + route_d = _Route( + selector=selector, + handler=handler, + is_invoke=False, + rank=23, + auth_handlers=["auth2"], + is_agentic=False, + ) assert route_a.priority == [0, 1, RouteRank.FIRST] assert route_b.priority == [1, 0, RouteRank.LAST] assert route_c.priority == [1, 1, RouteRank.DEFAULT] + assert route_d.priority == [1, 1, 23] @pytest.fixture(params=[None, [], ["authA1", "authA2"], ["github"]]) def auth_handlers_a(self, request): diff --git a/tests/hosting_core/storage/test_transcript_store_memory.py b/tests/hosting_core/storage/test_transcript_store_memory.py index 2d3fb631..691c3e02 100644 --- a/tests/hosting_core/storage/test_transcript_store_memory.py +++ b/tests/hosting_core/storage/test_transcript_store_memory.py @@ -4,7 +4,8 @@ from datetime import datetime, timezone import pytest from microsoft_agents.hosting.core.storage.transcript_memory_store import ( - TranscriptMemoryStore, PagedResult + TranscriptMemoryStore, + PagedResult, ) from microsoft_agents.activity import Activity, ConversationAccount @@ -12,12 +13,11 @@ @pytest.mark.asyncio async def test_get_transcript_empty(): store = TranscriptMemoryStore() - pagedResult = await store.get_transcript_activities( - "Channel 1", "Conversation 1" - ) + pagedResult = await store.get_transcript_activities("Channel 1", "Conversation 1") assert pagedResult.items == [] assert pagedResult.continuation_token is None + @pytest.mark.asyncio async def test_log_activity_add_one_activity(): store = TranscriptMemoryStore() @@ -30,9 +30,7 @@ async def test_log_activity_add_one_activity(): await store.log_activity(activity) # Ask for the activity we just added - pagedResult = await store.get_transcript_activities( - "Channel 1", "Conversation 1" - ) + pagedResult = await store.get_transcript_activities("Channel 1", "Conversation 1") assert len(pagedResult.items) == 1 assert pagedResult.items[0].channel_id == activity.channel_id @@ -41,16 +39,12 @@ async def test_log_activity_add_one_activity(): assert pagedResult.continuation_token is None # Ask for a channel that doesn't exist and make sure we get nothing - pagedResult = await store.get_transcript_activities( - "Invalid", "Conversation 1" - ) + pagedResult = await store.get_transcript_activities("Invalid", "Conversation 1") assert pagedResult.items == [] assert pagedResult.continuation_token is None # Ask for a ConversationID that doesn't exist and make sure we get nothing - pagedResult = await store.get_transcript_activities( - "Channel 1", "INVALID" - ) + pagedResult = await store.get_transcript_activities("Channel 1", "INVALID") assert pagedResult.items == [] assert pagedResult.continuation_token is None @@ -72,9 +66,7 @@ async def test_log_activity_add_two_activity_same_conversation(): await store.log_activity(activity2) # Ask for the activity we just added - pagedResult = await store.get_transcript_activities( - "Channel 1", "Conversation 1" - ) + pagedResult = await store.get_transcript_activities("Channel 1", "Conversation 1") assert len(pagedResult.items) == 2 assert pagedResult.items[0].channel_id == activity1.channel_id @@ -87,6 +79,7 @@ async def test_log_activity_add_two_activity_same_conversation(): assert pagedResult.continuation_token is None + @pytest.mark.asyncio async def test_log_activity_add_three_activity_same_conversation(): store = TranscriptMemoryStore() @@ -153,9 +146,7 @@ async def test_log_activity_add_two_activity_two_conversation(): await store.log_activity(activity2) # Ask for the activity we just added - pagedResult = await store.get_transcript_activities( - "Channel 1", "Conversation 1" - ) + pagedResult = await store.get_transcript_activities("Channel 1", "Conversation 1") assert len(pagedResult.items) == 1 assert pagedResult.items[0].channel_id == activity1.channel_id @@ -164,9 +155,7 @@ async def test_log_activity_add_two_activity_two_conversation(): assert pagedResult.continuation_token is None # Now grab Conversation 2 - pagedResult = await store.get_transcript_activities( - "Channel 1", "Conversation 2" - ) + pagedResult = await store.get_transcript_activities("Channel 1", "Conversation 2") assert len(pagedResult.items) == 1 assert pagedResult.items[0].channel_id == activity2.channel_id @@ -174,6 +163,7 @@ async def test_log_activity_add_two_activity_two_conversation(): assert pagedResult.items[0].text == activity2.text assert pagedResult.continuation_token is None + @pytest.mark.asyncio async def test_delete_one_transcript(): store = TranscriptMemoryStore() @@ -186,17 +176,13 @@ async def test_delete_one_transcript(): await store.log_activity(activity) # Ask for the activity we just added - pagedResult = await store.get_transcript_activities( - "Channel 1", "Conversation 1" - ) + pagedResult = await store.get_transcript_activities("Channel 1", "Conversation 1") assert len(pagedResult.items) == 1 # Now delete the transcript await store.delete_transcript("Channel 1", "Conversation 1") - pagedResult = await store.get_transcript_activities( - "Channel 1", "Conversation 1" - ) + pagedResult = await store.get_transcript_activities("Channel 1", "Conversation 1") assert len(pagedResult.items) == 0 @@ -224,17 +210,14 @@ async def test_delete_one_transcript_of_two(): await store.delete_transcript("Channel 1", "Conversation 1") # Make sure the one we deleted is gone - pagedResult = await store.get_transcript_activities( - "Channel 1", "Conversation 1" - ) + pagedResult = await store.get_transcript_activities("Channel 1", "Conversation 1") assert len(pagedResult.items) == 0 # Make sure the other one is still there - pagedResult = await store.get_transcript_activities( - "Channel 2", "Conversation 1" - ) + pagedResult = await store.get_transcript_activities("Channel 2", "Conversation 1") assert len(pagedResult.items) == 1 + @pytest.mark.asyncio async def test_list_transcripts(): store = TranscriptMemoryStore() From abfb22983fa84c88d51e84fd1d7b5dd606bb5a76 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 7 Oct 2025 08:37:17 -0700 Subject: [PATCH 65/67] Removed debug printing --- .../hosting/core/connector/client/connector_client.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py index 8156ea58..a93eb1a8 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/connector_client.py @@ -197,9 +197,6 @@ async def reply_to_activity( ) raise ValueError("conversationId and activityId are required") - print("\n*3") - print(conversation_id) - print("\n*3") conversation_id = self._normalize_conversation_id(conversation_id) url = f"v3/conversations/{conversation_id}/activities/{activity_id}" From 62aca1707e70d6b7dbd079f105a62d147b3bf93e Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 7 Oct 2025 09:35:54 -0700 Subject: [PATCH 66/67] Removed todos --- .../microsoft_agents/activity/activity.py | 2 -- .../microsoft_agents/hosting/core/app/agent_application.py | 1 - test_samples/agentic-test/src/agent.py | 3 +-- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 78e8ac7d..a573e10a 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -429,7 +429,6 @@ def create_reply(self, text: str = None, locale: str = None): def create_trace( self, name: str, value: object = None, value_type: str = None, label: str = None ): - # robrandao: TODO -> needs to handle Nones like create_reply """ Creates a new trace activity based on this activity. @@ -476,7 +475,6 @@ def create_trace( def create_trace_activity( name: str, value: object = None, value_type: str = None, label: str = None ): - # robrandao: TODO -> SkipNone """ Creates an instance of the :class:`Activity` class as a TraceActivity object. 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 a3a4fa3f..12e93b8e 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 @@ -639,7 +639,6 @@ async def on_turn(self, context: TurnContext): await self._start_long_running_call(context, self._on_turn) async def _on_turn(self, context: TurnContext): - # robrandao: TODO try: if context.activity.type != ActivityTypes.typing: await self._start_typing(context) diff --git a/test_samples/agentic-test/src/agent.py b/test_samples/agentic-test/src/agent.py index efbff47f..9e843719 100644 --- a/test_samples/agentic-test/src/agent.py +++ b/test_samples/agentic-test/src/agent.py @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) -load_dotenv() # robrandao: todo +load_dotenv() agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() @@ -31,7 +31,6 @@ ADAPTER.use(TranscriptLoggerMiddleware(ConsoleTranscriptLogger())) AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) -# robrandao: downloader? AGENT_APP = AgentApplication[TurnState]( storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config ) From b1d038319770e692a7ec1e7debe79bfc03d583df Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 7 Oct 2025 09:53:39 -0700 Subject: [PATCH 67/67] Changing whitespace --- .../microsoft_agents/hosting/core/app/agent_application.py | 4 ++-- 1 file changed, 2 insertions(+), 2 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 12e93b8e..0184965d 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 @@ -233,13 +233,13 @@ def add_route( """ if not selector or not handler: logger.error( - "AgentApplication.add_route(): selector and handler are required.", - stack_info=True, + "AgentApplication.add_route(): selector and handler are required." ) raise ApplicationError("selector and handler are required.") if is_agentic: selector = _agentic_selector(selector) + route = _Route[StateT]( selector, handler, is_invoke, rank, auth_handlers, is_agentic )