diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py index d0a8a764..f94e7fe1 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py @@ -67,6 +67,9 @@ class Channels(str, Enum): webchat = "webchat" """WebChat channel.""" + copilot_studio = "pva-studio" + """Microsoft Copilot Studio channel.""" + # TODO: validate the need of Self annotations in the following methods @staticmethod def supports_suggested_actions(channel_id: Self, button_cnt: int = 100) -> bool: 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 284e88ce..9b0dc3e8 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/role_types.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/role_types.py @@ -8,5 +8,6 @@ class RoleTypes(str, Enum): user = "user" agent = "bot" skill = "skill" + connector_user = "connectoruser" agentic_identity = "agenticAppInstance" agentic_user = "agenticUser" 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 2e12d9cc..79929ccf 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 @@ -9,6 +9,7 @@ from ._handlers import ( _UserAuthorization, AgenticUserAuthorization, + ConnectorUserAuthorization, _AuthorizationHandler, ) @@ -20,4 +21,5 @@ "_SignInResponse", "_UserAuthorization", "AgenticUserAuthorization", + "ConnectorUserAuthorization", ] 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 6757119c..cca1905b 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 @@ -4,11 +4,13 @@ """ from .agentic_user_authorization import AgenticUserAuthorization +from .connector_user_authorization import ConnectorUserAuthorization from ._user_authorization import _UserAuthorization from ._authorization_handler import _AuthorizationHandler __all__ = [ "AgenticUserAuthorization", + "ConnectorUserAuthorization", "_UserAuthorization", "_AuthorizationHandler", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/connector_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/connector_user_authorization.py new file mode 100644 index 00000000..e8d837ba --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/connector_user_authorization.py @@ -0,0 +1,229 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import logging +import jwt +from datetime import datetime, timezone, timedelta +from typing import Optional + +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 ._authorization_handler import _AuthorizationHandler +from .._sign_in_response import _SignInResponse + +logger = logging.getLogger(__name__) + + +class ConnectorUserAuthorization(_AuthorizationHandler): + """ + User Authorization handling for Copilot Studio Connector requests. + Extracts token from the identity and performs OBO token exchange. + """ + + 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 ConnectorUserAuthorization. + + :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_handler: Configuration for OAuth provider. + :type auth_handler: AuthHandler, Optional + :param auth_handler_id: Optional ID of the auth handler. + :type auth_handler_id: str, Optional + :param auth_handler_settings: Optional settings dict for the auth handler. + :type auth_handler_settings: dict, Optional + """ + super().__init__( + storage, + connection_manager, + auth_handler, + auth_handler_id=auth_handler_id, + auth_handler_settings=auth_handler_settings, + **kwargs, + ) + + async def _sign_in( + self, context: TurnContext, scopes: Optional[list[str]] = None + ) -> _SignInResponse: + """ + For connector requests, there is no separate sign-in flow. + The token is extracted from the identity. + + :param context: The turn context for the current turn of conversation. + :type context: TurnContext + :param scopes: Optional list of scopes (unused for connector auth). + :type scopes: Optional[list[str]], Optional + :return: A SignInResponse with the extracted token. + :rtype: _SignInResponse + """ + # Connector auth uses the token from the request, not a separate sign-in flow + token_response = await self.get_refreshed_token(context) + return _SignInResponse( + token_response=token_response, success=bool(token_response) + ) + + async def get_refreshed_token( + self, + context: TurnContext, + exchange_connection: Optional[str] = None, + exchange_scopes: Optional[list[str]] = None, + ) -> TokenResponse: + """ + Gets the connector user token and optionally exchanges it via OBO. + + :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. + :type exchange_connection: Optional[str], Optional + :param exchange_scopes: Optional list of scopes to request during token exchange. + :type exchange_scopes: Optional[list[str]], Optional + :return: The token response, potentially after OBO exchange. + :rtype: TokenResponse + """ + token_response = self._create_token_response(context) + + # Check if token is expired + if token_response.expiration: + try: + # Parse ISO 8601 format + expiration = datetime.fromisoformat( + token_response.expiration.replace("Z", "+00:00") + ) + if expiration <= datetime.now(timezone.utc): + raise ValueError( + f"Unexpected connector token expiration for handler: {self._id}" + ) + except (ValueError, AttributeError) as ex: + logger.error( + f"Error checking token expiration for handler {self._id}: {ex}" + ) + raise + + # Perform OBO exchange if configured + try: + return await self._handle_obo( + context, token_response, exchange_connection, exchange_scopes + ) + except Exception: + await self._sign_out(context) + raise + + async def _sign_out(self, context: TurnContext) -> None: + """ + Sign-out is a no-op for connector authorization. + + :param context: The turn context for the current turn of conversation. + :type context: TurnContext + """ + # No concept of sign-out with ConnectorAuth + logger.debug("Sign-out called for ConnectorUserAuthorization (no-op)") + pass + + async def _handle_obo( + self, + context: TurnContext, + input_token_response: TokenResponse, + exchange_connection: Optional[str] = None, + exchange_scopes: Optional[list[str]] = None, + ) -> TokenResponse: + """ + Exchanges a token for another token with different scopes via OBO flow. + + :param context: The context object for the current turn. + :type context: TurnContext + :param input_token_response: The input token to exchange. + :type input_token_response: TokenResponse + :param exchange_connection: Optional connection name for exchange. + :type exchange_connection: Optional[str] + :param exchange_scopes: Optional scopes for the exchanged token. + :type exchange_scopes: Optional[list[str]] + :return: The token response after exchange, or the original if exchange not configured. + :rtype: TokenResponse + """ + if not input_token_response: + return input_token_response + + connection_name = exchange_connection or self._handler.obo_connection_name + scopes = exchange_scopes or self._handler.scopes + + # If OBO is not configured, return token as-is + if not connection_name or not scopes: + return input_token_response + + # Check if token is exchangeable + if not input_token_response.is_exchangeable(): + # TODO: (connector) Should raise an error instead of just returning + return input_token_response + + # Get the connection that supports OBO + token_provider = self._connection_manager.get_connection(connection_name) + if not token_provider: + # TODO: (connector) use resource errors + raise ValueError(f"Connection '{connection_name}' not found") + + # Perform the OBO exchange + # Note: In Python, the acquire_token_on_behalf_of method is on the AccessTokenProviderBase + token = await token_provider.acquire_token_on_behalf_of( + scopes=scopes, + user_assertion=input_token_response.token, + ) + return TokenResponse(token=token) if token else None + + def _create_token_response(self, context: TurnContext) -> TokenResponse: + """ + Creates a TokenResponse from the security token in the turn context identity. + + :param context: The turn context for the current turn of conversation. + :type context: TurnContext + :return: A TokenResponse containing the extracted token. + :rtype: TokenResponse + :raises ValueError: If the identity doesn't have a security token. + """ + if not context.identity or not hasattr(context.identity, "security_token"): + raise ValueError( + f"Unexpected connector request - no security token found for handler: {self._id}" + ) + + security_token = context.identity.security_token + if not security_token: + raise ValueError( + f"Unexpected connector request - security token is None for handler: {self._id}" + ) + + token_response = TokenResponse(token=security_token) + + # Try to extract expiration and check if exchangeable + try: + # TODO: (connector) validate this decoding + jwt_token = jwt.decode(security_token, options={"verify_signature": False}) + + # Set expiration if present + if "exp" in jwt_token: + # JWT exp is in Unix timestamp (seconds since epoch) + expiration = datetime.fromtimestamp(jwt_token["exp"], tz=timezone.utc) + # Convert to ISO 8601 format + token_response.expiration = expiration.isoformat() + + except Exception as ex: + logger.warning(f"Failed to parse JWT token for handler {self._id}: {ex}") + raise ex + # If we can't parse the token, we'll still return it without expiration info + + return token_response 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 b2403300..9e545e2d 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 @@ -20,6 +20,7 @@ from ._sign_in_response import _SignInResponse from ._handlers import ( AgenticUserAuthorization, + ConnectorUserAuthorization, _UserAuthorization, _AuthorizationHandler, ) @@ -29,6 +30,7 @@ AUTHORIZATION_TYPE_MAP = { "userauthorization": _UserAuthorization, "agenticuserauthorization": AgenticUserAuthorization, + "connectoruserauthorization": ConnectorUserAuthorization, } 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 5cd8f089..4317759f 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 @@ -11,10 +11,12 @@ def __init__( claims: dict[str, str], is_authenticated: bool, authentication_type: Optional[str] = None, + security_token: Optional[str] = None, ): self.claims = claims self.is_authenticated = is_authenticated self.authentication_type = authentication_type + self.security_token = security_token def get_claim_value(self, claim_type: str) -> Optional[str]: return self.claims.get(claim_type) 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 9069e81d..b1fa00de 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 @@ -34,7 +34,7 @@ async def validate_token(self, token: str) -> ClaimsIdentity: # This probably should return a ClaimsIdentity logger.debug("JWT token validated successfully.") - return ClaimsIdentity(decoded_token, True) + return ClaimsIdentity(decoded_token, True, security_token=token) def get_anonymous_claims(self) -> ClaimsIdentity: logger.debug("Returning anonymous claims identity.") diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/__init__.py index 82d5c89a..17efa577 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/__init__.py @@ -9,11 +9,15 @@ # Teams API from .teams.teams_connector_client import TeamsConnectorClient +# MCS API +from .mcs.mcs_connector_client import MCSConnectorClient + __all__ = [ "ConnectorClient", "UserTokenClient", "UserTokenClientBase", "TeamsConnectorClient", + "MCSConnectorClient", "ConnectorClientBase", "get_product_info", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/mcs/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/mcs/__init__.py new file mode 100644 index 00000000..0f0f3174 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/mcs/__init__.py @@ -0,0 +1,3 @@ +from .mcs_connector_client import MCSConnectorClient + +__all__ = ["MCSConnectorClient"] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/mcs/mcs_connector_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/mcs/mcs_connector_client.py new file mode 100644 index 00000000..1d9a7533 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/connector/mcs/mcs_connector_client.py @@ -0,0 +1,242 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""MCS Connector Client for Microsoft Copilot Studio via Power Apps Connector.""" + +import logging +from typing import Optional +from aiohttp import ClientSession + +from microsoft_agents.activity import Activity, ResourceResponse +from ..connector_client_base import ConnectorClientBase +from ..attachments_base import AttachmentsBase +from ..conversations_base import ConversationsBase + + +logger = logging.getLogger(__name__) + + +class MCSConversations(ConversationsBase): + """ + Conversations implementation for Microsoft Copilot Studio Connector. + + Only supports SendToConversation and ReplyToActivity operations. + """ + + def __init__(self, client: ClientSession, endpoint: str): + self._client = client + self._endpoint = endpoint + + async def send_to_conversation( + self, + conversation_id: str, + activity: Activity, + **kwargs, + ) -> ResourceResponse: + """ + Send an activity to a conversation. + + :param conversation_id: The conversation ID (not used for MCS connector). + :param activity: The activity to send. + :return: A resource response. + """ + if activity is None: + raise ValueError("activity is required") + + logger.info("MCS Connector: Sending activity to conversation") + + async with self._client.post( + self._endpoint, + json=activity.serialize(), + headers={"Accept": "application/json", "Content-Type": "application/json"}, + ) as response: + if response.status >= 300: + logger.error( + "MCS Connector: Error sending activity: %s", response.status + ) + response.raise_for_status() + + # Check if there's a response body + content = await response.text() + if content: + data = await response.json() + # TODO: (connector) Validate response structure + return ResourceResponse(**data) + + return ResourceResponse(id="") + + async def reply_to_activity( + self, + conversation_id: str, + activity_id: str, + activity: Activity, + **kwargs, + ) -> ResourceResponse: + """ + Reply to an activity in a conversation. + + For MCS Connector, this falls back to send_to_conversation. + + :param conversation_id: The conversation ID. + :param activity_id: The activity ID to reply to. + :param activity: The activity to send. + :return: A resource response. + """ + return await self.send_to_conversation(conversation_id, activity, **kwargs) + + async def update_activity( + self, + conversation_id: str, + activity_id: str, + activity: Activity, + **kwargs, + ) -> ResourceResponse: + """Not supported for MCS Connector.""" + raise NotImplementedError( + "UpdateActivity is not supported for Microsoft Copilot Studio Connector" + ) + + async def delete_activity( + self, conversation_id: str, activity_id: str, **kwargs + ) -> None: + """Not supported for MCS Connector.""" + raise NotImplementedError( + "DeleteActivity is not supported for Microsoft Copilot Studio Connector" + ) + + async def get_conversation_members(self, conversation_id: str, **kwargs) -> list: + """Not supported for MCS Connector.""" + raise NotImplementedError( + "GetConversationMembers is not supported for Microsoft Copilot Studio Connector" + ) + + async def get_activity_members( + self, conversation_id: str, activity_id: str, **kwargs + ) -> list: + """Not supported for MCS Connector.""" + raise NotImplementedError( + "GetActivityMembers is not supported for Microsoft Copilot Studio Connector" + ) + + async def delete_conversation_member( + self, conversation_id: str, member_id: str, **kwargs + ) -> None: + """Not supported for MCS Connector.""" + raise NotImplementedError( + "DeleteConversationMember is not supported for Microsoft Copilot Studio Connector" + ) + + async def send_conversation_history( + self, conversation_id: str, transcript: dict, **kwargs + ) -> ResourceResponse: + """Not supported for MCS Connector.""" + raise NotImplementedError( + "SendConversationHistory is not supported for Microsoft Copilot Studio Connector" + ) + + async def upload_attachment( + self, conversation_id: str, attachment_upload: dict, **kwargs + ) -> ResourceResponse: + """Not supported for MCS Connector.""" + raise NotImplementedError( + "UploadAttachment is not supported for Microsoft Copilot Studio Connector" + ) + + async def create_conversation(self, parameters: dict, **kwargs) -> dict: + """Not supported for MCS Connector.""" + raise NotImplementedError( + "CreateConversation is not supported for Microsoft Copilot Studio Connector" + ) + + async def get_conversations( + self, continuation_token: Optional[str] = None, **kwargs + ) -> dict: + """Not supported for MCS Connector.""" + raise NotImplementedError( + "GetConversations is not supported for Microsoft Copilot Studio Connector" + ) + + async def get_conversation_paged_members( + self, + conversation_id: str, + page_size: Optional[int] = None, + continuation_token: Optional[str] = None, + **kwargs, + ) -> dict: + """Not supported for MCS Connector.""" + raise NotImplementedError( + "GetConversationPagedMembers is not supported for Microsoft Copilot Studio Connector" + ) + + +class MCSAttachments(AttachmentsBase): + """ + Attachments implementation for Microsoft Copilot Studio Connector. + + Attachments operations are not supported. + """ + + async def get_attachment_info(self, attachment_id: str, **kwargs) -> dict: + """Not supported for MCS Connector.""" + raise NotImplementedError( + "GetAttachmentInfo is not supported for Microsoft Copilot Studio Connector" + ) + + async def get_attachment(self, attachment_id: str, view_id: str, **kwargs) -> bytes: + """Not supported for MCS Connector.""" + raise NotImplementedError( + "GetAttachment is not supported for Microsoft Copilot Studio Connector" + ) + + +class MCSConnectorClient(ConnectorClientBase): + """ + Connector client suited for communicating with Microsoft Copilot Studio + via a Power Apps Connector request. + + Only supports SendToConversation and ReplyToActivity operations. + All other operations will raise NotImplementedError. + """ + + def __init__(self, endpoint: str, client: Optional[ClientSession] = None): + """ + Initialize the MCS Connector Client. + + :param endpoint: The endpoint URL for the MCS connector. + :param client: Optional aiohttp ClientSession. If not provided, one will be created. + """ + if not endpoint: + raise ValueError("endpoint is required") + + self._endpoint = endpoint + self._client = client or ClientSession() + self._conversations = MCSConversations(self._client, self._endpoint) + self._attachments = MCSAttachments() + + @property + def base_uri(self) -> str: + """Get the base URI for this connector client.""" + return self._endpoint + + @property + def attachments(self) -> AttachmentsBase: + """Get the attachments operations (not supported for MCS).""" + return self._attachments + + @property + def conversations(self) -> ConversationsBase: + """Get the conversations operations.""" + return self._conversations + + async def close(self): + """Close the client session.""" + if self._client: + await self._client.close() + + async def __aenter__(self): + """Async context manager entry.""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit.""" + await self.close() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/errors/error_resources.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/errors/error_resources.py index 4900cebe..5625be4f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/errors/error_resources.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/errors/error_resources.py @@ -112,6 +112,16 @@ class ErrorResources: -63017, ) + UnexpectedConnectorRequestToken = ErrorMessage( + "Connector request did not contain a valid security token for handler: {0}", + -63018, + ) + + UnexpectedConnectorTokenExpiration = ErrorMessage( + "Connector token has expired for handler: {0}", + -63019, + ) + # General/Validation Errors (-66000 to -66999) InvalidConfiguration = ErrorMessage( "Invalid configuration: {0}", 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 b2bc6440..ac91e20f 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 @@ -15,6 +15,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.connector.mcs import MCSConnectorClient from .channel_service_client_factory_base import ChannelServiceClientFactoryBase from .turn_context import TurnContext @@ -116,6 +117,17 @@ async def create_connector_client( audience, scopes or [f"{audience}/.default"] ) + # Check if this is a connector request (e.g., from Copilot Studio) + if ( + context + and context.activity.recipient + and context.activity.recipient.role == RoleTypes.connector_user + ): + return MCSConnectorClient( + endpoint=service_url, + token=token, + ) + return TeamsConnectorClient( endpoint=service_url, token=token, diff --git a/test_samples/copilot_studio_connector/README.md b/test_samples/copilot_studio_connector/README.md new file mode 100644 index 00000000..781e2a64 --- /dev/null +++ b/test_samples/copilot_studio_connector/README.md @@ -0,0 +1,170 @@ +# Copilot Studio Agent Connector Sample + +This sample demonstrates how to create an agent that can receive requests from Microsoft Copilot Studio via Power Apps Connector and use OAuth/OBO (On-Behalf-Of) token exchange to access Microsoft Graph on behalf of the user. + +## Overview + +This sample shows: +- Handling connector requests from Microsoft Copilot Studio (RoleTypes.connector_user) +- Using ConnectorUserAuthorization for OBO token exchange +- Calling Microsoft Graph API with the exchanged token +- Responding to Copilot Studio with personalized messages + +## Prerequisites + +1. Azure Bot resource with App Registration +2. Microsoft Copilot Studio agent configured to use the connector +3. OAuth Connection configured for Graph API access +4. Python 3.10 or higher + +## Configuration + +### 1. App Registration Setup + +Create or use an existing App Registration in Azure: +- Note the Application (client) ID +- Note the Directory (tenant) ID +- Create a client secret + +### 2. Configure appsettings.json + +```json +{ + "TokenValidation": { + "Enabled": true, + "Audiences": [ + "{{ClientId}}" + ], + "TenantId": "{{TenantId}}" + }, + "Connections": { + "ServiceConnection": { + "Settings": { + "AuthType": "ClientSecret", + "AuthorityEndpoint": "https://login.microsoftonline.com/{{TenantId}}", + "ClientId": "{{ClientId}}", + "ClientSecret": "{{ClientSecret}}" + } + }, + "GraphConnection": { + "Settings": { + "AuthType": "ClientSecret", + "AuthorityEndpoint": "https://login.microsoftonline.com/{{TenantId}}", + "ClientId": "{{ClientId}}", + "ClientSecret": "{{ClientSecret}}", + "Scopes": ["https://graph.microsoft.com/.default"] + } + } + }, + "AgentApplication": { + "UserAuthorization": { + "Handlers": { + "connector": { + "Type": "ConnectorUserAuthorization", + "Settings": { + "Name": "connector", + "OBOConnectionName": "GraphConnection", + "Scopes": ["https://graph.microsoft.com/.default"] + } + } + } + } + } +} +``` + +Replace: +- `{{ClientId}}` - Your App Registration client ID +- `{{TenantId}}` - Your Azure AD tenant ID +- `{{ClientSecret}}` - Your App Registration client secret + +### 3. Copilot Studio Configuration + +In Microsoft Copilot Studio: +1. Create or edit your agent +2. Add a Power Apps Connector action +3. Configure the connector to point to your agent's endpoint +4. Enable authentication with your App Registration + +## Running the Sample + +1. Install dependencies: +```bash +pip install -r requirements.txt +``` + +2. Run the agent: +```bash +python app.py +``` + +3. For local development, use a tunneling tool (ngrok, devtunnels): +```bash +devtunnels create +devtunnels host -p 3978 --allow-anonymous +``` + +4. Update your Azure Bot messaging endpoint with the tunnel URL + +## Code Structure + +### app.py +Main application entry point that: +- Configures the agent with authentication +- Sets up user authorization with ConnectorUserAuthorization +- Registers the custom agent + +### agent.py +The MyAgent class that: +- Checks for connector_user role messages +- Retrieves the OBO-exchanged token +- Calls Microsoft Graph to get user information +- Sends a personalized greeting + +## Key Concepts + +### Connector User Authorization + +The ConnectorUserAuthorization handler: +1. Extracts the security token from the incoming request +2. Checks token expiration +3. Performs OBO token exchange to get a Graph API token +4. Returns the exchanged token for API calls + +### Message Flow + +1. Copilot Studio sends message to agent with recipient.role = "connectoruser" +2. Agent receives message and detects connector_user role +3. Agent calls `UserAuthorization.GetTurnTokenAsync()` to get the exchanged Graph token +4. Agent uses token to call Microsoft Graph +5. Agent sends response back to Copilot Studio + +## Security Notes + +- Never store secrets in source code or appsettings.json in production +- Use Azure Key Vault or environment variables for sensitive values +- Ensure proper token validation is enabled +- Use secure communication (HTTPS) in production + +## Troubleshooting + +### Token Exchange Fails +- Verify OBO connection is configured correctly +- Check that scopes match your API permissions +- Ensure App Registration has proper permissions granted + +### Authentication Errors +- Verify client ID and tenant ID are correct +- Check that client secret is valid and not expired +- Ensure TokenValidation settings match your App Registration + +### Connector Not Receiving Messages +- Verify Azure Bot messaging endpoint is correct +- Check that Copilot Studio connector is configured properly +- Ensure authentication is set up in both places + +## Related Documentation + +- [Microsoft Agents SDK for Python](https://github.com/microsoft/Agents-for-python) +- [Microsoft Copilot Studio](https://learn.microsoft.com/microsoft-copilot-studio/) +- [On-Behalf-Of OAuth Flow](https://learn.microsoft.com/entra/identity-platform/v2-oauth2-on-behalf-of-flow) diff --git a/test_samples/copilot_studio_connector/agent.py b/test_samples/copilot_studio_connector/agent.py new file mode 100644 index 00000000..b10b8d0a --- /dev/null +++ b/test_samples/copilot_studio_connector/agent.py @@ -0,0 +1,128 @@ +""" +Copilot Studio Agent Connector Sample + +This agent demonstrates handling requests from Microsoft Copilot Studio via +Power Apps Connector and using OBO token exchange to access Microsoft Graph. +""" + +import logging +import aiohttp +from typing import Optional + +from microsoft_agents.hosting.core import ( + AgentApplication, + ApplicationOptions, + TurnState, + TurnContext, +) +from microsoft_agents.activity import ActivityTypes, RoleTypes + +logger = logging.getLogger(__name__) + + +class MyAgent(AgentApplication): + """ + Agent that handles connector requests from Microsoft Copilot Studio. + + This agent: + - Detects messages from Copilot Studio (connector_user role) + - Retrieves the OBO-exchanged token + - Calls Microsoft Graph to get user information + - Responds with a personalized greeting + """ + + def __init__(self, options: ApplicationOptions): + super().__init__(options) + + # Register handler for connector messages + # These are messages where the recipient role is connector_user + async def is_connector_message(turn_context: TurnContext) -> bool: + return ( + turn_context.activity.type == ActivityTypes.message + and turn_context.activity.recipient + and turn_context.activity.recipient.role == RoleTypes.connector_user + ) + + self.on_activity(is_connector_message, self._on_connector_message) + + async def _on_connector_message( + self, turn_context: TurnContext, turn_state: TurnState, cancellation_token=None + ): + """ + Handle messages from Microsoft Copilot Studio connector. + + :param turn_context: The turn context for this turn + :param turn_state: The turn state + :param cancellation_token: Cancellation token + """ + try: + # Get the user's OAuth token. Since OBO was configured in appsettings, + # it has already been exchanged for a Graph API token. + # If you don't know scopes until runtime, use exchange_turn_token_async instead. + access_token = await self.user_authorization.get_turn_token_async( + turn_context, cancellation_token=cancellation_token + ) + + if not access_token: + await turn_context.send_activity_async( + "Unable to retrieve access token", + cancellation_token=cancellation_token, + ) + return + + # Get user's display name from Microsoft Graph + display_name = await self._get_display_name(access_token) + + # Send personalized greeting + await turn_context.send_activity_async( + f"Hi, {display_name}!", cancellation_token=cancellation_token + ) + + except Exception as ex: + logger.error(f"Error handling connector message: {ex}", exc_info=True) + await turn_context.send_activity_async( + "Sorry, an error occurred while processing your request.", + cancellation_token=cancellation_token, + ) + + async def _get_display_name(self, token: str) -> str: + """ + Get the user's display name from Microsoft Graph API. + + :param token: The Graph API access token + :return: The user's display name or "Unknown" if unable to retrieve + """ + display_name = "Unknown" + + try: + graph_info = await self._get_graph_info(token) + if graph_info and "displayName" in graph_info: + display_name = graph_info["displayName"] + except Exception as ex: + logger.warning(f"Failed to get display name from Graph: {ex}") + + return display_name + + async def _get_graph_info(self, token: str) -> Optional[dict]: + """ + Call Microsoft Graph API to get user information. + + :param token: The Graph API access token + :return: Dictionary containing user information or None if failed + """ + graph_api_url = "https://graph.microsoft.com/v1.0/me" + + try: + async with aiohttp.ClientSession() as session: + headers = {"Authorization": f"Bearer {token}"} + async with session.get(graph_api_url, headers=headers) as response: + if response.status == 200: + return await response.json() + else: + logger.error( + f"Graph API returned status {response.status}: {await response.text()}" + ) + except Exception as ex: + logger.error(f"Error calling Graph API: {ex}", exc_info=True) + + return None diff --git a/test_samples/copilot_studio_connector/app.py b/test_samples/copilot_studio_connector/app.py new file mode 100644 index 00000000..69d16799 --- /dev/null +++ b/test_samples/copilot_studio_connector/app.py @@ -0,0 +1,73 @@ +""" +Main application entry point for Copilot Studio Agent Connector sample. +""" + +import logging +from aiohttp import web + +from microsoft_agents.storage import MemoryStorage +from microsoft_agents.hosting.aiohttp import CloudAdapter +from agent import MyAgent + +# Configure logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +async def messages(request: web.Request) -> web.Response: + """ + Handle incoming messages from Azure Bot Service or Copilot Studio. + + :param request: The incoming HTTP request + :return: HTTP response + """ + # Get the adapter and agent from the app context + adapter: CloudAdapter = request.app["adapter"] + agent: MyAgent = request.app["agent"] + + # Process the incoming activity + return await adapter.process(request, agent) + + +def create_app(config_path: str = "appsettings.json") -> web.Application: + """ + Create and configure the web application. + + :param config_path: Path to configuration file + :return: Configured aiohttp web application + """ + # Create agent options from configuration + # In production, use a proper config loading mechanism + # options = AgentApplicationOptions.from_configuration(config_path) + + # Create storage (use persistent storage in production) + storage = MemoryStorage() + + # Create the agent + agent = MyAgent(options) + + # Create the adapter + adapter = CloudAdapter(configuration=config_path, storage=storage) + + # Create the web application + app = web.Application() + app["adapter"] = adapter + app["agent"] = agent + + # Register routes + app.router.add_get("/", lambda _: web.Response(text="Microsoft Agents SDK Sample")) + app.router.add_post("/api/messages", messages) + + return app + + +if __name__ == "__main__": + # Create the app + app = create_app() + + # Run the web server + port = 3978 + logger.info(f"Starting Copilot Studio Connector sample on port {port}") + web.run_app(app, host="0.0.0.0", port=port) diff --git a/test_samples/copilot_studio_connector/appsettings.json b/test_samples/copilot_studio_connector/appsettings.json new file mode 100644 index 00000000..4c0678bc --- /dev/null +++ b/test_samples/copilot_studio_connector/appsettings.json @@ -0,0 +1,42 @@ +{ + "TokenValidation": { + "Enabled": true, + "Audiences": [ + "YOUR_CLIENT_ID_HERE" + ], + "TenantId": "YOUR_TENANT_ID_HERE" + }, + "Connections": { + "ServiceConnection": { + "Settings": { + "AuthType": "ClientSecret", + "AuthorityEndpoint": "https://login.microsoftonline.com/YOUR_TENANT_ID_HERE", + "ClientId": "YOUR_CLIENT_ID_HERE", + "ClientSecret": "YOUR_CLIENT_SECRET_HERE" + } + }, + "GraphConnection": { + "Settings": { + "AuthType": "ClientSecret", + "AuthorityEndpoint": "https://login.microsoftonline.com/YOUR_TENANT_ID_HERE", + "ClientId": "YOUR_CLIENT_ID_HERE", + "ClientSecret": "YOUR_CLIENT_SECRET_HERE", + "Scopes": ["https://graph.microsoft.com/.default"] + } + } + }, + "AgentApplication": { + "UserAuthorization": { + "Handlers": { + "connector": { + "Type": "ConnectorUserAuthorization", + "Settings": { + "Name": "connector", + "OBOConnectionName": "GraphConnection", + "Scopes": ["https://graph.microsoft.com/.default"] + } + } + } + } + } +} diff --git a/test_samples/copilot_studio_connector/env.TEMPLATE b/test_samples/copilot_studio_connector/env.TEMPLATE new file mode 100644 index 00000000..89af6af9 --- /dev/null +++ b/test_samples/copilot_studio_connector/env.TEMPLATE @@ -0,0 +1,29 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=client-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=client-secret +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=tenant-id + +CONNECTIONS__MCS__SETTINGS__CLIENTID=client-id +CONNECTIONS__MCS__SETTINGS__CLIENTSECRET=client-secret +CONNECTIONS__MCS__SETTINGS__TENANTID=tenant-id + +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GRAPH__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME=connection-name +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GRAPH__SETTINGS__OBOCONNECTIONNAME=connection-name +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GITHUB__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME=connection-name +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GITHUB__SETTINGS__OBOCONNECTIONNAME=connection-name +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__MCS__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME=connection-name +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__MCS__SETTINGS__OBOCONNECTIONNAME=connection-name + +COPILOTSTUDIOAGENT__ENVIRONMENTID=environment-id +COPILOTSTUDIOAGENT__SCHEMANAME=schema-name +COPILOTSTUDIOAGENT__TENANTID=tenant-id +COPILOTSTUDIOAGENT__AGENTAPPID=agent-app-id + +# Proactive messaging sample settings +PROACTIVEMESSAGING__BOTID=28:teams-app-id +PROACTIVEMESSAGING__AGENTID=teams-app-id +PROACTIVEMESSAGING__TENANTID=tenant-id +PROACTIVEMESSAGING__SCOPE=https://api.botframework.com/.default +PROACTIVEMESSAGING__CHANNELID=msteams +PROACTIVEMESSAGING__SERVICEURL=https://smba.trafficmanager.net/teams/ +# Optional default user (if not supplied per request) +PROACTIVEMESSAGING__USERAADOBJECTID= diff --git a/test_samples/copilot_studio_connector/requirements.txt b/test_samples/copilot_studio_connector/requirements.txt new file mode 100644 index 00000000..189c0486 --- /dev/null +++ b/test_samples/copilot_studio_connector/requirements.txt @@ -0,0 +1,6 @@ +microsoft-agents-activity +microsoft-agents-hosting-aiohttp +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-storage-blob +aiohttp