From 0756dfc37585205d736a32a4f5a89f5db502351b Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 26 Mar 2025 18:30:07 -0700 Subject: [PATCH 1/5] WIP BasicOAuthFlow --- .../agents/builder/basic_oauth_flow.py | 137 ++++++++ .../agents/builder/channel_adapter.py | 4 + .../agents/builder/channel_service_adapter.py | 4 - .../agents/builder/state/__init__.py | 0 .../agents/builder/state/agent_state.py | 325 ++++++++++++++++++ .../builder/state/state_property_accessor.py | 42 +++ .../agents/builder/state/user_state.py | 51 +++ .../agents/core/channel_adapter_protocol.py | 1 + .../agents/core/turn_context_protocol.py | 2 +- 9 files changed, 561 insertions(+), 5 deletions(-) create mode 100644 libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py create mode 100644 libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/__init__.py create mode 100644 libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/agent_state.py create mode 100644 libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/state_property_accessor.py create mode 100644 libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/user_state.py diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py new file mode 100644 index 00000000..add3be07 --- /dev/null +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py @@ -0,0 +1,137 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft.agents.core.models import Attachment +from microsoft.agents.connector import UserTokenClient +from microsoft.agents.builder.cards.card_factory import CardFactory +from microsoft.agents.core.turn_context_protocol import ( + TurnContextProtocol as TurnContext, +) +from microsoft.agents.builder.message_factory import MessageFactory + +from .channel_service_adapter import ChannelServiceAdapter +from .state.state_property_accessor import StatePropertyAccessor +from .state.user_state import UserState + + +class FlowState: + def __init__(self): + self.flow_started = False + self.user_token = "" + self.flow_expires = 0 + + +class BasicOAuthFlow: + """ + Manages the OAuth flow for Web Chat. + """ + + def __init__(self, user_state: UserState): + """ + Creates a new instance of BasicOAuthFlow. + :param user_state: The user state. + """ + self.user_token_client: UserTokenClient = None + self.state: FlowState | None = None + self.flow_state_accessor: StatePropertyAccessor = user_state.create_property( + "flowState" + ) + + async def get_oauth_token(self, context: TurnContext) -> str: + """ + Gets the OAuth token. + :param context: The turn context. + :return: The user token. + """ + self.state = await self.get_user_state(context) + if self.state.user_token: + return self.state.user_token + + if self.state.flow_expires and self.state.flow_expires < context.adapter.now(): + # logger.warn("Sign-in flow expired") + self.state.flow_started = False + self.state.user_token = "" + await context.send_activity( + MessageFactory.text("Sign-in session expired. Please try again.") + ) + + ret_val = "" + auth_config = context.adapter.auth_config + if not auth_config.connection_name: + raise ValueError( + "connectionName is not set in the auth config, review your environment variables" + ) + + self.user_token_client = context.turn_state.get( + context.adapter.USER_TOKEN_CLIENT_KEY + ) + + if self.state.flow_started: + user_token = await self.user_token_client.get_user_token( + auth_config.connection_name, + context.activity.channel_id, + context.activity.from_property.id, + ) + if user_token: + # logger.info("Token obtained") + self.state.user_token = user_token.token + self.state.flow_started = False + else: + code = context.activity.text + user_token = await self.user_token_client.get_user_token( + auth_config.connection_name, + context.activity.channel_id, + context.activity.from_property.id, + code, + ) + if user_token: + # logger.info("Token obtained with code") + self.state.user_token = user_token.token + self.state.flow_started = False + else: + # logger.error("Sign in failed") + await context.send_activity(MessageFactory.text("Sign in failed")) + ret_val = self.state.user_token + else: + signing_resource = await self.user_token_client.get_sign_in_resource( + auth_config.client_id, auth_config.connection_name, context.activity + ) + o_card: Attachment = CardFactory.oauth_card( + auth_config.connection_name, "Sign in", "", signing_resource + ) + await context.send_activity(MessageFactory.attachment(o_card)) + self.state.flow_started = True + self.state.flow_expires = context.adapter.now() + 30000 + # logger.info("OAuth flow started") + + await self.flow_state_accessor.set(context, self.state) + return ret_val + + async def sign_out(self, context: TurnContext): + """ + Signs the user out. + :param context: The turn context. + """ + await self.user_token_client.sign_out( + context.activity.from_property.id, + context.adapter.auth_config.connection_name, + 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. + """ + user_profile: FlowState | None = await self.flow_state_accessor.get( + context, None + ) + if user_profile is None: + user_profile = FlowState() + return user_profile 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 90c03755..0f4ed2df 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 @@ -21,6 +21,10 @@ class ChannelAdapter(ABC, ChannelAdapterProtocol): AGENT_IDENTITY_KEY = "AgentIdentity" OAUTH_SCOPE_KEY = "Microsoft.Agents.Builder.ChannelAdapter.OAuthScope" INVOKE_RESPONSE_KEY = "ChannelAdapter.InvokeResponse" + CONNECTOR_FACTORY_KEY = "ConnectorFactory" + USER_TOKEN_CLIENT_KEY = "UserTokenClient" + AGENT_CALLBACK_HANDLER_KEY = "AgentCallbackHandler" + CHANNEL_SERVICE_FACTORY_KEY = "ChannelServiceClientFactory" on_turn_error: Callable[[TurnContext, Exception], Awaitable] = None diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/channel_service_adapter.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/channel_service_adapter.py index 501e9e9f..975a40b5 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/channel_service_adapter.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/channel_service_adapter.py @@ -38,10 +38,6 @@ class ChannelServiceAdapter(ChannelAdapter, ABC): - CONNECTOR_FACTORY_KEY = "ConnectorFactory" - USER_TOKEN_CLIENT_KEY = "UserTokenClient" - AGENT_CALLBACK_HANDLER_KEY = "AgentCallbackHandler" - CHANNEL_SERVICE_FACTORY_KEY = "ChannelServiceClientFactory" _AGENT_CONNECTOR_CLIENT_KEY = "ConnectorClient" _INVOKE_RESPONSE_KEY = "ChannelServiceAdapter.InvokeResponse" diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/__init__.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/__init__.py new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..5057b505 --- /dev/null +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/agent_state.py @@ -0,0 +1,325 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import abstractmethod +from copy import deepcopy +from typing import Callable, Dict, Union + +from microsoft.agents.storage import Storage, StoreItem + +from .state_property_accessor import StatePropertyAccessor +from ..turn_context import TurnContext + + +class CachedAgentState: + """ + Internal cached bot state. + """ + + def __init__(self, state: Dict[str, StoreItem] = None): + self.state = state if state is not None else {} + self.hash = self.compute_hash(state) + + @property + def is_changed(self) -> bool: + return self.hash != self.compute_hash(self.state) + + def compute_hash(self, item: StoreItem) -> str: + return hash(item.store_item_to_json()) + + +class AgentState: + """ + Defines a state management object and automates the reading and writing of + associated state properties to a storage layer. + + .. remarks:: + Each state management object defines a scope for a storage layer. + State properties are created within a state management scope, and the Bot Framework + defines these scopes: :class:`ConversationState`, :class:`UserState`, and :class:`PrivateConversationState`. + You can define additional scopes for your bot. + """ + + def __init__(self, storage: Storage, context_service_key: str): + """ + Initializes a new instance of the :class:`BotState` class. + + :param storage: The storage layer this state management object will use to store and retrieve state + :type storage: :class:`bptbuilder.core.Storage` + :param context_service_key: The key for the state cache for this :class:`BotState` + :type context_service_key: str + + .. remarks:: + This constructor creates a state management object and associated scope. The object uses + the :param storage: to persist state property values and the :param context_service_key: to cache state + within the context for each turn. + + :raises: It raises an argument null exception. + """ + self.state_key = "state" + self._storage = storage + self._context_service_key = context_service_key + + def get_cached_state(self, turn_context: TurnContext): + """ + Gets the cached bot state instance that wraps the raw cached data for this "BotState" + from the turn context. + + :param turn_context: The context object for this turn. + :type turn_context: :class:`TurnContext` + :return: The cached bot state instance. + """ + _assert_value(turn_context, self.get_cached_state.__name__) + return turn_context.turn_state.get(self._context_service_key) + + def create_property(self, name: str) -> StatePropertyAccessor: + """ + Creates a property definition and registers it with this :class:`BotState`. + + :param name: The name of the property + :type name: str + :return: If successful, the state property accessor created + :rtype: :class:`StatePropertyAccessor` + """ + if not name: + raise TypeError("BotState.create_property(): name cannot be None or empty.") + return BotStatePropertyAccessor(self, name) + + def get(self, turn_context: TurnContext) -> Dict[str, StoreItem]: + _assert_value(turn_context, self.get.__name__) + cached = self.get_cached_state(turn_context) + + return getattr(cached, "state", None) + + async def load(self, turn_context: TurnContext, force: bool = False) -> None: + """ + Reads the current state object and caches it in the context object for this turn. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :param force: Optional, true to bypass the cache + :type force: bool + """ + _assert_value(turn_context, self.load.__name__) + + cached_state = self.get_cached_state(turn_context) + storage_key = self.get_storage_key(turn_context) + + if force or not cached_state or not cached_state.state: + items = await self._storage.read([storage_key]) + val = items.get(storage_key) + turn_context.turn_state[self._context_service_key] = CachedAgentState(val) + + async def save_changes( + self, turn_context: TurnContext, force: bool = False + ) -> None: + """ + Saves the state cached in the current context for this turn. + If the state has changed, it saves the state cached in the current context for this turn. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :param force: Optional, true to save state to storage whether or not there are changes + :type force: bool + """ + _assert_value(turn_context, self.save_changes.__name__) + + cached_state = self.get_cached_state(turn_context) + + if force or (cached_state is not None and cached_state.is_changed): + storage_key = self.get_storage_key(turn_context) + changes: Dict[str, StoreItem] = {storage_key: cached_state.state} + await self._storage.write(changes) + cached_state.hash = cached_state.compute_hash(cached_state.state) + + async def clear_state(self, turn_context: TurnContext): + """ + Clears any state currently stored in this state scope. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + + :return: None + + .. remarks:: + This function must be called in order for the cleared state to be persisted to the underlying store. + """ + _assert_value(turn_context, self.clear_state.__name__) + + # 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 + + async def delete(self, turn_context: TurnContext) -> None: + """ + Deletes any state currently stored in this state scope. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + + :return: None + """ + _assert_value(turn_context, self.delete.__name__) + + turn_context.turn_state.pop(self._context_service_key) + + storage_key = self.get_storage_key(turn_context) + await self._storage.delete({storage_key}) + + @abstractmethod + def get_storage_key(self, turn_context: TurnContext) -> str: + raise NotImplementedError() + + async def get_property_value(self, turn_context: TurnContext, property_name: str): + """ + Gets the value of the specified property in the turn context. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :param property_name: The property name + :type property_name: str + + :return: The value of the property + """ + _assert_value(turn_context, self.get_property_value.__name__) + + if not property_name: + raise TypeError( + "BotState.get_property_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 + return cached_state.state[property_name] + + async def delete_property_value( + self, turn_context: TurnContext, property_name: str + ) -> None: + """ + Deletes a property from the state cache in the turn context. + + :param turn_context: The context object for this turn + :type turn_context: :TurnContext` + :param property_name: The name of the property to delete + :type property_name: str + + :return: None + """ + _assert_value(turn_context, self.delete_property_value.__name__) + if not property_name: + raise TypeError("BotState.delete_property(): property_name cannot be None.") + cached_state = self.get_cached_state(turn_context) + del cached_state.state[property_name] + + async def set_property_value( + self, turn_context: TurnContext, property_name: str, value: StoreItem + ) -> None: + """ + Sets a property to the specified value in the turn context. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :param property_name: The property name + :type property_name: str + :param value: The value to assign to the property + :type value: StoreItem + + :return: None + """ + _assert_value(turn_context, self.set_property_value.__name__) + + 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 + + +class BotStatePropertyAccessor(StatePropertyAccessor): + """ + Defines methods for accessing a state property created in a :class:`BotState` object. + """ + + def __init__(self, bot_state: AgentState, name: str): + """ + Initializes a new instance of the :class:`BotStatePropertyAccessor` class. + + :param bot_state: The state object to access + :type bot_state: :class:`BotState` + :param name: The name of the state property to access + :type name: str + + """ + self._bot_state = bot_state + self._name = name + + @property + def name(self) -> str: + """ + The name of the property. + """ + return self._name + + async def delete(self, turn_context: TurnContext) -> None: + """ + Deletes the property. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + """ + await self._bot_state.load(turn_context, False) + await self._bot_state.delete_property_value(turn_context, self._name) + + async def get( + self, + turn_context: TurnContext, + default_value_or_factory: Union[Callable, StoreItem] = None, + ) -> StoreItem: + """ + Gets the property value. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :param default_value_or_factory: Defines the default value for the property + """ + await self._bot_state.load(turn_context, False) + try: + result = await self._bot_state.get_property_value(turn_context, self._name) + return result + except: + # ask for default value from factory + if not default_value_or_factory: + return None + result = ( + default_value_or_factory() + if callable(default_value_or_factory) + else deepcopy(default_value_or_factory) + ) + # save default value for any further calls + await self.set(turn_context, result) + return result + + async def set(self, turn_context: TurnContext, value: StoreItem) -> None: + """ + Sets the property value. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + + :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) + + +def _assert_value(value: StoreItem, func_name: str): + """ + Asserts that the value is present. + + :param value: The value to check + """ + if value is None: + raise TypeError( + f"BotStatePropertyAccessor.{func_name}: expecting {value.__class__.__name__} but got None instead." + ) diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/state_property_accessor.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/state_property_accessor.py new file mode 100644 index 00000000..502c23ad --- /dev/null +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/state_property_accessor.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import abstractmethod +from typing import Protocol + +from ..turn_context import TurnContext + + +class StatePropertyAccessor(Protocol): + @abstractmethod + async def get( + self, turn_context: TurnContext, default_value_or_factory=None + ) -> object: + """ + Get the property value from the source + :param turn_context: Turn Context. + :param default_value_or_factory: Function which defines the property + value to be returned if no value has been set. + + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def delete(self, turn_context: TurnContext) -> None: + """ + Saves store items to storage. + :param turn_context: Turn Context. + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def set(self, turn_context: TurnContext, value) -> None: + """ + Set the property value on the source. + :param turn_context: Turn Context. + :param value: + :return: + """ + raise NotImplementedError() diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/user_state.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/user_state.py new file mode 100644 index 00000000..c3744141 --- /dev/null +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/user_state.py @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft.agents.storage import Storage + +from ..turn_context import TurnContext +from .agent_state import AgentState + + +class UserState(AgentState): + """ + Reads and writes user state for your bot to storage. + """ + + no_key_error_message = ( + "UserState: channel_id and/or conversation missing from context.activity." + ) + + def __init__(self, storage: Storage, namespace=""): + """ + Creates a new UserState instance. + :param storage: + :param namespace: + """ + self.namespace = namespace + + super(UserState, self).__init__(storage, "Internal.UserState") + + def get_storage_key(self, turn_context: TurnContext) -> str: + """ + Returns the storage key for the current user state. + :param turn_context: + :return: + """ + channel_id = turn_context.activity.channel_id or self.__raise_type_error( + "Invalid Activity: missing channelId" + ) + user_id = turn_context.activity.from_property.id or self.__raise_type_error( + "Invalid Activity: missing from_property.id" + ) + + storage_key = None + if channel_id and user_id: + storage_key = f"{channel_id}/users/{user_id}" + if self.namespace: + storage_key += f"/{storage_key}" + + return storage_key + + def __raise_type_error(self, err: str = "NoneType found while expecting value"): + raise TypeError(err) diff --git a/libraries/Core/microsoft-agents-core/microsoft/agents/core/channel_adapter_protocol.py b/libraries/Core/microsoft-agents-core/microsoft/agents/core/channel_adapter_protocol.py index c22c61fe..4dbee974 100644 --- a/libraries/Core/microsoft-agents-core/microsoft/agents/core/channel_adapter_protocol.py +++ b/libraries/Core/microsoft-agents-core/microsoft/agents/core/channel_adapter_protocol.py @@ -11,6 +11,7 @@ class ChannelAdapterProtocol(Protocol): + USER_TOKEN_CLIENT_KEY = "UserTokenClient" on_turn_error: Optional[Callable[[TurnContextProtocol, Exception], Awaitable]] @abstractmethod diff --git a/libraries/Core/microsoft-agents-core/microsoft/agents/core/turn_context_protocol.py b/libraries/Core/microsoft-agents-core/microsoft/agents/core/turn_context_protocol.py index d614e91d..965a5ccb 100644 --- a/libraries/Core/microsoft-agents-core/microsoft/agents/core/turn_context_protocol.py +++ b/libraries/Core/microsoft-agents-core/microsoft/agents/core/turn_context_protocol.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Protocol, List, Callable, Awaitable, Optional, Generic, TypeVar +from typing import Protocol, List, Callable, Optional, Generic, TypeVar from abc import abstractmethod from microsoft.agents.core.models import ( From 1160ff8015579feedf2b7886a913f13a5985b295 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Thu, 27 Mar 2025 20:35:21 -0700 Subject: [PATCH 2/5] WIP Basic Sample code complete, fix creating TokenExchangeState --- .../microsoft/agents/builder/__init__.py | 4 + .../agents/builder/basic_oauth_flow.py | 79 ++++--- .../microsoft/agents/builder/card_factory.py | 193 ++++++++++++++++++ .../agents/builder/state/__init__.py | 5 + .../agents/builder/state/agent_state.py | 4 +- .../agents/core/channel_adapter_protocol.py | 1 - .../microsoft/agents/core/models/__init__.py | 3 +- test_samples/wc_sso_agent/app.py | 70 +++++++ test_samples/wc_sso_agent/config.py | 14 ++ test_samples/wc_sso_agent/graph_client.py | 31 +++ test_samples/wc_sso_agent/sso_agent.py | 74 +++++++ 11 files changed, 451 insertions(+), 27 deletions(-) create mode 100644 libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/card_factory.py create mode 100644 test_samples/wc_sso_agent/app.py create mode 100644 test_samples/wc_sso_agent/config.py create mode 100644 test_samples/wc_sso_agent/graph_client.py create mode 100644 test_samples/wc_sso_agent/sso_agent.py diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/__init__.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/__init__.py index e16c1d34..fb396430 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/__init__.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/__init__.py @@ -1,6 +1,8 @@ # Import necessary modules from .activity_handler import ActivityHandler from .agent import Agent +from .basic_oauth_flow import BasicOAuthFlow +from .card_factory import CardFactory from .channel_adapter import ChannelAdapter from .channel_api_handler_protocol import ChannelApiHandlerProtocol from .channel_service_adapter import ChannelServiceAdapter @@ -14,6 +16,8 @@ __all__ = [ "ActivityHandler", "Agent", + "BasicOAuthFlow", + "CardFactory", "ChannelAdapter", "ChannelApiHandlerProtocol", "ChannelServiceAdapter", diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py index add3be07..48d6e3ad 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py @@ -1,15 +1,16 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from microsoft.agents.core.models import Attachment +from datetime import datetime + from microsoft.agents.connector import UserTokenClient -from microsoft.agents.builder.cards.card_factory import CardFactory -from microsoft.agents.core.turn_context_protocol import ( +from microsoft.agents.core.models import ActionTypes, CardAction, Attachment, OAuthCard +from microsoft.agents.core import ( TurnContextProtocol as TurnContext, ) -from microsoft.agents.builder.message_factory import MessageFactory -from .channel_service_adapter import ChannelServiceAdapter +from .message_factory import MessageFactory +from .card_factory import CardFactory from .state.state_property_accessor import StatePropertyAccessor from .state.user_state import UserState @@ -26,11 +27,17 @@ class BasicOAuthFlow: Manages the OAuth flow for Web Chat. """ - def __init__(self, user_state: UserState): + def __init__(self, user_state: UserState, connection_name: str): """ Creates a new instance of BasicOAuthFlow. :param user_state: The user state. """ + if not connection_name: + raise ValueError( + "BasicOAuthFlow.__init__: connectionName expected but not found" + ) + + self.connection_name = connection_name self.user_token_client: UserTokenClient = None self.state: FlowState | None = None self.flow_state_accessor: StatePropertyAccessor = user_state.create_property( @@ -47,7 +54,10 @@ async def get_oauth_token(self, context: TurnContext) -> str: if self.state.user_token: return self.state.user_token - if self.state.flow_expires and self.state.flow_expires < context.adapter.now(): + if ( + self.state.flow_expires + and self.state.flow_expires < datetime.now().timestamp() + ): # logger.warn("Sign-in flow expired") self.state.flow_started = False self.state.user_token = "" @@ -56,52 +66,73 @@ async def get_oauth_token(self, context: TurnContext) -> str: ) ret_val = "" - auth_config = context.adapter.auth_config - if not auth_config.connection_name: + if not self.connection_name: raise ValueError( "connectionName is not set in the auth config, review your environment variables" ) + # TODO: Fix property discovery self.user_token_client = context.turn_state.get( context.adapter.USER_TOKEN_CLIENT_KEY ) if self.state.flow_started: - user_token = await self.user_token_client.get_user_token( - auth_config.connection_name, - context.activity.channel_id, + user_token = await self.user_token_client.user_token.get_token( context.activity.from_property.id, + self.connection_name, + context.activity.channel_id, ) if user_token: # logger.info("Token obtained") - self.state.user_token = user_token.token + self.state.user_token = user_token["token"] self.state.flow_started = False else: code = context.activity.text - user_token = await self.user_token_client.get_user_token( - auth_config.connection_name, - context.activity.channel_id, + user_token = await self.user_token_client.user_token.get_token( context.activity.from_property.id, + self.connection_name, + context.activity.channel_id, code, ) if user_token: # logger.info("Token obtained with code") - self.state.user_token = user_token.token + self.state.user_token = user_token["token"] self.state.flow_started = False else: # logger.error("Sign in failed") await context.send_activity(MessageFactory.text("Sign in failed")) ret_val = self.state.user_token else: - signing_resource = await self.user_token_client.get_sign_in_resource( - auth_config.client_id, auth_config.connection_name, context.activity + signing_resource = ( + await self.user_token_client.agent_sign_in.get_sign_in_resource( + context.activity.from_property.id, + self.connection_name, + context.activity, + ) ) + # TODO: move this to CardFactory o_card: Attachment = CardFactory.oauth_card( - auth_config.connection_name, "Sign in", "", signing_resource + OAuthCard( + text="Sign in", + connection_name=self.connection_name, + buttons=[ + CardAction( + title="Sign in", + text="", + type=ActionTypes.signin, + value=signing_resource.sign_in_link, + ) + ], + token_exchange_resource=signing_resource.token_exchange_resource, + ), + self.connection_name, + "Sign in", + "", + signing_resource, ) await context.send_activity(MessageFactory.attachment(o_card)) self.state.flow_started = True - self.state.flow_expires = context.adapter.now() + 30000 + self.state.flow_expires = datetime.now().timestamp() + 30000 # logger.info("OAuth flow started") await self.flow_state_accessor.set(context, self.state) @@ -112,16 +143,16 @@ async def sign_out(self, context: TurnContext): Signs the user out. :param context: The turn context. """ - await self.user_token_client.sign_out( + await self.user_token_client.user_token.sign_out( context.activity.from_property.id, - context.adapter.auth_config.connection_name, + self.connection_name, 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") + # logger.info("User signed out successfully") async def get_user_state(self, context: TurnContext) -> FlowState: """ diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/card_factory.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/card_factory.py new file mode 100644 index 00000000..d8befe09 --- /dev/null +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/card_factory.py @@ -0,0 +1,193 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft.agents.core.models import ( + AnimationCard, + Attachment, + AudioCard, + HeroCard, + OAuthCard, + ReceiptCard, + SigninCard, + ThumbnailCard, + VideoCard, +) + + +class ContentTypes: + adaptive_card = "application/vnd.microsoft.card.adaptive" + animation_card = "application/vnd.microsoft.card.animation" + audio_card = "application/vnd.microsoft.card.audio" + hero_card = "application/vnd.microsoft.card.hero" + receipt_card = "application/vnd.microsoft.card.receipt" + oauth_card = "application/vnd.microsoft.card.oauth" + signin_card = "application/vnd.microsoft.card.signin" + thumbnail_card = "application/vnd.microsoft.card.thumbnail" + video_card = "application/vnd.microsoft.card.video" + + +class CardFactory: + content_types = ContentTypes + + @staticmethod + def adaptive_card(card: dict) -> Attachment: + """ + Returns an attachment for an adaptive card. The attachment will contain the card and the + appropriate 'contentType'. Will raise a TypeError if the 'card' argument is not an + dict. + :param card: + :return: + """ + if not isinstance(card, dict): + raise TypeError( + "CardFactory.adaptive_card(): `card` argument is not of type dict, unable to prepare " + "attachment." + ) + + return Attachment( + content_type=CardFactory.content_types.adaptive_card, content=card + ) + + @staticmethod + def animation_card(card: AnimationCard) -> Attachment: + """ + Returns an attachment for an animation card. Will raise a TypeError if the 'card' argument is not an + AnimationCard. + :param card: + :return: + """ + if not isinstance(card, AnimationCard): + raise TypeError( + "CardFactory.animation_card(): `card` argument is not an instance of an AnimationCard, " + "unable to prepare attachment." + ) + + return Attachment( + content_type=CardFactory.content_types.animation_card, content=card + ) + + @staticmethod + def audio_card(card: AudioCard) -> Attachment: + """ + Returns an attachment for an audio card. Will raise a TypeError if 'card' argument is not an AudioCard. + :param card: + :return: + """ + if not isinstance(card, AudioCard): + raise TypeError( + "CardFactory.audio_card(): `card` argument is not an instance of an AudioCard, " + "unable to prepare attachment." + ) + + return Attachment( + content_type=CardFactory.content_types.audio_card, content=card + ) + + @staticmethod + def hero_card(card: HeroCard) -> Attachment: + """ + Returns an attachment for a hero card. Will raise a TypeError if 'card' argument is not a HeroCard. + + Hero cards tend to have one dominant full width image and the cards text & buttons can + usually be found below the image. + :return: + """ + if not isinstance(card, HeroCard): + raise TypeError( + "CardFactory.hero_card(): `card` argument is not an instance of an HeroCard, " + "unable to prepare attachment." + ) + + return Attachment( + content_type=CardFactory.content_types.hero_card, content=card + ) + + @staticmethod + def oauth_card(card: OAuthCard) -> Attachment: + """ + Returns an attachment for an OAuth card used by the Bot Frameworks Single Sign On (SSO) service. Will raise a + TypeError if 'card' argument is not a OAuthCard. + :param card: + :return: + """ + if not isinstance(card, OAuthCard): + raise TypeError( + "CardFactory.oauth_card(): `card` argument is not an instance of an OAuthCard, " + "unable to prepare attachment." + ) + + return Attachment( + content_type=CardFactory.content_types.oauth_card, content=card + ) + + @staticmethod + def receipt_card(card: ReceiptCard) -> Attachment: + """ + Returns an attachment for a receipt card. Will raise a TypeError if 'card' argument is not a ReceiptCard. + :param card: + :return: + """ + if not isinstance(card, ReceiptCard): + raise TypeError( + "CardFactory.receipt_card(): `card` argument is not an instance of an ReceiptCard, " + "unable to prepare attachment." + ) + + return Attachment( + content_type=CardFactory.content_types.receipt_card, content=card + ) + + @staticmethod + def signin_card(card: SigninCard) -> Attachment: + """ + Returns an attachment for a signin card. For channels that don't natively support signin cards an alternative + message will be rendered. Will raise a TypeError if 'card' argument is not a SigninCard. + :param card: + :return: + """ + if not isinstance(card, SigninCard): + raise TypeError( + "CardFactory.signin_card(): `card` argument is not an instance of an SigninCard, " + "unable to prepare attachment." + ) + + return Attachment( + content_type=CardFactory.content_types.signin_card, content=card + ) + + @staticmethod + def thumbnail_card(card: ThumbnailCard) -> Attachment: + """ + Returns an attachment for a thumbnail card. Thumbnail cards are similar to + but instead of a full width image, they're typically rendered with a smaller thumbnail version of + the image on either side and the text will be rendered in column next to the image. Any buttons + will typically show up under the card. Will raise a TypeError if 'card' argument is not a ThumbnailCard. + :param card: + :return: + """ + if not isinstance(card, ThumbnailCard): + raise TypeError( + "CardFactory.thumbnail_card(): `card` argument is not an instance of an ThumbnailCard, " + "unable to prepare attachment." + ) + + return Attachment( + content_type=CardFactory.content_types.thumbnail_card, content=card + ) + + @staticmethod + def video_card(card: VideoCard) -> Attachment: + """ + Returns an attachment for a video card. Will raise a TypeError if 'card' argument is not a VideoCard. + :param card: + :return: + """ + if not isinstance(card, VideoCard): + raise TypeError( + "CardFactory.video_card(): `card` argument is not an instance of an VideoCard, " + "unable to prepare attachment." + ) + + return Attachment( + content_type=CardFactory.content_types.video_card, content=card + ) diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/__init__.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/__init__.py index e69de29b..ba4a4f00 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/__init__.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/__init__.py @@ -0,0 +1,5 @@ +from .agent_state import AgentState +from .state_property_accessor import StatePropertyAccessor +from .user_state import UserState + +__all__ = ["AgentState", "StatePropertyAccessor", "UserState"] 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 5057b505..7ef7b059 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 @@ -25,7 +25,9 @@ def is_changed(self) -> bool: return self.hash != self.compute_hash(self.state) def compute_hash(self, item: StoreItem) -> str: - return hash(item.store_item_to_json()) + if item: + return hash(item.store_item_to_json()) + return "" class AgentState: diff --git a/libraries/Core/microsoft-agents-core/microsoft/agents/core/channel_adapter_protocol.py b/libraries/Core/microsoft-agents-core/microsoft/agents/core/channel_adapter_protocol.py index 4dbee974..c22c61fe 100644 --- a/libraries/Core/microsoft-agents-core/microsoft/agents/core/channel_adapter_protocol.py +++ b/libraries/Core/microsoft-agents-core/microsoft/agents/core/channel_adapter_protocol.py @@ -11,7 +11,6 @@ class ChannelAdapterProtocol(Protocol): - USER_TOKEN_CLIENT_KEY = "UserTokenClient" on_turn_error: Optional[Callable[[TurnContextProtocol, Exception], Awaitable]] @abstractmethod diff --git a/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/__init__.py b/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/__init__.py index 71eda3fa..1e7c2f10 100644 --- a/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/__init__.py +++ b/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/__init__.py @@ -1,4 +1,5 @@ from .agents_model import AgentsModel +from .action_types import ActionTypes from .activity import Activity from .activity_event_names import ActivityEventNames from .activity_types import ActivityTypes @@ -57,7 +58,6 @@ from .transcript import Transcript from .video_card import VideoCard -from .activity_types import ActivityTypes from .activity_importance import ActivityImportance from .attachment_layout_types import AttachmentLayoutTypes from .contact_relation_update_action_types import ContactRelationUpdateActionTypes @@ -81,6 +81,7 @@ __all__ = [ "AgentsModel", "Activity", + "ActionTypes", "ActivityEventNames", "AdaptiveCardInvokeAction", "AdaptiveCardInvokeResponse", diff --git a/test_samples/wc_sso_agent/app.py b/test_samples/wc_sso_agent/app.py new file mode 100644 index 00000000..44e4ed96 --- /dev/null +++ b/test_samples/wc_sso_agent/app.py @@ -0,0 +1,70 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from dotenv import load_dotenv +from aiohttp.web import Application, Request, Response, run_app + +from microsoft.agents.builder import RestChannelServiceClientFactory +from microsoft.agents.builder.state import UserState +from microsoft.agents.hosting.aiohttp import CloudAdapter, jwt_authorization_middleware +from microsoft.agents.authorization import ( + Connections, + AccessTokenProviderBase, + ClaimsIdentity, +) +from microsoft.agents.authentication.msal import MsalAuth +from microsoft.agents.storage import MemoryStorage + +from sso_agent import SsoAgent +from config import DefaultConfig + +load_dotenv() + +CONFIG = DefaultConfig() +AUTH_PROVIDER = MsalAuth(DefaultConfig()) + + +class DefaultConnection(Connections): + def get_default_connection(self) -> AccessTokenProviderBase: + pass + + def get_token_provider( + self, claims_identity: ClaimsIdentity, service_url: str + ) -> AccessTokenProviderBase: + return AUTH_PROVIDER + + def get_connection(self, connection_name: str) -> AccessTokenProviderBase: + pass + + +CHANNEL_CLIENT_FACTORY = RestChannelServiceClientFactory(CONFIG, DefaultConnection()) + +# Create adapter. +ADAPTER = CloudAdapter(CHANNEL_CLIENT_FACTORY) + +# Create the storage. +STORAGE = MemoryStorage() + +# Create the user state and conversation state. +USER_STATE = UserState(STORAGE) + +# Create the Agent +AGENT = SsoAgent(USER_STATE, CONFIG.CONNECTION_NAME) + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + adapter: CloudAdapter = req.app["adapter"] + return await adapter.process(req, AGENT) + + +APP = Application(middlewares=[jwt_authorization_middleware]) +APP.router.add_post("/api/messages", messages) +APP["agent_configuration"] = CONFIG +APP["adapter"] = ADAPTER + +if __name__ == "__main__": + try: + run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/test_samples/wc_sso_agent/config.py b/test_samples/wc_sso_agent/config.py new file mode 100644 index 00000000..7b6735bb --- /dev/null +++ b/test_samples/wc_sso_agent/config.py @@ -0,0 +1,14 @@ +from os import environ +from microsoft.agents.authentication.msal import AuthTypes, MsalAuthConfiguration + + +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.CONNECTION_NAME = "" or environ.get("CONNECTION_NAME") + self.PORT = 3978 diff --git a/test_samples/wc_sso_agent/graph_client.py b/test_samples/wc_sso_agent/graph_client.py new file mode 100644 index 00000000..879308d1 --- /dev/null +++ b/test_samples/wc_sso_agent/graph_client.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +from urllib.parse import urlparse, urljoin + +from requests_oauthlib import OAuth2Session + +AUTHORITY_URL = "https://login.microsoftonline.com/common" +RESOURCE = "https://graph.microsoft.com" +API_VERSION = "beta" + + +class GraphClient: + def __init__(self, token: str): + self.token = token + self.client = OAuth2Session( + token={"access_token": token, "token_type": "Bearer"} + ) + + async def get_me(self) -> dict: + response = self.client.get(self.api_endpoint("me")) + return json.loads(response.text) + + def api_endpoint(self, url): + """Convert a relative path such as /me/photo/$value to a full URI based + on the current RESOURCE and API_VERSION settings in config.py. + """ + if urlparse(url).scheme in ["http", "https"]: + return url # url is already complete + return urljoin(f"{RESOURCE}/{API_VERSION}/", url.lstrip("/")) diff --git a/test_samples/wc_sso_agent/sso_agent.py b/test_samples/wc_sso_agent/sso_agent.py new file mode 100644 index 00000000..dec4ed69 --- /dev/null +++ b/test_samples/wc_sso_agent/sso_agent.py @@ -0,0 +1,74 @@ +from microsoft.agents.core.models import ChannelAccount +from microsoft.agents.builder import ( + ActivityHandler, + BasicOAuthFlow, + MessageFactory, + TurnContext, +) +from microsoft.agents.builder.state import UserState + +from graph_client import GraphClient + + +class SsoAgent(ActivityHandler): + + def __init__(self, user_state: UserState, connection_name: str): + """ + Initializes a new instance of the SsoAgent class. + :param user_state: The user state. + """ + self.user_state = user_state + self.oauth_flow = BasicOAuthFlow(user_state, connection_name) + + async def on_members_added_activity( + self, members_added: list[ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity("Hello and welcome!") + + async def on_message_activity(self, turn_context: TurnContext): + if turn_context.activity.text == "login": + await self.oauth_flow.get_oauth_token(turn_context) + elif turn_context.activity.text == "logout": + await self.oauth_flow.sign_out(turn_context) + await turn_context.send_activity( + MessageFactory.text("You have been signed out.") + ) + else: + code = int(turn_context.activity.text) + + if len(turn_context.activity.text.strip()) != 6: + await turn_context.send_activity( + MessageFactory.text( + 'Please enter "login" to sign in or "logout" to sign out' + ) + ) + else: + pass + + async def on_turn(self, turn_context): + await super().on_turn(turn_context) + await self.user_state.save_changes(turn_context) + + async def get_token(self, turn_context: TurnContext): + """ + Gets the OAuth token. + :param turn_context: The turn context. + :return: The user token. + """ + user_token = await self.oauth_flow.get_oauth_token(turn_context) + if not user_token: + pass + + async def send_logged_user_info(self, turn_context: TurnContext, token: str): + """ + Sends the logged user info. + :param turn_context: The turn context. + """ + graph_client = GraphClient(token) + user_info = await graph_client.get_me() + message = ( + f"You are {user_info['displayName']} and your email is {user_info['mail']}." + ) + await turn_context.send_activity(MessageFactory.text(message)) From 53f54e5805ced9df93055ac3911d31abc69ac0f7 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Thu, 27 Mar 2025 23:47:07 -0700 Subject: [PATCH 3/5] WIP autogen client problem --- .../agents/builder/basic_oauth_flow.py | 26 +++++++++++++++---- .../core/models/token_exchange_state.py | 3 ++- test_samples/wc_sso_agent/app.py | 2 +- test_samples/wc_sso_agent/sso_agent.py | 4 +-- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py index 48d6e3ad..4d004241 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py @@ -4,7 +4,14 @@ from datetime import datetime from microsoft.agents.connector import UserTokenClient -from microsoft.agents.core.models import ActionTypes, CardAction, Attachment, OAuthCard +from microsoft.agents.core.models import ( + ActionTypes, + CardAction, + ConversationReference, + Attachment, + OAuthCard, + TokenExchangeState, +) from microsoft.agents.core import ( TurnContextProtocol as TurnContext, ) @@ -27,7 +34,7 @@ class BasicOAuthFlow: Manages the OAuth flow for Web Chat. """ - def __init__(self, user_state: UserState, connection_name: str): + def __init__(self, user_state: UserState, connection_name: str, app_id: str): """ Creates a new instance of BasicOAuthFlow. :param user_state: The user state. @@ -36,8 +43,13 @@ def __init__(self, user_state: UserState, connection_name: str): raise ValueError( "BasicOAuthFlow.__init__: connectionName expected but not found" ) + if not app_id: + raise ValueError( + "BasicOAuthFlow.__init__: appId expected but not found. Ensure the appId is set in your environment variables." + ) self.connection_name = connection_name + self.app_id = app_id self.user_token_client: UserTokenClient = None self.state: FlowState | None = None self.flow_state_accessor: StatePropertyAccessor = user_state.create_property( @@ -103,11 +115,15 @@ async def get_oauth_token(self, context: TurnContext) -> str: await context.send_activity(MessageFactory.text("Sign in failed")) ret_val = self.state.user_token else: + te_state = TokenExchangeState( + connection_name=self.connection_name, + conversation=context.activity.get_conversation_reference(), + relates_to=context.activity.relates_to, + ms_app_id=self.app_id, + ) signing_resource = ( await self.user_token_client.agent_sign_in.get_sign_in_resource( - context.activity.from_property.id, - self.connection_name, - context.activity, + state=te_state.model_dump_json(by_alias=True, exclude_unset=True) ) ) # TODO: move this to CardFactory diff --git a/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/token_exchange_state.py b/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/token_exchange_state.py index 56aa2c46..548d99ce 100644 --- a/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/token_exchange_state.py +++ b/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/token_exchange_state.py @@ -1,3 +1,4 @@ +from typing import Optional from pydantic import Field from .conversation_reference import ConversationReference @@ -22,6 +23,6 @@ class TokenExchangeState(AgentsModel): connection_name: NonEmptyString = None conversation: ConversationReference = None - relates_to: ConversationReference = None + relates_to: Optional[ConversationReference] = None agent_url: NonEmptyString = Field(None, alias="bot_url") ms_app_id: NonEmptyString = None diff --git a/test_samples/wc_sso_agent/app.py b/test_samples/wc_sso_agent/app.py index 44e4ed96..c2a2febb 100644 --- a/test_samples/wc_sso_agent/app.py +++ b/test_samples/wc_sso_agent/app.py @@ -49,7 +49,7 @@ def get_connection(self, connection_name: str) -> AccessTokenProviderBase: USER_STATE = UserState(STORAGE) # Create the Agent -AGENT = SsoAgent(USER_STATE, CONFIG.CONNECTION_NAME) +AGENT = SsoAgent(USER_STATE, CONFIG.CONNECTION_NAME, CONFIG.CLIENT_ID) # Listen for incoming requests on /api/messages diff --git a/test_samples/wc_sso_agent/sso_agent.py b/test_samples/wc_sso_agent/sso_agent.py index dec4ed69..9df7c511 100644 --- a/test_samples/wc_sso_agent/sso_agent.py +++ b/test_samples/wc_sso_agent/sso_agent.py @@ -12,13 +12,13 @@ class SsoAgent(ActivityHandler): - def __init__(self, user_state: UserState, connection_name: str): + def __init__(self, user_state: UserState, connection_name: str, app_id: str): """ Initializes a new instance of the SsoAgent class. :param user_state: The user state. """ self.user_state = user_state - self.oauth_flow = BasicOAuthFlow(user_state, connection_name) + self.oauth_flow = BasicOAuthFlow(user_state, connection_name, app_id) async def on_members_added_activity( self, members_added: list[ChannelAccount], turn_context: TurnContext From f4c19e227c1cce0a7a60ef01c4d91b8aba30e61a Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Fri, 28 Mar 2025 19:13:35 -0700 Subject: [PATCH 4/5] WIP Need to update storage serialization strategy --- .../agents/builder/basic_oauth_flow.py | 44 ++++++++++++------- .../rest_channel_service_client_factory.py | 4 +- .../agents/builder/state/agent_state.py | 39 ++++++++++------ .../builder/state/state_property_accessor.py | 4 +- .../connector/token/user_token_client.py | 20 ++++++++- .../agents/core/models/card_action.py | 8 ++-- .../agents/storage/memory_storage.py | 19 ++++---- .../microsoft/agents/storage/store_item.py | 4 +- test_samples/wc_sso_agent/sso_agent.py | 8 ++-- 9 files changed, 97 insertions(+), 53 deletions(-) diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py index 4d004241..5d5ebd70 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py @@ -1,20 +1,24 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import base64 from datetime import datetime +import json from microsoft.agents.connector import UserTokenClient from microsoft.agents.core.models import ( ActionTypes, CardAction, - ConversationReference, Attachment, OAuthCard, + SignInResource, TokenExchangeState, ) from microsoft.agents.core import ( TurnContextProtocol as TurnContext, ) +from microsoft.agents.storage import StoreItem +from pydantic import BaseModel, ConfigDict from .message_factory import MessageFactory from .card_factory import CardFactory @@ -22,11 +26,17 @@ from .state.user_state import UserState -class FlowState: - def __init__(self): - self.flow_started = False - self.user_token = "" - self.flow_expires = 0 +class FlowState(StoreItem, BaseModel): + flow_started: bool = False + user_token: str = "" + flow_expires: float = 0 + + 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) class BasicOAuthFlow: @@ -115,17 +125,23 @@ async def get_oauth_token(self, context: TurnContext) -> str: await context.send_activity(MessageFactory.text("Sign in failed")) ret_val = self.state.user_token else: - te_state = TokenExchangeState( + token_exchange_state = TokenExchangeState( connection_name=self.connection_name, conversation=context.activity.get_conversation_reference(), relates_to=context.activity.relates_to, ms_app_id=self.app_id, ) - signing_resource = ( + serialized_state = base64.b64encode( + json.dumps(token_exchange_state.model_dump(by_alias=True)).encode( + encoding="UTF-8", errors="strict" + ) + ).decode() + token_client_response = ( await self.user_token_client.agent_sign_in.get_sign_in_resource( - state=te_state.model_dump_json(by_alias=True, exclude_unset=True) + state=serialized_state, ) ) + signing_resource = SignInResource.model_validate(token_client_response) # TODO: move this to CardFactory o_card: Attachment = CardFactory.oauth_card( OAuthCard( @@ -140,11 +156,7 @@ async def get_oauth_token(self, context: TurnContext) -> str: ) ], token_exchange_resource=signing_resource.token_exchange_resource, - ), - self.connection_name, - "Sign in", - "", - signing_resource, + ) ) await context.send_activity(MessageFactory.attachment(o_card)) self.state.flow_started = True @@ -176,9 +188,7 @@ async def get_user_state(self, context: TurnContext) -> FlowState: :param context: The turn context. :return: The user state. """ - user_profile: FlowState | None = await self.flow_state_accessor.get( - context, None - ) + user_profile: FlowState | None = await self.flow_state_accessor.get(context) if user_profile is None: user_profile = FlowState() return user_profile diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/rest_channel_service_client_factory.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/rest_channel_service_client_factory.py index 66d642b0..d5c8497c 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 @@ -56,8 +56,10 @@ async def create_user_token_client( self, claims_identity: ClaimsIdentity, use_anonymous: bool = False ) -> UserTokenClient: return UserTokenClient( - credential=self._connections.get_token_provider( + credential_token_provider=self._connections.get_token_provider( claims_identity, self._token_service_endpoint ), + credential_resource_url=self._token_service_audience, + credential_scopes=[f"{self._token_service_audience}/.default"], endpoint=self._token_service_endpoint, ) 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 7ef7b059..f22b05fb 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 @@ -3,7 +3,7 @@ from abc import abstractmethod from copy import deepcopy -from typing import Callable, Dict, Union +from typing import Callable, Dict, Union, Type from microsoft.agents.storage import Storage, StoreItem @@ -11,23 +11,34 @@ from ..turn_context import TurnContext -class CachedAgentState: +class CachedAgentState(StoreItem): """ Internal cached bot state. """ def __init__(self, state: Dict[str, StoreItem] = None): self.state = state if state is not None else {} - self.hash = self.compute_hash(state) + self.hash = self.compute_hash() + + @property + def has_state(self) -> bool: + return bool(self.state) @property def is_changed(self) -> bool: - return self.hash != self.compute_hash(self.state) + return self.hash != self.compute_hash() + + def compute_hash(self) -> str: + return hash(str(self.store_item_to_json())) - def compute_hash(self, item: StoreItem) -> str: - if item: - return hash(item.store_item_to_json()) - return "" + def store_item_to_json(self) -> dict: + if not self.state: + return {} + return {key: value.store_item_to_json() for key, value in self.state.items()} + + @staticmethod + def from_json_to_store_item(json_data: dict) -> StoreItem: + return CachedAgentState(json_data) class AgentState: @@ -62,7 +73,7 @@ def __init__(self, storage: Storage, context_service_key: str): self._storage = storage self._context_service_key = context_service_key - def get_cached_state(self, turn_context: TurnContext): + def get_cached_state(self, turn_context: TurnContext) -> CachedAgentState: """ Gets the cached bot state instance that wraps the raw cached data for this "BotState" from the turn context. @@ -107,7 +118,7 @@ async def load(self, turn_context: TurnContext, force: bool = False) -> None: cached_state = self.get_cached_state(turn_context) storage_key = self.get_storage_key(turn_context) - if force or not cached_state or not cached_state.state: + if force or not cached_state: items = await self._storage.read([storage_key]) val = items.get(storage_key) turn_context.turn_state[self._context_service_key] = CachedAgentState(val) @@ -130,9 +141,9 @@ async def save_changes( if force or (cached_state is not None and cached_state.is_changed): storage_key = self.get_storage_key(turn_context) - changes: Dict[str, StoreItem] = {storage_key: cached_state.state} + changes: Dict[str, StoreItem] = {storage_key: cached_state} await self._storage.write(changes) - cached_state.hash = cached_state.compute_hash(cached_state.state) + cached_state.hash = cached_state.compute_hash() async def clear_state(self, turn_context: TurnContext): """ @@ -173,7 +184,9 @@ async def delete(self, turn_context: TurnContext) -> None: def get_storage_key(self, turn_context: TurnContext) -> str: raise NotImplementedError() - async def get_property_value(self, turn_context: TurnContext, property_name: str): + async def get_property_value( + self, turn_context: TurnContext, property_name: str + ) -> StoreItem: """ Gets the value of the specified property in the turn context. diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/state_property_accessor.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/state_property_accessor.py index 502c23ad..4a0ac363 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/state_property_accessor.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/state_property_accessor.py @@ -2,7 +2,9 @@ # Licensed under the MIT License. from abc import abstractmethod -from typing import Protocol +from typing import Protocol, Type, TypeVar + +from microsoft.agents.storage import StoreItem from ..turn_context import TurnContext diff --git a/libraries/Client/microsoft-agents-connector/microsoft/agents/connector/token/user_token_client.py b/libraries/Client/microsoft-agents-connector/microsoft/agents/connector/token/user_token_client.py index e8194e37..f3aaada7 100644 --- a/libraries/Client/microsoft-agents-connector/microsoft/agents/connector/token/user_token_client.py +++ b/libraries/Client/microsoft-agents-connector/microsoft/agents/connector/token/user_token_client.py @@ -12,6 +12,7 @@ from azure.core.credentials_async import AsyncTokenCredential from azure.core.pipeline import policies from azure.core.rest import AsyncHttpResponse, HttpRequest +from microsoft.agents.authorization import AccessTokenProviderBase from ._user_token_client_configuration import TokenConfiguration from .operations import ( @@ -21,6 +22,7 @@ ) from .._serialization import Deserializer, Serializer from ..agent_sign_in_base import AgentSignInBase +from .._agents_token_credential_adapter import AgentsTokenCredentialAdapter from ..user_token_base import UserTokenBase from ..user_token_client_base import UserTokenClientBase @@ -44,9 +46,23 @@ class UserTokenClient( """ def __init__( - self, credential: AsyncTokenCredential, endpoint: str = "", **kwargs: Any + self, + credential_token_provider: AccessTokenProviderBase, + credential_resource_url: str, + credential_scopes: list[str] = None, + endpoint: str = "", + **kwargs: Any ) -> None: - self._config = TokenConfiguration(credential=credential, **kwargs) + + agents_token_credential = AgentsTokenCredentialAdapter( + credential_token_provider, credential_resource_url + ) + self._config = TokenConfiguration( + credential=agents_token_credential, + credential_scopes=credential_scopes, + **kwargs + ) + _policies = kwargs.pop("policies", None) if _policies is None: _policies = [ 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 0c7c7903..db3b045c 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 @@ -30,9 +30,9 @@ class CardAction(AgentsModel): type: NonEmptyString title: NonEmptyString - image: NonEmptyString = None - text: NonEmptyString = None - display_text: NonEmptyString = None + image: str = None + text: str = None + display_text: str = None value: object = None channel_data: object = None - image_alt_text: NonEmptyString = None + image_alt_text: str = None diff --git a/libraries/Storage/microsoft-agents-storage/microsoft/agents/storage/memory_storage.py b/libraries/Storage/microsoft-agents-storage/microsoft/agents/storage/memory_storage.py index a3876e28..8b0845f2 100644 --- a/libraries/Storage/microsoft-agents-storage/microsoft/agents/storage/memory_storage.py +++ b/libraries/Storage/microsoft-agents-storage/microsoft/agents/storage/memory_storage.py @@ -21,14 +21,17 @@ async def read( with self._lock: for key in keys: if key in self._memory: - try: - result[key] = target_cls.from_json_to_store_item( - self._memory[key] - ) - except TypeError as error: - raise TypeError( - f"MemoryStorage.read(): could not deserialize in-memory item into {target_cls} class. Error: {error}" - ) + if not target_cls: + result[key] = self._memory[key] + else: + try: + result[key] = target_cls.from_json_to_store_item( + self._memory[key] + ) + except AttributeError as error: + raise TypeError( + f"MemoryStorage.read(): could not deserialize in-memory item into {target_cls} class. Error: {error}" + ) return result async def write(self, changes: dict[str, StoreItem]): diff --git a/libraries/Storage/microsoft-agents-storage/microsoft/agents/storage/store_item.py b/libraries/Storage/microsoft-agents-storage/microsoft/agents/storage/store_item.py index e2bda475..a2f7b13d 100644 --- a/libraries/Storage/microsoft-agents-storage/microsoft/agents/storage/store_item.py +++ b/libraries/Storage/microsoft-agents-storage/microsoft/agents/storage/store_item.py @@ -1,10 +1,10 @@ +from abc import ABC from typing import Protocol, runtime_checkable from ._type_aliases import JSON -@runtime_checkable -class StoreItem(Protocol): +class StoreItem(ABC): def store_item_to_json(self) -> JSON: pass diff --git a/test_samples/wc_sso_agent/sso_agent.py b/test_samples/wc_sso_agent/sso_agent.py index 9df7c511..79a459a2 100644 --- a/test_samples/wc_sso_agent/sso_agent.py +++ b/test_samples/wc_sso_agent/sso_agent.py @@ -36,8 +36,6 @@ async def on_message_activity(self, turn_context: TurnContext): MessageFactory.text("You have been signed out.") ) else: - code = int(turn_context.activity.text) - if len(turn_context.activity.text.strip()) != 6: await turn_context.send_activity( MessageFactory.text( @@ -45,7 +43,7 @@ async def on_message_activity(self, turn_context: TurnContext): ) ) else: - pass + await self.get_token(turn_context) async def on_turn(self, turn_context): await super().on_turn(turn_context) @@ -58,8 +56,8 @@ async def get_token(self, turn_context: TurnContext): :return: The user token. """ user_token = await self.oauth_flow.get_oauth_token(turn_context) - if not user_token: - pass + if user_token: + await self.send_logged_user_info(turn_context, user_token) async def send_logged_user_info(self, turn_context: TurnContext, token: str): """ From 7f4c4487073c44fddb433185431e2a79ad43ee07 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 31 Mar 2025 15:18:58 -0700 Subject: [PATCH 5/5] SSO Sample working --- .../agents/builder/basic_oauth_flow.py | 39 +++++++------ .../agents/builder/state/agent_state.py | 55 +++++++++++++++---- .../builder/state/state_property_accessor.py | 9 ++- test_samples/wc_sso_agent/graph_client.py | 2 +- 4 files changed, 74 insertions(+), 31 deletions(-) diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py index 5d5ebd70..e8f795ee 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py @@ -60,7 +60,6 @@ def __init__(self, user_state: UserState, connection_name: str, app_id: str): self.connection_name = connection_name self.app_id = app_id - self.user_token_client: UserTokenClient = None self.state: FlowState | None = None self.flow_state_accessor: StatePropertyAccessor = user_state.create_property( "flowState" @@ -94,15 +93,15 @@ async def get_oauth_token(self, context: TurnContext) -> str: ) # TODO: Fix property discovery - self.user_token_client = context.turn_state.get( + token_client: UserTokenClient = context.turn_state.get( context.adapter.USER_TOKEN_CLIENT_KEY ) if self.state.flow_started: - user_token = await self.user_token_client.user_token.get_token( - context.activity.from_property.id, - self.connection_name, - context.activity.channel_id, + 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, ) if user_token: # logger.info("Token obtained") @@ -110,11 +109,11 @@ async def get_oauth_token(self, context: TurnContext) -> str: self.state.flow_started = False else: code = context.activity.text - user_token = await self.user_token_client.user_token.get_token( - context.activity.from_property.id, - self.connection_name, - context.activity.channel_id, - code, + 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, + code=code, ) if user_token: # logger.info("Token obtained with code") @@ -137,7 +136,7 @@ async def get_oauth_token(self, context: TurnContext) -> str: ) ).decode() token_client_response = ( - await self.user_token_client.agent_sign_in.get_sign_in_resource( + await token_client.agent_sign_in.get_sign_in_resource( state=serialized_state, ) ) @@ -171,10 +170,14 @@ async def sign_out(self, context: TurnContext): Signs the user out. :param context: The turn context. """ - await self.user_token_client.user_token.sign_out( - context.activity.from_property.id, - self.connection_name, - context.activity.channel_id, + token_client: UserTokenClient = context.turn_state.get( + context.adapter.USER_TOKEN_CLIENT_KEY + ) + + await token_client.user_token.sign_out( + user_id=context.activity.from_property.id, + connection_name=self.connection_name, + channel_id=context.activity.channel_id, ) self.state.flow_started = False self.state.user_token = "" @@ -188,7 +191,9 @@ async def get_user_state(self, context: TurnContext) -> FlowState: :param context: The turn context. :return: The user state. """ - user_profile: FlowState | None = await self.flow_state_accessor.get(context) + user_profile: FlowState | None = await self.flow_state_accessor.get( + context, target_cls=FlowState + ) if user_profile is None: user_profile = FlowState() return user_profile 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 f22b05fb..096ae619 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 @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from __future__ import annotations + from abc import abstractmethod from copy import deepcopy from typing import Callable, Dict, Union, Type @@ -16,9 +18,14 @@ class CachedAgentState(StoreItem): Internal cached bot state. """ - def __init__(self, state: Dict[str, StoreItem] = None): - self.state = state if state is not None else {} - self.hash = self.compute_hash() + def __init__(self, state: Dict[str, StoreItem | dict] = None): + if state: + self.state = state + internal_hash = state.pop("CachedAgentState._hash", None) + self.hash = internal_hash or self.compute_hash() + else: + self.state = {} + self.hash = hash(str({})) @property def has_state(self) -> bool: @@ -34,7 +41,13 @@ def compute_hash(self) -> str: def store_item_to_json(self) -> dict: if not self.state: return {} - return {key: value.store_item_to_json() for key, value in self.state.items()} + # TODO: Might need to change this check to include Types that implement but not inherit. + serialized = { + key: value.store_item_to_json() if isinstance(value, StoreItem) else value + for key, value in self.state.items() + } + serialized["CachedAgentState._hash"] = self.hash + return serialized @staticmethod def from_json_to_store_item(json_data: dict) -> StoreItem: @@ -119,9 +132,9 @@ async def load(self, turn_context: TurnContext, force: bool = False) -> None: storage_key = self.get_storage_key(turn_context) if force or not cached_state: - items = await self._storage.read([storage_key]) - val = items.get(storage_key) - turn_context.turn_state[self._context_service_key] = CachedAgentState(val) + items = await self._storage.read([storage_key], target_cls=CachedAgentState) + val = items.get(storage_key, CachedAgentState()) + turn_context.turn_state[self._context_service_key] = val async def save_changes( self, turn_context: TurnContext, force: bool = False @@ -181,11 +194,17 @@ async def delete(self, turn_context: TurnContext) -> None: await self._storage.delete({storage_key}) @abstractmethod - def get_storage_key(self, turn_context: TurnContext) -> str: + def get_storage_key( + self, turn_context: TurnContext, *, target_cls: Type[StoreItem] = None + ) -> str: raise NotImplementedError() async def get_property_value( - self, turn_context: TurnContext, property_name: str + self, + turn_context: TurnContext, + property_name: str, + *, + target_cls: Type[StoreItem] = None, ) -> StoreItem: """ Gets the value of the specified property in the turn context. @@ -207,7 +226,17 @@ async def get_property_value( # 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 - return cached_state.state[property_name] + value = cached_state.state[property_name] + + if target_cls: + # Attempt to deserialize the value if it is not None + try: + return target_cls.from_json_to_store_item(value) + except AttributeError: + # If the value is not a StoreItem, just return it as is + pass + + return value async def delete_property_value( self, turn_context: TurnContext, property_name: str @@ -290,6 +319,8 @@ async def get( self, turn_context: TurnContext, default_value_or_factory: Union[Callable, StoreItem] = None, + *, + target_cls: Type[StoreItem] = None, ) -> StoreItem: """ Gets the property value. @@ -300,7 +331,9 @@ async def get( """ await self._bot_state.load(turn_context, False) try: - result = await self._bot_state.get_property_value(turn_context, self._name) + result = await self._bot_state.get_property_value( + turn_context, self._name, target_cls=target_cls + ) return result except: # ask for default value from factory diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/state_property_accessor.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/state_property_accessor.py index 4a0ac363..ed5c5170 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/state_property_accessor.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/state_property_accessor.py @@ -2,7 +2,8 @@ # Licensed under the MIT License. from abc import abstractmethod -from typing import Protocol, Type, TypeVar +from collections.abc import Callable +from typing import Protocol, Type, Union from microsoft.agents.storage import StoreItem @@ -12,7 +13,11 @@ class StatePropertyAccessor(Protocol): @abstractmethod async def get( - self, turn_context: TurnContext, default_value_or_factory=None + self, + turn_context: TurnContext, + default_value_or_factory: Union[Callable, StoreItem] = None, + *, + target_cls: Type[StoreItem] = None ) -> object: """ Get the property value from the source diff --git a/test_samples/wc_sso_agent/graph_client.py b/test_samples/wc_sso_agent/graph_client.py index 879308d1..a9b5bd7a 100644 --- a/test_samples/wc_sso_agent/graph_client.py +++ b/test_samples/wc_sso_agent/graph_client.py @@ -8,7 +8,7 @@ AUTHORITY_URL = "https://login.microsoftonline.com/common" RESOURCE = "https://graph.microsoft.com" -API_VERSION = "beta" +API_VERSION = "v1.0" class GraphClient: