From 70a9310dca9fbc6c7914c4165486c364078e22c5 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 4 Jun 2025 18:12:54 -0700 Subject: [PATCH 01/27] Authorization WIP --- .../agents/builder/app/oauth/authorization.py | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/oauth/authorization.py 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..4c94b49c --- /dev/null +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/oauth/authorization.py @@ -0,0 +1,228 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations +import os +from typing import Dict, Optional, Callable, Awaitable, Protocol + +from microsoft.agents.storage import Storage +from microsoft.agents.core.models import TokenResponse + +from ...turn_context import TurnContext +from ...app.state.turn_state import TurnState +from ...oauth_flow import OAuthFlow +from ...state.user_state import UserState + + +class AuthHandler(Protocol): + """ + Interface defining an authorization handler for OAuth flows. + """ + + name: Optional[str] = None + auto: Optional[bool] = None + flow: Optional[OAuthFlow] = None + title: Optional[str] = None + text: Optional[str] = None + + +# Type alias for authorization handlers dictionary +AuthorizationHandlers = Dict[str, AuthHandler] + + +class Authorization: + """ + Class responsible for managing authorization and OAuth flows. + """ + + def __init__(self, storage: Storage, auth_handlers: AuthorizationHandlers): + """ + 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) + + if not auth_handlers or len(auth_handlers) == 0: + raise ValueError("The authorization does not have any auth handlers") + + self._auth_handlers = auth_handlers + self._sign_in_handler: Optional[ + Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]] + ] = None + + # Configure each auth handler + for handler_key, auth_handler in self._auth_handlers.items(): + # Set connection name from environment if not provided + if ( + auth_handler.name is None + and os.getenv(f"{handler_key}_connectionName") is None + ): + raise ValueError( + f"AuthHandler name {handler_key}_connectionName not set in authorization " + f"and not found in env vars." + ) + + # Set properties from environment variables if not already set + auth_handler.name = auth_handler.name or os.getenv( + f"{handler_key}_connectionName" + ) + auth_handler.title = auth_handler.title or os.getenv( + f"{handler_key}_connectionTitle" + ) + auth_handler.text = auth_handler.text or os.getenv( + f"{handler_key}_connectionText" + ) + auth_handler.auto = ( + auth_handler.auto + if auth_handler.auto is not None + else os.getenv(f"{handler_key}_connectionAuto") == "true" + ) + + # 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( + user_state=user_state, + connection_name=auth_handler.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) + + def get_flow_state(self, auth_handler_id: Optional[str] = None) -> bool: + """ + 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: + Whether the flow has started. + """ + flow = self.resolver_handler(auth_handler_id).flow + if flow is None: + return False + + # Return flow state if available + return flow.state.flow_started if flow.state else False + + async def begin_or_continue_flow( + self, + context: TurnContext, + state: TurnState, + auth_handler_id: Optional[str] = None, + ) -> 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. + """ + flow = self.resolver_handler(auth_handler_id).flow + if flow is None: + raise ValueError("OAuth flow is not configured for the auth handler") + + # Get the current flow state + flow_state = await flow._get_user_state(context) + + if not flow_state.flow_started: + token_response = await flow.begin_flow(context) + 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 and self._sign_in_handler: + await self._sign_in_handler(context, state, auth_handler_id) + + 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 From cb09d3ae10a8397b83efab8e2579b440304f7f19 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Thu, 5 Jun 2025 16:52:27 -0700 Subject: [PATCH 02/27] Sample close to completion. Testing pending. --- .../agents/builder/app/agent_application.py | 64 +-- .../agents/builder/app/app_options.py | 7 + .../agents/builder/app/oauth/__init__.py | 3 + .../agents/builder/app/oauth/authorization.py | 16 +- test_samples/app_style/authorization_agent.py | 368 ++++++++++++++++++ .../emtpy_agent.py | 0 6 files changed, 425 insertions(+), 33 deletions(-) create mode 100644 libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/oauth/__init__.py create mode 100644 test_samples/app_style/authorization_agent.py rename test_samples/{app_style_emtpy => app_style}/emtpy_agent.py (100%) 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..32865df4 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 @@ -36,6 +36,7 @@ from .route import Route, RouteHandler from .state import TurnState from ..channel_service_adapter import ChannelServiceAdapter +from .oauth import Authorization from .typing import Typing StateT = TypeVar("StateT", bound=TurnState) @@ -59,7 +60,7 @@ class AgentApplication(Agent, Generic[StateT]): _options: ApplicationOptions _adapter: Optional[ChannelServiceAdapter] = None - # _auth: Optional[AuthManager[StateT]] = None + _auth: Optional[Authorization] = None _before_turn: List[RouteHandler[StateT]] = [] _after_turn: List[RouteHandler[StateT]] = [] _routes: List[Route[StateT]] = [] @@ -105,17 +106,13 @@ 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)) - """ + auth_handlers = options.authorization or kwargs.get("authorization") - # TODO: Disabling AI chain for now - self._ai = None + if auth_handlers: + self._auth = Authorization( + storage=self._options.storage, + auth_handlers=auth_handlers, + ) @property def adapter(self) -> ChannelServiceAdapter: @@ -156,7 +153,7 @@ 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]]] ) -> Callable[[RouteHandler[StateT]], RouteHandler[StateT]]: """ Registers a new activity event listener. This method can be used as either @@ -175,7 +172,7 @@ 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)) @@ -439,6 +436,32 @@ async def on_after_turn(context: TurnContext, state: TurnState): self._after_turn.append(func) return func + 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 when a user successfully signs in. + + ```python + # Use this method as a decorator + @app.on_sign_in_success + async def sign_in_success(context: TurnContext, state: TurnState): + print("hello world!") + return True + ``` + """ + + 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 error( self, func: Callable[[TurnContext, Exception], Awaitable[None]] ) -> Callable[[TurnContext, Exception], Awaitable[None]]: @@ -526,6 +549,7 @@ 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 = ( @@ -552,6 +576,7 @@ async def _authenticate_user(self, context: TurnContext, state): 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: @@ -569,19 +594,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"), 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..4e3155fa 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: 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..8c78df04 --- /dev/null +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/oauth/__init__.py @@ -0,0 +1,3 @@ +from .authorization import Authorization, AuthorizationHandlers, AuthHandler + +__all__ = ["Authorization", "AuthorizationHandlers", "AuthHandler"] 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 index 4c94b49c..9621c51f 100644 --- 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 @@ -2,6 +2,7 @@ # Licensed under the MIT License. from __future__ import annotations +from dataclasses import dataclass import os from typing import Dict, Optional, Callable, Awaitable, Protocol @@ -14,7 +15,8 @@ from ...state.user_state import UserState -class AuthHandler(Protocol): +@dataclass +class AuthHandler: """ Interface defining an authorization handler for OAuth flows. """ @@ -64,27 +66,27 @@ def __init__(self, storage: Storage, auth_handlers: AuthorizationHandlers): # Set connection name from environment if not provided if ( auth_handler.name is None - and os.getenv(f"{handler_key}_connectionName") is None + and os.getenv(f"{handler_key}_CONNECTION_NAME") is None ): raise ValueError( - f"AuthHandler name {handler_key}_connectionName not set in authorization " + f"AuthHandler name {handler_key}_CONNECTION_NAME not set in authorization " f"and not found in env vars." ) # Set properties from environment variables if not already set auth_handler.name = auth_handler.name or os.getenv( - f"{handler_key}_connectionName" + f"{handler_key}_CONNECTION_NAME" ) auth_handler.title = auth_handler.title or os.getenv( - f"{handler_key}_connectionTitle" + f"{handler_key}_CONNECTION_TITLE" ) auth_handler.text = auth_handler.text or os.getenv( - f"{handler_key}_connectionText" + f"{handler_key}_CONNECTION_TEXT" ) auth_handler.auto = ( auth_handler.auto if auth_handler.auto is not None - else os.getenv(f"{handler_key}_connectionAuto") == "true" + else os.getenv(f"{handler_key}_CONNECTION_AUTO") == "true" ) # Create OAuth flow with configuration diff --git a/test_samples/app_style/authorization_agent.py b/test_samples/app_style/authorization_agent.py new file mode 100644 index 00000000..725da6fd --- /dev/null +++ b/test_samples/app_style/authorization_agent.py @@ -0,0 +1,368 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import re +import sys +import traceback +from aiohttp.web import Application, Request, Response, run_app +from dotenv import load_dotenv + +from os import environ +from microsoft.agents.authentication.msal import AuthTypes, MsalAuthConfiguration +from microsoft.agents.builder.app import AgentApplication, TurnState +from microsoft.agents.builder.app.oauth import AuthHandler +from microsoft.agents.hosting.aiohttp import ( + CloudAdapter, + jwt_authorization_middleware, + start_agent_process, +) +from microsoft.agents.authorization import ( + Connections, + AccessTokenProviderBase, + ClaimsIdentity, +) +from microsoft.agents.authentication.msal import MsalAuth + +from microsoft.agents.builder import ( + RestChannelServiceClientFactory, + TurnContext, + MessageFactory, +) +from microsoft.agents.storage import MemoryStorage +from microsoft.agents.core.models import ActivityTypes, TokenResponse + +load_dotenv() + + +class DefaultConfig(MsalAuthConfiguration): + """Agent Configuration""" + + def __init__(self) -> None: + self.AUTH_TYPE = AuthTypes.client_secret + self.TENANT_ID = "" or environ.get("TENANT_ID") + self.CLIENT_ID = "" or environ.get("CLIENT_ID") + self.CLIENT_SECRET = "" or environ.get("CLIENT_SECRET") + self.PORT = 3978 + + +CONFIG = DefaultConfig() +AUTH_PROVIDER = MsalAuth(CONFIG) + + +class DefaultConnection(Connections): + def get_default_connection(self) -> AccessTokenProviderBase: + pass + + def get_token_provider( + self, claims_identity: ClaimsIdentity, service_url: str + ) -> AccessTokenProviderBase: + return AUTH_PROVIDER + + def get_connection(self, connection_name: str) -> AccessTokenProviderBase: + pass + + +CHANNEL_CLIENT_FACTORY = RestChannelServiceClientFactory(CONFIG, DefaultConnection()) + +# Create adapter. +ADAPTER = CloudAdapter(CHANNEL_CLIENT_FACTORY) + +AGENT_APP = AgentApplication[TurnState]( + storage=MemoryStorage(), + adapter=ADAPTER, + authorization={ + "graph": AuthHandler(title="Graph API", text="Connect to Microsoft Graph"), + "github": AuthHandler( + title="GitHub", + text="Connect to GitHub", + ), + }, +) + + +@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"^(login|signin|sign in)$", re.IGNORECASE)) +async def sign_in( + context: TurnContext, state: TurnState, handler_id: str = None +) -> TokenResponse: + """ + Internal method to begin or continue sign-in flow for 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.begin_or_continue_flow( + context, state, handler_id + ) + if token_response and token_response.token: + await context.send_activity( + MessageFactory.text( + f"Successfully signed in to {handler_id or 'service'}." + ) + ) + return token_response + 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"^(me|profile)$", re.IGNORECASE)) +async def profile_request( + context: TurnContext, state: TurnState, handler_id: str = "graph" +) -> 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, 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 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 = { + "displayName": "User Name", + "mail": "user@example.com", + "id": "user-id-12345", + } + + 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"^(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.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.message(re.compile(r"^(prs|pull requests|pullrequests)$", re.IGNORECASE)) +async def on_pull_requests(context: TurnContext, state: TurnState): + await context.send_activity("PR command is not implemented yet.") + + +@AGENT_APP.message(re.compile(r"^\d{6}$")) +async def on_magic_code(context: TurnContext, state: TurnState): + # Handle 6-digit magic codes for OAuth verification + if AGENT_APP.auth: + for handler_id in AGENT_APP.auth._auth_handlers.keys(): + try: + token_response = await AGENT_APP.auth.begin_or_continue_flow( + context, state, handler_id + ) + if token_response and token_response.token: + await _handle_sign_in_success(context, state, handler_id) + return True + except Exception: + # Continue trying other handlers + continue + + await context.send_activity( + MessageFactory.text("Invalid verification code. Please try again.") + ) + else: + await on_message(context, state) + + +@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.") + + +# Listen for incoming requests on /api/messages +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"] = CONFIG +APP["agent_app"] = AGENT_APP +APP["adapter"] = ADAPTER + +if __name__ == "__main__": + try: + run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error 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 From 71c9c888e85c043d72b162953b77970fa8d1e0b2 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Fri, 6 Jun 2025 16:09:30 -0700 Subject: [PATCH 03/27] Auth working with some sample bugs --- .../agents/builder/app/agent_application.py | 4 +- .../authorization/jwt_token_validator.py | 2 +- .../agents/core/models/oauth_card.py | 3 +- test_samples/app_style/authorization_agent.py | 73 ++++++++--- test_samples/app_style/shared/__init__.py | 4 + .../app_style/shared/github_api_client.py | 114 ++++++++++++++++++ .../app_style/shared/user_graph_client.py | 28 +++++ 7 files changed, 208 insertions(+), 20 deletions(-) create mode 100644 test_samples/app_style/shared/__init__.py create mode 100644 test_samples/app_style/shared/github_api_client.py create mode 100644 test_samples/app_style/shared/user_graph_client.py 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 32865df4..f44ef97b 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 @@ -204,8 +204,8 @@ 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 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/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/test_samples/app_style/authorization_agent.py b/test_samples/app_style/authorization_agent.py index 725da6fd..560a8446 100644 --- a/test_samples/app_style/authorization_agent.py +++ b/test_samples/app_style/authorization_agent.py @@ -31,6 +31,8 @@ from microsoft.agents.storage import MemoryStorage from microsoft.agents.core.models import ActivityTypes, TokenResponse +from shared import GraphClient, GitHubClient + load_dotenv() @@ -71,8 +73,8 @@ def get_connection(self, connection_name: str) -> AccessTokenProviderBase: storage=MemoryStorage(), adapter=ADAPTER, authorization={ - "graph": AuthHandler(title="Graph API", text="Connect to Microsoft Graph"), - "github": AuthHandler( + "GRAPH": AuthHandler(title="Graph API", text="Connect to Microsoft Graph"), + "GITHUB": AuthHandler( title="GitHub", text="Connect to GitHub", ), @@ -80,7 +82,7 @@ def get_connection(self, connection_name: str) -> AccessTokenProviderBase: ) -@AGENT_APP.message(re.compile(r"^(status|auth status|check status)$", re.IGNORECASE)) +@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. @@ -119,7 +121,7 @@ async def status(context: TurnContext, state: TurnState) -> bool: return False -@AGENT_APP.message(re.compile(r"^(logout|signout|sign out)$", re.IGNORECASE)) +@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: @@ -148,7 +150,7 @@ async def sign_out( return False -@AGENT_APP.message(re.compile(r"^(login|signin|sign in)$", re.IGNORECASE)) +@AGENT_APP.message(re.compile(r"^(login|signin|sign in)", re.IGNORECASE)) async def sign_in( context: TurnContext, state: TurnState, handler_id: str = None ) -> TokenResponse: @@ -179,9 +181,17 @@ async def sign_in( return None +@AGENT_APP.message(re.compile(r"^(gh login|gh signin|user)$", re.IGNORECASE)) +async def sign_in_github(context: TurnContext, state: TurnState) -> TokenResponse: + """ + Internal method to begin or continue sign-in flow for GitHub. + """ + return await sign_in(context, state, handler_id="GITHUB") + + @AGENT_APP.message(re.compile(r"^(me|profile)$", re.IGNORECASE)) async def profile_request( - context: TurnContext, state: TurnState, handler_id: str = "graph" + context: TurnContext, state: TurnState, handler_id: str = None ) -> dict: """ Internal method to get user profile information using the specified handler. @@ -205,11 +215,7 @@ async def profile_request( # 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 = { - "displayName": "User Name", - "mail": "user@example.com", - "id": "user-id-12345", - } + 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)) @@ -222,6 +228,46 @@ async def profile_request( return None +@AGENT_APP.message(re.compile(r"^(github profile|gh profile)$", re.IGNORECASE)) +async def profile_github( + context: TurnContext, state: TurnState, handler_id: str = "GITHUB" +) -> 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, 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." + ) + ) + + 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" @@ -289,11 +335,6 @@ async def handle_sign_in_success( ) -@AGENT_APP.message(re.compile(r"^(prs|pull requests|pullrequests)$", re.IGNORECASE)) -async def on_pull_requests(context: TurnContext, state: TurnState): - await context.send_activity("PR command is not implemented yet.") - - @AGENT_APP.message(re.compile(r"^\d{6}$")) async def on_magic_code(context: TurnContext, state: TurnState): # Handle 6-digit magic codes for OAuth verification diff --git a/test_samples/app_style/shared/__init__.py b/test_samples/app_style/shared/__init__.py new file mode 100644 index 00000000..e648e984 --- /dev/null +++ b/test_samples/app_style/shared/__init__.py @@ -0,0 +1,4 @@ +from .user_graph_client import GraphClient +from .github_api_client import GitHubClient + +__all__ = ["GraphClient", "GitHubClient"] 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/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}" + ) From bee66dc365ea73f60de3fd7e1ef06a484bce6b58 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 9 Jun 2025 16:50:37 -0700 Subject: [PATCH 04/27] Sample update --- test_samples/app_style/authorization_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_samples/app_style/authorization_agent.py b/test_samples/app_style/authorization_agent.py index 560a8446..0591f165 100644 --- a/test_samples/app_style/authorization_agent.py +++ b/test_samples/app_style/authorization_agent.py @@ -345,7 +345,7 @@ async def on_magic_code(context: TurnContext, state: TurnState): context, state, handler_id ) if token_response and token_response.token: - await _handle_sign_in_success(context, state, handler_id) + await handle_sign_in_success(context, state, handler_id) return True except Exception: # Continue trying other handlers From 139f2f7df113e91159b1635f6d8db590c9c92fdd Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 10 Jun 2025 16:47:02 -0700 Subject: [PATCH 05/27] WIP. Need to reconcile MSAL client lifecycle with tentative API --- .../microsoft/agents/builder/app/oauth/authorization.py | 2 +- .../microsoft/agents/builder/oauth_flow.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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 index 9621c51f..d313019c 100644 --- 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 @@ -98,7 +98,7 @@ def __init__(self, storage: Storage, auth_handlers: AuthorizationHandlers): auth_handler.flow = OAuthFlow( user_state=user_state, - connection_name=auth_handler.name, + abs_oauth_connection_name=auth_handler.name, messages_configuration=messages_config if messages_config else None, ) 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..e6aac512 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 @@ -50,7 +50,7 @@ class OAuthFlow: def __init__( self, user_state: UserState, - connection_name: str, + abs_oauth_connection_name: str, messages_configuration: dict[str, str] = None, **kwargs, ): @@ -58,14 +58,14 @@ def __init__( Creates a new instance of OAuthFlow. :param user_state: The user state. """ - if not connection_name: + if not abs_oauth_connection_name: raise ValueError( "OAuthFlow.__init__: connectionName expected but not found" ) self.messages_configuration = messages_configuration or {} - self.connection_name = connection_name + self.connection_name = abs_oauth_connection_name self.state: FlowState | None = None self.flow_state_accessor: StatePropertyAccessor = user_state.create_property( "flowState" From fdd0f07f0013ba6b6ea60f4a4ac50d8dfbd58758 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Thu, 12 Jun 2025 17:06:06 -0700 Subject: [PATCH 06/27] WIP, need to complete application class changes --- .../agents/authentication/msal/msal_auth.py | 25 ++ .../agents/builder/app/agent_application.py | 42 ++- .../agents/builder/app/oauth/__init__.py | 9 +- .../agents/builder/app/oauth/authorization.py | 126 ++++++++- .../microsoft/agents/builder/oauth_flow.py | 245 ++++++++++-------- .../access_token_provider_base.py | 12 + 6 files changed, 331 insertions(+), 128 deletions(-) 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..75d343f3 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 @@ -48,6 +48,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/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 f44ef97b..30f4bab4 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,6 +4,7 @@ """ from __future__ import annotations +from functools import partial import re from typing import ( @@ -36,7 +37,7 @@ from .route import Route, RouteHandler from .state import TurnState from ..channel_service_adapter import ChannelServiceAdapter -from .oauth import Authorization +from .oauth import Authorization, SignInState from .typing import Typing StateT = TypeVar("StateT", bound=TurnState) @@ -108,6 +109,12 @@ def __init__(self, options: ApplicationOptions = None, **kwargs) -> None: auth_handlers = options.authorization or kwargs.get("authorization") + self._turn_state_factory = ( + options.turn_state_factory + or kwargs.get("turn_state_factory", None) + or partial(TurnState.with_storage, self._options.storage) + ) + if auth_handlers: self._auth = Authorization( storage=self._options.storage, @@ -153,7 +160,10 @@ def options(self) -> ApplicationOptions: return self._options def activity( - self, activity_type: Union[str, ActivityTypes, List[Union[str, ActivityTypes]]] + 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 @@ -181,7 +191,10 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: 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 @@ -216,7 +229,10 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: 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 @@ -262,7 +278,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: 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 @@ -303,7 +319,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: 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 @@ -356,9 +372,7 @@ def __call(func: RouteHandler[StateT]) -> RouteHandler[StateT]: 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]], ]: @@ -503,10 +517,12 @@ 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 = cast( + SignInState, turn_state.get_value(Authorization.SIGN_IN_STATE_KEY) + ) + + if self._authorization: + flow_state = await self._auth.get_flow_state(sign_in_state.handler_id) if not await self._run_before_turn_middleware(context, turn_state): return 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 index 8c78df04..ff280c7f 100644 --- 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 @@ -1,3 +1,8 @@ -from .authorization import Authorization, AuthorizationHandlers, AuthHandler +from .authorization import ( + Authorization, + AuthorizationHandlers, + AuthHandler, + SignInState, +) -__all__ = ["Authorization", "AuthorizationHandlers", "AuthHandler"] +__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 index d313019c..d6d4543f 100644 --- 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 @@ -4,17 +4,37 @@ from __future__ import annotations from dataclasses import dataclass import os -from typing import Dict, Optional, Callable, Awaitable, Protocol +import jwt +from typing import Dict, Optional, Callable, Awaitable from microsoft.agents.storage import Storage -from microsoft.agents.core.models import TokenResponse +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 +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() + + @staticmethod + def from_json_to_store_item(json_data: dict) -> "StoreItem": + return FlowState.model_validate(json_data) + + @dataclass class AuthHandler: """ @@ -37,6 +57,8 @@ class Authorization: Class responsible for managing authorization and OAuth flows. """ + SIGN_IN_STATE_KEY = "user.__SIGNIN_STATE_" + def __init__(self, storage: Storage, auth_handlers: AuthorizationHandlers): """ Creates a new instance of Authorization. @@ -121,7 +143,79 @@ async def get_token( return await auth_handler.flow.get_user_token(context) - def get_flow_state(self, auth_handler_id: Optional[str] = None) -> bool: + 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 auth_handler.flow is None: + 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(context, token_response.token, scopes) + + 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, context: TurnContext, token: str, scopes: list[str] + ) -> 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() + 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 + return await auth_handler.flow.exchange_token_on_behalf_of( + context, scopes, token + ) + + def get_flow_state(self, auth_handler_id: Optional[str] = None) -> FlowState: """ Gets the current state of the OAuth flow. @@ -129,14 +223,15 @@ def get_flow_state(self, auth_handler_id: Optional[str] = None) -> bool: auth_handler_id: Optional ID of the auth handler to check, defaults to first handler. Returns: - Whether the flow has started. + The flow state object. """ flow = self.resolver_handler(auth_handler_id).flow if flow is None: - return False + # Return a default FlowState if no flow is configured + return FlowState() # Return flow state if available - return flow.state.flow_started if flow.state else False + return flow.state if flow.state else FlowState() async def begin_or_continue_flow( self, @@ -155,6 +250,13 @@ async def begin_or_continue_flow( Returns: The token response from the OAuth provider. """ + # Get or initialize sign-in state + sign_in_state = state.get_value(self.SIGN_IN_STATE_KEY) + if sign_in_state is None: + sign_in_state = SignInState( + continuation_activity=None, handler_id=None, completed=False + ) + flow = self.resolver_handler(auth_handler_id).flow if flow is None: raise ValueError("OAuth flow is not configured for the auth handler") @@ -164,11 +266,17 @@ async def begin_or_continue_flow( if not flow_state.flow_started: token_response = await flow.begin_flow(context) + sign_in_state.continuation_activity = context.activity + sign_in_state.handler_id = auth_handler_id + 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 and self._sign_in_handler: - await self._sign_in_handler(context, state, auth_handler_id) + if token_response and token_response.token: + if self._sign_in_handler: + await self._sign_in_handler(context, state, auth_handler_id) + sign_in_state.completed = True + state.set_value(self.SIGN_IN_STATE_KEY, sign_in_state) return token_response 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 e6aac512..65c5039c 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 Optional from microsoft.agents.connector.client import UserTokenClient from microsoft.agents.core.models import ( @@ -16,6 +15,7 @@ OAuthCard, TokenExchangeState, TokenResponse, + Activity, ) from microsoft.agents.core import ( TurnContextProtocol as TurnContext, @@ -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,101 +46,127 @@ 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, 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 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 = abs_oauth_connection_name + # 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 accessor self.state: FlowState | None = None self.flow_state_accessor: StatePropertyAccessor = user_state.create_property( "flowState" ) 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. + + Args: + context: The turn context. - :param context: The turn context. - :return: A TokenResponse object. + Returns: + A TokenResponse object. """ - # logger.info('Starting OAuth flow') - self.state = await self._get_user_state(context) + self.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 + self.state.abs_oauth_connection_name = self.abs_oauth_connection_name await self.flow_state_accessor.set(context, self.state) - # logger.info('User token retrieved successfully from service') 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"), @@ -157,8 +185,8 @@ async def begin_flow(self, context: TurnContext) -> TokenResponse: # Update flow state self.state.flow_started = True self.state.flow_expires = datetime.now().timestamp() + 30000 + self.state.abs_oauth_connection_name = self.abs_oauth_connection_name await self.flow_state_accessor.set(context, self.state) - # logger.info('OAuth begin flow completed, waiting for user to sign in') # Return in-progress response return TokenResponse() @@ -166,17 +194,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 + self.state + and self.state.flow_expires != 0 and datetime.now().timestamp() > self.state.flow_expires ): - # logger.warn("Flow expired") - self.state.flow_started = False await context.send_activity( MessageFactory.text( self.messages_configuration.get( @@ -193,57 +224,59 @@ 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: + if result and result.token: self.state.flow_started = False - self.state.user_token = token_response.token + self.state.flow_expires = 0 + self.state.abs_oauth_connection_name = ( + self.abs_oauth_connection_name + ) await self.flow_state_accessor.set(context, self.state) - return token_response - return TokenResponse() + return result + else: + await context.send_activity( + MessageFactory.text("Invalid code. Please try again.") + ) + self.state.flow_started = True + self.state.flow_expires = datetime.now().timestamp() + 30000 + await self.flow_state_accessor.set(context, self.state) + 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.state.flow_started = False + self.state.abs_oauth_connection_name = self.abs_oauth_connection_name + await self.flow_state_accessor.set(context, self.state) + return result return TokenResponse() # Handle token exchange invoke activity @@ -251,73 +284,62 @@ 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) return user_token_resp else: - # logger.warn('Token exchange failed') self.state.flow_started = True - await self.flow_state_accessor.set(context, self.state) 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: """ Gets the user state. - :param context: The turn context. - :return: The user state. + + Args: + context: The turn context. + + Returns: + The user state. """ user_profile: FlowState | None = await self.flow_state_accessor.get( context, target_cls=FlowState @@ -325,3 +347,18 @@ async def _get_user_state(self, context: TurnContext) -> FlowState: if user_profile is None: user_profile = FlowState() return user_profile + + async def _initialize_token_client(self, context: TurnContext) -> None: + """ + Initializes the user token client if not already set. + + Args: + context: The turn context. + """ + if self.user_token_client is None: + self.user_token_client = context.turn_state.get( + context.adapter.USER_TOKEN_CLIENT_KEY + ) + + if self.user_token_client is None: + raise ValueError("UserTokenClient is not available in turn state") 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() From 1c49fc179e14e11ddbd06612aa6b4dd6345381fc Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Fri, 13 Jun 2025 16:22:33 -0700 Subject: [PATCH 07/27] Changes code complete. Sample updates ppending --- .../microsoft/agents/builder/app/__init__.py | 4 +- .../agents/builder/app/agent_application.py | 132 +++++++++--------- .../microsoft/agents/builder/app/route.py | 4 +- .../app/{typing.py => typing_indicator.py} | 2 +- 4 files changed, 70 insertions(+), 72 deletions(-) rename libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/{typing.py => typing_indicator.py} (98%) 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 30f4bab4..2df56b84 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 @@ -38,7 +38,7 @@ from .state import TurnState from ..channel_service_adapter import ChannelServiceAdapter from .oauth import Authorization, SignInState -from .typing import Typing +from .typing_indicator import TypingIndicator StateT = TypeVar("StateT", bound=TurnState) IN_SIGN_IN_KEY = "__InSignInFlow__" @@ -57,13 +57,13 @@ class AgentApplication(Agent, Generic[StateT]): and other AI capabilities. """ - typing: Typing + typing: TypingIndicator _options: ApplicationOptions _adapter: Optional[ChannelServiceAdapter] = None _auth: Optional[Authorization] = None - _before_turn: List[RouteHandler[StateT]] = [] - _after_turn: List[RouteHandler[StateT]] = [] + _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 @@ -72,7 +72,7 @@ def __init__(self, options: ApplicationOptions = None, **kwargs) -> None: """ Creates a new AgentApplication instance. """ - self.typing = Typing() + self.typing = TypingIndicator() self._routes = [] if not options: @@ -185,7 +185,9 @@ def __selector(context: TurnContext): 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 @@ -223,7 +225,9 @@ def __selector(context: TurnContext): 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 @@ -272,7 +276,9 @@ 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 @@ -313,7 +319,9 @@ 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 @@ -367,7 +375,9 @@ 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 @@ -397,7 +407,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"]) @@ -409,47 +419,14 @@ 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]: - """ - 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. - - ```python - # Use this method as a decorator - @app.before_turn - async def on_before_turn(context: TurnContext, state: TurnState): - print("hello world!") - return True - ``` - """ - - self._before_turn.append(func) - return func - - def after_turn(self, func: RouteHandler[StateT]) -> RouteHandler[StateT]: - """ - 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. - - ```python - # Use this method as a decorator - @app.after_turn - async def on_after_turn(context: TurnContext, state: TurnState): - print("hello world!") - return True - ``` - """ - - self._after_turn.append(func) - return func - def on_sign_in_success( self, func: Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]] ) -> Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]]: @@ -521,8 +498,27 @@ async def _on_turn(self, context: TurnContext): SignInState, turn_state.get_value(Authorization.SIGN_IN_STATE_KEY) ) - if self._authorization: - flow_state = await self._auth.get_flow_state(sign_in_state.handler_id) + if self._auth and sign_in_state: + flow_state = self._auth.get_flow_state(sign_in_state.handler_id) + if ( + flow_state.flow_started + and flow_state.abs_oauth_connection_name + == self._auth._auth_handlers[sign_in_state.handler_id].name + ): + token_response = await self._auth.begin_or_continue_flow( + context, turn_state, sign_in_state.handler_id + ) + if ( + sign_in_state.completed + and token_response + and token_response.token + ): + saved_activity = ( + sign_in_state.continuation_activity.model_copy() + ) + await self.on_turn(TurnContext(self._adapter, saved_activity)) + turn_state.delete_value(Authorization.SIGN_IN_STATE_KEY) + return if not await self._run_before_turn_middleware(context, turn_state): return @@ -533,9 +529,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: @@ -594,15 +589,15 @@ async def _authenticate_user(self, context: TurnContext, state): 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: @@ -618,7 +613,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) @@ -626,20 +621,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: + for route in self._routes: if route.selector(context): - await route.handler(context, state) - return + 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 + ) + if token_response and token_response.token: + sign_in_complete = True + if sign_in_complete: + await route.handler(context, state) - for route in routes: - if route.selector(context): - await route.handler(context, state) return async def _start_long_running_call( 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/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. """ From c7d4da06c801a0e7dea4929e8074d5486367ef2f Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 16 Jun 2025 17:09:56 -0700 Subject: [PATCH 08/27] WIP State broken --- .../msal/msal_auth_configuration.py | 27 ++++++- .../agents/builder/app/agent_application.py | 4 +- .../agents/builder/app/oauth/authorization.py | 2 +- .../microsoft/agents/builder/oauth_flow.py | 15 ++-- .../agents/builder/state/agent_state.py | 19 ++--- test_samples/app_style/authorization_agent.py | 80 ++++++------------- test_samples/app_style/auto_auth.py | 0 test_samples/app_style/shared/__init__.py | 3 +- test_samples/app_style/shared/start_server.py | 27 +++++++ 9 files changed, 96 insertions(+), 81 deletions(-) create mode 100644 test_samples/app_style/auto_auth.py create mode 100644 test_samples/app_style/shared/start_server.py 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 index 73c3824b..1f6e13ac 100644 --- 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 @@ -1,11 +1,11 @@ -from typing import Protocol, Optional +from typing import Optional from microsoft.agents.authorization import AgentAuthConfiguration from .auth_types import AuthTypes -class MsalAuthConfiguration(AgentAuthConfiguration, Protocol): +class MsalAuthConfiguration(AgentAuthConfiguration): """ Configuration for MSAL authentication. """ @@ -17,3 +17,26 @@ class MsalAuthConfiguration(AgentAuthConfiguration, Protocol): 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("AUTH_TYPE", AuthTypes.client_secret) + self.CLIENT_ID = client_id or kwargs.get("CLIENT_ID", None) + self.AUTHORITY = authority or kwargs.get("AUTHORITY", None) + self.TENANT_ID = tenant_id or kwargs.get("TENANT_ID", None) + self.CLIENT_SECRET = client_secret or kwargs.get("CLIENT_SECRET", None) + self.CERT_PEM_FILE = cert_pem_file or kwargs.get("CERT_PEM_FILE", None) + self.CERT_KEY_FILE = cert_key_file or kwargs.get("CERT_KEY_FILE", None) + self.CONNECTION_NAME = connection_name or kwargs.get("CONNECTION_NAME", None) + self.SCOPES = scopes or kwargs.get("SCOPES", None) 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 2df56b84..75d50b13 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 @@ -66,7 +66,7 @@ class AgentApplication(Agent, Generic[StateT]): _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: """ @@ -549,7 +549,7 @@ def _remove_mentions(self, context: TurnContext): 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) 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 index d6d4543f..4ed6e51b 100644 --- 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 @@ -57,7 +57,7 @@ class Authorization: Class responsible for managing authorization and OAuth flows. """ - SIGN_IN_STATE_KEY = "user.__SIGNIN_STATE_" + SIGN_IN_STATE_KEY = f"{UserState.__name__}.__SIGNIN_STATE_" def __init__(self, storage: Storage, auth_handlers: AuthorizationHandlers): """ 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 65c5039c..f76137f7 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 @@ -328,8 +328,9 @@ async def sign_out(self, context: TurnContext) -> None: channel_id=context.activity.channel_id, ) - self.state.flow_expires = 0 - await self.flow_state_accessor.set(context, self.state) + if self.state: + self.state.flow_expires = 0 + await self.flow_state_accessor.set(context, self.state) async def _get_user_state(self, context: TurnContext) -> FlowState: """ @@ -355,10 +356,8 @@ async def _initialize_token_client(self, context: TurnContext) -> None: Args: context: The turn context. """ - if self.user_token_client is None: - self.user_token_client = context.turn_state.get( - context.adapter.USER_TOKEN_CLIENT_KEY - ) - if self.user_token_client is None: - raise ValueError("UserTokenClient is not available in turn state") + # 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 + ) 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..1af60660 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: """ @@ -187,7 +188,7 @@ def get_storage_key( ) -> str: raise NotImplementedError() - async def get_property_value( + def get_value( self, turn_context: TurnContext, property_name: str, @@ -205,9 +206,7 @@ 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." - ) + raise TypeError("BotState.get_value(): property_name cannot be None.") cached_state = self.get_cached_state(turn_context) # if there is no value, this will throw, to signal to IPropertyAccesor that a default value should be computed @@ -224,9 +223,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, turn_context: TurnContext, property_name: str) -> None: """ Deletes a property from the state cache in the turn context. @@ -242,7 +239,7 @@ async def delete_property_value( cached_state = self.get_cached_state(turn_context) del cached_state.state[property_name] - async def set_property_value( + def set_value( self, turn_context: TurnContext, property_name: str, value: StoreItem ) -> None: """ @@ -300,7 +297,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) + await self._bot_state.delete_value(turn_context, self._name) async def get( self, @@ -318,7 +315,7 @@ async def get( """ await self._bot_state.load(turn_context, False) try: - result = await self._bot_state.get_property_value( + result = await self._bot_state.get_value( turn_context, self._name, target_cls=target_cls ) return result @@ -345,4 +342,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) + await self._bot_state.set_value(turn_context, self._name, value) diff --git a/test_samples/app_style/authorization_agent.py b/test_samples/app_style/authorization_agent.py index 0591f165..68b1b956 100644 --- a/test_samples/app_style/authorization_agent.py +++ b/test_samples/app_style/authorization_agent.py @@ -4,17 +4,15 @@ import re import sys import traceback -from aiohttp.web import Application, Request, Response, run_app -from dotenv import load_dotenv +from dotenv import load_dotenv from os import environ -from microsoft.agents.authentication.msal import AuthTypes, MsalAuthConfiguration + +from microsoft.agents.authentication.msal import MsalAuthConfiguration from microsoft.agents.builder.app import AgentApplication, TurnState from microsoft.agents.builder.app.oauth import AuthHandler from microsoft.agents.hosting.aiohttp import ( CloudAdapter, - jwt_authorization_middleware, - start_agent_process, ) from microsoft.agents.authorization import ( Connections, @@ -31,25 +29,12 @@ from microsoft.agents.storage import MemoryStorage from microsoft.agents.core.models import ActivityTypes, TokenResponse -from shared import GraphClient, GitHubClient +from shared import GraphClient, GitHubClient, start_server load_dotenv() - -class DefaultConfig(MsalAuthConfiguration): - """Agent Configuration""" - - def __init__(self) -> None: - self.AUTH_TYPE = AuthTypes.client_secret - self.TENANT_ID = "" or environ.get("TENANT_ID") - self.CLIENT_ID = "" or environ.get("CLIENT_ID") - self.CLIENT_SECRET = "" or environ.get("CLIENT_SECRET") - self.PORT = 3978 - - -CONFIG = DefaultConfig() -AUTH_PROVIDER = MsalAuth(CONFIG) - +AUTH_CONFIG = MsalAuthConfiguration(**environ) +AUTH_PROVIDER = MsalAuth(AUTH_CONFIG) class DefaultConnection(Connections): def get_default_connection(self) -> AccessTokenProviderBase: @@ -64,7 +49,7 @@ def get_connection(self, connection_name: str) -> AccessTokenProviderBase: pass -CHANNEL_CLIENT_FACTORY = RestChannelServiceClientFactory(CONFIG, DefaultConnection()) +CHANNEL_CLIENT_FACTORY = RestChannelServiceClientFactory(AUTH_CONFIG, DefaultConnection()) # Create adapter. ADAPTER = CloudAdapter(CHANNEL_CLIENT_FACTORY) @@ -339,20 +324,21 @@ async def handle_sign_in_success( async def on_magic_code(context: TurnContext, state: TurnState): # Handle 6-digit magic codes for OAuth verification if AGENT_APP.auth: - for handler_id in AGENT_APP.auth._auth_handlers.keys(): - try: - token_response = await AGENT_APP.auth.begin_or_continue_flow( - context, state, handler_id - ) - if token_response and token_response.token: - await handle_sign_in_success(context, state, handler_id) - return True - except Exception: - # Continue trying other handlers - continue + for handler_id, handler in AGENT_APP.auth._auth_handlers.items(): + if handler.flow and handler.flow.state.flow_started: + try: + token_response = await AGENT_APP.auth.begin_or_continue_flow( + context, state, handler_id + ) + if token_response and token_response.token: + await handle_sign_in_success(context, state, handler_id) + return + except Exception: + # Continue trying other handlers + continue await context.send_activity( - MessageFactory.text("Invalid verification code. Please try again.") + MessageFactory.text("Failed to verify the code: please check that the code is correct and that you started a sign-in process.") ) else: await on_message(context, state) @@ -385,25 +371,7 @@ async def on_error(context: TurnContext, error: Exception): await context.send_activity("The bot encountered an error or bug.") -# Listen for incoming requests on /api/messages -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"] = CONFIG -APP["agent_app"] = AGENT_APP -APP["adapter"] = ADAPTER - -if __name__ == "__main__": - try: - run_app(APP, host="localhost", port=CONFIG.PORT) - except Exception as error: - raise error +start_server( + agent_application=AGENT_APP, + auth_configuration=AUTH_CONFIG, +) 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/shared/__init__.py b/test_samples/app_style/shared/__init__.py index e648e984..1fb73bd2 100644 --- a/test_samples/app_style/shared/__init__.py +++ b/test_samples/app_style/shared/__init__.py @@ -1,4 +1,5 @@ from .user_graph_client import GraphClient from .github_api_client import GitHubClient +from .start_server import start_server -__all__ = ["GraphClient", "GitHubClient"] +__all__ = ["GraphClient", "GitHubClient", "start_server"] 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..a318a85a --- /dev/null +++ b/test_samples/app_style/shared/start_server.py @@ -0,0 +1,27 @@ +from os import environ +from microsoft.agents.builder.app.agent_application 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): + 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 \ No newline at end of file From d8d157bf41e0c4eb392dc2056b12829a39dae5ad Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 16 Jun 2025 18:57:49 -0700 Subject: [PATCH 09/27] WIP AgentState broken --- .../agents/builder/app/oauth/authorization.py | 2 +- .../microsoft/agents/builder/oauth_flow.py | 34 ++++++++++++++++--- .../agents/builder/state/agent_state.py | 13 ++++--- 3 files changed, 36 insertions(+), 13 deletions(-) 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 index 4ed6e51b..62f7b43a 100644 --- 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 @@ -262,7 +262,7 @@ async def begin_or_continue_flow( raise ValueError("OAuth flow is not configured for the auth handler") # Get the current flow state - flow_state = await flow._get_user_state(context) + flow_state = await flow._get_flow_state(context) if not flow_state.flow_started: token_response = await flow.begin_flow(context) 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 f76137f7..158e70b1 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 @@ -80,10 +80,7 @@ def __init__( self.token_exchange_id: Optional[str] = None # Initialize state and flow state accessor - self.state: FlowState | None = None - self.flow_state_accessor: StatePropertyAccessor = user_state.create_property( - "flowState" - ) + self._user_state = user_state async def get_user_token(self, context: TurnContext) -> TokenResponse: """ @@ -332,7 +329,7 @@ async def sign_out(self, context: TurnContext) -> None: self.state.flow_expires = 0 await self.flow_state_accessor.set(context, self.state) - async def _get_user_state(self, context: TurnContext) -> FlowState: + async def _get_flow_state(self, context: TurnContext) -> FlowState: """ Gets the user state. @@ -342,6 +339,9 @@ async def _get_user_state(self, context: TurnContext) -> FlowState: Returns: The user state. """ + storage_key = self._get_storage_key(context) + + items = await self._user_state.get(storage_key) user_profile: FlowState | None = await self.flow_state_accessor.get( context, target_cls=FlowState ) @@ -349,6 +349,9 @@ async def _get_user_state(self, context: TurnContext) -> FlowState: user_profile = FlowState() return user_profile + async def _save_flow_state(self, context: TurnContext) -> None: + pass + async def _initialize_token_client(self, context: TurnContext) -> None: """ Initializes the user token client if not already set. @@ -361,3 +364,24 @@ async def _initialize_token_client(self, context: TurnContext) -> None: 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.") + conversation_id = ( + context.activity.conversation.id if context.activity.conversation else None + ) + if not conversation_id: + raise ValueError("Conversation ID is not set in the activity.") + + return f"oauth/{self.abs_oauth_connection_name}/{channel_id}/{conversation_id}/flowState" 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 1af60660..c9ad6556 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 @@ -125,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: @@ -143,13 +143,12 @@ 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): """ @@ -190,8 +189,8 @@ def get_storage_key( def get_value( self, - turn_context: TurnContext, property_name: str, + default_value_factory: Callable[[], StoreItem] = None, *, target_cls: Type[StoreItem] = None, ) -> StoreItem: From 03ed22b82aa8e883e9bc09624aee3dcd713797ca Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Tue, 17 Jun 2025 00:27:00 -0700 Subject: [PATCH 10/27] State mostly fixed. Minor bugs pending for state, will test OBO. --- .../microsoft/agents/builder/oauth_flow.py | 39 +++++++++++++------ .../agents/builder/state/agent_state.py | 37 ++++++++++-------- 2 files changed, 48 insertions(+), 28 deletions(-) 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 158e70b1..645dc7a8 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 @@ -140,7 +140,8 @@ async def begin_flow(self, context: TurnContext) -> TokenResponse: self.state.flow_started = False self.state.flow_expires = 0 self.state.abs_oauth_connection_name = self.abs_oauth_connection_name - await self.flow_state_accessor.set(context, self.state) + self._user_state.set_value(self._get_storage_key(context), self.state) + await self._save_flow_state(context) return user_token # No token, need to start sign-in flow @@ -183,7 +184,8 @@ async def begin_flow(self, context: TurnContext) -> TokenResponse: self.state.flow_started = True self.state.flow_expires = datetime.now().timestamp() + 30000 self.state.abs_oauth_connection_name = self.abs_oauth_connection_name - await self.flow_state_accessor.set(context, self.state) + self._user_state.set_value(self._get_storage_key(context), self.state) + await self._save_flow_state(context) # Return in-progress response return TokenResponse() @@ -236,7 +238,10 @@ async def continue_flow(self, context: TurnContext) -> TokenResponse: self.state.abs_oauth_connection_name = ( self.abs_oauth_connection_name ) - await self.flow_state_accessor.set(context, self.state) + self._user_state.set_value( + self._get_storage_key(context), self.state + ) + await self._save_flow_state(context) return result else: await context.send_activity( @@ -244,7 +249,10 @@ async def continue_flow(self, context: TurnContext) -> TokenResponse: ) self.state.flow_started = True self.state.flow_expires = datetime.now().timestamp() + 30000 - await self.flow_state_accessor.set(context, self.state) + self._user_state.set_value( + self._get_storage_key(context), self.state + ) + await self._save_flow_state(context) return TokenResponse() else: await context.send_activity( @@ -272,7 +280,8 @@ async def continue_flow(self, context: TurnContext) -> TokenResponse: if result and result.token: self.state.flow_started = False self.state.abs_oauth_connection_name = self.abs_oauth_connection_name - await self.flow_state_accessor.set(context, self.state) + self._user_state.set_value(self._get_storage_key(context), self.state) + await self._save_flow_state(context) return result return TokenResponse() @@ -302,7 +311,8 @@ async def continue_flow(self, context: TurnContext) -> TokenResponse: if user_token_resp and user_token_resp.token: self.state.flow_started = False - await self.flow_state_accessor.set(context, self.state) + self._user_state.set_value(self._get_storage_key(context), self.state) + await self._save_flow_state(context) return user_token_resp else: self.state.flow_started = True @@ -327,7 +337,8 @@ async def sign_out(self, context: TurnContext) -> None: if self.state: self.state.flow_expires = 0 - await self.flow_state_accessor.set(context, self.state) + self._user_state.set_value(self._get_storage_key(context), self.state) + await self._save_flow_state(context) async def _get_flow_state(self, context: TurnContext) -> FlowState: """ @@ -341,16 +352,22 @@ async def _get_flow_state(self, context: TurnContext) -> FlowState: """ storage_key = self._get_storage_key(context) - items = await self._user_state.get(storage_key) - user_profile: FlowState | None = await self.flow_state_accessor.get( - context, target_cls=FlowState + await self._user_state.load(context) + + user_profile: FlowState | None = self._user_state.get_value( + storage_key, target_cls=FlowState ) if user_profile is None: user_profile = FlowState() return user_profile async def _save_flow_state(self, context: TurnContext) -> None: - pass + """ + Saves the flow state to the user state. + Args: + context: The turn context. + """ + await self._user_state.save(context) async def _initialize_token_client(self, context: TurnContext) -> None: """ 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 c9ad6556..cfc652e8 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 @@ -206,13 +206,20 @@ def get_value( """ if not property_name: raise TypeError("BotState.get_value(): property_name cannot be None.") - cached_state = self.get_cached_state(turn_context) # 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 target_cls: + 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 and value: # Attempt to deserialize the value if it is not None try: return target_cls.from_json_to_store_item(value) @@ -222,7 +229,7 @@ def get_value( return value - def delete_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. @@ -235,12 +242,11 @@ def delete_value(self, turn_context: TurnContext, property_name: str) -> None: """ 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] - def set_value( - self, turn_context: TurnContext, property_name: str, value: StoreItem - ) -> None: + if self._cached_state[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. @@ -255,8 +261,7 @@ def set_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): @@ -296,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_value(turn_context, self._name) + self._bot_state.delete_value(self._name) async def get( self, @@ -314,9 +319,7 @@ async def get( """ await self._bot_state.load(turn_context, False) try: - result = await self._bot_state.get_value( - turn_context, self._name, target_cls=target_cls - ) + result = self._bot_state.get_value(self._name, target_cls=target_cls) return result except: # ask for default value from factory @@ -328,7 +331,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: @@ -341,4 +344,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_value(turn_context, self._name, value) + self._bot_state.set_value(self._name, value) From 8fe97f00781c074fba702b5c435e242a64d7dce9 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 17 Jun 2025 11:22:57 -0700 Subject: [PATCH 11/27] State fixed. Testing OBO and auth handlers in routes --- .../microsoft/agents/builder/oauth_flow.py | 3 ++- test_samples/app_style/authorization_agent.py | 8 +++----- 2 files changed, 5 insertions(+), 6 deletions(-) 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 645dc7a8..9069a4e2 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 @@ -79,8 +79,9 @@ def __init__( self.user_token_client = user_token_client self.token_exchange_id: Optional[str] = None - # Initialize state and flow state accessor + # Initialize state and flow state self._user_state = user_state + self.state = None async def get_user_token(self, context: TurnContext) -> TokenResponse: """ diff --git a/test_samples/app_style/authorization_agent.py b/test_samples/app_style/authorization_agent.py index 68b1b956..bf645f9a 100644 --- a/test_samples/app_style/authorization_agent.py +++ b/test_samples/app_style/authorization_agent.py @@ -325,14 +325,12 @@ async def on_magic_code(context: TurnContext, state: TurnState): # Handle 6-digit magic codes for OAuth verification if AGENT_APP.auth: for handler_id, handler in AGENT_APP.auth._auth_handlers.items(): - if handler.flow and handler.flow.state.flow_started: + if handler.flow and handler.flow.state and handler.flow.state.flow_started: try: - token_response = await AGENT_APP.auth.begin_or_continue_flow( + await AGENT_APP.auth.begin_or_continue_flow( context, state, handler_id ) - if token_response and token_response.token: - await handle_sign_in_success(context, state, handler_id) - return + return except Exception: # Continue trying other handlers continue From 31eed8b6e17d886b808a823f253aea932a59b546 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 18 Jun 2025 16:04:19 -0700 Subject: [PATCH 12/27] Starting refactor for CloudAdapter and auth interfaces --- .../authorization/agent_auth_configuration.py | 34 ++++++++++++++++++- .../agents/hosting/aiohttp/cloud_adapter.py | 9 +++-- 2 files changed, 40 insertions(+), 3 deletions(-) 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..47efe85c 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 microsoft.agents.authentication.msal.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("AUTH_TYPE", AuthTypes.client_secret) + self.CLIENT_ID = client_id or kwargs.get("CLIENT_ID", None) + self.AUTHORITY = authority or kwargs.get("AUTHORITY", None) + self.TENANT_ID = tenant_id or kwargs.get("TENANT_ID", None) + self.CLIENT_SECRET = client_secret or kwargs.get("CLIENT_SECRET", None) + self.CERT_PEM_FILE = cert_pem_file or kwargs.get("CERT_PEM_FILE", None) + self.CERT_KEY_FILE = cert_key_file or kwargs.get("CERT_KEY_FILE", None) + self.CONNECTION_NAME = connection_name or kwargs.get("CONNECTION_NAME", None) + self.SCOPES = scopes or kwargs.get("SCOPES", None) @property def ISSUERS(self) -> list[str]: 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..5fc6589a 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,8 @@ HTTPUnauthorized, HTTPUnsupportedMediaType, ) -from microsoft.agents.authorization import ClaimsIdentity +from microsoft.agents.authorization import AgentAuthConfiguration, ClaimsIdentity, Connections +from microsoft.agents.builder.app.oauth import Authorization from microsoft.agents.core.models import ( Activity, DeliveryModes, @@ -31,13 +32,17 @@ class CloudAdapter(ChannelServiceAdapter, AgentHttpAdapter): def __init__( self, - channel_service_client_factory: ChannelServiceClientFactoryBase, + *, + service_auth_configuration: AgentAuthConfiguration = None, + authorization: Authorization = None, + 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): From 8acc045d477598384fb1432bcc85604d598e54da Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Thu, 19 Jun 2025 16:45:07 -0700 Subject: [PATCH 13/27] WIP. Major object creation and configuration refactoring --- .../agents/authentication/msal/__init__.py | 4 -- .../agents/authentication/msal/msal_auth.py | 7 ++-- .../msal/msal_auth_configuration.py | 42 ------------------- .../agents/builder/app/agent_application.py | 4 ++ .../agents/builder/app/oauth/authorization.py | 5 +-- .../rest_channel_service_client_factory.py | 9 ++-- .../authorization/agent_auth_configuration.py | 4 +- .../agents/authorization}/auth_types.py | 0 .../agents/hosting/aiohttp/cloud_adapter.py | 13 +++--- test_samples/app_style/authorization_agent.py | 9 ++-- 10 files changed, 25 insertions(+), 72 deletions(-) delete mode 100644 libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/msal_auth_configuration.py rename libraries/{Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal => Core/microsoft-agents-authorization/microsoft/agents/authorization}/auth_types.py (100%) 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..6fbe3158 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,5 @@ -from .auth_types import AuthTypes -from .msal_auth_configuration import MsalAuthConfiguration from .msal_auth import MsalAuth __all__ = [ - "AuthTypes", - "MsalAuthConfiguration", "MsalAuth", ] 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 75d343f3..683adf90 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,16 @@ 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( 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 1f6e13ac..00000000 --- a/libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/msal_auth_configuration.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import Optional - -from microsoft.agents.authorization import AgentAuthConfiguration - -from .auth_types import AuthTypes - - -class MsalAuthConfiguration(AgentAuthConfiguration): - """ - 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] - - 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("AUTH_TYPE", AuthTypes.client_secret) - self.CLIENT_ID = client_id or kwargs.get("CLIENT_ID", None) - self.AUTHORITY = authority or kwargs.get("AUTHORITY", None) - self.TENANT_ID = tenant_id or kwargs.get("TENANT_ID", None) - self.CLIENT_SECRET = client_secret or kwargs.get("CLIENT_SECRET", None) - self.CERT_PEM_FILE = cert_pem_file or kwargs.get("CERT_PEM_FILE", None) - self.CERT_KEY_FILE = cert_key_file or kwargs.get("CERT_KEY_FILE", None) - self.CONNECTION_NAME = connection_name or kwargs.get("CONNECTION_NAME", None) - self.SCOPES = scopes or kwargs.get("SCOPES", None) 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 75d50b13..aa634173 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 @@ -93,6 +93,10 @@ def __init__(self, options: ApplicationOptions = None, **kwargs) -> None: The `ApplicationOptions.storage` property is required and was not configured. """ ) + + if not self._options.adapter: + # TODO: Load configuration, create adapter. + pass if options.long_running_messages and ( not options.adapter or not options.bot_app_id 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 index 62f7b43a..8054171c 100644 --- 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 @@ -209,11 +209,10 @@ async def _handle_obo( auth_handler = self.resolver_handler() 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 - return await auth_handler.flow.exchange_token_on_behalf_of( - context, scopes, token - ) + return await token_client.user_token.exchange_token() def get_flow_state(self, auth_handler_id: Optional[str] = None) -> FlowState: """ 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/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 47efe85c..b6057174 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,6 +1,6 @@ -from typing import Protocol, Optional +from typing import Optional -from microsoft.agents.authentication.msal.auth_types import AuthTypes +from microsoft.agents.authorization.auth_types import AuthTypes class AgentAuthConfiguration: 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/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 5fc6589a..3900e4a5 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 @@ -23,6 +23,7 @@ ChannelServiceAdapter, ChannelServiceClientFactoryBase, MessageFactory, + RestChannelServiceClientFactory, TurnContext, ) @@ -33,8 +34,7 @@ class CloudAdapter(ChannelServiceAdapter, AgentHttpAdapter): def __init__( self, *, - service_auth_configuration: AgentAuthConfiguration = None, - authorization: Authorization = None, + connection_manager: Connections, channel_service_client_factory: ChannelServiceClientFactoryBase = None, ): """ @@ -42,9 +42,6 @@ def __init__( :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}" print(format_exc()) @@ -60,7 +57,11 @@ 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 + + #TODO: Implement creating a connection manager out of environment variables + self._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 index bf645f9a..0da17e3b 100644 --- a/test_samples/app_style/authorization_agent.py +++ b/test_samples/app_style/authorization_agent.py @@ -8,7 +8,6 @@ from dotenv import load_dotenv from os import environ -from microsoft.agents.authentication.msal import MsalAuthConfiguration from microsoft.agents.builder.app import AgentApplication, TurnState from microsoft.agents.builder.app.oauth import AuthHandler from microsoft.agents.hosting.aiohttp import ( @@ -18,11 +17,11 @@ Connections, AccessTokenProviderBase, ClaimsIdentity, + AgentAuthConfiguration ) from microsoft.agents.authentication.msal import MsalAuth from microsoft.agents.builder import ( - RestChannelServiceClientFactory, TurnContext, MessageFactory, ) @@ -33,7 +32,7 @@ load_dotenv() -AUTH_CONFIG = MsalAuthConfiguration(**environ) +AUTH_CONFIG = AgentAuthConfiguration(**environ) AUTH_PROVIDER = MsalAuth(AUTH_CONFIG) class DefaultConnection(Connections): @@ -49,10 +48,8 @@ def get_connection(self, connection_name: str) -> AccessTokenProviderBase: pass -CHANNEL_CLIENT_FACTORY = RestChannelServiceClientFactory(AUTH_CONFIG, DefaultConnection()) - # Create adapter. -ADAPTER = CloudAdapter(CHANNEL_CLIENT_FACTORY) +ADAPTER = CloudAdapter(DefaultConnection()) AGENT_APP = AgentApplication[TurnState]( storage=MemoryStorage(), From d8fff78005eb6a6e604f8122239433adc9c8d620 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 23 Jun 2025 16:54:18 -0700 Subject: [PATCH 14/27] WIP gonfiguration loading dane, propagating to classes --- .../agents/authentication/msal/__init__.py | 2 + .../agents/authentication/msal/msal_auth.py | 5 +- .../msal/msal_connection_manager.py | 52 +++++++++++++++++++ .../agents/builder/app/agent_application.py | 40 ++++++++++++-- .../agents/builder/app/oauth/authorization.py | 1 - .../microsoft/agents/core/__init__.py | 2 + .../agents/core/_load_configuration.py | 25 +++++++++ .../agents/hosting/aiohttp/cloud_adapter.py | 14 +++-- test_samples/app_style/authorization_agent.py | 38 +++----------- test_samples/app_style/shared/start_server.py | 10 ++-- 10 files changed, 148 insertions(+), 41 deletions(-) create mode 100644 libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/msal_connection_manager.py create mode 100644 libraries/Core/microsoft-agents-core/microsoft/agents/core/_load_configuration.py 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 6fbe3158..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,5 +1,7 @@ from .msal_auth import MsalAuth +from .msal_connection_manager import MsalConnectionManager __all__ = [ "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 683adf90..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,7 +13,10 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes -from microsoft.agents.authorization import AccessTokenProviderBase, AgentAuthConfiguration +from microsoft.agents.authorization import ( + AccessTokenProviderBase, + AgentAuthConfiguration, +) from microsoft.agents.authorization.auth_types import AuthTypes 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..953524a5 --- /dev/null +++ b/libraries/Authentication/microsoft-agents-authentication-msal/microsoft/agents/authentication/msal/msal_connection_manager.py @@ -0,0 +1,52 @@ +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: Dict[str, AgentAuthConfiguration] = None, + connections_map=List[Dict[str, str]], + **kwargs + ): + self._connections = connections + self._connections_map = connections_map or kwargs.get("CONNECTIONS_MAP", None) + if not self._connections: + connections_configs = kwargs.get("CONNECTIONS", {}) + for connection_name, connection_settings in connections_configs: + self._connections[connection_name] = MsalAuth( + AgentAuthConfiguration(**connection_settings.get("SETTINGS", None)) + ) + 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 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 aa634173..74f8e788 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 @@ -6,20 +6,25 @@ from __future__ import annotations 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 + from .. import Agent, TurnContext from microsoft.agents.core.models import ( Activity, @@ -72,10 +77,13 @@ def __init__(self, options: ApplicationOptions = None, **kwargs) -> None: """ Creates a new AgentApplication instance. """ + load_dotenv() self.typing = TypingIndicator() + self.configuration: dict = self.parse_env_vars_configuration(environ) self._routes = [] if not options: + # TODO: consolidate configuration story # Take the options from the kwargs and create an ApplicationOptions instance option_kwargs = dict( filter( @@ -93,9 +101,11 @@ def __init__(self, options: ApplicationOptions = None, **kwargs) -> None: The `ApplicationOptions.storage` property is required and was not configured. """ ) - + if not self._options.adapter: - # TODO: Load configuration, create adapter. + + auth_configuration = AgentAuthConfiguration(self.configuration) + self._options.adapter = CloudAdapter() pass if options.long_running_messages and ( @@ -551,6 +561,30 @@ 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 = self._turn_state_factory() 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 index 8054171c..3ec5da88 100644 --- 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 @@ -209,7 +209,6 @@ async def _handle_obo( auth_handler = self.resolver_handler() 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 return await token_client.user_token.exchange_token() 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..d5a74868 --- /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(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"], + } 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 3900e4a5..244d0690 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 AgentAuthConfiguration, ClaimsIdentity, Connections +from microsoft.agents.authorization import ( + AgentAuthConfiguration, + ClaimsIdentity, + Connections, +) from microsoft.agents.builder.app.oauth import Authorization from microsoft.agents.core.models import ( Activity, @@ -42,6 +46,7 @@ def __init__( :param channel_service_client_factory: The factory to use to create the channel service client. """ + async def on_turn_error(context: TurnContext, error: Exception): error_message = f"Exception caught : {error}" print(format_exc()) @@ -58,8 +63,11 @@ async def on_turn_error(context: TurnContext, error: Exception): self.on_turn_error = on_turn_error - #TODO: Implement creating a connection manager out of environment variables - self._channel_service_client_factory = channel_service_client_factory or RestChannelServiceClientFactory(connection_manager) + # TODO: Implement creating a connection manager out of environment variables + self._channel_service_client_factory = ( + channel_service_client_factory + or RestChannelServiceClientFactory(connection_manager) + ) super().__init__(channel_service_client_factory) diff --git a/test_samples/app_style/authorization_agent.py b/test_samples/app_style/authorization_agent.py index 0da17e3b..e83263bd 100644 --- a/test_samples/app_style/authorization_agent.py +++ b/test_samples/app_style/authorization_agent.py @@ -5,51 +5,27 @@ import sys import traceback -from dotenv import load_dotenv -from os import environ - from microsoft.agents.builder.app import AgentApplication, TurnState from microsoft.agents.builder.app.oauth import AuthHandler from microsoft.agents.hosting.aiohttp import ( CloudAdapter, ) -from microsoft.agents.authorization import ( - Connections, - AccessTokenProviderBase, - ClaimsIdentity, - AgentAuthConfiguration -) -from microsoft.agents.authentication.msal import MsalAuth +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() - -AUTH_CONFIG = AgentAuthConfiguration(**environ) -AUTH_PROVIDER = MsalAuth(AUTH_CONFIG) - -class DefaultConnection(Connections): - def get_default_connection(self) -> AccessTokenProviderBase: - pass +agents_sdk_config = load_configuration_from_env() - def get_token_provider( - self, claims_identity: ClaimsIdentity, service_url: str - ) -> AccessTokenProviderBase: - return AUTH_PROVIDER - - def get_connection(self, connection_name: str) -> AccessTokenProviderBase: - pass - - -# Create adapter. -ADAPTER = CloudAdapter(DefaultConnection()) +CONNECTION_MANAGER = MsalConnectionManager(agents_sdk_config) +ADAPTER = CloudAdapter(CONNECTION_MANAGER) AGENT_APP = AgentApplication[TurnState]( storage=MemoryStorage(), @@ -333,7 +309,9 @@ async def on_magic_code(context: TurnContext, state: TurnState): continue await context.send_activity( - MessageFactory.text("Failed to verify the code: please check that the code is correct and that you started a sign-in process.") + MessageFactory.text( + "Failed to verify the code: please check that the code is correct and that you started a sign-in process." + ) ) else: await on_message(context, state) diff --git a/test_samples/app_style/shared/start_server.py b/test_samples/app_style/shared/start_server.py index a318a85a..87cc21f6 100644 --- a/test_samples/app_style/shared/start_server.py +++ b/test_samples/app_style/shared/start_server.py @@ -1,9 +1,14 @@ from os import environ from microsoft.agents.builder.app.agent_application import AgentApplication -from microsoft.agents.hosting.aiohttp import jwt_authorization_middleware, start_agent_process, CloudAdapter +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): async def entry_point(req: Request) -> Response: agent: AgentApplication = req.app["agent_app"] @@ -14,7 +19,6 @@ async def entry_point(req: Request) -> Response: adapter, ) - APP = Application(middlewares=[jwt_authorization_middleware]) APP.router.add_post("/api/messages", entry_point) APP["agent_configuration"] = auth_configuration @@ -24,4 +28,4 @@ async def entry_point(req: Request) -> Response: try: run_app(APP, host="localhost", port=environ.get("PORT", 3978)) except Exception as error: - raise error \ No newline at end of file + raise error From 0e0b5d11f7b87e0530a5be39cf5716f1367ab295 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 24 Jun 2025 15:52:19 -0700 Subject: [PATCH 15/27] Lib changes mostly complete. Test sample pending. --- .../msal/msal_connection_manager.py | 4 +- .../agents/builder/app/agent_application.py | 29 +++-- .../agents/builder/app/oauth/authorization.py | 103 +++++++++++------- 3 files changed, 86 insertions(+), 50 deletions(-) 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 index 953524a5..f37c7500 100644 --- 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 @@ -20,8 +20,8 @@ def __init__( self._connections = connections self._connections_map = connections_map or kwargs.get("CONNECTIONS_MAP", None) if not self._connections: - connections_configs = kwargs.get("CONNECTIONS", {}) - for connection_name, connection_settings in connections_configs: + connections_configs: Dict[str, Dict] = kwargs.get("CONNECTIONS", {}) + for connection_name, connection_settings in connections_configs.items(): self._connections[connection_name] = MsalAuth( AgentAuthConfiguration(**connection_settings.get("SETTINGS", None)) ) 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 74f8e788..11bdbe70 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 @@ -23,9 +23,10 @@ ) from dotenv import load_dotenv -from microsoft.agents.authorization import AgentAuthConfiguration +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, @@ -73,7 +74,14 @@ class AgentApplication(Agent, Generic[StateT]): _error: Optional[Callable[[TurnContext, Exception], Awaitable[None]]] = 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, + configuration: Dict = None, + **kwargs, + ) -> None: """ Creates a new AgentApplication instance. """ @@ -82,6 +90,8 @@ def __init__(self, options: ApplicationOptions = None, **kwargs) -> None: self.configuration: dict = self.parse_env_vars_configuration(environ) self._routes = [] + configuration = configuration or load_configuration_from_env() + if not options: # TODO: consolidate configuration story # Take the options from the kwargs and create an ApplicationOptions instance @@ -102,12 +112,6 @@ def __init__(self, options: ApplicationOptions = None, **kwargs) -> None: """ ) - if not self._options.adapter: - - auth_configuration = AgentAuthConfiguration(self.configuration) - self._options.adapter = CloudAdapter() - pass - if options.long_running_messages and ( not options.adapter or not options.bot_app_id ): @@ -129,9 +133,18 @@ def __init__(self, options: ApplicationOptions = None, **kwargs) -> None: or partial(TurnState.with_storage, self._options.storage) ) + # TODO: decide how to initialize the Authorization (params vs options vs kwargs) + if not connection_manager: + raise ApplicationError( + """ + The `AgentApplication` requires a `Connections` instance to be passed as the + `connection_manager` parameter. + """ + ) if auth_handlers: self._auth = Authorization( storage=self._options.storage, + connection_manager=connection_manager, auth_handlers=auth_handlers, ) 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 index 3ec5da88..9f35e6e8 100644 --- 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 @@ -7,12 +7,14 @@ 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 ...channel_service_adapter import ChannelServiceAdapter from ...app.state.turn_state import TurnState from ...oauth_flow import OAuthFlow, FlowState from ...state.user_state import UserState @@ -35,17 +37,39 @@ def from_json_to_store_item(json_data: dict) -> "StoreItem": return FlowState.model_validate(json_data) -@dataclass class AuthHandler: """ Interface defining an authorization handler for OAuth flows. """ - name: Optional[str] = None - auto: Optional[bool] = None - flow: Optional[OAuthFlow] = None - title: Optional[str] = None - text: Optional[str] = None + 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( + "AZURE_BOT_OAUTH_CONNECTION_NAME" + ) + self.obo_connection_name = obo_connection_name or kwargs.get( + "OBO_CONNECTION_NAME" + ) + self.flow = None # Type alias for authorization handlers dictionary @@ -59,7 +83,14 @@ class Authorization: SIGN_IN_STATE_KEY = f"{UserState.__name__}.__SIGNIN_STATE_" - def __init__(self, storage: Storage, auth_handlers: AuthorizationHandlers): + def __init__( + self, + storage: Storage, + connection_manager: Connections, + auth_handlers: AuthorizationHandlers = None, + auto_signin: bool = None, + **kwargs, + ): """ Creates a new instance of Authorization. @@ -74,9 +105,19 @@ def __init__(self, storage: Storage, auth_handlers: AuthorizationHandlers): raise ValueError("Storage is required for Authorization") user_state = UserState(storage) + self._connection_manager = connection_manager + + self._auto_signin = ( + auto_signin if auto_signin is not None else kwargs.get("AUTOSIGNIN", False) + ) - if not auth_handlers or len(auth_handlers) == 0: - raise ValueError("The authorization does not have any auth handlers") + if not auth_handlers: + handlers_congif: Dict = kwargs.get("HANDLERS") + if handlers_congif: + raise ValueError("The authorization does not have any auth handlers") + auth_handlers = { + name: AuthHandler(**config) for name, config in handlers_congif.items() + } self._auth_handlers = auth_handlers self._sign_in_handler: Optional[ @@ -84,33 +125,7 @@ def __init__(self, storage: Storage, auth_handlers: AuthorizationHandlers): ] = None # Configure each auth handler - for handler_key, auth_handler in self._auth_handlers.items(): - # Set connection name from environment if not provided - if ( - auth_handler.name is None - and os.getenv(f"{handler_key}_CONNECTION_NAME") is None - ): - raise ValueError( - f"AuthHandler name {handler_key}_CONNECTION_NAME not set in authorization " - f"and not found in env vars." - ) - - # Set properties from environment variables if not already set - auth_handler.name = auth_handler.name or os.getenv( - f"{handler_key}_CONNECTION_NAME" - ) - auth_handler.title = auth_handler.title or os.getenv( - f"{handler_key}_CONNECTION_TITLE" - ) - auth_handler.text = auth_handler.text or os.getenv( - f"{handler_key}_CONNECTION_TEXT" - ) - auth_handler.auto = ( - auth_handler.auto - if auth_handler.auto is not None - else os.getenv(f"{handler_key}_CONNECTION_AUTO") == "true" - ) - + for auth_handler in self._auth_handlers.values(): # Create OAuth flow with configuration messages_config = {} if auth_handler.title: @@ -167,7 +182,7 @@ async def exchange_token( 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(context, token_response.token, scopes) + return await self._handle_obo(token_response.token, scopes, auth_handler_id) return token_response @@ -193,7 +208,7 @@ def _is_exchangeable(self, token: Optional[str]) -> bool: return False async def _handle_obo( - self, context: TurnContext, token: str, scopes: list[str] + self, token: str, scopes: list[str], handler_id: str = None ) -> TokenResponse: """ Handles On-Behalf-Of token exchange. @@ -206,12 +221,20 @@ async def _handle_obo( Returns: The new token response. """ - auth_handler = self.resolver_handler() + 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 - return await token_client.user_token.exchange_token() + token_provider: AccessTokenProviderBase = ( + self._connection_manager.get_token_provider( + auth_handler.obo_connection_name + ) + ) + return await token_provider.aquire_token_on_behalf_of( + scopes=scopes, + user_assertion=token, + ) def get_flow_state(self, auth_handler_id: Optional[str] = None) -> FlowState: """ From aaad66b2c2a402b02c082c0610fcb2e091bb4094 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 25 Jun 2025 17:18:10 -0700 Subject: [PATCH 16/27] Fixed previous scenarios. Multpile handler working. MCS OBO testing getting ready. --- .../msal/msal_connection_manager.py | 40 +++-- .../agents/builder/app/agent_application.py | 38 ++-- .../agents/builder/app/app_options.py | 2 +- .../agents/builder/app/oauth/authorization.py | 30 ++-- .../authorization/agent_auth_configuration.py | 14 +- .../agents/authorization/connections.py | 8 + .../agents/core/_load_configuration.py | 11 +- .../agents/hosting/aiohttp/cloud_adapter.py | 4 +- test_samples/app_style/authorization_agent.py | 26 ++- test_samples/app_style/mcs_agent.py | 168 ++++++++++++++++++ test_samples/app_style/shared/start_server.py | 7 +- 11 files changed, 275 insertions(+), 73 deletions(-) create mode 100644 test_samples/app_style/mcs_agent.py 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 index f37c7500..4c61101a 100644 --- 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 @@ -13,20 +13,34 @@ class MsalConnectionManager(Connections): def __init__( self, - connections: Dict[str, AgentAuthConfiguration] = None, - connections_map=List[Dict[str, str]], + connections_configurations: Dict[str, AgentAuthConfiguration] = None, + connections_map: List[Dict[str, str]] = None, **kwargs ): - self._connections = connections - self._connections_map = connections_map or kwargs.get("CONNECTIONS_MAP", None) - if not self._connections: - connections_configs: Dict[str, Dict] = kwargs.get("CONNECTIONS", {}) - for connection_name, connection_settings in connections_configs.items(): + 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.get("SETTINGS", None)) + 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", {}) ) - if not self._connections.get("SERVICE_CONNECTION", None): - raise ValueError("No service connection configuration provided.") + 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: """ @@ -50,3 +64,9 @@ def get_token_provider( 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/agent_application.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/agent_application.py index 11bdbe70..aa96ccc0 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 @@ -79,18 +79,16 @@ def __init__( options: ApplicationOptions = None, *, connection_manager: Connections = None, - configuration: Dict = None, + authorization: Authorization = None, **kwargs, ) -> None: """ Creates a new AgentApplication instance. """ - load_dotenv() self.typing = TypingIndicator() - self.configuration: dict = self.parse_env_vars_configuration(environ) self._routes = [] - configuration = configuration or load_configuration_from_env() + configuration = kwargs if not options: # TODO: consolidate configuration story @@ -125,8 +123,6 @@ def __init__( if options.adapter: self._adapter = options.adapter - auth_handlers = options.authorization or kwargs.get("authorization") - self._turn_state_factory = ( options.turn_state_factory or kwargs.get("turn_state_factory", None) @@ -134,19 +130,23 @@ def __init__( ) # TODO: decide how to initialize the Authorization (params vs options vs kwargs) - if not connection_manager: - raise ApplicationError( - """ - The `AgentApplication` requires a `Connections` instance to be passed as the - `connection_manager` parameter. - """ - ) - if auth_handlers: - self._auth = Authorization( - storage=self._options.storage, - connection_manager=connection_manager, - auth_handlers=auth_handlers, - ) + 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: 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 4e3155fa..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 @@ -84,7 +84,7 @@ class ApplicationOptions: If not provided, the default `TurnState` will be used. """ - authorization: Optional[AuthorizationHandlers] = None + 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/authorization.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/app/oauth/authorization.py index 9f35e6e8..1e968418 100644 --- 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 @@ -2,8 +2,6 @@ # Licensed under the MIT License. from __future__ import annotations -from dataclasses import dataclass -import os import jwt from typing import Dict, Optional, Callable, Awaitable @@ -14,7 +12,6 @@ from pydantic import BaseModel from ...turn_context import TurnContext -from ...channel_service_adapter import ChannelServiceAdapter from ...app.state.turn_state import TurnState from ...oauth_flow import OAuthFlow, FlowState from ...state.user_state import UserState @@ -64,12 +61,12 @@ def __init__( 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( - "AZURE_BOT_OAUTH_CONNECTION_NAME" + "AZUREBOTOAUTHCONNECTIONNAME" ) self.obo_connection_name = obo_connection_name or kwargs.get( - "OBO_CONNECTION_NAME" + "OBOCONNECTIONNAME" ) - self.flow = None + self.flow: OAuthFlow = None # Type alias for authorization handlers dictionary @@ -107,16 +104,25 @@ def __init__( 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 kwargs.get("AUTOSIGNIN", False) + auto_signin + if auto_signin is not None + else auth_configuration.get("AUTOSIGNIN", False) ) if not auth_handlers: - handlers_congif: Dict = kwargs.get("HANDLERS") - if handlers_congif: + 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 = { - name: AuthHandler(**config) for name, config in handlers_congif.items() + handler_name: AuthHandler( + name=handler_name, **config.get("SETTINGS", {}) + ) + for handler_name, config in handlers_congif.items() } self._auth_handlers = auth_handlers @@ -135,7 +141,7 @@ def __init__( auth_handler.flow = OAuthFlow( user_state=user_state, - abs_oauth_connection_name=auth_handler.name, + abs_oauth_connection_name=auth_handler.abs_oauth_connection_name, messages_configuration=messages_config if messages_config else None, ) @@ -176,7 +182,7 @@ async def exchange_token( The token response from the OAuth provider. """ auth_handler = self.resolver_handler(auth_handler_id) - if auth_handler.flow is None: + 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) 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 b6057174..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 @@ -31,14 +31,14 @@ def __init__( scopes: Optional[list[str]] = None, **kwargs: Optional[dict[str, str]], ): - self.AUTH_TYPE = auth_type or kwargs.get("AUTH_TYPE", AuthTypes.client_secret) - self.CLIENT_ID = client_id or kwargs.get("CLIENT_ID", None) + 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("TENANT_ID", None) - self.CLIENT_SECRET = client_secret or kwargs.get("CLIENT_SECRET", None) - self.CERT_PEM_FILE = cert_pem_file or kwargs.get("CERT_PEM_FILE", None) - self.CERT_KEY_FILE = cert_key_file or kwargs.get("CERT_KEY_FILE", None) - self.CONNECTION_NAME = connection_name or kwargs.get("CONNECTION_NAME", 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 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-core/microsoft/agents/core/_load_configuration.py b/libraries/Core/microsoft-agents-core/microsoft/agents/core/_load_configuration.py index d5a74868..49e854bd 100644 --- a/libraries/Core/microsoft-agents-core/microsoft/agents/core/_load_configuration.py +++ b/libraries/Core/microsoft-agents-core/microsoft/agents/core/_load_configuration.py @@ -1,10 +1,11 @@ from typing import Any, Dict -def load_configuration_from_env(vars: Dict[str, 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("__") @@ -18,8 +19,8 @@ def load_configuration_from_env(vars: Dict[str, Any]) -> dict: 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"], + "AGENTAPPLICATION": result.get("AGENTAPPLICATION", {}), + "COPILOTSTUDIOAGENT": result.get("COPILOTSTUDIOAGENT", {}), + "CONNECTIONS": result.get("CONNECTIONS", {}), + "CONNECTIONSMAP": result.get("CONNECTIONSMAP", {}), } 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 244d0690..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 @@ -17,7 +17,6 @@ ClaimsIdentity, Connections, ) -from microsoft.agents.builder.app.oauth import Authorization from microsoft.agents.core.models import ( Activity, DeliveryModes, @@ -63,8 +62,7 @@ async def on_turn_error(context: TurnContext, error: Exception): self.on_turn_error = on_turn_error - # TODO: Implement creating a connection manager out of environment variables - self._channel_service_client_factory = ( + channel_service_client_factory = ( channel_service_client_factory or RestChannelServiceClientFactory(connection_manager) ) diff --git a/test_samples/app_style/authorization_agent.py b/test_samples/app_style/authorization_agent.py index e83263bd..7731edae 100644 --- a/test_samples/app_style/authorization_agent.py +++ b/test_samples/app_style/authorization_agent.py @@ -1,12 +1,14 @@ # 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 AuthHandler +from microsoft.agents.builder.app.oauth import Authorization from microsoft.agents.hosting.aiohttp import ( CloudAdapter, ) @@ -22,21 +24,17 @@ from shared import GraphClient, GitHubClient, start_server -agents_sdk_config = load_configuration_from_env() +load_dotenv() -CONNECTION_MANAGER = MsalConnectionManager(agents_sdk_config) -ADAPTER = CloudAdapter(CONNECTION_MANAGER) +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=MemoryStorage(), - adapter=ADAPTER, - authorization={ - "GRAPH": AuthHandler(title="Graph API", text="Connect to Microsoft Graph"), - "GITHUB": AuthHandler( - title="GitHub", - text="Connect to GitHub", - ), - }, + storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config ) @@ -346,5 +344,5 @@ async def on_error(context: TurnContext, error: Exception): start_server( agent_application=AGENT_APP, - auth_configuration=AUTH_CONFIG, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), ) diff --git a/test_samples/app_style/mcs_agent.py b/test_samples/app_style/mcs_agent.py new file mode 100644 index 00000000..06c010c0 --- /dev/null +++ b/test_samples/app_style/mcs_agent.py @@ -0,0 +1,168 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from os import environ +from dotenv import load_dotenv + +from microsoft.agents.builder.app import AgentApplication, TurnState +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 +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): + environment_id = environ.get("ENVIRONMENT_ID") + agent_identifier = environ.get("AGENT_IDENTIFIER") + + if not environment_id: + raise ValueError("ENVIRONMENT_ID must be provided in environment variables") + if not agent_identifier: + raise ValueError( + "AGENT_IDENTIFIER must be provided in environment variables" + ) + + # Call parent constructor with required parameters + super().__init__( + environment_id=environment_id, + agent_identifier=agent_identifier, + cloud=None, # Will use default + copilot_agent_type=None, # Will use default + custom_power_platform_cloud=None, + ) + + +# 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.invoke) +async def invoke(context: TurnContext, state: TurnState) -> None: + await context.send_activity(MessageFactory.text("Invoke received.")) + + +@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("conversation.conversationId") + + # 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"] + ) + + 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( + "conversation.conversationId", activity.conversation.id + ) + break + 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: + 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() + return CopilotClient(settings, token) + + +def load_copilot_studio_connection_settings_from_env() -> McsConnectionSettings: + """Load Copilot Studio connection settings from environment variables""" + return McsConnectionSettings() + + +# 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/start_server.py b/test_samples/app_style/shared/start_server.py index 87cc21f6..2c589432 100644 --- a/test_samples/app_style/shared/start_server.py +++ b/test_samples/app_style/shared/start_server.py @@ -1,5 +1,6 @@ from os import environ -from microsoft.agents.builder.app.agent_application import AgentApplication +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, @@ -9,7 +10,9 @@ from microsoft.agents.hosting.aiohttp._start_agent_process import start_agent_process -def start_server(agent_application: AgentApplication, auth_configuration): +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"] From 0a778996562949f7530242fedfbc750b52a1cac3 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Thu, 26 Jun 2025 16:45:55 -0700 Subject: [PATCH 17/27] MCS new sample 80% working, configuration needs to be double checked --- .../agents/builder/app/oauth/authorization.py | 10 ++- test_samples/app_style/mcs_agent.py | 75 +++++++++++-------- 2 files changed, 50 insertions(+), 35 deletions(-) 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 index 1e968418..536cabb8 100644 --- 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 @@ -233,14 +233,16 @@ async def _handle_obo( # Use the flow's OBO method to exchange the token token_provider: AccessTokenProviderBase = ( - self._connection_manager.get_token_provider( - auth_handler.obo_connection_name - ) + self._connection_manager.get_connection(auth_handler.obo_connection_name) ) - return await token_provider.aquire_token_on_behalf_of( + 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: """ diff --git a/test_samples/app_style/mcs_agent.py b/test_samples/app_style/mcs_agent.py index 06c010c0..8fa9c79d 100644 --- a/test_samples/app_style/mcs_agent.py +++ b/test_samples/app_style/mcs_agent.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. from os import environ +from typing import Optional from dotenv import load_dotenv from microsoft.agents.builder.app import AgentApplication, TurnState @@ -10,7 +11,12 @@ 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 +from microsoft.agents.copilotstudio.client import ( + ConnectionSettings, + CopilotClient, + PowerPlatformCloud, + AgentType, +) from microsoft.agents.hosting.aiohttp import CloudAdapter from microsoft.agents.authentication.msal import MsalConnectionManager @@ -31,24 +37,41 @@ class McsConnectionSettings(ConnectionSettings): """Connection settings for MCS that loads from environment variables""" - def __init__(self): - environment_id = environ.get("ENVIRONMENT_ID") - agent_identifier = environ.get("AGENT_IDENTIFIER") - - if not environment_id: - raise ValueError("ENVIRONMENT_ID must be provided in environment variables") - if not agent_identifier: - raise ValueError( - "AGENT_IDENTIFIER must be provided in 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("App Client 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 + ) - # Call parent constructor with required parameters super().__init__( - environment_id=environment_id, - agent_identifier=agent_identifier, - cloud=None, # Will use default - copilot_agent_type=None, # Will use default - custom_power_platform_cloud=None, + environment_id, + agent_identifier, + cloud, + copilot_agent_type, + custom_power_platform_cloud, ) @@ -79,12 +102,7 @@ async def sign_out(context: TurnContext, state: TurnState) -> None: await context.send_activity(MessageFactory.text("User signed out")) -@AGENT_APP.activity(ActivityTypes.invoke) -async def invoke(context: TurnContext, state: TurnState) -> None: - await context.send_activity(MessageFactory.text("Invoke received.")) - - -@AGENT_APP.activity(ActivityTypes.message, auth_handlers=["mcs"]) +@AGENT_APP.activity(ActivityTypes.message, auth_handlers=["MCS"]) async def message(context: TurnContext, state: TurnState) -> None: await _handle_message(context, state) @@ -93,7 +111,7 @@ 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("conversation.conversationId") + conversation_id = state.get_value("ConversationState.conversationId") # Get OBO token for Power Platform API if not AGENT_APP.auth: @@ -102,7 +120,7 @@ async def _handle_message(context: TurnContext, state: TurnState) -> None: try: obo_token = await AGENT_APP.auth.exchange_token( - context, ["https://api.powerplatform.com/.default"] + context, ["https://api.powerplatform.com/.default"], "MCS" ) if not obo_token or not obo_token.token: @@ -150,15 +168,10 @@ async def _status(context: TurnContext, state: TurnState) -> None: def _create_client(token: str) -> CopilotClient: """Create CopilotClient with connection settings from environment""" - settings = McsConnectionSettings() + settings = McsConnectionSettings(**agents_sdk_config) return CopilotClient(settings, token) -def load_copilot_studio_connection_settings_from_env() -> McsConnectionSettings: - """Load Copilot Studio connection settings from environment variables""" - return McsConnectionSettings() - - # Create and start the agent if __name__ == "__main__": # Use the start_server function from shared module From 7e4950f08e175ded9f2f67cc0be88ded3a53610f Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Fri, 27 Jun 2025 17:11:25 -0600 Subject: [PATCH 18/27] ConversationState bug pending --- .../agents/builder/app/state/conversation_state.py | 2 +- test_samples/app_style/mcs_agent.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) 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/test_samples/app_style/mcs_agent.py b/test_samples/app_style/mcs_agent.py index 8fa9c79d..bf2490a2 100644 --- a/test_samples/app_style/mcs_agent.py +++ b/test_samples/app_style/mcs_agent.py @@ -5,7 +5,7 @@ from typing import Optional from dotenv import load_dotenv -from microsoft.agents.builder.app import AgentApplication, TurnState +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 @@ -52,7 +52,7 @@ def __init__( self.tenant_id = tenant_id or kwargs.get("TENANTID") if not self.app_client_id: - raise ValueError("App Client ID must be provided") + raise ValueError("Agent App ID must be provided") if not self.tenant_id: raise ValueError("Tenant ID must be provided") @@ -111,7 +111,9 @@ 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.conversationId") + conversation_id = state.get_value( + ConversationState.CONTEXT_SERVICE_KEY + ".conversation_id", None + ) # Get OBO token for Power Platform API if not AGENT_APP.auth: @@ -137,7 +139,8 @@ async def _handle_message(context: TurnContext, state: TurnState) -> None: await context.send_activity(MessageFactory.text(activity.text)) if activity.conversation and activity.conversation.id: state.set_value( - "conversation.conversationId", activity.conversation.id + ConversationState.CONTEXT_SERVICE_KEY + ".conversation_id", + activity.conversation.id, ) break else: @@ -168,7 +171,7 @@ async def _status(context: TurnContext, state: TurnState) -> None: def _create_client(token: str) -> CopilotClient: """Create CopilotClient with connection settings from environment""" - settings = McsConnectionSettings(**agents_sdk_config) + settings = McsConnectionSettings(**agents_sdk_config.get("COPILOTSTUDIOAGENT", {})) return CopilotClient(settings, token) From b15bb03fa59455b197398557a8461dee4f7ea585 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Mon, 30 Jun 2025 17:05:34 -0600 Subject: [PATCH 19/27] Serialization and state management fixe --- .../agents/builder/app/agent_application.py | 4 +-- .../agents/builder/app/oauth/authorization.py | 6 ++-- .../agents/builder/app/state/turn_state.py | 10 +++++-- .../connector/client/connector_client.py | 6 ++-- .../connector/teams/teams_connector_client.py | 28 +++++++++++++------ test_samples/app_style/mcs_agent.py | 6 ++-- 6 files changed, 40 insertions(+), 20 deletions(-) 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 aa96ccc0..6805be53 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 @@ -521,8 +521,8 @@ async def _on_turn(self, context: TurnContext): turn_state = await self._initialize_state(context) - sign_in_state = cast( - SignInState, turn_state.get_value(Authorization.SIGN_IN_STATE_KEY) + sign_in_state = turn_state.get_value( + Authorization.SIGN_IN_STATE_KEY, target_cls=SignInState ) if self._auth and sign_in_state: 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 index 536cabb8..a6c80328 100644 --- 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 @@ -27,11 +27,11 @@ class SignInState(StoreItem, BaseModel): completed: Optional[bool] = False def store_item_to_json(self) -> dict: - return self.model_dump() + return self.model_dump(exclude_unset=True) @staticmethod def from_json_to_store_item(json_data: dict) -> "StoreItem": - return FlowState.model_validate(json_data) + return SignInState.model_validate(json_data) class AuthHandler: @@ -280,7 +280,7 @@ async def begin_or_continue_flow( The token response from the OAuth provider. """ # Get or initialize sign-in state - sign_in_state = state.get_value(self.SIGN_IN_STATE_KEY) + sign_in_state = 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 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/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/test_samples/app_style/mcs_agent.py b/test_samples/app_style/mcs_agent.py index bf2490a2..ed3310be 100644 --- a/test_samples/app_style/mcs_agent.py +++ b/test_samples/app_style/mcs_agent.py @@ -1,6 +1,7 @@ # 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 @@ -112,7 +113,7 @@ async def _handle_message(context: TurnContext, state: TurnState) -> None: # Get conversation ID from state conversation_id = state.get_value( - ConversationState.CONTEXT_SERVICE_KEY + ".conversation_id", None + ConversationState.CONTEXT_SERVICE_KEY + ".conversation_id", target_cls=str ) # Get OBO token for Power Platform API @@ -142,7 +143,7 @@ async def _handle_message(context: TurnContext, state: TurnState) -> None: ConversationState.CONTEXT_SERVICE_KEY + ".conversation_id", activity.conversation.id, ) - break + await state.save(context) else: # Continue existing conversation async for activity in copilot_client.ask_question( @@ -157,6 +158,7 @@ async def _handle_message(context: TurnContext, state: TurnState) -> None: 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)}") ) From 608c59c485b5506143b8a564c974adbb8d12bae4 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Mon, 30 Jun 2025 20:36:43 -0600 Subject: [PATCH 20/27] Continuation activity fix --- .../agents/builder/app/agent_application.py | 45 ++++++++++++--- .../agents/builder/app/oauth/authorization.py | 42 +++++++++++--- .../agents/builder/channel_adapter.py | 1 + .../agents/builder/state/agent_state.py | 2 +- test_samples/app_style/authorization_agent.py | 55 +++---------------- 5 files changed, 80 insertions(+), 65 deletions(-) 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 6805be53..aa723984 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,6 +4,7 @@ """ from __future__ import annotations +from copy import copy from functools import partial from os import environ @@ -479,6 +480,32 @@ async def sign_in_success(context: TurnContext, state: TurnState): """ ) return func + + 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 when a user fails to sign in. + + ```python + # Use this method as a decorator + @app.on_sign_in_failure + async def sign_in_failure(context: TurnContext, state: TurnState): + print("hello world!") + return True + ``` + """ + + 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( self, func: Callable[[TurnContext, Exception], Awaitable[None]] @@ -525,26 +552,26 @@ async def _on_turn(self, context: TurnContext): Authorization.SIGN_IN_STATE_KEY, target_cls=SignInState ) - if self._auth and sign_in_state: + 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 - and flow_state.abs_oauth_connection_name - == self._auth._auth_handlers[sign_in_state.handler_id].name ): 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 ( - sign_in_state.completed - and token_response + token_response and token_response.token ): - saved_activity = ( - sign_in_state.continuation_activity.model_copy() - ) - await self.on_turn(TurnContext(self._adapter, saved_activity)) + 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): 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 index a6c80328..07ab24e3 100644 --- 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 @@ -129,6 +129,9 @@ def __init__( 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(): @@ -266,7 +269,8 @@ async def begin_or_continue_flow( self, context: TurnContext, state: TurnState, - auth_handler_id: Optional[str] = None, + auth_handler_id: str, + sec_route: bool = True, ) -> TokenResponse: """ Begins or continues an OAuth flow. @@ -279,6 +283,7 @@ async def begin_or_continue_flow( 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 = state.get_value(self.SIGN_IN_STATE_KEY, target_cls=SignInState) if sign_in_state is None: @@ -286,27 +291,37 @@ async def begin_or_continue_flow( continuation_activity=None, handler_id=None, completed=False ) - flow = self.resolver_handler(auth_handler_id).flow + 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) - sign_in_state.continuation_activity = context.activity - sign_in_state.handler_id = auth_handler_id - state.set_value(self.SIGN_IN_STATE_KEY, sign_in_state) + if sec_route: + sign_in_state.continuation_activity = context.activity + sign_in_state.handler_id = auth_handler_id + 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, state, auth_handler_id) - sign_in_state.completed = True - state.set_value(self.SIGN_IN_STATE_KEY, sign_in_state) - + if sec_route: + state.delete_value(self.SIGN_IN_STATE_KEY) + else: + if self._sign_in_failed_handler: + await self._sign_in_failed_handler(context, state, auth_handler_id) + + + await state.save(context) return token_response def resolver_handler(self, auth_handler_id: Optional[str] = None) -> AuthHandler: @@ -365,3 +380,14 @@ def on_sign_in_success( 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/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/state/agent_state.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/agent_state.py index cfc652e8..5bd8dcd7 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 @@ -243,7 +243,7 @@ def delete_value(self, property_name: str) -> None: if not property_name: raise TypeError("BotState.delete_property(): property_name cannot be None.") - if self._cached_state[property_name]: + 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: diff --git a/test_samples/app_style/authorization_agent.py b/test_samples/app_style/authorization_agent.py index 7731edae..42134c98 100644 --- a/test_samples/app_style/authorization_agent.py +++ b/test_samples/app_style/authorization_agent.py @@ -106,48 +106,9 @@ async def sign_out( return False -@AGENT_APP.message(re.compile(r"^(login|signin|sign in)", re.IGNORECASE)) -async def sign_in( - context: TurnContext, state: TurnState, handler_id: str = None -) -> TokenResponse: - """ - Internal method to begin or continue sign-in flow for 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.begin_or_continue_flow( - context, state, handler_id - ) - if token_response and token_response.token: - await context.send_activity( - MessageFactory.text( - f"Successfully signed in to {handler_id or 'service'}." - ) - ) - return token_response - 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"^(gh login|gh signin|user)$", re.IGNORECASE)) -async def sign_in_github(context: TurnContext, state: TurnState) -> TokenResponse: - """ - Internal method to begin or continue sign-in flow for GitHub. - """ - return await sign_in(context, state, handler_id="GITHUB") - - -@AGENT_APP.message(re.compile(r"^(me|profile)$", re.IGNORECASE)) +@AGENT_APP.message(re.compile(r"^(me|profile)$", re.IGNORECASE), auth_handlers=["GRAPH"]) async def profile_request( - context: TurnContext, state: TurnState, handler_id: str = None + context: TurnContext, state: TurnState ) -> dict: """ Internal method to get user profile information using the specified handler. @@ -159,11 +120,11 @@ async def profile_request( return None try: - token_response = await AGENT_APP.auth.get_token(context, handler_id) + 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 {handler_id}. Please sign in first." + f"Not authenticated with Graph. Please sign in first." ) ) return None @@ -184,9 +145,9 @@ async def profile_request( return None -@AGENT_APP.message(re.compile(r"^(github profile|gh profile)$", re.IGNORECASE)) +@AGENT_APP.message(re.compile(r"^(github profile|gh profile)$", re.IGNORECASE), auth_handlers=["GITHUB"]) async def profile_github( - context: TurnContext, state: TurnState, handler_id: str = "GITHUB" + context: TurnContext, state: TurnState ) -> dict: """ Internal method to get GitHub profile information. @@ -198,11 +159,11 @@ async def profile_github( return None try: - token_response = await AGENT_APP.auth.get_token(context, handler_id) + 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 {handler_id}. Please sign in first." + f"Not authenticated with Github. Please sign in first." ) ) From 060f0a4f4c218be0e7fd9611911e3b22a7842315 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Mon, 30 Jun 2025 20:37:22 -0600 Subject: [PATCH 21/27] formatting: Continuation activity fix --- .../agents/builder/app/agent_application.py | 15 ++++----------- .../agents/builder/app/oauth/authorization.py | 3 +-- test_samples/app_style/authorization_agent.py | 17 +++++++++-------- 3 files changed, 14 insertions(+), 21 deletions(-) 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 aa723984..f052f292 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 @@ -480,7 +480,7 @@ async def sign_in_success(context: TurnContext, state: TurnState): """ ) return func - + def on_sign_in_failure( self, func: Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]] ) -> Callable[[TurnContext, StateT, Optional[str]], Awaitable[None]]: @@ -554,19 +554,12 @@ async def _on_turn(self, context: TurnContext): 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 - ): + 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 - ): + 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) 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 index 07ab24e3..8b134fa0 100644 --- 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 @@ -319,7 +319,6 @@ async def begin_or_continue_flow( else: if self._sign_in_failed_handler: await self._sign_in_failed_handler(context, state, auth_handler_id) - await state.save(context) return token_response @@ -380,7 +379,7 @@ def on_sign_in_success( 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]], diff --git a/test_samples/app_style/authorization_agent.py b/test_samples/app_style/authorization_agent.py index 42134c98..7a2df1f4 100644 --- a/test_samples/app_style/authorization_agent.py +++ b/test_samples/app_style/authorization_agent.py @@ -106,10 +106,10 @@ async def sign_out( return False -@AGENT_APP.message(re.compile(r"^(me|profile)$", re.IGNORECASE), auth_handlers=["GRAPH"]) -async def profile_request( - context: TurnContext, state: TurnState -) -> dict: +@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. """ @@ -145,10 +145,11 @@ async def profile_request( 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: +@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. """ From 63b065e15c50f1729d7cc345e371f257c8c7ee78 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Mon, 30 Jun 2025 23:36:00 -0600 Subject: [PATCH 22/27] Multiple auth handlers working, unit tests fixed. --- .../agents/builder/app/agent_application.py | 39 +++--------------- .../agents/builder/state/agent_state.py | 16 ++++++-- .../tests/test_agent_state.py | 21 ++-------- test_samples/app_style/authorization_agent.py | 41 ++++++++----------- 4 files changed, 38 insertions(+), 79 deletions(-) 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 f052f292..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 @@ -631,35 +631,6 @@ 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: StateT): for before_turn in self._internal_before_turn: is_ok = await before_turn(context, state) @@ -702,11 +673,11 @@ async def _on_activity(self, context: TurnContext, state: StateT): token_response = await self._auth.begin_or_continue_flow( context, state, auth_handler_id ) - if token_response and token_response.token: - sign_in_complete = True - if sign_in_complete: - await route.handler(context, state) - + 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/state/agent_state.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/agent_state.py index 5bd8dcd7..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 @@ -150,7 +150,7 @@ async def save(self, turn_context: TurnContext, force: bool = False) -> None: await self._storage.write(changes) 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: """ @@ -318,8 +318,18 @@ 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 = self._bot_state.get_value(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: # ask for default value from factory 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/test_samples/app_style/authorization_agent.py b/test_samples/app_style/authorization_agent.py index 7a2df1f4..8f206dce 100644 --- a/test_samples/app_style/authorization_agent.py +++ b/test_samples/app_style/authorization_agent.py @@ -231,6 +231,23 @@ async def pull_requests( 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: """ @@ -253,30 +270,6 @@ async def handle_sign_in_success( ) -@AGENT_APP.message(re.compile(r"^\d{6}$")) -async def on_magic_code(context: TurnContext, state: TurnState): - # Handle 6-digit magic codes for OAuth verification - if AGENT_APP.auth: - for handler_id, handler in AGENT_APP.auth._auth_handlers.items(): - if handler.flow and handler.flow.state and handler.flow.state.flow_started: - try: - await AGENT_APP.auth.begin_or_continue_flow( - context, state, handler_id - ) - return - except Exception: - # Continue trying other handlers - continue - - await context.send_activity( - MessageFactory.text( - "Failed to verify the code: please check that the code is correct and that you started a sign-in process." - ) - ) - else: - await on_message(context, state) - - @AGENT_APP.conversation_update("membersAdded") async def on_members_added(context: TurnContext, _state: TurnState): await context.send_activity( From c3c4c4bac19e35f53befad42730a27b73e1e09b6 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Mon, 30 Jun 2025 23:44:21 -0600 Subject: [PATCH 23/27] Updating dependencies --- libraries/Builder/microsoft-agents-builder/pyproject.toml | 1 + 1 file changed, 1 insertion(+) 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] From 9f0a4f879b08c357dac01871888097dbf1b417cb Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Tue, 1 Jul 2025 21:12:09 -0600 Subject: [PATCH 24/27] multiple handlers error path working --- .../agents/builder/app/oauth/authorization.py | 22 +++-- .../microsoft/agents/builder/oauth_flow.py | 93 +++++++++---------- 2 files changed, 56 insertions(+), 59 deletions(-) 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 index 8b134fa0..b0d0b341 100644 --- 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 @@ -143,7 +143,7 @@ def __init__( messages_config["button_text"] = auth_handler.text auth_handler.flow = OAuthFlow( - user_state=user_state, + storage=storage, abs_oauth_connection_name=auth_handler.abs_oauth_connection_name, messages_configuration=messages_config if messages_config else None, ) @@ -263,12 +263,12 @@ def get_flow_state(self, auth_handler_id: Optional[str] = None) -> FlowState: return FlowState() # Return flow state if available - return flow.state if flow.state else FlowState() + return flow.flow_state or FlowState() async def begin_or_continue_flow( self, context: TurnContext, - state: TurnState, + turn_state: TurnState, auth_handler_id: str, sec_route: bool = True, ) -> TokenResponse: @@ -285,7 +285,9 @@ async def begin_or_continue_flow( """ auth_handler = self.resolver_handler(auth_handler_id) # Get or initialize sign-in state - sign_in_state = state.get_value(self.SIGN_IN_STATE_KEY, target_cls=SignInState) + 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 @@ -307,20 +309,22 @@ async def begin_or_continue_flow( if sec_route: sign_in_state.continuation_activity = context.activity sign_in_state.handler_id = auth_handler_id - state.set_value(self.SIGN_IN_STATE_KEY, sign_in_state) + 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, state, auth_handler_id) + await self._sign_in_handler(context, turn_state, auth_handler_id) if sec_route: - state.delete_value(self.SIGN_IN_STATE_KEY) + turn_state.delete_value(self.SIGN_IN_STATE_KEY) else: if self._sign_in_failed_handler: - await self._sign_in_failed_handler(context, state, auth_handler_id) + await self._sign_in_failed_handler( + context, turn_state, auth_handler_id + ) - await state.save(context) + await turn_state.save(context) return token_response def resolver_handler(self, auth_handler_id: Optional[str] = None) -> AuthHandler: 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 9069a4e2..5c7c4ef9 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 @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime -from typing import Optional +from typing import Dict, Optional from microsoft.agents.connector.client import UserTokenClient from microsoft.agents.core.models import ( @@ -20,7 +20,7 @@ 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 @@ -51,7 +51,7 @@ class OAuthFlow: def __init__( self, - user_state: UserState, + storage: Storage, abs_oauth_connection_name: str, user_token_client: Optional[UserTokenClient] = None, messages_configuration: dict[str, str] = None, @@ -80,8 +80,8 @@ def __init__( self.token_exchange_id: Optional[str] = None # Initialize state and flow state - self._user_state = user_state - self.state = None + self._storage = storage + self.flow_state = None async def get_user_token(self, context: TurnContext) -> TokenResponse: """ @@ -120,7 +120,7 @@ async def begin_flow(self, context: TurnContext) -> TokenResponse: Returns: A TokenResponse object. """ - self.state = FlowState() + self.flow_state = FlowState() if not self.abs_oauth_connection_name: raise ValueError("connectionName is not set") @@ -138,10 +138,9 @@ async def begin_flow(self, context: TurnContext) -> TokenResponse: if user_token and user_token.token: # Already have token, return it - self.state.flow_started = False - self.state.flow_expires = 0 - self.state.abs_oauth_connection_name = self.abs_oauth_connection_name - self._user_state.set_value(self._get_storage_key(context), self.state) + 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 @@ -182,10 +181,9 @@ 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 - self.state.abs_oauth_connection_name = self.abs_oauth_connection_name - self._user_state.set_value(self._get_storage_key(context), self.state) + 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 @@ -204,9 +202,9 @@ async def continue_flow(self, context: TurnContext) -> TokenResponse: await self._initialize_token_client(context) if ( - self.state - and 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 ): await context.send_activity( MessageFactory.text( @@ -234,25 +232,19 @@ async def continue_flow(self, context: TurnContext) -> TokenResponse: ) if result and result.token: - self.state.flow_started = False - self.state.flow_expires = 0 - self.state.abs_oauth_connection_name = ( + self.flow_state.flow_started = False + self.flow_state.flow_expires = 0 + self.flow_state.abs_oauth_connection_name = ( self.abs_oauth_connection_name ) - self._user_state.set_value( - self._get_storage_key(context), self.state - ) await self._save_flow_state(context) return result else: await context.send_activity( MessageFactory.text("Invalid code. Please try again.") ) - self.state.flow_started = True - self.state.flow_expires = datetime.now().timestamp() + 30000 - self._user_state.set_value( - self._get_storage_key(context), self.state - ) + self.flow_state.flow_started = True + self.flow_state.flow_expires = datetime.now().timestamp() + 30000 await self._save_flow_state(context) return TokenResponse() else: @@ -279,9 +271,10 @@ async def continue_flow(self, context: TurnContext) -> TokenResponse: ) if result and result.token: - self.state.flow_started = False - self.state.abs_oauth_connection_name = self.abs_oauth_connection_name - self._user_state.set_value(self._get_storage_key(context), self.state) + 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() @@ -311,12 +304,11 @@ async def continue_flow(self, context: TurnContext) -> TokenResponse: ) if user_token_resp and user_token_resp.token: - self.state.flow_started = False - self._user_state.set_value(self._get_storage_key(context), self.state) + self.flow_state.flow_started = False await self._save_flow_state(context) return user_token_resp else: - self.state.flow_started = True + self.flow_state.flow_started = True return TokenResponse() return TokenResponse() @@ -336,9 +328,8 @@ async def sign_out(self, context: TurnContext) -> None: channel_id=context.activity.channel_id, ) - if self.state: - self.state.flow_expires = 0 - self._user_state.set_value(self._get_storage_key(context), self.state) + 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: @@ -353,14 +344,12 @@ async def _get_flow_state(self, context: TurnContext) -> FlowState: """ storage_key = self._get_storage_key(context) - await self._user_state.load(context) - - user_profile: FlowState | None = self._user_state.get_value( - storage_key, target_cls=FlowState + storage_result: Dict[str, FlowState] | None = await self._storage.read( + [storage_key], target_cls=FlowState ) - if user_profile is None: - user_profile = FlowState() - return user_profile + 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: """ @@ -368,7 +357,7 @@ async def _save_flow_state(self, context: TurnContext) -> None: Args: context: The turn context. """ - await self._user_state.save(context) + await self._storage.write({self._get_storage_key(context): self.flow_state}) async def _initialize_token_client(self, context: TurnContext) -> None: """ @@ -396,10 +385,14 @@ def _get_storage_key(self, context: TurnContext) -> str: channel_id = context.activity.channel_id if not channel_id: raise ValueError("Channel ID is not set in the activity.") - conversation_id = ( - context.activity.conversation.id if context.activity.conversation else None + user_id = ( + context.activity.from_property.id + if context.activity.from_property + else None ) - if not conversation_id: - raise ValueError("Conversation ID is not set in the activity.") + if not user_id: + raise ValueError("User ID is not set in the activity.") - return f"oauth/{self.abs_oauth_connection_name}/{channel_id}/{conversation_id}/flowState" + return ( + f"oauth/{self.abs_oauth_connection_name}/{channel_id}/{user_id}/flowState" + ) From fa0c109f0693db60f289e1c7758f57f9ad428400 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Wed, 2 Jul 2025 13:34:07 -0600 Subject: [PATCH 25/27] Adding env template --- test_samples/app_style/env.TEMPLATE | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 test_samples/app_style/env.TEMPLATE 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 From f581404dd138302c8b75a1c8375310dabecd2b4d Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Mon, 7 Jul 2025 14:59:08 -0500 Subject: [PATCH 26/27] Model fixes --- .../microsoft/agents/builder/oauth_flow.py | 1 + .../microsoft/agents/core/models/card_action.py | 3 ++- .../microsoft/agents/core/models/entity.py | 6 ++++++ 3 files changed, 9 insertions(+), 1 deletion(-) 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 5c7c4ef9..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 @@ -170,6 +170,7 @@ async def begin_flow(self, context: TurnContext) -> TokenResponse: 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, 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 From 9ca32b0b4d56219893d217e0389ebfb44cd7e5a9 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Mon, 7 Jul 2025 15:02:26 -0500 Subject: [PATCH 27/27] Removing sample specific configuration loading from package --- .../microsoft/agents/core/_load_configuration.py | 1 - 1 file changed, 1 deletion(-) 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 index 49e854bd..f3c6afa3 100644 --- a/libraries/Core/microsoft-agents-core/microsoft/agents/core/_load_configuration.py +++ b/libraries/Core/microsoft-agents-core/microsoft/agents/core/_load_configuration.py @@ -20,7 +20,6 @@ def load_configuration_from_env(env_vars: Dict[str, Any]) -> dict: return { "AGENTAPPLICATION": result.get("AGENTAPPLICATION", {}), - "COPILOTSTUDIOAGENT": result.get("COPILOTSTUDIOAGENT", {}), "CONNECTIONS": result.get("CONNECTIONS", {}), "CONNECTIONSMAP": result.get("CONNECTIONSMAP", {}), }