diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py index 730b8754b..85286180d 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py @@ -70,6 +70,7 @@ from .thumbnail_url import ThumbnailUrl from .token_exchange_invoke_request import TokenExchangeInvokeRequest from .token_exchange_invoke_response import TokenExchangeInvokeResponse +from .token_exchange_request import TokenExchangeRequest from .token_exchange_state import TokenExchangeState from .token_or_sign_in_resource_response import TokenOrSignInResourceResponse from .token_request import TokenRequest @@ -170,6 +171,7 @@ "ThumbnailUrl", "TokenExchangeInvokeRequest", "TokenExchangeInvokeResponse", + "TokenExchangeRequest", "TokenExchangeState", "TokenRequest", "TokenResponse", diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py index 77b246bff..270399b63 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_id.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Optional, Any +from typing import Optional, Any, overload from pydantic_core import CoreSchema, core_schema from pydantic import GetCoreSchemaHandler @@ -117,6 +117,15 @@ def get_sub_channel(channel_id: str | ChannelId | None) -> str | None: sub = value.split(":", 1)[1].strip() if ":" in value else None return sub or None + @overload + @staticmethod + def get_channel(channel_id: ChannelId) -> str: ... + @overload + @staticmethod + def get_channel(channel_id: str) -> str: ... + @overload + @staticmethod + def get_channel(channel_id: None) -> None: ... @staticmethod def get_channel(channel_id: str | ChannelId | None) -> str | None: """Return the Bot Framework channel without an optional sub-channel.""" diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/token_exchange_request.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/token_exchange_request.py new file mode 100644 index 000000000..4d59756fa --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/token_exchange_request.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .agents_model import AgentsModel +from ._type_aliases import NonEmptyString + + +class TokenExchangeRequest(AgentsModel): + """TokenExchangeRequest. + + Either the token to exchange or the uri to exchange. + + :param uri: The URI for the exchange request. + :param token: The token to be exchanged. + """ + + uri: NonEmptyString | None = None + token: NonEmptyString | None = None 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 400b3286d..d886d40e1 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 @@ -12,7 +12,7 @@ from microsoft_agents.activity import ( Activity, ActivityTypes, - TokenExchangeState, + TokenExchangeRequest, TokenResponse, SignInResource, ) @@ -38,12 +38,6 @@ class _OAuthFlow: This class is responsible for managing the entire OAuth flow, including obtaining user tokens, signing out users, and handling token exchanges. - - Contract with other classes (usage of other classes is enforced in unit tests): - TurnContext.activity.channel_id - TurnContext.activity.from_property.id - - UserTokenClient: user_token.get_token(), user_token.sign_out() """ def __init__( @@ -125,13 +119,11 @@ async def get_user_token(self, magic_code: str | None = None) -> TokenResponse: self._user_id, self._abs_oauth_connection_name, ) - token_response: TokenResponse = ( - await self._user_token_client.user_token.get_token( - user_id=self._user_id, - connection_name=self._abs_oauth_connection_name, - channel_id=self._channel_id, - code=magic_code, - ) + token_response: TokenResponse = await self._user_token_client.get_user_token( + user_id=self._user_id, + connection_name=self._abs_oauth_connection_name, + channel_id=self._channel_id, + magic_code=magic_code, ) if token_response: logger.info("User token obtained successfully: %s", token_response) @@ -153,7 +145,7 @@ async def sign_out(self) -> None: self._user_id, self._abs_oauth_connection_name, ) - await self._user_token_client.user_token.sign_out( + await self._user_token_client.sign_out_user( user_id=self._user_id, connection_name=self._abs_oauth_connection_name, channel_id=self._channel_id, @@ -185,18 +177,9 @@ async def begin_flow(self, activity: Activity) -> _FlowResponse: logger.debug("Starting new OAuth flow") - token_exchange_state = TokenExchangeState( + res = await self._user_token_client.get_token_or_sign_in_resource( connection_name=self._abs_oauth_connection_name, - conversation=activity.get_conversation_reference(force_base_channel=True), - relates_to=activity.relates_to, - ms_app_id=self._ms_app_id, - ) - - res = await self._user_token_client.user_token._get_token_or_sign_in_resource( - activity.from_property.id, - self._abs_oauth_connection_name, - token_exchange_state.conversation.channel_id, - token_exchange_state.get_encoded_state(), + activity=activity, ) if res.token_response: @@ -251,16 +234,18 @@ async def _continue_from_invoke_token_exchange( """Handles the continuation of the flow from an invoke activity for token exchange.""" token_exchange_request = activity.value try: - token_response = await self._user_token_client.user_token.exchange_token( + token_response = await self._user_token_client.exchange_token( user_id=self._user_id, connection_name=self._abs_oauth_connection_name, channel_id=self._channel_id, - body=token_exchange_request, + exchange_request=TokenExchangeRequest.model_validate( + token_exchange_request + ), ) return token_response, _FlowErrorTag.NONE except Exception as e: # A 400 with 'ConsentRequired' means the user hasn't consented yet. - # Return None so the caller can send a 412 back to Teams, which will + # Return TokenResponse() so the caller can send a 412 back to Teams, which will # prompt the user for consent and retry the token exchange. # Any other error is a critical failure and should propagate. if getattr(e, "status", None) == 400 and "Consent Required" in getattr( @@ -271,7 +256,7 @@ async def _continue_from_invoke_token_exchange( self._user_id, ) - return None, _FlowErrorTag.PRECONDITION_FAILED + return TokenResponse(), _FlowErrorTag.PRECONDITION_FAILED raise async def continue_flow(self, activity: Activity) -> _FlowResponse: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/agent_sign_in.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/agent_sign_in.py new file mode 100644 index 000000000..ee1ddefea --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/agent_sign_in.py @@ -0,0 +1,95 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging +from aiohttp import ClientSession + +from microsoft_agents.activity import SignInResource +from ..telemetry import user_token_client_spans as spans +from ..agent_sign_in_base import AgentSignInBase + +logger = logging.getLogger(__name__) + + +class AgentSignIn(AgentSignInBase): + """Implementation of agent sign-in operations.""" + + def __init__(self, client: ClientSession): + self.client = client + + async def get_sign_in_url( + self, + state: str, + code_challenge: str | None = None, + emulator_url: str | None = None, + final_redirect: str | None = None, + ) -> str: + """ + Get sign-in URL. + + :param state: State parameter for OAuth flow. + :param code_challenge: Code challenge for PKCE. + :param emulator_url: Emulator URL if used. + :param final_redirect: Final redirect URL. + :return: The sign-in URL. + """ + params = {"state": state} + if code_challenge: + params["codeChallenge"] = code_challenge + if emulator_url: + params["emulatorUrl"] = emulator_url + if final_redirect: + params["finalRedirect"] = final_redirect + + logger.info( + "AgentSignIn.get_sign_in_url(): Getting sign-in URL with params: %s", + params, + ) + async with self.client.get( + "api/agentsignin/getSignInUrl", params=params + ) as response: + if response.status >= 300: + logger.error("Error getting sign-in URL: %s", response.status) + response.raise_for_status() + + return await response.text() + + async def get_sign_in_resource( + self, + state: str, + code_challenge: str | None = None, + emulator_url: str | None = None, + final_redirect: str | None = None, + ) -> SignInResource: + """ + Get sign-in resource. + + :param state: State parameter for OAuth flow. + :param code_challenge: Code challenge for PKCE. + :param emulator_url: Emulator URL if used. + :param final_redirect: Final redirect URL. + :return: The sign-in resource. + """ + with spans.GetSignInResource() as span: + params = {"state": state} + if code_challenge: + params["codeChallenge"] = code_challenge + if emulator_url: + params["emulatorUrl"] = emulator_url + if final_redirect: + params["finalRedirect"] = final_redirect + + logger.info( + "AgentSignIn.get_sign_in_resource(): Getting sign-in resource with params: %s", + params, + ) + async with self.client.get( + "api/botsignin/getSignInResource", params=params + ) as response: + span.share(http_method="GET", status_code=response.status) + if response.status >= 300: + logger.error("Error getting sign-in resource: %s", response.status) + response.raise_for_status() + + data = await response.json() + return SignInResource.model_validate(data) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token.py new file mode 100644 index 000000000..e02cce9eb --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token.py @@ -0,0 +1,248 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging + +from aiohttp import ClientResponseError, ClientSession + +from microsoft_agents.activity import ( + ChannelId, + TokenOrSignInResourceResponse, + TokenResponse, + TokenStatus, +) +from ..telemetry import user_token_client_spans as spans +from ..user_token_base import UserTokenBase + +logger = logging.getLogger(__name__) + + +class UserToken(UserTokenBase): + """Implementation of user token operations.""" + + def __init__(self, client: ClientSession): + self.client = client + + async def get_token( + self, + user_id: str, + connection_name: str, + channel_id: str | None = None, + code: str | None = None, + ) -> TokenResponse: + + channel_id = ChannelId.get_channel(channel_id) + + with spans.GetUserToken( + connection_name=connection_name, user_id=user_id, channel_id=channel_id + ) as span: + params = {"userId": user_id, "connectionName": connection_name} + + if channel_id: + params["channelId"] = channel_id + if code: + params["code"] = code + + safe_params = dict(params) + if "code" in safe_params: + safe_params["code"] = "" + + logger.info( + "UserToken.get_token(): Getting token with params: %s", safe_params + ) + async with self.client.get( + "api/usertoken/GetToken", params=params + ) as response: + span.share(http_method="GET", status_code=response.status) + + if response.status >= 300: + logger.error("Error getting token: %s", response.status) + response.raise_for_status() + + data = await response.json() + return TokenResponse.model_validate(data) + + async def _get_token_or_sign_in_resource( + self, + user_id: str, + connection_name: str, + channel_id: str, + state: str, + code: str = "", + final_redirect: str = "", + fwd_url: str = "", + ) -> TokenOrSignInResourceResponse: + """Get token or sign-in resource for a user.""" + + channel_id = ChannelId.get_channel(channel_id) + + with spans.GetTokenOrSignInResource( + connection_name=connection_name, user_id=user_id, channel_id=channel_id + ) as span: + params = { + "userId": user_id, + "connectionName": connection_name, + "channelId": channel_id, + "state": state, + "code": code, + "finalRedirect": final_redirect, + "fwdUrl": fwd_url, + } + + logger.info("Getting token or sign-in resource with params: %s", params) + async with self.client.get( + "/api/usertoken/GetTokenOrSignInResource", params=params + ) as response: + span.share(http_method="GET", status_code=response.status) + + if response.status != 200: + logger.error( + "Error getting token or sign-in resource: %s", response.status + ) + response.raise_for_status() + + data = await response.json() + return TokenOrSignInResourceResponse.model_validate(data) + + async def get_aad_tokens( + self, + user_id: str, + connection_name: str, + channel_id: str | None = None, + body: dict | None = None, + ) -> dict[str, TokenResponse]: + """Get AAD tokens for a user.""" + + channel_id = ChannelId.get_channel(channel_id) + + with spans.GetAadTokens( + connection_name=connection_name, user_id=user_id, channel_id=channel_id + ) as span: + params = {"userId": user_id, "connectionName": connection_name} + + if channel_id: + params["channelId"] = channel_id + + logger.info("Getting AAD tokens with params: %s and body: %s", params, body) + async with self.client.post( + "api/usertoken/GetAadTokens", params=params, json=body + ) as response: + span.share(http_method="POST", status_code=response.status) + + if response.status >= 300: + logger.error("Error getting AAD tokens: %s", response.status) + response.raise_for_status() + + data = await response.json() + return {k: TokenResponse.model_validate(v) for k, v in data.items()} + + async def sign_out( + self, + user_id: str, + connection_name: str | None = None, + channel_id: str | None = None, + ) -> None: + """Sign out user from a connection.""" + + channel_id = ChannelId.get_channel(channel_id) + + with spans.SignOut( + user_id=user_id, connection_name=connection_name, channel_id=channel_id + ) as span: + params = {"userId": user_id} + + if connection_name: + params["connectionName"] = connection_name + if channel_id: + params["channelId"] = channel_id + + logger.info("Signing out user %s with params: %s", user_id, params) + async with self.client.delete( + "api/usertoken/SignOut", params=params + ) as response: + span.share(http_method="DELETE", status_code=response.status) + + if response.status >= 300: + logger.error("Error signing out: %s", response.status) + response.raise_for_status() + + async def get_token_status( + self, + user_id: str, + channel_id: str | None = None, + include: str | None = None, + ) -> list[TokenStatus]: + """Get token status for a user.""" + + channel_id = ChannelId.get_channel(channel_id) + + with spans.GetTokenStatus(user_id=user_id, channel_id=channel_id) as span: + params = {"userId": user_id} + + if channel_id: + params["channelId"] = channel_id + if include: + params["include"] = include + + logger.info( + "Getting token status for user %s with params: %s", user_id, params + ) + async with self.client.get( + "api/usertoken/GetTokenStatus", params=params + ) as response: + span.share(http_method="GET", status_code=response.status) + + if response.status >= 300: + logger.error("Error getting token status: %s", response.status) + response.raise_for_status() + + data = await response.json() + return [TokenStatus.model_validate(status) for status in data] + + async def exchange_token( + self, + user_id: str, + connection_name: str, + channel_id: str, + body: dict | None = None, + ) -> TokenResponse: + """Exchange token for a user.""" + + channel_id = ChannelId.get_channel(channel_id) + + with spans.ExchangeToken( + connection_name=connection_name, user_id=user_id, channel_id=channel_id + ) as span: + params = { + "userId": user_id, + "connectionName": connection_name, + "channelId": channel_id, + } + + logger.info( + "Exchanging token with params: %s (body keys: %s)", + params, + list(body.keys()) if isinstance(body, dict) else None, + ) + async with self.client.post( + "api/usertoken/exchange", params=params, json=body + ) as response: + span.share(http_method="POST", status_code=response.status) + + if response.status >= 300: + response_text = await response.text("utf-8") + logger.error( + "Error exchanging token: %s %s", + response.status, + response_text, + ) + raise ClientResponseError( + response.request_info, + response.history, + status=response.status, + message=response_text, + headers=response.headers, + ) + + data = await response.json() + return TokenResponse.model_validate(data) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py index da3f5df4f..0a00543c0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/client/user_token_client.py @@ -4,328 +4,26 @@ """User Token Client for Microsoft Agents.""" import logging -from typing import Optional -from aiohttp import ClientResponseError, ClientSession +from aiohttp import ClientSession from microsoft_agents.hosting.core.connector import UserTokenClientBase from microsoft_agents.activity import ( - ChannelId, + Activity, TokenOrSignInResourceResponse, TokenResponse, TokenStatus, SignInResource, + TokenExchangeRequest, + TokenExchangeState, ) from ..get_product_info import get_product_info -from ..telemetry import user_token_client_spans as spans from ..user_token_base import UserTokenBase from ..agent_sign_in_base import AgentSignInBase -logger = logging.getLogger(__name__) - - -class AgentSignIn(AgentSignInBase): - """Implementation of agent sign-in operations.""" - - def __init__(self, client: ClientSession): - self.client = client - - async def get_sign_in_url( - self, - state: str, - code_challenge: Optional[str] = None, - emulator_url: Optional[str] = None, - final_redirect: Optional[str] = None, - ) -> str: - """ - Get sign-in URL. - - :param state: State parameter for OAuth flow. - :param code_challenge: Code challenge for PKCE. - :param emulator_url: Emulator URL if used. - :param final_redirect: Final redirect URL. - :return: The sign-in URL. - """ - params = {"state": state} - if code_challenge: - params["codeChallenge"] = code_challenge - if emulator_url: - params["emulatorUrl"] = emulator_url - if final_redirect: - params["finalRedirect"] = final_redirect - - logger.info( - "AgentSignIn.get_sign_in_url(): Getting sign-in URL with params: %s", - params, - ) - async with self.client.get( - "api/agentsignin/getSignInUrl", params=params - ) as response: - if response.status >= 300: - logger.error("Error getting sign-in URL: %s", response.status) - response.raise_for_status() - - return await response.text() - - async def get_sign_in_resource( - self, - state: str, - code_challenge: Optional[str] = None, - emulator_url: Optional[str] = None, - final_redirect: Optional[str] = None, - ) -> SignInResource: - """ - Get sign-in resource. - - :param state: State parameter for OAuth flow. - :param code_challenge: Code challenge for PKCE. - :param emulator_url: Emulator URL if used. - :param final_redirect: Final redirect URL. - :return: The sign-in resource. - """ - with spans.GetSignInResource() as span: - params = {"state": state} - if code_challenge: - params["codeChallenge"] = code_challenge - if emulator_url: - params["emulatorUrl"] = emulator_url - if final_redirect: - params["finalRedirect"] = final_redirect - - logger.info( - "AgentSignIn.get_sign_in_resource(): Getting sign-in resource with params: %s", - params, - ) - async with self.client.get( - "api/botsignin/getSignInResource", params=params - ) as response: - span.share(http_method="GET", status_code=response.status) - if response.status >= 300: - logger.error("Error getting sign-in resource: %s", response.status) - response.raise_for_status() - - data = await response.json() - return SignInResource.model_validate(data) - - -class UserToken(UserTokenBase): - """Implementation of user token operations.""" - - def __init__(self, client: ClientSession): - self.client = client - - async def get_token( - self, - user_id: str, - connection_name: str, - channel_id: Optional[str] = None, - code: Optional[str] = None, - ) -> TokenResponse: - - channel_id = ChannelId.get_channel(channel_id) - - with spans.GetUserToken( - connection_name=connection_name, user_id=user_id, channel_id=channel_id - ) as span: - params = {"userId": user_id, "connectionName": connection_name} - - if channel_id: - params["channelId"] = channel_id - if code: - params["code"] = code - - logger.info("User_token.get_token(): Getting token with params: %s", params) - async with self.client.get( - "api/usertoken/GetToken", params=params - ) as response: - span.share(http_method="GET", status_code=response.status) - - if response.status >= 300: - logger.error("Error getting token: %s", response.status) - response.raise_for_status() - - data = await response.json() - return TokenResponse.model_validate(data) - - async def _get_token_or_sign_in_resource( - self, - user_id: str, - connection_name: str, - channel_id: str, - state: str, - code: str = "", - final_redirect: str = "", - fwd_url: str = "", - ) -> TokenOrSignInResourceResponse: - """Get token or sign-in resource for a user.""" - - channel_id = ChannelId.get_channel(channel_id) - - with spans.GetTokenOrSignInResource( - connection_name=connection_name, user_id=user_id, channel_id=channel_id - ) as span: - params = { - "userId": user_id, - "connectionName": connection_name, - "channelId": channel_id, - "state": state, - "code": code, - "finalRedirect": final_redirect, - "fwdUrl": fwd_url, - } - - logger.info("Getting token or sign-in resource with params: %s", params) - async with self.client.get( - "/api/usertoken/GetTokenOrSignInResource", params=params - ) as response: - span.share(http_method="GET", status_code=response.status) - - if response.status != 200: - logger.error( - "Error getting token or sign-in resource: %s", response.status - ) - response.raise_for_status() - - data = await response.json() - return TokenOrSignInResourceResponse.model_validate(data) +from .agent_sign_in import AgentSignIn +from .user_token import UserToken - async def get_aad_tokens( - self, - user_id: str, - connection_name: str, - channel_id: Optional[str] = None, - body: Optional[dict] = None, - ) -> dict[str, TokenResponse]: - """Get AAD tokens for a user.""" - - channel_id = ChannelId.get_channel(channel_id) - - with spans.GetAadTokens( - connection_name=connection_name, user_id=user_id, channel_id=channel_id - ) as span: - params = {"userId": user_id, "connectionName": connection_name} - - if channel_id: - params["channelId"] = channel_id - - logger.info("Getting AAD tokens with params: %s and body: %s", params, body) - async with self.client.post( - "api/usertoken/GetAadTokens", params=params, json=body - ) as response: - span.share(http_method="POST", status_code=response.status) - - if response.status >= 300: - logger.error("Error getting AAD tokens: %s", response.status) - response.raise_for_status() - - data = await response.json() - return {k: TokenResponse.model_validate(v) for k, v in data.items()} - - async def sign_out( - self, - user_id: str, - connection_name: Optional[str] = None, - channel_id: Optional[str] = None, - ) -> None: - """Sign out user from a connection.""" - - channel_id = ChannelId.get_channel(channel_id) - - with spans.SignOut( - user_id=user_id, connection_name=connection_name, channel_id=channel_id - ) as span: - params = {"userId": user_id} - - if connection_name: - params["connectionName"] = connection_name - if channel_id: - params["channelId"] = channel_id - - logger.info("Signing out user %s with params: %s", user_id, params) - async with self.client.delete( - "api/usertoken/SignOut", params=params - ) as response: - span.share(http_method="DELETE", status_code=response.status) - - if response.status >= 300: - logger.error("Error signing out: %s", response.status) - response.raise_for_status() - - async def get_token_status( - self, - user_id: str, - channel_id: Optional[str] = None, - include: Optional[str] = None, - ) -> list[TokenStatus]: - """Get token status for a user.""" - - channel_id = ChannelId.get_channel(channel_id) - - with spans.GetTokenStatus(user_id=user_id, channel_id=channel_id) as span: - params = {"userId": user_id} - - if channel_id: - params["channelId"] = channel_id - if include: - params["include"] = include - - logger.info( - "Getting token status for user %s with params: %s", user_id, params - ) - async with self.client.get( - "api/usertoken/GetTokenStatus", params=params - ) as response: - span.share(http_method="GET", status_code=response.status) - - if response.status >= 300: - logger.error("Error getting token status: %s", response.status) - response.raise_for_status() - - data = await response.json() - return [TokenStatus.model_validate(status) for status in data] - - async def exchange_token( - self, - user_id: str, - connection_name: str, - channel_id: str, - body: Optional[dict] = None, - ) -> TokenResponse: - """Exchange token for a user.""" - - channel_id = ChannelId.get_channel(channel_id) - - with spans.ExchangeToken( - connection_name=connection_name, user_id=user_id, channel_id=channel_id - ) as span: - params = { - "userId": user_id, - "connectionName": connection_name, - "channelId": channel_id, - } - - logger.info("Exchanging token with params: %s and body: %s", params, body) - async with self.client.post( - "api/usertoken/exchange", params=params, json=body - ) as response: - span.share(http_method="POST", status_code=response.status) - - if response.status >= 300: - response_text = await response.text("utf-8") - logger.error( - "Error exchanging token: %s %s", - response.status, - response_text, - ) - raise ClientResponseError( - response.request_info, - response.history, - status=response.status, - message=response_text, - headers=response.headers, - ) - - data = await response.json() - return TokenResponse.model_validate(data) +logger = logging.getLogger(__name__) class UserTokenClient(UserTokenClientBase): @@ -333,14 +31,29 @@ class UserTokenClient(UserTokenClientBase): UserTokenClient is a client for interacting with the Microsoft M365 Agents SDK User Token API. """ - def __init__(self, endpoint: str, token: str, *, session: ClientSession = None): + def __init__( + self, + endpoint: str, + token: str, + *, + app_id: str | None = None, + session: ClientSession | None = None, + ): """ Initialize a new instance of UserTokenClient. :param endpoint: The endpoint URL for the token service. :param token: The authentication token to use. + :param app_id: The application ID. :param session: The aiohttp ClientSession to use for HTTP requests. """ + self._app_id = app_id + if not self._app_id: + logger.warning( + "App ID is not provided. Some operations may not work without an App ID." + " In the future, creation of UserTokenClient without an App ID will be deprecated." + ) + if not endpoint.endswith("/"): endpoint += "/" @@ -387,6 +100,199 @@ def user_token(self) -> UserTokenBase: """ return self._user_token + @staticmethod + def _create_token_exchange_state( + app_id: str, + connection_name: str, + activity: Activity, + ) -> str: + """ + Creates a token exchange state string. + + :param app_id: The application ID. + :param connection_name: The connection name. + :param activity: The activity to use for the token exchange state. + :return: The token exchange state string. + """ + return TokenExchangeState( + connection_name=connection_name, + conversation=activity.get_conversation_reference(force_base_channel=True), + relates_to=activity.relates_to, + ms_app_id=app_id, + ).get_encoded_state() + + async def get_user_token( + self, + user_id: str, + connection_name: str, + channel_id: str, + magic_code: str | None = None, + ) -> TokenResponse: + """ + Gets the user token for a user. + + :param user_id: The ID of the user. + :param connection_name: The name of the connection. + :param channel_id: The channel ID associated with the user. + :param magic_code: The magic code for the token exchange, if any. + :return: The token response. + """ + return await self._user_token.get_token( + user_id, + connection_name, + channel_id, + code=magic_code, + ) + + async def get_sign_in_resource( + self, + connection_name: str, + activity: Activity, + final_redirect: str | None = None, + ) -> SignInResource: + """ + Gets the sign-in resource for a user. + + :param connection_name: The name of the connection. + :param activity: The activity to use for the sign-in resource. + :param final_redirect: The final redirect URL after sign-in. + :return: The sign-in resource. + """ + if not self._app_id: + raise ValueError( + "App ID must be provided in the creation of UserTokenClient to get sign-in resource." + ) + + state = UserTokenClient._create_token_exchange_state( + self._app_id, connection_name, activity + ) + return await self._agent_sign_in.get_sign_in_resource( + state, final_redirect=final_redirect + ) + + async def sign_out_user( + self, + user_id: str, + connection_name: str, + channel_id: str, + ) -> None: + """ + Signs out a user from the specified connection. + + :param user_id: The ID of the user to sign out. + :param connection_name: The name of the connection to sign out from. + :param channel_id: The channel ID associated with the user. + """ + await self._user_token.sign_out( + user_id, + connection_name, + channel_id, + ) + + async def get_token_status( + self, + user_id: str, + channel_id: str, + include: str | None = None, + ) -> list[TokenStatus]: + """ + Gets the token status for a user. + + :param user_id: The ID of the user. + :param channel_id: The channel ID associated with the user. + :param include: Optional filter for included token statuses. + :return: A list of token statuses. + """ + return await self._user_token.get_token_status( + user_id, + channel_id, + include=include, + ) + + async def get_aad_tokens( + self, + user_id: str, + connection_name: str, + resource_urls: list[str], + channel_id: str, + ) -> dict[str, TokenResponse]: + """ + Gets the AAD tokens for a user. + + :param user_id: The ID of the user. + :param connection_name: The name of the connection. + :param resource_urls: A list of resource URLs to get tokens for. + :param channel_id: The channel ID associated with the user. + :return: A dictionary mapping resource URLs to token responses. + """ + # todo: verify correctness of resource URL input + return await self._user_token.get_aad_tokens( + user_id, connection_name, channel_id, {"resourceUrls": resource_urls} + ) + + async def exchange_token( + self, + user_id: str, + connection_name: str, + channel_id: str, + exchange_request: TokenExchangeRequest, + ) -> TokenResponse: + """ + Exchanges a token for a user. + + :param user_id: The ID of the user. + :param connection_name: The name of the connection. + :param channel_id: The channel ID associated with the user. + :param exchange_request: The token exchange request. + :return: The token response. + """ + return await self._user_token.exchange_token( + user_id, + connection_name, + channel_id, + exchange_request.model_dump(exclude_none=True), + ) + + async def get_token_or_sign_in_resource( + self, + connection_name: str, + activity: Activity, + code: str | None = None, + final_redirect: str | None = None, + fwd_url: str | None = None, + ) -> TokenOrSignInResourceResponse: + """ + Gets the token or sign-in resource for a user. + + :param connection_name: The name of the connection. + :param activity: The activity to use for the token or sign-in resource. + :param code: The magic code to use for the token exchange. + :param final_redirect: The final redirect URL after sign-in. + :param fwd_url: The forward URL to use for the token exchange. + :return: The token or sign-in resource. + """ + if not activity.channel_id: + raise ValueError( + "Activity must have a channel_id to get token or sign-in resource." + ) + if not self._app_id: + raise ValueError( + "App ID must be provided in the creation of UserTokenClient to get the token or sign-in resource." + ) + + state = UserTokenClient._create_token_exchange_state( + self._app_id, connection_name, activity + ) + return await self._user_token._get_token_or_sign_in_resource( + user_id=activity.from_property.id, + connection_name=connection_name, + channel_id=activity.channel_id, + state=state, + code=code or "", + final_redirect=final_redirect or "", + fwd_url=fwd_url or "", + ) + async def close(self) -> None: """Close the HTTP session.""" if self.client: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/user_token_client_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/user_token_client_base.py index 020dd1116..17e672fd7 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/user_token_client_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/user_token_client_base.py @@ -4,12 +4,22 @@ from abc import abstractmethod from typing import Protocol, runtime_checkable +from microsoft_agents.activity import ( + Activity, + SignInResource, + TokenExchangeRequest, + TokenOrSignInResourceResponse, + TokenResponse, + TokenStatus, +) + from .agent_sign_in_base import AgentSignInBase from .user_token_base import UserTokenBase @runtime_checkable class UserTokenClientBase(Protocol): + """UserTokenClientBase is a protocol that defines the interface for a User Token Client.""" @property def agent_sign_in(self) -> AgentSignInBase: @@ -24,6 +34,85 @@ def user_token(self) -> UserTokenBase: "user_token property must be implemented by subclasses." ) + @abstractmethod + async def get_user_token( + self, + user_id: str, + connection_name: str, + channel_id: str, + magic_code: str | None = None, + ) -> TokenResponse: + raise NotImplementedError( + "get_user_token method must be implemented by subclasses." + ) + + @abstractmethod + async def get_sign_in_resource( + self, + connection_name: str, + activity: Activity, + final_redirect: str | None = None, + ) -> SignInResource: + raise NotImplementedError( + "get_sign_in_resource method must be implemented by subclasses." + ) + + @abstractmethod + async def sign_out_user( + self, user_id: str, connection_name: str, channel_id: str + ) -> None: + raise NotImplementedError( + "sign_out_user method must be implemented by subclasses." + ) + + @abstractmethod + async def get_token_status( + self, + user_id: str, + channel_id: str, + include: str | None = None, + ) -> list[TokenStatus]: + raise NotImplementedError( + "get_token_status method must be implemented by subclasses." + ) + + @abstractmethod + async def get_aad_tokens( + self, + user_id: str, + connection_name: str, + resource_urls: list[str], + channel_id: str, + ) -> dict[str, TokenResponse]: + raise NotImplementedError( + "get_aad_tokens method must be implemented by subclasses." + ) + + @abstractmethod + async def exchange_token( + self, + user_id: str, + connection_name: str, + channel_id: str, + exchange_request: TokenExchangeRequest, + ) -> TokenResponse: + raise NotImplementedError( + "exchange_token method must be implemented by subclasses." + ) + + @abstractmethod + async def get_token_or_sign_in_resource( + self, + connection_name: str, + activity: Activity, + code: str | None = None, + final_redirect: str | None = None, + fwd_url: str | None = None, + ) -> TokenOrSignInResourceResponse: + raise NotImplementedError( + "get_token_or_sign_in_resource method must be implemented by subclasses." + ) + @abstractmethod async def close(self) -> None: """Close the client and release any resources.""" 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 ab265cf11..4a8189c3f 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 @@ -163,7 +163,11 @@ async def create_user_token_client( ): if use_anonymous: - return UserTokenClient(endpoint=self._token_service_endpoint, token="") + return UserTokenClient( + app_id=claims_identity.get_app_id(), + endpoint=self._token_service_endpoint, + token="", + ) if context.activity.is_agentic_request(): token = await self._get_agentic_token( @@ -183,6 +187,7 @@ async def create_user_token_client( raise ValueError("Failed to obtain token for user token client") return UserTokenClient( + app_id=claims_identity.get_app_id(), endpoint=self._token_service_endpoint, token=token, ) diff --git a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/prompts/oauth_prompt.py b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/prompts/oauth_prompt.py index 2a36b8ce3..8fd172844 100644 --- a/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/prompts/oauth_prompt.py +++ b/libraries/microsoft-agents-hosting-dialogs/microsoft_agents/hosting/dialogs/prompts/oauth_prompt.py @@ -18,7 +18,7 @@ OAuthCard, TokenResponse, TokenExchangeInvokeRequest, - TokenExchangeInvokeResponse, + TokenExchangeRequest, InvokeResponse, ) from microsoft_agents.hosting.core import ( @@ -429,11 +429,11 @@ async def _exchange_token( user_token_client = OAuthPrompt._get_user_token_client(context) - return await user_token_client.user_token.exchange_token( + return await user_token_client.exchange_token( user_id, self._settings.connection_name, channel_id, - {"token": input_token_response.token}, + TokenExchangeRequest(token=input_token_response.token), ) async def _continue_flow( diff --git a/tests/_common/testing_objects/mocks/mock_user_token_client.py b/tests/_common/testing_objects/mocks/mock_user_token_client.py index 63c168315..273b69c23 100644 --- a/tests/_common/testing_objects/mocks/mock_user_token_client.py +++ b/tests/_common/testing_objects/mocks/mock_user_token_client.py @@ -22,6 +22,58 @@ def mock_UserTokenClient( mock_user_token_client = mocker.Mock(spec=UserTokenClient) + async def get_user_token(user_id, connection_name, channel_id, magic_code=None): + return await mock_user_token_client.user_token.get_token( + user_id=user_id, + connection_name=connection_name, + channel_id=channel_id, + code=magic_code, + ) + + async def sign_out_user(user_id, connection_name, channel_id): + return await mock_user_token_client.user_token.sign_out( + user_id=user_id, + connection_name=connection_name, + channel_id=channel_id, + ) + + async def exchange_token(user_id, connection_name, channel_id, exchange_request): + return await mock_user_token_client.user_token.exchange_token( + user_id=user_id, + connection_name=connection_name, + channel_id=channel_id, + body=exchange_request.model_dump(exclude_none=True), + ) + + async def get_token_or_sign_in_resource( + connection_name, + activity, + code=None, + final_redirect=None, + fwd_url=None, + ): + state = UserTokenClient._create_token_exchange_state( + "test-app-id", + connection_name, + activity, + ) + conversation = activity.get_conversation_reference(force_base_channel=True) + return await mock_user_token_client.user_token._get_token_or_sign_in_resource( + activity.from_property.id, + connection_name, + conversation.channel_id, + state, + ) + + mock_user_token_client.get_user_token = mocker.AsyncMock( + side_effect=get_user_token + ) + mock_user_token_client.sign_out_user = mocker.AsyncMock(side_effect=sign_out_user) + mock_user_token_client.exchange_token = mocker.AsyncMock(side_effect=exchange_token) + mock_user_token_client.get_token_or_sign_in_resource = mocker.AsyncMock( + side_effect=get_token_or_sign_in_resource + ) + if get_token_return is not SKIP: if isinstance(get_token_return, str): get_token_return = TokenResponse(token=get_token_return) diff --git a/tests/_common/testing_objects/testing_user_token_client.py b/tests/_common/testing_objects/testing_user_token_client.py index e4de63577..2350b1a69 100644 --- a/tests/_common/testing_objects/testing_user_token_client.py +++ b/tests/_common/testing_objects/testing_user_token_client.py @@ -18,6 +18,11 @@ ResourceResponse, RoleTypes, InvokeResponse, + SignInResource, + TokenExchangeRequest, + TokenOrSignInResourceResponse, + TokenResponse, + TokenStatus, ) from microsoft_agents.hosting.core.channel_adapter import ChannelAdapter from microsoft_agents.hosting.core.turn_context import TurnContext @@ -26,6 +31,87 @@ AgentCallbackHandler = Callable[[TurnContext], Awaitable] +class _TestingUserTokenOperations: + def __init__(self, client: "TestingUserTokenClient"): + self._client = client + + async def get_token( + self, + user_id: str, + connection_name: str, + channel_id: str, + code: str | None = None, + ) -> TokenResponse | None: + key = self._client._get_key(connection_name, channel_id, user_id) + entry = self._client._store.get(key) + if entry: + token, stored_code = entry + if stored_code is None or (code is not None and code == stored_code): + return TokenResponse( + connection_name=connection_name, + token=token, + channel_id=channel_id, + ) + return None + + async def sign_out( + self, user_id: str, connection_name: str, channel_id: str + ) -> None: + key = self._client._get_key(connection_name, channel_id, user_id) + self._client._store.pop(key, None) + + async def exchange_token( + self, user_id: str, connection_name: str, channel_id: str, body: dict | None + ) -> TokenResponse | None: + exchangeable_item = (body or {}).get("token") or (body or {}).get("uri") + key = self._client._get_exchange_key( + connection_name, channel_id, user_id, exchangeable_item or "" + ) + if key in self._client._throw_on_exchange: + raise Exception("Token exchange not allowed for this item.") + token = self._client._exchange_store.get(key) + if token: + return TokenResponse( + connection_name=connection_name, + token=token, + channel_id=channel_id, + ) + return None + + async def _get_token_or_sign_in_resource( + self, + user_id: str, + connection_name: str, + channel_id: str, + state: str, + code: str = "", + final_redirect: str = "", + fwd_url: str = "", + ) -> TokenOrSignInResourceResponse: + token_response = await self.get_token(user_id, connection_name, channel_id) + if token_response: + return TokenOrSignInResourceResponse(token_response=token_response) + return TokenOrSignInResourceResponse( + sign_in_resource=SignInResource( + sign_in_link=f"https://token.botframework.com/oauthcards?state={state or ''}" + ) + ) + + async def get_token_status( + self, user_id: str, channel_id: str, include: str | None = None + ) -> list[TokenStatus]: + return [] + + +class _TestingAgentSignIn: + async def get_sign_in_resource( + self, state: str | None = None, final_redirect: str | None = None + ) -> SignInResource: + return SignInResource( + sign_in_link=f"https://token.botframework.com/oauthcards?state={state or ''}" + ) + + # patch userTokenclient class TestingUserTokenClient(UserTokenClient): """A mock user token client for testing.""" @@ -34,6 +120,16 @@ def __init__(self): self._store = {} self._exchange_store = {} self._throw_on_exchange = {} + self._user_token = _TestingUserTokenOperations(self) + self._agent_sign_in = _TestingAgentSignIn() + + @property + def user_token(self) -> _TestingUserTokenOperations: + return self._user_token + + @property + def agent_sign_in(self) -> _TestingAgentSignIn: + return self._agent_sign_in def add_user_token( self, @@ -85,3 +181,97 @@ def _get_exchange_key( exchangeable_item: str, ) -> str: return f"{connection_name}:{channel_id}:{user_id}:{exchangeable_item}" + + async def get_user_token( + self, + user_id: str, + connection_name: str, + channel_id: str, + magic_code: str | None = None, + ) -> TokenResponse: + return await self.user_token.get_token( + user_id, connection_name, channel_id, code=magic_code + ) + + async def get_sign_in_resource( + self, + connection_name: str, + activity: Activity, + final_redirect: str | None = None, + ) -> SignInResource: + return await self.agent_sign_in.get_sign_in_resource( + final_redirect=final_redirect + ) + + async def sign_out_user( + self, user_id: str, connection_name: str, channel_id: str + ) -> None: + return await self.user_token.sign_out(user_id, connection_name, channel_id) + + async def get_token_status( + self, + user_id: str, + channel_id: str, + include: str | None = None, + ) -> list[TokenStatus]: + return await self.user_token.get_token_status(user_id, channel_id, include) + + async def get_aad_tokens( + self, + user_id: str, + connection_name: str, + resource_urls: list[str], + channel_id: str, + ) -> dict[str, TokenResponse]: + """ + Get fake AAD tokens for resource URLs using the stored user token. + + The testing adapter stores one token per user/connection/channel. For + AAD-token requests, mirror that token across each requested resource URL. + """ + key = self._get_key(connection_name, channel_id, user_id) + entry = self._store.get(key) + if not entry: + return {} + + token, _ = entry + return { + resource_url: TokenResponse( + connection_name=connection_name, + token=token, + channel_id=channel_id, + ) + for resource_url in resource_urls + } + + async def exchange_token( + self, + user_id: str, + connection_name: str, + channel_id: str, + exchange_request: TokenExchangeRequest, + ) -> TokenResponse: + return await self.user_token.exchange_token( + user_id, + connection_name, + channel_id, + body=exchange_request.model_dump(exclude_none=True), + ) + + async def get_token_or_sign_in_resource( + self, + connection_name: str, + activity: Activity, + code: str | None = None, + final_redirect: str | None = None, + fwd_url: str | None = None, + ) -> TokenOrSignInResourceResponse: + return await self.user_token._get_token_or_sign_in_resource( + activity.from_property.id, + connection_name, + activity.channel_id, + "", + code or "", + final_redirect or "", + fwd_url or "", + ) diff --git a/tests/hosting_dialogs/helpers.py b/tests/hosting_dialogs/helpers.py index 87529bf28..f693ed5d9 100644 --- a/tests/hosting_dialogs/helpers.py +++ b/tests/hosting_dialogs/helpers.py @@ -12,9 +12,11 @@ from microsoft_agents.activity import ( Activity, ActivityTypes, + TokenExchangeRequest, TokenResponse, SignInResource, TokenOrSignInResourceResponse, + TokenStatus, ) from microsoft_agents.hosting.core import TurnContext, UserTokenClientBase from microsoft_agents.hosting.core.authorization import ClaimsIdentity @@ -71,7 +73,14 @@ async def exchange_token(self, user_id, connection_name, channel_id, body=None): return None async def _get_token_or_sign_in_resource( - self, user_id, connection_name, channel_id, state, *_ + self, + user_id, + connection_name, + channel_id, + state, + code="", + final_redirect="", + fwd_url="", ): key = self._key(connection_name, channel_id, user_id) entry = self._store.get(key) @@ -111,14 +120,98 @@ def __init__(self): self._store = {} self._exchange_store = {} self._throw_on_exchange = {} - self.user_token = _MockUserToken( + self._user_token = _MockUserToken( self._store, self._exchange_store, self._throw_on_exchange ) - self.agent_sign_in = _MockAgentSignIn() + self._agent_sign_in = _MockAgentSignIn() + + @property + def user_token(self) -> _MockUserToken: + return self._user_token + + @property + def agent_sign_in(self) -> _MockAgentSignIn: + return self._agent_sign_in async def close(self) -> None: return None + async def get_user_token( + self, + user_id: str, + connection_name: str, + channel_id: str, + magic_code: str | None = None, + ) -> TokenResponse: + return await self.user_token.get_token( + user_id, connection_name, channel_id, code=magic_code + ) + + async def get_sign_in_resource( + self, + connection_name: str, + activity: Activity, + final_redirect: str | None = None, + ) -> SignInResource: + return await self.agent_sign_in.get_sign_in_resource() + + async def sign_out_user( + self, user_id: str, connection_name: str, channel_id: str + ) -> None: + return await self.user_token.sign_out(user_id, connection_name, channel_id) + + async def get_token_status( + self, + user_id: str, + channel_id: str, + include: str | None = None, + ) -> list[TokenStatus]: + return [] + + async def get_aad_tokens( + self, + user_id: str, + connection_name: str, + resource_urls: list[str], + channel_id: str, + ) -> dict[str, TokenResponse]: + token_response = await self.get_user_token(user_id, connection_name, channel_id) + if not token_response: + return {} + return {resource_url: token_response for resource_url in resource_urls} + + async def exchange_token( + self, + user_id: str, + connection_name: str, + channel_id: str, + exchange_request: TokenExchangeRequest, + ) -> TokenResponse: + return await self.user_token.exchange_token( + user_id, + connection_name, + channel_id, + body=exchange_request.model_dump(exclude_none=True), + ) + + async def get_token_or_sign_in_resource( + self, + connection_name: str, + activity: Activity, + code: str | None = None, + final_redirect: str | None = None, + fwd_url: str | None = None, + ) -> TokenOrSignInResourceResponse: + return await self.user_token._get_token_or_sign_in_resource( + activity.from_property.id, + connection_name, + activity.channel_id, + "", + code or "", + final_redirect or "", + fwd_url or "", + ) + def add_user_token( self, connection_name: str,