diff --git a/libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/__init__.py b/libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/__init__.py index f75940b9..8536f337 100644 --- a/libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/__init__.py +++ b/libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/__init__.py @@ -1,9 +1,7 @@ -from .auth_types import AuthTypes -from .msal_auth_configuration import MsalAuthConfiguration from .msal_auth import MsalAuth +from .msal_connection_manager import MsalConnectionManager __all__ = [ - "AuthTypes", - "MsalAuthConfiguration", "MsalAuth", + "MsalConnectionManager", ] diff --git a/libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/msal_auth.py b/libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/msal_auth.py index 51b2bd1a..0e2c9184 100644 --- a/libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/msal_auth.py +++ b/libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/msal_auth.py @@ -13,17 +13,19 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes -from microsoft.agents.authorization import AccessTokenProviderBase +from microsoft.agents.authorization import ( + AccessTokenProviderBase, + AgentAuthConfiguration, +) -from .auth_types import AuthTypes -from .msal_auth_configuration import MsalAuthConfiguration +from microsoft.agents.authorization.auth_types import AuthTypes class MsalAuth(AccessTokenProviderBase): _client_credential_cache = None - def __init__(self, msal_configuration: MsalAuthConfiguration): + def __init__(self, msal_configuration: AgentAuthConfiguration): self._msal_configuration = msal_configuration async def get_access_token( @@ -48,6 +50,31 @@ async def get_access_token( # TODO: Handling token error / acquisition failed return auth_result_payload["access_token"] + async def aquire_token_on_behalf_of( + self, scopes: list[str], user_assertion: str + ) -> str: + """ + Acquire a token on behalf of a user. + :param scopes: The scopes for which to get the token. + :param user_assertion: The user assertion token. + :return: The access token as a string. + """ + + msal_auth_client = self._create_client_application() + if isinstance(msal_auth_client, ManagedIdentityClient): + raise NotImplementedError( + "On-behalf-of flow is not supported with Managed Identity authentication." + ) + elif isinstance(msal_auth_client, ConfidentialClientApplication): + # TODO: Handling token error / acquisition failed + return msal_auth_client.acquire_token_on_behalf_of( + user_assertion=user_assertion, scopes=scopes + )["access_token"] + + raise NotImplementedError( + f"On-behalf-of flow is not supported with the current authentication type: {msal_auth_client.__class__.__name__}" + ) + def _create_client_application( self, ) -> ManagedIdentityClient | ConfidentialClientApplication: diff --git a/libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/msal_auth_configuration.py b/libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/msal_auth_configuration.py deleted file mode 100644 index 73c3824b..00000000 --- a/libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/msal_auth_configuration.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Protocol, Optional - -from microsoft.agents.authorization import AgentAuthConfiguration - -from .auth_types import AuthTypes - - -class MsalAuthConfiguration(AgentAuthConfiguration, Protocol): - """ - Configuration for MSAL authentication. - """ - - AUTH_TYPE: AuthTypes - CLIENT_SECRET: Optional[str] - CERT_PEM_FILE: Optional[str] - CERT_KEY_FILE: Optional[str] - CONNECTION_NAME: Optional[str] - SCOPES: Optional[list[str]] - AUTHORITY: Optional[str] diff --git a/libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/msal_connection_manager.py b/libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/msal_connection_manager.py new file mode 100644 index 00000000..4c61101a --- /dev/null +++ b/libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/msal_connection_manager.py @@ -0,0 +1,72 @@ +from typing import Dict, List, Optional +from microsoft.agents.authorization import ( + AgentAuthConfiguration, + AccessTokenProviderBase, + ClaimsIdentity, + Connections, +) + +from .msal_auth import MsalAuth + + +class MsalConnectionManager(Connections): + + def __init__( + self, + connections_configurations: Dict[str, AgentAuthConfiguration] = None, + connections_map: List[Dict[str, str]] = None, + **kwargs + ): + self._connections: Dict[str, MsalAuth] = {} + self._connections_map = connections_map or kwargs.get("CONNECTIONS_MAP", {}) + self._service_connection_configuration: AgentAuthConfiguration = None + + if connections_configurations: + for ( + connection_name, + connection_settings, + ) in connections_configurations.items(): + self._connections[connection_name] = MsalAuth( + AgentAuthConfiguration(**connection_settings) + ) + else: + raw_configurations: Dict[str, Dict] = kwargs.get("CONNECTIONS", {}) + for connection_name, connection_settings in raw_configurations.items(): + parsed_configuration = AgentAuthConfiguration( + **connection_settings.get("SETTINGS", {}) + ) + self._connections[connection_name] = MsalAuth(parsed_configuration) + if connection_name == "SERVICE_CONNECTION": + self._service_connection_configuration = parsed_configuration + + if not self._connections.get("SERVICE_CONNECTION", None): + raise ValueError("No service connection configuration provided.") + + def get_connection(self, connection_name: Optional[str]) -> AccessTokenProviderBase: + """ + Get the OAuth connection for the agent. + """ + return self._connections.get(connection_name, None) + + def get_default_connection(self) -> AccessTokenProviderBase: + """ + Get the default OAuth connection for the agent. + """ + return self._connections.get("SERVICE_CONNECTION", None) + + def get_token_provider( + self, claims_identity: ClaimsIdentity, service_url: str + ) -> AccessTokenProviderBase: + """ + Get the OAuth token provider for the agent. + """ + if not self._connections_map: + 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: + """ + Get the default connection configuration for the agent. + """ + return self._service_connection_configuration diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/__init__.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/__init__.py index e21dd234..b5ee917c 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/__init__.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/__init__.py @@ -11,7 +11,7 @@ from .input_file import InputFile, InputFileDownloader from .query import Query from .route import Route, RouteHandler -from .typing import Typing +from .typing_indicator import TypingIndicator from .state.conversation_state import ConversationState from .state.state import State, StatePropertyAccessor, state from .state.temp_state import TempState @@ -30,7 +30,7 @@ "Query", "Route", "RouteHandler", - "Typing", + "TypingIndicator", "StatePropertyAccessor", "ConversationState", "state", diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/agent_application.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/agent_application.py index f08ca9bb..5ffe1831 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/agent_application.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/agent_application.py @@ -4,22 +4,30 @@ """ from __future__ import annotations +from copy import copy +from functools import partial +from os import environ import re from typing import ( + Any, Awaitable, Callable, + Dict, Generic, List, Optional, Pattern, - Tuple, TypeVar, Union, cast, ) +from dotenv import load_dotenv +from microsoft.agents.authorization import AgentAuthConfiguration, Connections + from .. import Agent, TurnContext +from microsoft.agents.core import load_configuration_from_env from microsoft.agents.core.models import ( Activity, ActivityTypes, @@ -36,7 +44,8 @@ from .route import Route, RouteHandler from .state import TurnState from ..channel_service_adapter import ChannelServiceAdapter -from .typing import Typing +from .oauth import Authorization, SignInState +from .typing_indicator import TypingIndicator StateT = TypeVar("StateT", bound=TurnState) IN_SIGN_IN_KEY = "__InSignInFlow__" @@ -55,25 +64,35 @@ class AgentApplication(Agent, Generic[StateT]): and other AI capabilities. """ - typing: Typing + typing: TypingIndicator _options: ApplicationOptions _adapter: Optional[ChannelServiceAdapter] = None - # _auth: Optional[AuthManager[StateT]] = None - _before_turn: List[RouteHandler[StateT]] = [] - _after_turn: List[RouteHandler[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]] = [] _error: Optional[Callable[[TurnContext, Exception], Awaitable[None]]] = None - _turn_state_factory: Optional[Callable[[TurnContext], Awaitable[StateT]]] = None + _turn_state_factory: Optional[Callable[[TurnContext], StateT]] = None - def __init__(self, options: ApplicationOptions = None, **kwargs) -> None: + def __init__( + self, + options: ApplicationOptions = None, + *, + connection_manager: Connections = None, + authorization: Authorization = None, + **kwargs, + ) -> None: """ Creates a new AgentApplication instance. """ - self.typing = Typing() + self.typing = TypingIndicator() self._routes = [] + configuration = kwargs + if not options: + # TODO: consolidate configuration story # Take the options from the kwargs and create an ApplicationOptions instance option_kwargs = dict( filter( @@ -105,17 +124,30 @@ def __init__(self, options: ApplicationOptions = None, **kwargs) -> None: if options.adapter: self._adapter = options.adapter - """ - if options.auth: - self._auth = AuthManager[StateT](default=options.auth.default) - - for name, opts in options.auth.settings.items(): - if isinstance(opts, OAuthOptions): - self._auth.set(name, OAuth[StateT](opts)) - """ + self._turn_state_factory = ( + options.turn_state_factory + or kwargs.get("turn_state_factory", None) + or partial(TurnState.with_storage, self._options.storage) + ) - # TODO: Disabling AI chain for now - self._ai = None + # TODO: decide how to initialize the Authorization (params vs options vs kwargs) + if authorization: + self._auth = authorization + else: + if not connection_manager: + raise ApplicationError( + """ + The `AgentApplication` requires a `Connections` instance to be passed as the + `connection_manager` parameter. + """ + ) + else: + self._auth = Authorization( + storage=self._options.storage, + connection_manager=connection_manager, + handlers=options.authorization_handlers, + **configuration, + ) @property def adapter(self) -> ChannelServiceAdapter: @@ -156,7 +188,10 @@ def options(self) -> ApplicationOptions: return self._options def activity( - self, type: Union[str, Pattern[str], List[Union[str, Pattern[str]]]] + self, + activity_type: Union[str, ActivityTypes, List[Union[str, ActivityTypes]]], + *, + auth_handlers: Optional[List[str]] = None, ) -> Callable[[RouteHandler[StateT]], RouteHandler[StateT]]: """ Registers a new activity event listener. This method can be used as either @@ -175,16 +210,21 @@ async def on_event(context: TurnContext, state: TurnState): """ def __selector(context: TurnContext): - return type == context.activity.type + return activity_type == context.activity.type def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: - self._routes.append(Route[StateT](__selector, func)) + self._routes.append( + Route[StateT](__selector, func, auth_handlers=auth_handlers) + ) return func return __call def message( - self, select: Union[str, Pattern[str], List[Union[str, Pattern[str]]]] + self, + select: Union[str, Pattern[str], List[Union[str, Pattern[str]]]], + *, + 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 @@ -207,19 +247,24 @@ def __selector(context: TurnContext): text = context.activity.text if context.activity.text else "" if isinstance(select, Pattern): - hits = re.match(select, text) - return hits is not None and len(hits.regs) == 1 + hits = re.fullmatch(select, text) + return hits is not None return text == select def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: - self._routes.append(Route[StateT](__selector, func)) + self._routes.append( + Route[StateT](__selector, func, auth_handlers=auth_handlers) + ) return func return __call def conversation_update( - self, type: ConversationUpdateTypes + self, + type: ConversationUpdateTypes, + *, + 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 @@ -259,13 +304,15 @@ def __selector(context: TurnContext): return False def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: - self._routes.append(Route[StateT](__selector, func)) + self._routes.append( + Route[StateT](__selector, func, auth_handlers=auth_handlers) + ) return func return __call def message_reaction( - self, type: MessageReactionTypes + 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 @@ -300,13 +347,15 @@ def __selector(context: TurnContext): return False def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: - self._routes.append(Route[StateT](__selector, func)) + self._routes.append( + Route[StateT](__selector, func, auth_handlers=auth_handlers) + ) return func return __call def message_update( - self, type: MessageUpdateTypes + 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 @@ -354,14 +403,14 @@ def __selector(context: TurnContext): return False def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: - self._routes.append(Route[StateT](__selector, func)) + self._routes.append( + Route[StateT](__selector, func, auth_handlers=auth_handlers) + ) return func return __call - def handoff( - self, - ) -> Callable[ + def handoff(self, *, auth_handlers: Optional[List[str]] = None) -> Callable[ [Callable[[TurnContext, StateT, str], Awaitable[None]]], Callable[[TurnContext, StateT, str], Awaitable[None]], ]: @@ -386,7 +435,7 @@ def __selector(context: TurnContext) -> bool: def __call( func: Callable[[TurnContext, StateT, str], Awaitable[None]], ) -> Callable[[TurnContext, StateT, str], Awaitable[None]]: - async def __handler__(context: TurnContext, state: StateT): + async def __handler(context: TurnContext, state: StateT): if not context.activity.value: return False await func(context, state, context.activity.value["continuation"]) @@ -398,45 +447,64 @@ async def __handler__(context: TurnContext, state: StateT): ) return True - self._routes.append(Route[StateT](__selector, __handler__, True)) + self._routes.append( + Route[StateT](__selector, __handler, True, auth_handlers) + ) + self._routes = sorted(self._routes, key=lambda route: not route.is_invoke) return func return __call - def before_turn(self, func: RouteHandler[StateT]) -> RouteHandler[StateT]: + def on_sign_in_success( + self, func: Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]] + ) -> Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]]: """ - Registers a new event listener that will be executed before turns. - This method can be used as either a decorator or a method and - is called in the order they are registered. + Registers a new event listener that will be executed when a user successfully signs in. ```python # Use this method as a decorator - @app.before_turn - async def on_before_turn(context: TurnContext, state: TurnState): + @app.on_sign_in_success + async def sign_in_success(context: TurnContext, state: TurnState): print("hello world!") return True ``` """ - self._before_turn.append(func) + if self._auth: + self._auth.on_sign_in_success(func) + else: + raise ApplicationError( + """ + The `AgentApplication.on_sign_in_success` method is unavailable because + no Auth options were configured. + """ + ) return func - def after_turn(self, func: RouteHandler[StateT]) -> RouteHandler[StateT]: + def on_sign_in_failure( + self, func: Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]] + ) -> Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]]: """ - Registers a new event listener that will be executed after turns. - This method can be used as either a decorator or a method and - is called in the order they are registered. + Registers a new event listener that will be executed when a user fails to sign in. ```python # Use this method as a decorator - @app.after_turn - async def on_after_turn(context: TurnContext, state: TurnState): + @app.on_sign_in_failure + async def sign_in_failure(context: TurnContext, state: TurnState): print("hello world!") return True ``` """ - self._after_turn.append(func) + if self._auth: + self._auth.on_sign_in_failure(func) + else: + raise ApplicationError( + """ + The `AgentApplication.on_sign_in_failure` method is unavailable because + no Auth options were configured. + """ + ) return func def error( @@ -480,10 +548,24 @@ async def _on_turn(self, context: TurnContext): turn_state = await self._initialize_state(context) - """ - if not await self._authenticate_user(context, state): - return - """ + sign_in_state = turn_state.get_value( + Authorization.SIGN_IN_STATE_KEY, target_cls=SignInState + ) + + if self._auth and sign_in_state and not sign_in_state.completed: + flow_state = self._auth.get_flow_state(sign_in_state.handler_id) + if flow_state.flow_started: + token_response = await self._auth.begin_or_continue_flow( + context, turn_state, sign_in_state.handler_id + ) + saved_activity = sign_in_state.continuation_activity.model_copy() + if token_response and token_response.token: + new_context = copy(context) + new_context.activity = saved_activity + await self.on_turn(new_context) + turn_state.delete_value(Authorization.SIGN_IN_STATE_KEY) + await turn_state.save(context) + return if not await self._run_before_turn_middleware(context, turn_state): return @@ -494,9 +576,8 @@ async def _on_turn(self, context: TurnContext): if not await self._run_after_turn_middleware(context, turn_state): await turn_state.save(context) - return - await turn_state.save(context) + return except ApplicationError as err: await self._on_error(context, err) finally: @@ -513,9 +594,33 @@ 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: + """ + Parses environment variables and returns a dictionary with the relevant configuration. + """ + result = {} + for key, value in vars.items(): + levels = key.split("__") + current_level = result + last_level = None + for next_level in levels: + if next_level not in current_level: + current_level[next_level] = {} + last_level = current_level + current_level = current_level[next_level] + last_level[levels[-1]] = value + + return { + "AGENT_APPLICATION": result["AGENT_APPLICATION"], + "COPILOT_STUDIO_AGENT": result["COPILOT_STUDIO_AGENT"], + "CONNECTIONS": result["CONNECTIONS"], + "CONNECTIONS_MAP": result["CONNECTIONS_MAP"], + } + async def _initialize_state(self, context: TurnContext) -> StateT: if self._turn_state_factory: - turn_state = await self._turn_state_factory() + turn_state = self._turn_state_factory() else: turn_state = TurnState.with_storage(self._options.storage) await turn_state.load(context, self._options.storage) @@ -526,42 +631,15 @@ async def _initialize_state(self, context: TurnContext) -> StateT: turn_state.temp.input = context.activity.text return turn_state - async def _authenticate_user(self, context: TurnContext, state): - if self.options.auth and self._auth: - auth_condition = ( - isinstance(self.options.auth.auto, bool) and self.options.auth.auto - ) or (callable(self.options.auth.auto) and self.options.auth.auto(context)) - user_in_sign_in = IN_SIGN_IN_KEY in state.user - if auth_condition or user_in_sign_in: - key: Optional[str] = state.user.get( - IN_SIGN_IN_KEY, self.options.auth.default - ) - - if key is not None: - state.user[IN_SIGN_IN_KEY] = key - res = await self._auth.sign_in(context, state, key=key) - if res.status == "complete": - del state.user[IN_SIGN_IN_KEY] - - if res.status == "pending": - await state.save(context, self._options.storage) - return False - - if res.status == "error" and res.reason != "invalid-activity": - del state.user[IN_SIGN_IN_KEY] - raise ApplicationError(f"[{res.reason}] => {res.message}") - - return True - - async def _run_before_turn_middleware(self, context: TurnContext, state): - for before_turn in self._before_turn: + async def _run_before_turn_middleware(self, context: TurnContext, state: StateT): + for before_turn in self._internal_before_turn: is_ok = await before_turn(context, state) if not is_ok: await state.save(context, self._options.storage) return False return True - async def _handle_file_downloads(self, context: TurnContext, state): + async def _handle_file_downloads(self, context: TurnContext, state: StateT): if self._options.file_downloaders and len(self._options.file_downloaders) > 0: input_files = state.temp.input_files if state.temp.input_files else [] for file_downloader in self._options.file_downloaders: @@ -569,19 +647,6 @@ async def _handle_file_downloads(self, context: TurnContext, state): input_files.extend(files) state.temp.input_files = input_files - async def _run_ai_chain(self, context: TurnContext, state: StateT): - if ( - self._ai - and self._options.ai - and context.activity.type == ActivityTypes.message - and (context.activity.text or self._contains_non_text_attachments(context)) - ): - is_ok = await self._ai.run(context, state) - if not is_ok: - await state.save(context, self._options.storage) - return False - return True - def _contains_non_text_attachments(self, context: TurnContext): non_text_attachments = filter( lambda a: not a.content_type.startswith("text/html"), @@ -590,7 +655,7 @@ def _contains_non_text_attachments(self, context: TurnContext): return len(list(non_text_attachments)) > 0 async def _run_after_turn_middleware(self, context: TurnContext, state: StateT): - for after_turn in self._after_turn: + for after_turn in self._internal_after_turn: is_ok = await after_turn(context, state) if not is_ok: await state.save(context, self._options.storage) @@ -598,20 +663,21 @@ async def _run_after_turn_middleware(self, context: TurnContext, state: StateT): return True async def _on_activity(self, context: TurnContext, state: StateT): - # ensure we handle invokes first - routes = filter(lambda r: not r.is_invoke and r.selector(context), self._routes) - invoke_routes = filter( - lambda r: r.is_invoke and r.selector(context), self._routes - ) - - for route in invoke_routes: - if route.selector(context): - await route.handler(context, state) - return - - for route in routes: + for route in self._routes: if route.selector(context): - await route.handler(context, state) + if not route.auth_handlers: + await route.handler(context, state) + else: + sign_in_complete = False + for auth_handler_id in route.auth_handlers: + token_response = await self._auth.begin_or_continue_flow( + context, state, auth_handler_id + ) + sign_in_complete = token_response and token_response.token + if not sign_in_complete: + break + if sign_in_complete: + await route.handler(context, state) return async def _start_long_running_call( diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/app_options.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/app_options.py index cbc1d17c..260c4512 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/app_options.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/app_options.py @@ -9,6 +9,7 @@ from logging import Logger from typing import Callable, List, Optional +from microsoft.agents.builder.app.oauth.authorization import AuthorizationHandlers from microsoft.agents.storage import Storage # from .auth import AuthOptions @@ -82,3 +83,9 @@ class ApplicationOptions: This should return an instance of `TurnState` or a subclass. If not provided, the default `TurnState` will be used. """ + + authorization_handlers: Optional[AuthorizationHandlers] = None + """ + Optional. Authorization handler for OAuth flows. + If not provided, no OAuth flows will be supported. + """ diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/oauth/__init__.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/oauth/__init__.py new file mode 100644 index 00000000..ff280c7f --- /dev/null +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/oauth/__init__.py @@ -0,0 +1,8 @@ +from .authorization import ( + Authorization, + AuthorizationHandlers, + AuthHandler, + SignInState, +) + +__all__ = ["Authorization", "AuthorizationHandlers", "AuthHandler", "SignInState"] diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/oauth/authorization.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/oauth/authorization.py new file mode 100644 index 00000000..b0d0b341 --- /dev/null +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/oauth/authorization.py @@ -0,0 +1,396 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations +import jwt +from typing import Dict, Optional, Callable, Awaitable + +from microsoft.agents.authorization import Connections, AccessTokenProviderBase +from microsoft.agents.storage import Storage +from microsoft.agents.core.models import TokenResponse, Activity +from microsoft.agents.storage import StoreItem +from pydantic import BaseModel + +from ...turn_context import TurnContext +from ...app.state.turn_state import TurnState +from ...oauth_flow import OAuthFlow, FlowState +from ...state.user_state import UserState + + +class SignInState(StoreItem, BaseModel): + """ + Interface defining the sign-in state for OAuth flows. + """ + + continuation_activity: Optional[Activity] = None + handler_id: Optional[str] = None + completed: Optional[bool] = False + + def store_item_to_json(self) -> dict: + return self.model_dump(exclude_unset=True) + + @staticmethod + def from_json_to_store_item(json_data: dict) -> "StoreItem": + return SignInState.model_validate(json_data) + + +class AuthHandler: + """ + Interface defining an authorization handler for OAuth flows. + """ + + def __init__( + self, + name: str = None, + title: str = None, + text: str = None, + abs_oauth_connection_name: str = None, + obo_connection_name: str = None, + **kwargs, + ): + """ + 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. + """ + 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" + ) + self.obo_connection_name = obo_connection_name or kwargs.get( + "OBOCONNECTIONNAME" + ) + self.flow: OAuthFlow = None + + +# Type alias for authorization handlers dictionary +AuthorizationHandlers = Dict[str, AuthHandler] + + +class Authorization: + """ + Class responsible for managing authorization and OAuth flows. + """ + + SIGN_IN_STATE_KEY = f"{UserState.__name__}.__SIGNIN_STATE_" + + def __init__( + self, + storage: Storage, + connection_manager: Connections, + auth_handlers: AuthorizationHandlers = None, + auto_signin: bool = None, + **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 storage is None: + raise ValueError("Storage is required for Authorization") + + user_state = UserState(storage) + self._connection_manager = connection_manager + + auth_configuration: Dict = kwargs.get("AGENTAPPLICATION", {}).get( + "USERAUTHORIZATION", {} + ) + + self._auto_signin = ( + auto_signin + if auto_signin is not None + else auth_configuration.get("AUTOSIGNIN", False) + ) + + if not auth_handlers: + handlers_congif: Dict[str, Dict] = auth_configuration.get("HANDLERS") + if not handlers_congif: + raise ValueError("The authorization does not have any auth handlers") + auth_handlers = { + handler_name: AuthHandler( + name=handler_name, **config.get("SETTINGS", {}) + ) + for handler_name, config in handlers_congif.items() + } + + self._auth_handlers = auth_handlers + self._sign_in_handler: Optional[ + Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]] + ] = None + self._sign_in_failed_handler: Optional[ + Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]] + ] = None + + # Configure each auth handler + for auth_handler in self._auth_handlers.values(): + # Create OAuth flow with configuration + messages_config = {} + if auth_handler.title: + messages_config["card_title"] = auth_handler.title + if auth_handler.text: + messages_config["button_text"] = auth_handler.text + + auth_handler.flow = OAuthFlow( + storage=storage, + abs_oauth_connection_name=auth_handler.abs_oauth_connection_name, + messages_configuration=messages_config if messages_config else None, + ) + + async def get_token( + self, context: TurnContext, auth_handler_id: Optional[str] = None + ) -> 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. + """ + auth_handler = self.resolver_handler(auth_handler_id) + if auth_handler.flow is None: + raise ValueError("OAuth flow is not configured for the auth handler") + + return await auth_handler.flow.get_user_token(context) + + 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. + """ + auth_handler = self.resolver_handler(auth_handler_id) + if not auth_handler.flow: + raise ValueError("OAuth flow is not configured for the auth handler") + + token_response = await auth_handler.flow.get_user_token(context) + + if self._is_exchangeable(token_response.token if token_response else None): + return await self._handle_obo(token_response.token, scopes, auth_handler_id) + + return token_response + + def _is_exchangeable(self, token: Optional[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. + """ + if not token: + return False + + 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: + 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.resolver_handler(handler_id) + if auth_handler.flow is None: + raise ValueError("OAuth flow is not configured for the auth handler") + + # Use the flow's OBO method to exchange the token + token_provider: AccessTokenProviderBase = ( + self._connection_manager.get_connection(auth_handler.obo_connection_name) + ) + token = await token_provider.aquire_token_on_behalf_of( + scopes=scopes, + user_assertion=token, + ) + return TokenResponse( + token=token, + scopes=scopes, # Expiration can be set based on the token provider's response + ) + + def get_flow_state(self, auth_handler_id: Optional[str] = None) -> FlowState: + """ + Gets the current state of the OAuth flow. + + Args: + auth_handler_id: Optional ID of the auth handler to check, defaults to first handler. + + Returns: + The flow state object. + """ + flow = self.resolver_handler(auth_handler_id).flow + if flow is None: + # Return a default FlowState if no flow is configured + return FlowState() + + # Return flow state if available + return flow.flow_state or FlowState() + + async def begin_or_continue_flow( + self, + context: TurnContext, + turn_state: TurnState, + auth_handler_id: str, + sec_route: bool = True, + ) -> TokenResponse: + """ + Begins or continues an OAuth flow. + + Args: + context: The context object for the current 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. + """ + auth_handler = self.resolver_handler(auth_handler_id) + # Get or initialize sign-in state + sign_in_state = turn_state.get_value( + self.SIGN_IN_STATE_KEY, target_cls=SignInState + ) + if sign_in_state is None: + sign_in_state = SignInState( + continuation_activity=None, handler_id=None, completed=False + ) + + flow = auth_handler.flow + if flow is None: + raise ValueError("OAuth flow is not configured for the auth handler") + + token_response = await flow.get_user_token(context) + if token_response and token_response.token: + return token_response + + # Get the current flow state + flow_state = await flow._get_flow_state(context) + + if not flow_state.flow_started: + token_response = await flow.begin_flow(context) + if sec_route: + sign_in_state.continuation_activity = context.activity + sign_in_state.handler_id = auth_handler_id + turn_state.set_value(self.SIGN_IN_STATE_KEY, sign_in_state) + else: + token_response = await flow.continue_flow(context) + # Check if sign-in was successful and call handler if configured + if token_response and token_response.token: + if self._sign_in_handler: + await self._sign_in_handler(context, turn_state, auth_handler_id) + if sec_route: + turn_state.delete_value(self.SIGN_IN_STATE_KEY) + else: + if self._sign_in_failed_handler: + await self._sign_in_failed_handler( + context, turn_state, auth_handler_id + ) + + await turn_state.save(context) + return token_response + + def resolver_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: + 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 + first_key = next(iter(self._auth_handlers)) + return self._auth_handlers[first_key] + + async def sign_out( + self, + context: TurnContext, + state: TurnState, + auth_handler_id: Optional[str] = None, + ) -> None: + """ + 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. + state: The state object for the current turn. + auth_handler_id: Optional ID of the auth handler to use for sign out. + """ + if auth_handler_id is None: + # Sign out from all handlers + for handler_key, auth_handler in self._auth_handlers.items(): + if auth_handler.flow: + await auth_handler.flow.sign_out(context) + else: + # Sign out from specific handler + auth_handler = self.resolver_handler(auth_handler_id) + if auth_handler.flow: + await auth_handler.flow.sign_out(context) + + 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_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_failed_handler = handler diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/route.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/route.py index 921a172b..761a7ede 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/route.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/route.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import Awaitable, Callable, Generic, TypeVar +from typing import Awaitable, Callable, Generic, List, TypeVar from .. import TurnContext from .state import TurnState @@ -24,7 +24,9 @@ def __init__( 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/Builder/microsoft-agents-builder/microsoft/agents/builder/app/state/conversation_state.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/state/conversation_state.py index c301cf89..2281a771 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/state/conversation_state.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/state/conversation_state.py @@ -18,7 +18,7 @@ class ConversationState(AgentState): Default Conversation State """ - CONTEXT_SERVICE_KEY = "conversation_state" + CONTEXT_SERVICE_KEY = "ConversationState" def __init__(self, storage: Storage) -> None: """ diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/state/turn_state.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/state/turn_state.py index c65a3c97..19db88fd 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/state/turn_state.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/state/turn_state.py @@ -107,7 +107,11 @@ def has_value(self, path: str) -> bool: ) def get_value( - self, name: str, default_value_factory: Optional[Callable[[], T]] = None + self, + name: str, + default_value_factory: Optional[Callable[[], T]] = None, + *, + target_cls: Type[T] = None, ) -> T: """ Gets a value from state. @@ -122,7 +126,9 @@ def get_value( scope, property_name = self._get_scope_and_path(name) scope_obj = self.get_scope_by_name(scope) if hasattr(scope_obj, "get_value"): - return scope_obj.get_value(property_name, default_value_factory) + return scope_obj.get_value( + property_name, default_value_factory, target_cls=target_cls + ) return None def set_value(self, path: str, value: Any) -> None: diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/typing.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/typing_indicator.py similarity index 98% rename from libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/typing.py rename to libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/typing_indicator.py index a81492d1..cf67f52a 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/typing.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/typing_indicator.py @@ -12,7 +12,7 @@ from microsoft.agents.core.models import Activity, ActivityTypes -class Typing: +class TypingIndicator: """ Encapsulates the logic for sending "typing" activity to the user. """ diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/channel_adapter.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/channel_adapter.py index 80c742f4..c379a5b3 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/channel_adapter.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/channel_adapter.py @@ -8,6 +8,7 @@ from microsoft.agents.core import ChannelAdapterProtocol from microsoft.agents.core.models import ( Activity, + ConversationAccount, ConversationReference, ConversationParameters, ResourceResponse, diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/oauth_flow.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/oauth_flow.py index f90a8397..43c54beb 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/oauth_flow.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/oauth_flow.py @@ -3,9 +3,8 @@ from __future__ import annotations -import base64 from datetime import datetime -import json +from typing import Dict, Optional from microsoft.agents.connector.client import UserTokenClient from microsoft.agents.core.models import ( @@ -16,11 +15,12 @@ OAuthCard, TokenExchangeState, TokenResponse, + Activity, ) from microsoft.agents.core import ( TurnContextProtocol as TurnContext, ) -from microsoft.agents.storage import StoreItem +from microsoft.agents.storage import StoreItem, Storage from pydantic import BaseModel from .message_factory import MessageFactory @@ -33,6 +33,8 @@ class FlowState(StoreItem, BaseModel): flow_started: bool = False user_token: str = "" flow_expires: float = 0 + abs_oauth_connection_name: Optional[str] = None + continuation_activity: Optional[Activity] = None def store_item_to_json(self) -> dict: return self.model_dump() @@ -44,106 +46,131 @@ def from_json_to_store_item(json_data: dict) -> "StoreItem": class OAuthFlow: """ - Manages the OAuth flow for Web Chat. + Manages the OAuth flow. """ def __init__( self, - user_state: UserState, - connection_name: str, + storage: Storage, + abs_oauth_connection_name: str, + user_token_client: Optional[UserTokenClient] = None, messages_configuration: dict[str, str] = None, **kwargs, ): """ Creates a new instance of OAuthFlow. - :param user_state: The user state. + + Args: + user_state: The user state. + abs_oauth_connection_name: The OAuth connection name. + user_token_client: Optional user token client. + messages_configuration: Optional messages configuration for backward compatibility. """ - if not connection_name: + if not abs_oauth_connection_name: raise ValueError( "OAuthFlow.__init__: connectionName expected but not found" ) + # Handle backward compatibility with messages_configuration self.messages_configuration = messages_configuration or {} - self.connection_name = connection_name - self.state: FlowState | None = None - self.flow_state_accessor: StatePropertyAccessor = user_state.create_property( - "flowState" - ) + # Initialize properties + self.abs_oauth_connection_name = abs_oauth_connection_name + self.user_token_client = user_token_client + self.token_exchange_id: Optional[str] = None + + # Initialize state and flow state + self._storage = storage + self.flow_state = None async def get_user_token(self, context: TurnContext) -> TokenResponse: - token_client: UserTokenClient = context.turn_state.get( - context.adapter.USER_TOKEN_CLIENT_KEY - ) + """ + Retrieves the user token from the user token service. + + Args: + context: The turn context containing the activity information. + + Returns: + The user token response. + + Raises: + ValueError: If the channelId or from properties are not set in the activity. + """ + await self._initialize_token_client(context) if not context.activity.from_property: raise ValueError("User ID is not set in the activity.") - return await token_client.user_token.get_token( + if not context.activity.channel_id: + raise ValueError("Channel ID is not set in the activity.") + + return await self.user_token_client.user_token.get_token( user_id=context.activity.from_property.id, - connection_name=self.connection_name, + connection_name=self.abs_oauth_connection_name, channel_id=context.activity.channel_id, ) async def begin_flow(self, context: TurnContext) -> TokenResponse: """ - Starts the OAuth flow. + Begins the OAuth flow. - :param context: The turn context. - :return: A TokenResponse object. + Args: + context: The turn context. + + Returns: + A TokenResponse object. """ - # logger.info('Starting OAuth flow') - self.state = await self._get_user_state(context) + self.flow_state = FlowState() - if not self.connection_name: - raise ValueError( - "connectionName is not set in the auth config, review your environment variables" - ) + if not self.abs_oauth_connection_name: + raise ValueError("connectionName is not set") - # Get token client from turn state - token_client: UserTokenClient = context.turn_state.get( - context.adapter.USER_TOKEN_CLIENT_KEY - ) + await self._initialize_token_client(context) - # Try to get existing token - user_token = await token_client.user_token.get_token( - user_id=context.activity.from_property.id, - connection_name=self.connection_name, - channel_id=context.activity.channel_id, + activity = context.activity + + # Try to get existing token first + user_token = await self.user_token_client.user_token.get_token( + user_id=activity.from_property.id, + connection_name=self.abs_oauth_connection_name, + channel_id=activity.channel_id, ) if user_token and user_token.token: # Already have token, return it - self.state.flow_started = False - self.state.flow_expires = 0 - await self.flow_state_accessor.set(context, self.state) - # logger.info('User token retrieved successfully from service') + self.flow_state.flow_started = False + self.flow_state.flow_expires = 0 + self.flow_state.abs_oauth_connection_name = self.abs_oauth_connection_name + await self._save_flow_state(context) return user_token # No token, need to start sign-in flow token_exchange_state = TokenExchangeState( - connection_name=self.connection_name, - conversation=context.activity.get_conversation_reference(), - relates_to=context.activity.relates_to, + connection_name=self.abs_oauth_connection_name, + conversation=activity.get_conversation_reference(), + relates_to=activity.relates_to, ms_app_id=context.turn_state.get(context.adapter.AGENT_IDENTITY_KEY).claims[ "aud" ], ) - signing_resource = await token_client.agent_sign_in.get_sign_in_resource( - state=token_exchange_state.get_encoded_state(), + signing_resource = ( + await self.user_token_client.agent_sign_in.get_sign_in_resource( + state=token_exchange_state.get_encoded_state(), + ) ) # Create the OAuth card o_card: Attachment = CardFactory.oauth_card( OAuthCard( text=self.messages_configuration.get("card_title", "Sign in"), - connection_name=self.connection_name, + connection_name=self.abs_oauth_connection_name, buttons=[ CardAction( title=self.messages_configuration.get("button_text", "Sign in"), type=ActionTypes.signin, value=signing_resource.sign_in_link, + channel_data=None, ) ], token_exchange_resource=signing_resource.token_exchange_resource, @@ -155,10 +182,10 @@ async def begin_flow(self, context: TurnContext) -> TokenResponse: await context.send_activity(MessageFactory.attachment(o_card)) # Update flow state - self.state.flow_started = True - self.state.flow_expires = datetime.now().timestamp() + 30000 - await self.flow_state_accessor.set(context, self.state) - # logger.info('OAuth begin flow completed, waiting for user to sign in') + self.flow_state.flow_started = True + self.flow_state.flow_expires = datetime.now().timestamp() + 30000 + self.flow_state.abs_oauth_connection_name = self.abs_oauth_connection_name + await self._save_flow_state(context) # Return in-progress response return TokenResponse() @@ -166,17 +193,20 @@ async def begin_flow(self, context: TurnContext) -> TokenResponse: async def continue_flow(self, context: TurnContext) -> TokenResponse: """ Continues the OAuth flow. - :param context: The turn context. - :return: A TokenResponse object. + + Args: + context: The turn context. + + Returns: + A TokenResponse object. """ - self.state = await self._get_user_state(context) + await self._initialize_token_client(context) if ( - self.state.flow_expires != 0 - and datetime.now().timestamp() > self.state.flow_expires + self.flow_state + and self.flow_state.flow_expires != 0 + and datetime.now().timestamp() > self.flow_state.flow_expires ): - # logger.warn("Flow expired") - self.state.flow_started = False await context.send_activity( MessageFactory.text( self.messages_configuration.get( @@ -193,57 +223,61 @@ async def continue_flow(self, context: TurnContext) -> TokenResponse: if cont_flow_activity.type == ActivityTypes.message: magic_code = cont_flow_activity.text - # Get token client from turn state - token_client: UserTokenClient = context.turn_state.get( - context.adapter.USER_TOKEN_CLIENT_KEY - ) - - # Try to get token with the code - result = await token_client.user_token.get_token( - user_id=cont_flow_activity.from_property.id, - connection_name=self.connection_name, - channel_id=cont_flow_activity.channel_id, - code=magic_code, - ) + # Validate magic code format (6 digits) + if magic_code and magic_code.isdigit() and len(magic_code) == 6: + result = await self.user_token_client.user_token.get_token( + user_id=cont_flow_activity.from_property.id, + connection_name=self.abs_oauth_connection_name, + channel_id=cont_flow_activity.channel_id, + code=magic_code, + ) - if result: - token_response = TokenResponse.model_validate(result) - if token_response.token: - self.state.flow_started = False - self.state.user_token = token_response.token - await self.flow_state_accessor.set(context, self.state) - return token_response - return TokenResponse() + if result and result.token: + self.flow_state.flow_started = False + self.flow_state.flow_expires = 0 + self.flow_state.abs_oauth_connection_name = ( + self.abs_oauth_connection_name + ) + await self._save_flow_state(context) + return result + else: + await context.send_activity( + MessageFactory.text("Invalid code. Please try again.") + ) + self.flow_state.flow_started = True + self.flow_state.flow_expires = datetime.now().timestamp() + 30000 + await self._save_flow_state(context) + return TokenResponse() + else: + await context.send_activity( + MessageFactory.text( + "Invalid code format. Please enter a 6-digit code." + ) + ) + return TokenResponse() # Handle verify state invoke activity if ( cont_flow_activity.type == ActivityTypes.invoke and cont_flow_activity.name == "signin/verifyState" ): - # logger.info('Continuing OAuth flow with verifyState') token_verify_state = cont_flow_activity.value magic_code = token_verify_state.get("state") - # Get token client from turn state - token_client: UserTokenClient = context.turn_state.get( - context.adapter.USER_TOKEN_CLIENT_KEY - ) - - # Try to get token with the code - result = await token_client.user_token.get_token( + result = await self.user_token_client.user_token.get_token( user_id=cont_flow_activity.from_property.id, - connection_name=self.connection_name, + connection_name=self.abs_oauth_connection_name, channel_id=cont_flow_activity.channel_id, code=magic_code, ) - if result: - token_response = TokenResponse.model_validate(result) - if token_response.token: - self.state.flow_started = False - self.state.user_token = token_response.token - await self.flow_state_accessor.set(context, self.state) - return token_response + if result and result.token: + self.flow_state.flow_started = False + self.flow_state.abs_oauth_connection_name = ( + self.abs_oauth_connection_name + ) + await self._save_flow_state(context) + return result return TokenResponse() # Handle token exchange invoke activity @@ -251,77 +285,115 @@ async def continue_flow(self, context: TurnContext) -> TokenResponse: cont_flow_activity.type == ActivityTypes.invoke and cont_flow_activity.name == "signin/tokenExchange" ): - # logger.info('Continuing OAuth flow with tokenExchange') token_exchange_request = cont_flow_activity.value # Dedupe checks to prevent duplicate processing token_exchange_id = token_exchange_request.get("id") - if ( - hasattr(self, "token_exchange_id") - and self.token_exchange_id == token_exchange_id - ): + if self.token_exchange_id == token_exchange_id: # Already processed this request return TokenResponse() # Store this request ID self.token_exchange_id = token_exchange_id - # Get token client from turn state - token_client: UserTokenClient = context.turn_state.get( - context.adapter.USER_TOKEN_CLIENT_KEY - ) - # Exchange the token - user_token_resp = await token_client.user_token.exchange_token( + user_token_resp = await self.user_token_client.user_token.exchange_token( user_id=cont_flow_activity.from_property.id, - connection_name=self.connection_name, + connection_name=self.abs_oauth_connection_name, channel_id=cont_flow_activity.channel_id, body=token_exchange_request, ) if user_token_resp and user_token_resp.token: - # logger.info('Token exchanged') - self.state.flow_started = False - self.state.user_token = user_token_resp.token - await self.flow_state_accessor.set(context, self.state) + self.flow_state.flow_started = False + await self._save_flow_state(context) return user_token_resp else: - # logger.warn('Token exchange failed') - self.state.flow_started = True - await self.flow_state_accessor.set(context, self.state) + self.flow_state.flow_started = True return TokenResponse() return TokenResponse() - async def sign_out(self, context: TurnContext): + async def sign_out(self, context: TurnContext) -> None: """ Signs the user out. - :param context: The turn context. + + Args: + context: The turn context. """ - token_client: UserTokenClient = context.turn_state.get( - context.adapter.USER_TOKEN_CLIENT_KEY - ) + await self._initialize_token_client(context) - await token_client.user_token.sign_out( + await self.user_token_client.user_token.sign_out( user_id=context.activity.from_property.id, - connection_name=self.connection_name, + connection_name=self.abs_oauth_connection_name, channel_id=context.activity.channel_id, ) - self.state.flow_started = False - self.state.user_token = "" - self.state.flow_expires = 0 - await self.flow_state_accessor.set(context, self.state) - # logger.info("User signed out successfully") - async def _get_user_state(self, context: TurnContext) -> FlowState: + if self.flow_state: + self.flow_state.flow_expires = 0 + await self._save_flow_state(context) + + async def _get_flow_state(self, context: TurnContext) -> FlowState: """ Gets the user state. - :param context: The turn context. - :return: The user state. + + Args: + context: The turn context. + + Returns: + The user state. + """ + storage_key = self._get_storage_key(context) + + storage_result: Dict[str, FlowState] | None = await self._storage.read( + [storage_key], target_cls=FlowState + ) + if not storage_result or storage_key not in storage_result: + return FlowState() + return storage_result[storage_key] + + async def _save_flow_state(self, context: TurnContext) -> None: """ - user_profile: FlowState | None = await self.flow_state_accessor.get( - context, target_cls=FlowState + Saves the flow state to the user state. + Args: + context: The turn context. + """ + await self._storage.write({self._get_storage_key(context): self.flow_state}) + + async def _initialize_token_client(self, context: TurnContext) -> None: + """ + Initializes the user token client if not already set. + + Args: + context: The turn context. + """ + + # TODO: Change this to caching when the story is implemented, for now we're getting it from TurnContext (new with every request) + self.user_token_client = context.turn_state.get( + context.adapter.USER_TOKEN_CLIENT_KEY + ) + + def _get_storage_key(self, context: TurnContext) -> str: + """ + Gets the storage key for the flow state. + + Args: + context: The turn context. + + Returns: + The storage key. + """ + channel_id = context.activity.channel_id + if not channel_id: + raise ValueError("Channel ID is not set in the activity.") + user_id = ( + context.activity.from_property.id + if context.activity.from_property + else None + ) + if not user_id: + raise ValueError("User ID is not set in the activity.") + + return ( + f"oauth/{self.abs_oauth_connection_name}/{channel_id}/{user_id}/flowState" ) - if user_profile is None: - user_profile = FlowState() - return user_profile diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/rest_channel_service_client_factory.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/rest_channel_service_client_factory.py index f61c0391..2c797959 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/rest_channel_service_client_factory.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/rest_channel_service_client_factory.py @@ -19,12 +19,11 @@ class RestChannelServiceClientFactory(ChannelServiceClientFactoryBase): def __init__( self, - configuration: Any, - connections: Connections, + connection_manager: Connections, token_service_endpoint=AuthenticationConstants.AGENTS_SDK_OAUTH_URL, token_service_audience=AuthenticationConstants.AGENTS_SDK_SCOPE, ) -> None: - self._connections = connections + self._connection_manager = connection_manager self._token_service_endpoint = token_service_endpoint self._token_service_audience = token_service_audience @@ -46,7 +45,7 @@ async def create_connector_client( ) token_provider: AccessTokenProviderBase = ( - self._connections.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 ) @@ -64,7 +63,7 @@ async def create_user_token_client( self, claims_identity: ClaimsIdentity, use_anonymous: bool = False ) -> UserTokenClient: token_provider = ( - self._connections.get_token_provider( + self._connection_manager.get_token_provider( claims_identity, self._token_service_endpoint ) if not use_anonymous diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/agent_state.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/agent_state.py index 99f03e3c..6e076c73 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/agent_state.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/agent_state.py @@ -83,6 +83,7 @@ def __init__(self, storage: Storage, context_service_key: str): self.state_key = "state" self._storage = storage self._context_service_key = context_service_key + self._cached_state: CachedAgentState = None def get_cached_state(self, turn_context: TurnContext) -> CachedAgentState: """ @@ -124,12 +125,12 @@ async def load(self, turn_context: TurnContext, force: bool = False) -> None: :param force: Optional, true to bypass the cache :type force: bool """ - cached_state = self.get_cached_state(turn_context) storage_key = self.get_storage_key(turn_context) - if force or not cached_state: + if force or not self._cached_state: items = await self._storage.read([storage_key], target_cls=CachedAgentState) val = items.get(storage_key, CachedAgentState()) + self._cached_state = val turn_context.turn_state[self._context_service_key] = val async def save(self, turn_context: TurnContext, force: bool = False) -> None: @@ -142,15 +143,14 @@ async def save(self, turn_context: TurnContext, force: bool = False) -> None: :param force: Optional, true to save state to storage whether or not there are changes :type force: bool """ - cached_state = self.get_cached_state(turn_context) - if force or (cached_state is not None and cached_state.is_changed): + if force or (self._cached_state is not None and self._cached_state.is_changed): storage_key = self.get_storage_key(turn_context) - changes: Dict[str, StoreItem] = {storage_key: cached_state} + changes: Dict[str, StoreItem] = {storage_key: self._cached_state} await self._storage.write(changes) - cached_state.hash = cached_state.compute_hash() + self._cached_state.hash = self._cached_state.compute_hash() - async def clear(self, turn_context: TurnContext): + def clear(self, turn_context: TurnContext): """ Clears any state currently stored in this state scope. @@ -165,7 +165,7 @@ async def clear(self, turn_context: TurnContext): # Explicitly setting the hash will mean IsChanged is always true. And that will force a Save. cache_value = CachedAgentState() cache_value.hash = "" - turn_context.turn_state[self._context_service_key] = cache_value + self._cached_state = cache_value async def delete(self, turn_context: TurnContext) -> None: """ @@ -187,10 +187,10 @@ def get_storage_key( ) -> str: raise NotImplementedError() - async def get_property_value( + def get_value( self, - turn_context: TurnContext, property_name: str, + default_value_factory: Callable[[], StoreItem] = None, *, target_cls: Type[StoreItem] = None, ) -> StoreItem: @@ -205,16 +205,21 @@ async def get_property_value( :return: The value of the property """ if not property_name: - raise TypeError( - "BotState.get_property_value(): property_name cannot be None." - ) - cached_state = self.get_cached_state(turn_context) + raise TypeError("BotState.get_value(): property_name cannot be None.") # if there is no value, this will throw, to signal to IPropertyAccesor that a default value should be computed # This allows this to work with value types - value = cached_state.state[property_name] + value = ( + self._cached_state.state.get(property_name, None) + if self._cached_state + else None + ) + + if not value and default_value_factory: + # If the value is None and a factory is provided, call the factory to get a default value + return default_value_factory() - if target_cls: + if target_cls and value: # Attempt to deserialize the value if it is not None try: return target_cls.from_json_to_store_item(value) @@ -224,9 +229,7 @@ async def get_property_value( return value - async def delete_property_value( - self, turn_context: TurnContext, property_name: str - ) -> None: + def delete_value(self, property_name: str) -> None: """ Deletes a property from the state cache in the turn context. @@ -239,12 +242,11 @@ async def delete_property_value( """ if not property_name: raise TypeError("BotState.delete_property(): property_name cannot be None.") - cached_state = self.get_cached_state(turn_context) - del cached_state.state[property_name] - async def set_property_value( - self, turn_context: TurnContext, property_name: str, value: StoreItem - ) -> None: + if self._cached_state.state.get(property_name): + del self._cached_state.state[property_name] + + def set_value(self, property_name: str, value: StoreItem) -> None: """ Sets a property to the specified value in the turn context. @@ -259,8 +261,7 @@ async def set_property_value( """ if not property_name: raise TypeError("BotState.delete_property(): property_name cannot be None.") - cached_state = self.get_cached_state(turn_context) - cached_state.state[property_name] = value + self._cached_state.state[property_name] = value class BotStatePropertyAccessor(StatePropertyAccessor): @@ -300,7 +301,7 @@ async def delete(self, turn_context: TurnContext) -> None: :type turn_context: :class:`TurnContext` """ await self._bot_state.load(turn_context, False) - await self._bot_state.delete_property_value(turn_context, self._name) + self._bot_state.delete_value(self._name) async def get( self, @@ -317,9 +318,17 @@ async def get( :param default_value_or_factory: Defines the default value for the property """ await self._bot_state.load(turn_context, False) + + def default_value_factory(): + if callable(default_value_or_factory): + return default_value_or_factory() + return deepcopy(default_value_or_factory) + try: - result = await self._bot_state.get_property_value( - turn_context, self._name, target_cls=target_cls + result = self._bot_state.get_value( + self._name, + default_value_factory=default_value_factory, + target_cls=target_cls, ) return result except: @@ -332,7 +341,7 @@ async def get( else deepcopy(default_value_or_factory) ) # save default value for any further calls - await self.set(turn_context, result) + self.set(result) return result async def set(self, turn_context: TurnContext, value: StoreItem) -> None: @@ -345,4 +354,4 @@ async def set(self, turn_context: TurnContext, value: StoreItem) -> None: :param value: The value to assign to the property """ await self._bot_state.load(turn_context, False) - await self._bot_state.set_property_value(turn_context, self._name, value) + self._bot_state.set_value(self._name, value) diff --git a/libraries/Builder/microsoft-agents-builder/pyproject.toml b/libraries/Builder/microsoft-agents-builder/pyproject.toml index 8dd3cf6f..712ccb20 100644 --- a/libraries/Builder/microsoft-agents-builder/pyproject.toml +++ b/libraries/Builder/microsoft-agents-builder/pyproject.toml @@ -16,6 +16,7 @@ classifiers = [ dependencies = [ "microsoft-agents-connector", "microsoft-agents-core", + "python-dotenv>=1.1.1", ] [project.urls] diff --git a/libraries/Builder/microsoft-agents-builder/tests/test_agent_state.py b/libraries/Builder/microsoft-agents-builder/tests/test_agent_state.py index 70c7b279..250e59c4 100644 --- a/libraries/Builder/microsoft-agents-builder/tests/test_agent_state.py +++ b/libraries/Builder/microsoft-agents-builder/tests/test_agent_state.py @@ -296,7 +296,8 @@ async def test_clear(self): await prop_accessor.set(self.context, TestDataItem("test_value")) # Clear state - await self.user_state.clear(self.context) + self.user_state.clear(self.context) + await self.user_state.save(self.context) # Verify state is cleared value = await prop_accessor.get(self.context) @@ -410,22 +411,6 @@ async def test_cached_state_hash_computation(self): # State should now be changed assert cached_state.is_changed - @pytest.mark.asyncio - async def test_bot_state_property_accessor_functionality(self): - """Test BotStatePropertyAccessor specific functionality.""" - accessor = BotStatePropertyAccessor(self.user_state, "test_prop") - - # Test getting with default value - default_value = TestDataItem("default") - value = await accessor.get(self.context, default_value) - assert isinstance(value, TestDataItem) - assert value.value == "default" - - # Test that default value is saved - retrieved_again = await accessor.get(self.context) - assert isinstance(retrieved_again, TestDataItem) - assert retrieved_again.value == "default" - @pytest.mark.asyncio async def test_concurrent_state_operations(self): """Test concurrent state operations.""" @@ -489,7 +474,7 @@ async def test_storage_exceptions_handling(self): def test_agent_state_context_service_key(self): """Test that AgentState has correct context service key.""" assert self.user_state._context_service_key == "Internal.UserState" - assert self.conversation_state._context_service_key == "conversation_state" + assert self.conversation_state._context_service_key == "ConversationState" @pytest.mark.asyncio async def test_memory_storage_integration(self): diff --git a/libraries/Client/microsoft-agents-connector/microsoft/agents/connector/client/connector_client.py b/libraries/Client/microsoft-agents-connector/microsoft/agents/connector/client/connector_client.py index 70da161f..5d09d4a1 100644 --- a/libraries/Client/microsoft-agents-connector/microsoft/agents/connector/client/connector_client.py +++ b/libraries/Client/microsoft-agents-connector/microsoft/agents/connector/client/connector_client.py @@ -148,7 +148,7 @@ async def create_conversation( async with self.client.post( "v3/conversations", - json=body.model_dump(by_alias=True, exclude_unset=True), + json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), ) as response: if response.status >= 400: logger.error(f"Error creating conversation: {response.status}") @@ -175,7 +175,7 @@ async def reply_to_activity( async with self.client.post( url, - json=body.model_dump(by_alias=True, exclude_unset=True), + json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), ) as response: if response.status >= 400: logger.error(f"Error replying to activity: {response.status}") @@ -204,7 +204,7 @@ async def send_to_conversation( async with self.client.post( url, - json=body.model_dump(by_alias=True, exclude_unset=True), + json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"), ) as response: if response.status >= 400: logger.error(f"Error sending to conversation: {response.status}") diff --git a/libraries/Client/microsoft-agents-connector/microsoft/agents/connector/teams/teams_connector_client.py b/libraries/Client/microsoft-agents-connector/microsoft/agents/connector/teams/teams_connector_client.py index 80d80908..0ea90866 100644 --- a/libraries/Client/microsoft-agents-connector/microsoft/agents/connector/teams/teams_connector_client.py +++ b/libraries/Client/microsoft-agents-connector/microsoft/agents/connector/teams/teams_connector_client.py @@ -169,7 +169,9 @@ async def create_conversation( """ async with self.client.post( "v3/conversations", - json=conversation_parameters.model_dump(by_alias=True, exclude_unset=True), + json=conversation_parameters.model_dump( + by_alias=True, exclude_unset=True, mode="json" + ), headers={"Content-Type": "application/json"}, ) as response: response.raise_for_status() @@ -187,7 +189,9 @@ async def send_meeting_notification( """ async with self.client.post( f"v1/meetings/{meeting_id}/notification", - json=notification.model_dump(by_alias=True, exclude_unset=True), + json=notification.model_dump( + by_alias=True, exclude_unset=True, mode="json" + ), ) as response: response.raise_for_status() return MeetingNotificationResponse.model_validate(await response.json()) @@ -204,9 +208,11 @@ async def send_message_to_list_of_users( :return: The batch operation response. """ content = { - "activity": activity.model_dump(by_alias=True, exclude_unset=True), + "activity": activity.model_dump( + by_alias=True, exclude_unset=True, mode="json" + ), "members": [ - member.model_dump(by_alias=True, exclude_unset=True) + member.model_dump(by_alias=True, exclude_unset=True, mode="json") for member in members ], "tenantId": tenant_id, @@ -229,7 +235,9 @@ async def send_message_to_all_users_in_tenant( :return: The batch operation response. """ content = { - "activity": activity.model_dump(by_alias=True, exclude_unset=True), + "activity": activity.model_dump( + by_alias=True, exclude_unset=True, mode="json" + ), "tenantId": tenant_id, } @@ -251,7 +259,9 @@ async def send_message_to_all_users_in_team( :return: The batch operation response. """ content = { - "activity": activity.model_dump(by_alias=True, exclude_unset=True), + "activity": activity.model_dump( + by_alias=True, exclude_unset=True, mode="json" + ), "tenantId": tenant_id, "teamId": team_id, } @@ -274,10 +284,12 @@ async def send_message_to_list_of_channels( :return: The batch operation response. """ content = { - "activity": activity.model_dump(by_alias=True, exclude_unset=True), + "activity": activity.model_dump( + by_alias=True, exclude_unset=True, mode="json" + ), "tenantId": tenant_id, "members": [ - member.model_dump(by_alias=True, exclude_unset=True) + member.model_dump(by_alias=True, exclude_unset=True, mode="json") for member in members ], } diff --git a/libraries/Core/microsoft-agents-authorization/microsoft/agents/authorization/access_token_provider_base.py b/libraries/Core/microsoft-agents-authorization/microsoft/agents/authorization/access_token_provider_base.py index 3d861981..3c413e61 100644 --- a/libraries/Core/microsoft-agents-authorization/microsoft/agents/authorization/access_token_provider_base.py +++ b/libraries/Core/microsoft-agents-authorization/microsoft/agents/authorization/access_token_provider_base.py @@ -16,3 +16,15 @@ async def get_access_token( :return: The access token as a string. """ pass + + async def aquire_token_on_behalf_of( + self, scopes: list[str], user_assertion: str + ) -> str: + """ + Acquire a token on behalf of a user. + + :param scopes: The scopes for which to get the token. + :param user_assertion: The user assertion token. + :return: The access token as a string. + """ + raise NotImplementedError() diff --git a/libraries/Core/microsoft-agents-authorization/microsoft/agents/authorization/agent_auth_configuration.py b/libraries/Core/microsoft-agents-authorization/microsoft/agents/authorization/agent_auth_configuration.py index 430b6364..fb7f5507 100644 --- a/libraries/Core/microsoft-agents-authorization/microsoft/agents/authorization/agent_auth_configuration.py +++ b/libraries/Core/microsoft-agents-authorization/microsoft/agents/authorization/agent_auth_configuration.py @@ -1,13 +1,45 @@ -from typing import Protocol, Optional +from typing import Optional +from microsoft.agents.authorization.auth_types import AuthTypes -class AgentAuthConfiguration(Protocol): + +class AgentAuthConfiguration: """ Configuration for Agent authentication. """ TENANT_ID: Optional[str] CLIENT_ID: Optional[str] + AUTH_TYPE: AuthTypes + CLIENT_SECRET: Optional[str] + CERT_PEM_FILE: Optional[str] + CERT_KEY_FILE: Optional[str] + CONNECTION_NAME: Optional[str] + SCOPES: Optional[list[str]] + AUTHORITY: Optional[str] + + def __init__( + self, + auth_type: AuthTypes = None, + client_id: str = None, + tenant_id: Optional[str] = None, + client_secret: Optional[str] = None, + cert_pem_file: Optional[str] = None, + cert_key_file: Optional[str] = None, + connection_name: Optional[str] = None, + authority: Optional[str] = None, + 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) + self.TENANT_ID = tenant_id or kwargs.get("TENANTID", None) + self.CLIENT_SECRET = client_secret or kwargs.get("CLIENTSECRET", None) + self.CERT_PEM_FILE = cert_pem_file or kwargs.get("CERTPEMFILE", None) + 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) @property def ISSUERS(self) -> list[str]: diff --git a/libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/auth_types.py b/libraries/Core/microsoft-agents-authorization/microsoft/agents/authorization/auth_types.py similarity index 100% rename from libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/auth_types.py rename to libraries/Core/microsoft-agents-authorization/microsoft/agents/authorization/auth_types.py diff --git a/libraries/Core/microsoft-agents-authorization/microsoft/agents/authorization/connections.py b/libraries/Core/microsoft-agents-authorization/microsoft/agents/authorization/connections.py index a3bf0275..b5026022 100644 --- a/libraries/Core/microsoft-agents-authorization/microsoft/agents/authorization/connections.py +++ b/libraries/Core/microsoft-agents-authorization/microsoft/agents/authorization/connections.py @@ -1,6 +1,7 @@ from abc import abstractmethod from typing import Protocol +from .agent_auth_configuration import AgentAuthConfiguration from .access_token_provider_base import AccessTokenProviderBase from .claims_identity import ClaimsIdentity @@ -29,3 +30,10 @@ def get_token_provider( Get the OAuth token provider for the agent. """ raise NotImplementedError() + + @abstractmethod + def get_default_connection_configuration(self) -> AgentAuthConfiguration: + """ + Get the default connection configuration for the agent. + """ + raise NotImplementedError() diff --git a/libraries/Core/microsoft-agents-authorization/microsoft/agents/authorization/jwt_token_validator.py b/libraries/Core/microsoft-agents-authorization/microsoft/agents/authorization/jwt_token_validator.py index 837f9b15..1c0811f6 100644 --- a/libraries/Core/microsoft-agents-authorization/microsoft/agents/authorization/jwt_token_validator.py +++ b/libraries/Core/microsoft-agents-authorization/microsoft/agents/authorization/jwt_token_validator.py @@ -16,7 +16,7 @@ def validate_token(self, token: str) -> ClaimsIdentity: token, key=key, algorithms=["RS256"], - leeway=5.0, + leeway=300.0, options={"verify_aud": False}, ) if decoded_token["aud"] != self.configuration.CLIENT_ID: diff --git a/libraries/Core/microsoft-agents-core/microsoft/agents/core/__init__.py b/libraries/Core/microsoft-agents-core/microsoft/agents/core/__init__.py index d8a18591..4bd84712 100644 --- a/libraries/Core/microsoft-agents-core/microsoft/agents/core/__init__.py +++ b/libraries/Core/microsoft-agents-core/microsoft/agents/core/__init__.py @@ -1,7 +1,9 @@ from .channel_adapter_protocol import ChannelAdapterProtocol from .turn_context_protocol import TurnContextProtocol +from ._load_configuration import load_configuration_from_env __all__ = [ + "load_configuration_from_env", "ChannelAdapterProtocol", "TurnContextProtocol", ] diff --git a/libraries/Core/microsoft-agents-core/microsoft/agents/core/_load_configuration.py b/libraries/Core/microsoft-agents-core/microsoft/agents/core/_load_configuration.py new file mode 100644 index 00000000..f3c6afa3 --- /dev/null +++ b/libraries/Core/microsoft-agents-core/microsoft/agents/core/_load_configuration.py @@ -0,0 +1,25 @@ +from typing import Any, Dict + + +def load_configuration_from_env(env_vars: Dict[str, Any]) -> dict: + """ + Parses environment variables and returns a dictionary with the relevant configuration. + """ + vars = env_vars.copy() + result = {} + for key, value in vars.items(): + levels = key.split("__") + current_level = result + last_level = None + for next_level in levels: + if next_level not in current_level: + current_level[next_level] = {} + last_level = current_level + current_level = current_level[next_level] + last_level[levels[-1]] = value + + return { + "AGENTAPPLICATION": result.get("AGENTAPPLICATION", {}), + "CONNECTIONS": result.get("CONNECTIONS", {}), + "CONNECTIONSMAP": result.get("CONNECTIONSMAP", {}), + } diff --git a/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/card_action.py b/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/card_action.py index db3b045c..677d251a 100644 --- a/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/card_action.py +++ b/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/card_action.py @@ -1,3 +1,4 @@ +from typing import Optional from .agents_model import AgentsModel from ._type_aliases import NonEmptyString @@ -34,5 +35,5 @@ class CardAction(AgentsModel): text: str = None display_text: str = None value: object = None - channel_data: object = None + channel_data: Optional[object] = None image_alt_text: str = None diff --git a/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/entity.py b/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/entity.py index 260086f2..af250a86 100644 --- a/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/entity.py +++ b/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/entity.py @@ -1,3 +1,4 @@ +from typing import Any from .agents_model import AgentsModel, ConfigDict from ._type_aliases import NonEmptyString @@ -12,3 +13,8 @@ class Entity(AgentsModel): model_config = ConfigDict(extra="allow") type: NonEmptyString + + @property + def additional_properties(self) -> dict[str, Any]: + """Returns the set of properties that are not None.""" + return self.model_extra diff --git a/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/oauth_card.py b/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/oauth_card.py index 2269a54a..dcf28594 100644 --- a/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/oauth_card.py +++ b/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/oauth_card.py @@ -1,3 +1,4 @@ +from typing import Optional from .card_action import CardAction from .agents_model import AgentsModel from .token_exchange_resource import TokenExchangeResource @@ -19,5 +20,5 @@ class OAuthCard(AgentsModel): text: NonEmptyString = None connection_name: NonEmptyString = None buttons: list[CardAction] = None - token_exchange_resource: TokenExchangeResource = None + token_exchange_resource: Optional[TokenExchangeResource] = None token_post_resource: TokenPostResource = None diff --git a/libraries/Hosting/microsoft-agents-hosting-aiohttp/microsoft/agents/hosting/aiohttp/cloud_adapter.py b/libraries/Hosting/microsoft-agents-hosting-aiohttp/microsoft/agents/hosting/aiohttp/cloud_adapter.py index bcbfdf2a..a4cd14c8 100644 --- a/libraries/Hosting/microsoft-agents-hosting-aiohttp/microsoft/agents/hosting/aiohttp/cloud_adapter.py +++ b/libraries/Hosting/microsoft-agents-hosting-aiohttp/microsoft/agents/hosting/aiohttp/cloud_adapter.py @@ -12,7 +12,11 @@ HTTPUnauthorized, HTTPUnsupportedMediaType, ) -from microsoft.agents.authorization import ClaimsIdentity +from microsoft.agents.authorization import ( + AgentAuthConfiguration, + ClaimsIdentity, + Connections, +) from microsoft.agents.core.models import ( Activity, DeliveryModes, @@ -22,6 +26,7 @@ ChannelServiceAdapter, ChannelServiceClientFactoryBase, MessageFactory, + RestChannelServiceClientFactory, TurnContext, ) @@ -31,14 +36,15 @@ class CloudAdapter(ChannelServiceAdapter, AgentHttpAdapter): def __init__( self, - channel_service_client_factory: ChannelServiceClientFactoryBase, + *, + connection_manager: Connections, + channel_service_client_factory: ChannelServiceClientFactoryBase = None, ): """ Initializes a new instance of the CloudAdapter class. :param channel_service_client_factory: The factory to use to create the channel service client. """ - super().__init__(channel_service_client_factory) async def on_turn_error(context: TurnContext, error: Exception): error_message = f"Exception caught : {error}" @@ -55,7 +61,13 @@ async def on_turn_error(context: TurnContext, error: Exception): ) self.on_turn_error = on_turn_error - self._channel_service_client_factory = channel_service_client_factory + + channel_service_client_factory = ( + channel_service_client_factory + or RestChannelServiceClientFactory(connection_manager) + ) + + super().__init__(channel_service_client_factory) async def process(self, request: Request, agent: Agent) -> Optional[Response]: if not request: diff --git a/test_samples/app_style/authorization_agent.py b/test_samples/app_style/authorization_agent.py new file mode 100644 index 00000000..8f206dce --- /dev/null +++ b/test_samples/app_style/authorization_agent.py @@ -0,0 +1,303 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from os import environ +import re +import sys +import traceback + +from dotenv import load_dotenv +from microsoft.agents.builder.app import AgentApplication, TurnState +from microsoft.agents.builder.app.oauth import Authorization +from microsoft.agents.hosting.aiohttp import ( + CloudAdapter, +) +from microsoft.agents.authentication.msal import MsalConnectionManager + +from microsoft.agents.builder import ( + TurnContext, + MessageFactory, +) +from microsoft.agents.storage import MemoryStorage +from microsoft.agents.core import load_configuration_from_env +from microsoft.agents.core.models import ActivityTypes, TokenResponse + +from shared import GraphClient, GitHubClient, start_server + +load_dotenv() + +agents_sdk_config = load_configuration_from_env(environ) + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config +) + + +@AGENT_APP.message(re.compile(r"^(status|auth status|check status)", re.IGNORECASE)) +async def status(context: TurnContext, state: TurnState) -> bool: + """ + Internal method to check authorization status for all configured handlers. + Returns True if at least one handler has a valid token. + """ + if not AGENT_APP.auth: + await context.send_activity( + MessageFactory.text("Authorization is not configured.") + ) + return False + + try: + # Check status for each auth handler + status_messages = [] + has_valid_token = False + + for handler_id in AGENT_APP.auth._auth_handlers.keys(): + try: + token_response = await AGENT_APP.auth.get_token(context, handler_id) + if token_response and token_response.token: + status_messages.append(f"✅ {handler_id}: Connected") + has_valid_token = True + else: + status_messages.append(f"❌ {handler_id}: Not connected") + except Exception as e: + status_messages.append(f"❌ {handler_id}: Error - {str(e)}") + + status_text = "Authorization Status:\n" + "\n".join(status_messages) + await context.send_activity(MessageFactory.text(status_text)) + return has_valid_token + + except Exception as e: + await context.send_activity( + MessageFactory.text(f"Error checking status: {str(e)}") + ) + return False + + +@AGENT_APP.message(re.compile(r"^(logout|signout|sign out)", re.IGNORECASE)) +async def sign_out( + context: TurnContext, state: TurnState, handler_id: str = None +) -> bool: + """ + Internal method to sign out from the specified handler or all handlers. + """ + if not AGENT_APP.auth: + await context.send_activity( + MessageFactory.text("Authorization is not configured.") + ) + return False + + try: + await AGENT_APP.auth.sign_out(context, state, handler_id) + if handler_id: + await context.send_activity( + MessageFactory.text(f"Successfully signed out from {handler_id}.") + ) + else: + await context.send_activity( + MessageFactory.text("Successfully signed out from all services.") + ) + return True + except Exception as e: + await context.send_activity(MessageFactory.text(f"Error signing out: {str(e)}")) + return False + + +@AGENT_APP.message( + re.compile(r"^(me|profile)$", re.IGNORECASE), auth_handlers=["GRAPH"] +) +async def profile_request(context: TurnContext, state: TurnState) -> dict: + """ + Internal method to get user profile information using the specified handler. + """ + if not AGENT_APP.auth: + await context.send_activity( + MessageFactory.text("Authorization is not configured.") + ) + return None + + try: + token_response = await AGENT_APP.auth.get_token(context, "GRAPH") + if not token_response or not token_response.token: + await context.send_activity( + MessageFactory.text( + f"Not authenticated with Graph. Please sign in first." + ) + ) + return None + + # TODO: Implement actual profile request using the token + # This would require making HTTP requests to the Graph API or other services + # For now, return a placeholder + profile_info = await GraphClient.get_me(token_response.token) + + profile_text = f"Profile Information:\nName: {profile_info['displayName']}\nEmail: {profile_info['mail']}\nID: {profile_info['id']}" + await context.send_activity(MessageFactory.text(profile_text)) + return profile_info + + except Exception as e: + await context.send_activity( + MessageFactory.text(f"Error getting profile: {str(e)}") + ) + return None + + +@AGENT_APP.message( + re.compile(r"^(github profile|gh profile)$", re.IGNORECASE), + auth_handlers=["GITHUB"], +) +async def profile_github(context: TurnContext, state: TurnState) -> dict: + """ + Internal method to get GitHub profile information. + """ + if not AGENT_APP.auth: + await context.send_activity( + MessageFactory.text("Authorization is not configured.") + ) + return None + + try: + token_response = await AGENT_APP.auth.get_token(context, "GITHUB") + if not token_response or not token_response.token: + await context.send_activity( + MessageFactory.text( + f"Not authenticated with Github. Please sign in first." + ) + ) + + profile_info = await GitHubClient.get_current_profile(token_response.token) + profile_text = ( + f"GitHub Profile Information:\n" + f"Name: {profile_info['displayName']}\n" + f"Email: {profile_info['mail']}\n" + f"Username: {profile_info['givenName']}\n" + ) + + await context.send_activity(MessageFactory.text(profile_text)) + return profile_info + + except Exception as e: + await context.send_activity( + MessageFactory.text(f"Error during sign-in: {str(e)}") + ) + return None + + +@AGENT_APP.message(re.compile(r"^(prs|pull requests)$", re.IGNORECASE)) +async def pull_requests( + context: TurnContext, state: TurnState, handler_id: str = "github" +) -> list: + """ + Internal method to get pull requests using the specified handler (typically GitHub). + """ + if not AGENT_APP.auth: + await context.send_activity( + MessageFactory.text("Authorization is not configured.") + ) + return [] + + try: + token_response = await AGENT_APP.auth.get_token(context, handler_id) + if not token_response or not token_response.token: + await context.send_activity( + MessageFactory.text( + f"Not authenticated with {handler_id}. Please sign in first." + ) + ) + return [] + + # TODO: Implement actual GitHub API request using the token + # This would require making HTTP requests to the GitHub API + # For now, return placeholder data + pull_requests = [ + {"title": "Fix authentication bug", "number": 123, "state": "open"}, + {"title": "Add new feature", "number": 124, "state": "open"}, + {"title": "Update documentation", "number": 125, "state": "closed"}, + ] + + pr_text = "Pull Requests:\n" + "\n".join( + [f"#{pr['number']}: {pr['title']} ({pr['state']})" for pr in pull_requests] + ) + await context.send_activity(MessageFactory.text(pr_text)) + return pull_requests + + except Exception as e: + await context.send_activity( + MessageFactory.text(f"Error getting pull requests: {str(e)}") + ) + return [] + + +@AGENT_APP.message( + re.compile(r"^(all profiles|all)", re.IGNORECASE), auth_handlers=["GRAPH", "GITHUB"] +) +async def all_profiles(context: TurnContext, state: TurnState) -> dict: + """ + Internal method to get profiles from all configured handlers. + """ + try: + await profile_request(context, state) + await profile_github(context, state) + except Exception as e: + await context.send_activity( + MessageFactory.text(f"Error retrieving profiles: {str(e)}") + ) + return None + + +@AGENT_APP.activity(ActivityTypes.invoke) +async def invoke(context: TurnContext, state: TurnState) -> str: + """ + Internal method to process template expansion or function invocation. + """ + await AGENT_APP.auth.begin_or_continue_flow(context, state) + + +@AGENT_APP.on_sign_in_success +async def handle_sign_in_success( + context: TurnContext, state: TurnState, handler_id: str = None +) -> bool: + """ + Internal method to handle successful sign-in events. + """ + await context.send_activity( + MessageFactory.text( + f"Successfully signed in to {handler_id or 'service'}. You can now use authorized features." + ) + ) + + +@AGENT_APP.conversation_update("membersAdded") +async def on_members_added(context: TurnContext, _state: TurnState): + await context.send_activity( + "Welcome to the Authorization Agent! " + "You can use commands like 'login', 'status', 'profile', 'prs', or 'logout'. " + "For OAuth flows, enter the 6-digit verification code when prompted." + ) + return True + + +@AGENT_APP.activity("message") +async def on_message(context: TurnContext, state: TurnState): + await context.send_activity(f"You said: {context.activity.text}") + + +@AGENT_APP.error +async def on_error(context: TurnContext, error: Exception): + # This check writes out errors to console log .vs. app insights. + # NOTE: In production environment, you should consider logging this to Azure + # application insights. + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + + # Send a message to the user + await context.send_activity("The bot encountered an error or bug.") + + +start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), +) diff --git a/test_samples/app_style/auto_auth.py b/test_samples/app_style/auto_auth.py new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/app_style_emtpy/emtpy_agent.py b/test_samples/app_style/emtpy_agent.py similarity index 100% rename from test_samples/app_style_emtpy/emtpy_agent.py rename to test_samples/app_style/emtpy_agent.py diff --git a/test_samples/app_style/env.TEMPLATE b/test_samples/app_style/env.TEMPLATE new file mode 100644 index 00000000..f9adb9ac --- /dev/null +++ b/test_samples/app_style/env.TEMPLATE @@ -0,0 +1,19 @@ +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 \ No newline at end of file diff --git a/test_samples/app_style/mcs_agent.py b/test_samples/app_style/mcs_agent.py new file mode 100644 index 00000000..ed3310be --- /dev/null +++ b/test_samples/app_style/mcs_agent.py @@ -0,0 +1,186 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import traceback +from os import environ +from typing import Optional +from dotenv import load_dotenv + +from microsoft.agents.builder.app import AgentApplication, TurnState, ConversationState +from microsoft.agents.builder.app.oauth import Authorization +from microsoft.agents.builder import TurnContext, MessageFactory +from microsoft.agents.storage import MemoryStorage +from microsoft.agents.core.models import ActivityTypes, Activity +from microsoft.agents.core import load_configuration_from_env +from microsoft.agents.copilotstudio.client import ( + ConnectionSettings, + CopilotClient, + PowerPlatformCloud, + AgentType, +) +from microsoft.agents.hosting.aiohttp import CloudAdapter +from microsoft.agents.authentication.msal import MsalConnectionManager + +from shared import start_server + +load_dotenv() + +# Load configuration from environment +agents_sdk_config = load_configuration_from_env(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) + + +class McsConnectionSettings(ConnectionSettings): + """Connection settings for MCS that loads from environment variables""" + + def __init__( + self, + app_client_id: Optional[str] = None, + tenant_id: Optional[str] = None, + environment_id: Optional[str] = None, + agent_identifier: Optional[str] = None, + cloud: Optional[PowerPlatformCloud] = None, + copilot_agent_type: Optional[AgentType] = None, + custom_power_platform_cloud: Optional[str] = None, + **kwargs: Optional[str], + ) -> None: + self.app_client_id = app_client_id or kwargs.get("AGENTAPPID") + self.tenant_id = tenant_id or kwargs.get("TENANTID") + + if not self.app_client_id: + raise ValueError("Agent App ID must be provided") + if not self.tenant_id: + raise ValueError("Tenant ID must be provided") + + environment_id = environment_id or kwargs.get("ENVIRONMENTID") + agent_identifier = agent_identifier or kwargs.get("SCHEMANAME") + cloud = cloud or PowerPlatformCloud[kwargs.get("CLOUD", "UNKNOWN")] + copilot_agent_type = ( + copilot_agent_type or AgentType[kwargs.get("COPILOTAGENTTYPE", "PUBLISHED")] + ) + custom_power_platform_cloud = custom_power_platform_cloud or kwargs.get( + "CUSTOMPOWERPLATFORMCLOUD", None + ) + + super().__init__( + environment_id, + agent_identifier, + cloud, + copilot_agent_type, + custom_power_platform_cloud, + ) + + +# Create the agent instance +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config +) + + +@AGENT_APP.conversation_update("membersAdded") +async def status(context: TurnContext, state: TurnState) -> None: + await context.send_activity( + MessageFactory.text("Welcome to the MCS Agent demo!, ready to chat with MCS!") + ) + + +@AGENT_APP.on_sign_in_success +async def signin_success( + context: TurnContext, state: TurnState, handler_id: str = None +) -> None: + await context.send_activity(MessageFactory.text("User signed in successfully")) + + +@AGENT_APP.message("/logout") +async def sign_out(context: TurnContext, state: TurnState) -> None: + if AGENT_APP.auth: + await AGENT_APP.auth.sign_out(context, state) + await context.send_activity(MessageFactory.text("User signed out")) + + +@AGENT_APP.activity(ActivityTypes.message, auth_handlers=["MCS"]) +async def message(context: TurnContext, state: TurnState) -> None: + await _handle_message(context, state) + + +async def _handle_message(context: TurnContext, state: TurnState) -> None: + """Handle incoming messages with MCS integration""" + + # Get conversation ID from state + conversation_id = state.get_value( + ConversationState.CONTEXT_SERVICE_KEY + ".conversation_id", target_cls=str + ) + + # Get OBO token for Power Platform API + if not AGENT_APP.auth: + await _status(context, state) + return + + try: + obo_token = await AGENT_APP.auth.exchange_token( + context, ["https://api.powerplatform.com/.default"], "MCS" + ) + + if not obo_token or not obo_token.token: + await _status(context, state) + return + + # Create CopilotClient + copilot_client = _create_client(obo_token.token) + + if not conversation_id: + # Start new conversation + async for activity in copilot_client.start_conversation(): + if activity.type == ActivityTypes.message: + await context.send_activity(MessageFactory.text(activity.text)) + if activity.conversation and activity.conversation.id: + state.set_value( + ConversationState.CONTEXT_SERVICE_KEY + ".conversation_id", + activity.conversation.id, + ) + await state.save(context) + else: + # Continue existing conversation + async for activity in copilot_client.ask_question( + context.activity.text, conversation_id + ): + print(f"Received activity: {activity.type}, {activity.text}") + + if activity.type == ActivityTypes.message: + await context.send_activity(activity) + elif activity.type == "typing": + typing_activity = Activity(type=ActivityTypes.typing) + await context.send_activity(typing_activity) + + except Exception as e: + traceback.print_exc() + await context.send_activity( + MessageFactory.text(f"Error communicating with MCS: {str(e)}") + ) + + +async def _status(context: TurnContext, state: TurnState) -> None: + """Send status message when not authenticated""" + await context.send_activity( + MessageFactory.text("Welcome to the MCS Agent demo!, ready to chat with MCS!") + ) + + +def _create_client(token: str) -> CopilotClient: + """Create CopilotClient with connection settings from environment""" + settings = McsConnectionSettings(**agents_sdk_config.get("COPILOTSTUDIOAGENT", {})) + return CopilotClient(settings, token) + + +# Create and start the agent +if __name__ == "__main__": + # Use the start_server function from shared module + start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + ) diff --git a/test_samples/app_style/shared/__init__.py b/test_samples/app_style/shared/__init__.py new file mode 100644 index 00000000..1fb73bd2 --- /dev/null +++ b/test_samples/app_style/shared/__init__.py @@ -0,0 +1,5 @@ +from .user_graph_client import GraphClient +from .github_api_client import GitHubClient +from .start_server import start_server + +__all__ = ["GraphClient", "GitHubClient", "start_server"] diff --git a/test_samples/app_style/shared/github_api_client.py b/test_samples/app_style/shared/github_api_client.py new file mode 100644 index 00000000..8d6e5730 --- /dev/null +++ b/test_samples/app_style/shared/github_api_client.py @@ -0,0 +1,114 @@ +# filepath: c:\Agents-for-python\test_samples\app_style\shared\github_api_client.py +import aiohttp +from typing import List, Dict, Any + + +class PullRequest: + """ + Represents a GitHub pull request. + """ + + def __init__(self, id: int, title: str, url: str): + self.id = id + self.title = title + self.url = url + + def to_dict(self) -> Dict[str, Any]: + return {"id": self.id, "title": self.title, "url": self.url} + + +class GitHubClient: + """ + A simple GitHub API client using aiohttp. + """ + + @staticmethod + async def get_current_profile(token: str) -> Dict[str, Any]: + """ + Get information about the current authenticated user. + """ + async with aiohttp.ClientSession() as session: + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "AgentsSDKDemo", + "Content-Type": "application/json", + } + async with session.get( + "https://api.github.com/user", headers=headers + ) as response: + if response.status == 200: + data = await response.json() + return { + "displayName": data.get("name", ""), + "mail": data.get("html_url", ""), + "jobTitle": "", + "givenName": data.get("login", ""), + "surname": "", + "imageUri": data.get("avatar_url", ""), + } + else: + error_text = await response.text() + raise Exception( + f"Error fetching user profile: {response.status} - {error_text}" + ) + + @staticmethod + async def get_pull_requests(owner: str, repo: str, token: str) -> List[PullRequest]: + """ + Get pull requests for a specific repository. + """ + async with aiohttp.ClientSession() as session: + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "AgentsSDKDemo", + "Content-Type": "application/json", + } + url = f"https://api.github.com/repos/{owner}/{repo}/pulls" + async with session.get(url, headers=headers) as response: + if response.status == 200: + data = await response.json() + return [ + PullRequest( + id=pr.get("id"), + title=pr.get("title"), + url=pr.get("html_url"), + ) + for pr in data + ] + else: + error_text = await response.text() + raise Exception( + f"Error fetching pull requests: {response.status} - {error_text}" + ) + + @staticmethod + async def get_user_pull_requests(token: str) -> List[PullRequest]: + """ + Get pull requests created by the authenticated user across all repositories. + """ + async with aiohttp.ClientSession() as session: + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "AgentsSDKDemo", + "Content-Type": "application/json", + } + url = "https://api.github.com/search/issues?q=type:pr+author:@me" + async with session.get(url, headers=headers) as response: + if response.status == 200: + data = await response.json() + return [ + PullRequest( + id=pr.get("id"), + title=pr.get("title"), + url=pr.get("html_url"), + ) + for pr in data.get("items", []) + ] + else: + error_text = await response.text() + raise Exception( + f"Error fetching user pull requests: {response.status} - {error_text}" + ) diff --git a/test_samples/app_style/shared/start_server.py b/test_samples/app_style/shared/start_server.py new file mode 100644 index 00000000..2c589432 --- /dev/null +++ b/test_samples/app_style/shared/start_server.py @@ -0,0 +1,34 @@ +from os import environ +from microsoft.agents.authorization import AgentAuthConfiguration +from microsoft.agents.builder.app import AgentApplication +from microsoft.agents.hosting.aiohttp import ( + jwt_authorization_middleware, + start_agent_process, + CloudAdapter, +) +from aiohttp.web import Request, Response, Application, run_app +from microsoft.agents.hosting.aiohttp._start_agent_process import start_agent_process + + +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/app_style/shared/user_graph_client.py b/test_samples/app_style/shared/user_graph_client.py new file mode 100644 index 00000000..5b1efc14 --- /dev/null +++ b/test_samples/app_style/shared/user_graph_client.py @@ -0,0 +1,28 @@ +import aiohttp + + +class GraphClient: + """ + A simple Microsoft Graph client using aiohttp. + """ + + @staticmethod + async def get_me(token: str): + """ + Get information about the current user. + """ + async with aiohttp.ClientSession() as session: + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + async with session.get( + f"https://graph.microsoft.com/v1.0/me", headers=headers + ) as response: + if response.status == 200: + return await response.json() + else: + error_text = await response.text() + raise Exception( + f"Error from Graph API: {response.status} - {error_text}" + )