diff --git a/changelog.md b/changelog.md index 2fed6ddbf..ad2977ee2 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,12 @@ +# Microsoft 365 Agents SDK for Python - Release Notes v1.10.0 (Unreleased) + +**Previous Version:** 1.9.0 (Released 2026-10-19) + +- **Auth Handler Configuration**: Added `AuthHandlerSettings` and a unified `settings` parameter accepting a settings object or dictionary across authorization handlers (#633). +- **Deprecation**: Deprecated `AuthHandler` in favor of `AuthHandlerSettings`, and handler constructor parameters `auth_handler` and `auth_handler_settings` in favor of `settings`, while retaining backward compatibility (#633). + +--- + # Microsoft 365 Agents SDK for Python - Release Notes v1.9.0 **Release Date:** 2026-10-19 diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py index e1338972e..ef717587c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py @@ -44,6 +44,7 @@ Authorization, AuthHandler, AgenticUserAuthorization, + AuthHandlerSettings, ) # App State @@ -151,6 +152,7 @@ "TempState", "Authorization", "AuthHandler", + "AuthHandlerSettings", "AccessTokenProviderBase", "AuthenticationConstants", "AnonymousTokenProvider", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py index 07ca96394..30635a97d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py @@ -18,6 +18,7 @@ from .oauth import ( Authorization, AuthHandler, + AuthHandlerSettings, AgenticUserAuthorization, ) @@ -59,6 +60,7 @@ "TempState", "Authorization", "AuthHandler", + "AuthHandlerSettings", "AgenticUserAuthorization", # Proactive "Conversation", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py index 283168f26..99527d972 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field from typing import Callable, Optional -from microsoft_agents.hosting.core.app.oauth import AuthHandler +from microsoft_agents.hosting.core.app.oauth import AuthHandlerSettings from microsoft_agents.hosting.core.authorization import Connections from microsoft_agents.hosting.core.storage import Storage @@ -81,7 +81,7 @@ class ApplicationOptions: If not provided, the default `TurnState` will be used. """ - authorization_handlers: Optional[dict[str, AuthHandler]] = None + authorization_handlers: Optional[dict[str, AuthHandlerSettings]] = None """ Optional. Authorization handler for OAuth flows. If not provided, no OAuth flows will be supported. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py index 79929ccfb..1e95dcf9a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py @@ -3,7 +3,7 @@ from .authorization import Authorization -from .auth_handler import AuthHandler +from .auth_handler import AuthHandler, AuthHandlerSettings from ._sign_in_state import _SignInState from ._sign_in_response import _SignInResponse from ._handlers import ( @@ -16,6 +16,7 @@ __all__ = [ "Authorization", "AuthHandler", + "AuthHandlerSettings", "_AuthorizationHandler", "_SignInState", "_SignInResponse", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py index 99b9760ad..702a43b58 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_authorization_handler.py @@ -3,16 +3,17 @@ Licensed under the MIT License. """ -from abc import ABC -from typing import Optional import logging +import warnings + +from abc import ABC from microsoft_agents.activity import TokenResponse from ....turn_context import TurnContext from ....storage import Storage from ....authorization import Connections -from ..auth_handler import AuthHandler +from ..auth_handler import AuthHandlerSettings from .._sign_in_response import _SignInResponse logger = logging.getLogger(__name__) @@ -23,16 +24,17 @@ class _AuthorizationHandler(ABC): _storage: Storage _connection_manager: Connections - _handler: AuthHandler + _handler: AuthHandlerSettings def __init__( self, storage: Storage, connection_manager: Connections, - auth_handler: Optional[AuthHandler] = None, + settings: dict | AuthHandlerSettings | None = None, *, - auth_handler_id: Optional[str] = None, - auth_handler_settings: Optional[dict] = None, + auth_handler_id: str | None = None, + auth_handler: AuthHandlerSettings | None = None, + auth_handler_settings: dict | None = None, **kwargs, ) -> None: """ @@ -42,28 +44,45 @@ def __init__( :type storage: Storage :param connection_manager: The connection manager for OAuth providers. :type connection_manager: Connections - :param auth_handlers: Configuration for OAuth providers. - :type auth_handlers: dict[str, AuthHandler], Optional + :param settings: Optional settings for the auth handler. + :type settings: dict | AuthHandlerSettings | None + :param auth_handler: (Deprecated) Configuration for OAuth provider. + :type auth_handler: AuthHandlerSettings | None + :param auth_handler_id: Optional ID of the auth handler. + :type auth_handler_id: str | None + :param auth_handler_settings: (Deprecated) Optional settings dict for the auth handler. + :type auth_handler_settings: dict | None :raises ValueError: When storage is None or no auth handlers provided. """ if not storage: raise ValueError("Storage is required for Authorization") - if not auth_handler and not auth_handler_settings: + if not settings and not auth_handler and not auth_handler_settings: raise ValueError( - "At least one of auth_handler or auth_handler_settings is required." + "At least one of settings or auth_handler or auth_handler_settings is required." + ) + if auth_handler or auth_handler_settings: + warnings.warn( + "auth_handler and auth_handler_settings are deprecated. Please use settings instead.", + DeprecationWarning, + stacklevel=2, ) self._storage = storage self._connection_manager = connection_manager - if auth_handler: + if settings: + if isinstance(settings, dict): + self._handler = AuthHandlerSettings(**settings) + else: + self._handler = settings + elif auth_handler: # deprecated self._handler = auth_handler - else: + else: # deprecated if not auth_handler_settings: raise ValueError( "auth_handler_settings must be provided if auth_handler is None." ) - self._handler = AuthHandler._from_settings(auth_handler_settings) + self._handler = AuthHandlerSettings(**auth_handler_settings) self._id = auth_handler_id or self._handler.name if not self._id: @@ -72,14 +91,14 @@ def __init__( ) async def _sign_in( - self, context: TurnContext, scopes: Optional[list[str]] = None + self, context: TurnContext, scopes: list[str] | None = None ) -> _SignInResponse: """Initiate or continue the sign-in process for the user with the given auth handler. :param context: The turn context for the current turn of conversation. :type context: TurnContext :param scopes: Optional list of scopes to request during sign-in. If None, default scopes will be used. - :type scopes: Optional[list[str]], Optional + :type scopes: list[str] | None :return: A SignInResponse indicating the result of the sign-in attempt. :rtype: SignInResponse """ @@ -88,17 +107,17 @@ async def _sign_in( async def get_refreshed_token( self, context: TurnContext, - exchange_connection: Optional[str] = None, - exchange_scopes: Optional[list[str]] = None, + exchange_connection: str | None = None, + exchange_scopes: list[str] | None = None, ) -> TokenResponse: """Attempts to get a refreshed token for the user with the given scopes :param context: The turn context for the current turn of conversation. :type context: TurnContext :param exchange_connection: Optional name of the connection to use for token exchange. If None, default connection will be used. - :type exchange_connection: Optional[str], Optional + :type exchange_connection: str | None :param exchange_scopes: Optional list of scopes to request during token exchange. If None, default scopes will be used. - :type exchange_scopes: Optional[list[str]], Optional + :type exchange_scopes: list[str] | None """ raise NotImplementedError() @@ -108,6 +127,6 @@ async def _sign_out(self, context: TurnContext) -> None: :param context: The turn context for the current turn of conversation. :type context: TurnContext :param auth_handler_id: The ID of the auth handler to sign out from. If None, sign out from all handlers. - :type auth_handler_id: Optional[str] + :type auth_handler_id: str | None """ raise NotImplementedError() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py index 5b1dafd05..f8e39d072 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py @@ -5,8 +5,6 @@ import logging -from typing import Optional - from microsoft_agents.activity import TokenResponse from ....turn_context import TurnContext @@ -15,7 +13,7 @@ from ._authorization_handler import _AuthorizationHandler from ....storage import Storage from ....authorization import Connections -from ..auth_handler import AuthHandler +from ..auth_handler import AuthHandlerSettings from ..telemetry import spans logger = logging.getLogger(__name__) @@ -28,10 +26,11 @@ def __init__( self, storage: Storage, connection_manager: Connections, - auth_handler: Optional[AuthHandler] = None, + settings: dict | AuthHandlerSettings | None = None, *, - auth_handler_id: Optional[str] = None, - auth_handler_settings: Optional[dict] = None, + auth_handler_id: str | None = None, + auth_handler: AuthHandlerSettings | None = None, + auth_handler_settings: dict | None = None, **kwargs, ) -> None: """ @@ -41,21 +40,26 @@ def __init__( :type storage: Storage :param connection_manager: The connection manager for OAuth providers. :type connection_manager: Connections - :param auth_handlers: Configuration for OAuth providers. - :type auth_handlers: dict[str, AuthHandler], Optional + :param settings: Optional settings for the auth handler. + :type settings: dict | AuthHandlerSettings | None + :param auth_handler: (Deprecated) Configuration for OAuth provider. + :type auth_handler: AuthHandlerSettings | None + :param auth_handler_id: Optional ID of the auth handler. + :type auth_handler_id: str | None + :param auth_handler_settings: (Deprecated) Optional settings dict for the auth handler. + :type auth_handler_settings: dict | None :raises ValueError: When storage is None or no auth handlers provided. """ super().__init__( storage, connection_manager, - auth_handler, + settings, + auth_handler=auth_handler, auth_handler_id=auth_handler_id, auth_handler_settings=auth_handler_settings, **kwargs, ) - self._alt_blueprint_name = ( - auth_handler._alt_blueprint_name if auth_handler else None - ) + self._alt_blueprint_name = self._handler._alt_blueprint_name async def get_agentic_instance_token(self, context: TurnContext) -> TokenResponse: """Gets the agentic instance token for the current agent instance. @@ -63,7 +67,7 @@ async def get_agentic_instance_token(self, context: TurnContext) -> TokenRespons :param context: The context object for the current turn. :type context: TurnContext :return: The agentic instance token, or None if not an agentic request. - :rtype: Optional[str] + :rtype: TokenResponse """ if not context.activity.is_agentic_request(): @@ -103,7 +107,7 @@ async def get_agentic_user_token( :param scopes: The scopes to request for the token. :type scopes: list[str] :return: The agentic user token, or None if not an agentic request or no user. - :rtype: Optional[str] + :rtype: TokenResponse """ logger.info("Retrieving agentic user token for scopes: %s", scopes) @@ -163,8 +167,8 @@ async def get_agentic_user_token( async def _sign_in( self, context: TurnContext, - exchange_connection: Optional[str] = None, - exchange_scopes: Optional[list[str]] = None, + exchange_connection: str | None = None, + exchange_scopes: list[str] | None = None, ) -> _SignInResponse: """Retrieves the agentic user token if available. @@ -173,7 +177,7 @@ async def _sign_in( :param connection_name: The name of the connection to use for sign-in. :type connection_name: str :param scopes: The scopes to request for the token. - :type scopes: Optional[list[str]] + :type scopes: list[str] | None :return: A _SignInResponse containing the token response and flow state tag. :rtype: _SignInResponse """ @@ -189,17 +193,17 @@ async def _sign_in( async def get_refreshed_token( self, context: TurnContext, - exchange_connection: Optional[str] = None, - exchange_scopes: Optional[list[str]] = None, + exchange_connection: str | None = None, + exchange_scopes: list[str] | None = None, ) -> TokenResponse: """Attempts to get a refreshed token for the user with the given scopes :param context: The turn context for the current turn of conversation. :type context: TurnContext :param exchange_connection: Optional name of the connection to use for token exchange. If None, default connection will be used. - :type exchange_connection: Optional[str], Optional + :type exchange_connection: str | None :param exchange_scopes: Optional list of scopes to request during token exchange. If None, default scopes will be used. - :type exchange_scopes: Optional[list[str]], Optional + :type exchange_scopes: list[str] | None """ with spans.AgenticToken( auth_handler_id=self._id, @@ -211,6 +215,6 @@ async def get_refreshed_token( return await self.get_agentic_user_token(context, exchange_scopes) async def sign_out( - self, context: TurnContext, auth_handler_id: Optional[str] = None + self, context: TurnContext, auth_handler_id: str | None = None ) -> None: """Nothing to do for agentic sign out.""" diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/connector_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/connector_user_authorization.py index 0e57433a0..1682c51f1 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/connector_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/connector_user_authorization.py @@ -6,7 +6,6 @@ import logging import jwt from datetime import datetime, timezone -from typing import Optional from microsoft_agents.activity import TokenResponse from microsoft_agents.hosting.core.errors import ErrorResources @@ -15,7 +14,7 @@ from ....turn_context import TurnContext from ....storage import Storage from ....authorization import Connections -from ..auth_handler import AuthHandler +from ..auth_handler import AuthHandlerSettings from ._authorization_handler import _AuthorizationHandler from .._sign_in_response import _SignInResponse @@ -32,10 +31,11 @@ def __init__( self, storage: Storage, connection_manager: Connections, - auth_handler: Optional[AuthHandler] = None, + settings: dict | AuthHandlerSettings | None = None, *, - auth_handler_id: Optional[str] = None, - auth_handler_settings: Optional[dict] = None, + auth_handler_id: str | None = None, + auth_handler: AuthHandlerSettings | None = None, + auth_handler_settings: dict | None = None, **kwargs, ) -> None: """ @@ -45,17 +45,20 @@ def __init__( :type storage: Storage :param connection_manager: The connection manager for OAuth providers. :type connection_manager: Connections - :param auth_handler: Configuration for OAuth provider. - :type auth_handler: AuthHandler, Optional + :param settings: Optional settings for the auth handler. + :type settings: dict | AuthHandlerSettings | None + :param auth_handler: (Deprecated) Configuration for OAuth provider. + :type auth_handler: AuthHandlerSettings | None :param auth_handler_id: Optional ID of the auth handler. - :type auth_handler_id: str, Optional - :param auth_handler_settings: Optional settings dict for the auth handler. - :type auth_handler_settings: dict, Optional + :type auth_handler_id: str | None + :param auth_handler_settings: (Deprecated) Optional settings dict for the auth handler. + :type auth_handler_settings: dict | None """ super().__init__( storage, connection_manager, - auth_handler, + settings, + auth_handler=auth_handler, auth_handler_id=auth_handler_id, auth_handler_settings=auth_handler_settings, **kwargs, @@ -64,8 +67,8 @@ def __init__( async def _sign_in( self, context: TurnContext, - exchange_connection: Optional[str] = None, - exchange_scopes: Optional[list[str]] = None, + exchange_connection: str | None = None, + exchange_scopes: list[str] | None = None, ) -> _SignInResponse: """ For connector requests, there is no separate sign-in flow. @@ -74,9 +77,9 @@ async def _sign_in( :param context: The turn context for the current turn of conversation. :type context: TurnContext :param exchange_connection: Optional connection name for token exchange. - :type exchange_connection: Optional[str] + :type exchange_connection: str | None :param exchange_scopes: Optional list of scopes (unused for connector auth). - :type exchange_scopes: Optional[list[str]] + :type exchange_scopes: list[str] | None :return: A SignInResponse with the extracted token. :rtype: _SignInResponse """ @@ -89,8 +92,8 @@ async def _sign_in( async def get_refreshed_token( self, context: TurnContext, - exchange_connection: Optional[str] = None, - exchange_scopes: Optional[list[str]] = None, + exchange_connection: str | None = None, + exchange_scopes: list[str] | None = None, ) -> TokenResponse: """ Gets the connector user token and optionally exchanges it via OBO. @@ -98,9 +101,9 @@ async def get_refreshed_token( :param context: The turn context for the current turn of conversation. :type context: TurnContext :param exchange_connection: Optional name of the connection to use for token exchange. - :type exchange_connection: Optional[str], Optional + :type exchange_connection: str | None :param exchange_scopes: Optional list of scopes to request during token exchange. - :type exchange_scopes: Optional[list[str]], Optional + :type exchange_scopes: list[str] | None :return: The token response, potentially after OBO exchange. :rtype: TokenResponse """ @@ -146,8 +149,8 @@ async def _handle_obo( self, context: TurnContext, input_token_response: TokenResponse, - exchange_connection: Optional[str] = None, - exchange_scopes: Optional[list[str]] = None, + exchange_connection: str | None = None, + exchange_scopes: list[str] | None = None, ) -> TokenResponse: """ Exchanges a token for another token with different scopes via OBO flow. @@ -157,9 +160,9 @@ async def _handle_obo( :param input_token_response: The input token to exchange. :type input_token_response: TokenResponse :param exchange_connection: Optional connection name for exchange. - :type exchange_connection: Optional[str] + :type exchange_connection: str | None :param exchange_scopes: Optional scopes for the exchanged token. - :type exchange_scopes: Optional[list[str]] + :type exchange_scopes: list[str] | None :return: The token response after exchange, or the original if exchange not configured. :rtype: TokenResponse """ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py index 4d11b2128..bf75fd0b4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py @@ -4,14 +4,25 @@ """ import logging -from typing import Optional +import warnings logger = logging.getLogger(__name__) +_BASE_KEYS = [ + "name", + "title", + "text", + "azurebotoauthconnectionname", + "oboconnectionname", + "type", + "scopes", + "alt_blueprint_name", +] + # name due to compat. # see AuthorizationHandler for a class that does work. -class AuthHandler: +class AuthHandlerSettings: """ Interface defining an authorization handler for OAuth flows. """ @@ -26,17 +37,17 @@ class AuthHandler: def __init__( self, - name: str = "", - title: str = "", - text: str = "", - abs_oauth_connection_name: str = "", - obo_connection_name: str = "", - auth_type: str = "", - scopes: Optional[list[str]] = None, + name: str | None = None, + title: str | None = None, + text: str | None = None, + abs_oauth_connection_name: str | None = None, + obo_connection_name: str | None = None, + auth_type: str | None = None, + scopes: list[str] | None = None, **kwargs, ): """ - Initializes a new instance of AuthHandler. + Initializes a new instance of AuthHandlerSettings. :param name: The name of the handler. This is how it is accessed programatically in this library. @@ -52,48 +63,74 @@ def __init__( :param auth_type: The authorization variant used. This is likely to change in the future to accept a class that implements AuthorizationVariant. :type auth_type: str + :param scopes: List of scopes to request during sign-in. + :type scopes: list[str] | None + :param kwargs: Additional keyword arguments for the OAuth handler. + _alt_blueprint_name: Alternative blueprint name for the OAuth handler. + :type kwargs: dict """ - self.name = name or kwargs.get("NAME", "") - self.title = title or kwargs.get("TITLE", "") - self.text = text or kwargs.get("TEXT", "") + + kwargs = {k.lower(): v for k, v in kwargs.items()} + + self.name = name or kwargs.get("name", "") + self.title = title or kwargs.get("title", "") + self.text = text or kwargs.get("text", "") self.abs_oauth_connection_name = abs_oauth_connection_name or kwargs.get( - "AZUREBOTOAUTHCONNECTIONNAME", "" + "azurebotoauthconnectionname", "" ) self.obo_connection_name = obo_connection_name or kwargs.get( - "OBOCONNECTIONNAME", "" + "oboconnectionname", "" ) - self.auth_type = auth_type or kwargs.get("TYPE", "UserAuthorization") - self.auth_type = self.auth_type.lower() - if scopes: - self.scopes = list(scopes) - else: - self.scopes = AuthHandler._format_scopes(kwargs.get("SCOPES", "")) - self._alt_blueprint_name = kwargs.get("ALT_BLUEPRINT_NAME", None) + self.auth_type = (auth_type or kwargs.get("type", "UserAuthorization")).lower() + self.scopes = AuthHandlerSettings._format_scopes( + scopes or kwargs.get("scopes", "") + ) + self._alt_blueprint_name = kwargs.get("alt_blueprint_name", None) - @staticmethod - def _format_scopes(scopes: str) -> list[str]: - lst = scopes.strip().split(" ") - return [s for s in lst if s] + self._kwargs = {k: v for k, v in kwargs.items() if k not in _BASE_KEYS} + + @property + def kwargs(self) -> dict: + return dict(self._kwargs) @staticmethod - def _from_settings(settings: dict): + def _format_scopes(scopes: str | list[str]) -> list[str]: + if isinstance(scopes, str): + lst = scopes.strip().split(" ") + return [s for s in lst if s] + elif isinstance(scopes, list): + return list(scopes) + else: + return [] + + def _to_dict(self) -> dict: """ - Creates an AuthHandler instance from a settings dictionary. + Converts the AuthHandlerSettings instance to a dictionary. - :param settings: The settings dictionary containing configuration for the AuthHandler. - :type settings: dict - :return: An instance of AuthHandler configured with the provided settings. - :rtype: AuthHandler + :return: A dictionary representation of the AuthHandlerSettings instance. + :rtype: dict """ - if not settings: - raise ValueError("Settings dictionary is required to create AuthHandler") - - return AuthHandler( - name=settings.get("NAME", ""), - title=settings.get("TITLE", ""), - text=settings.get("TEXT", ""), - abs_oauth_connection_name=settings.get("AZUREBOTOAUTHCONNECTIONNAME", ""), - obo_connection_name=settings.get("OBOCONNECTIONNAME", ""), - auth_type=settings.get("TYPE", ""), - scopes=AuthHandler._format_scopes(settings.get("SCOPES", "")), + base = { + "name": self.name, + "title": self.title, + "text": self.text, + "azurebotoauthconnectionname": self.abs_oauth_connection_name, + "oboconnectionname": self.obo_connection_name, + "type": self.auth_type, + "scopes": self.scopes, + "alt_blueprint_name": self._alt_blueprint_name, + } + return {**self._kwargs, **base} + + +class AuthHandler(AuthHandlerSettings): + """Deprecated. Use AuthHandlerSettings instead.""" + + def __init__(self, *args, **kwargs): + """Initialize the deprecated AuthHandler class.""" + warnings.warn( + "AuthHandler is deprecated. Please use AuthHandlerSettings instead.", + DeprecationWarning, + stacklevel=2, ) + super().__init__(*args, **kwargs) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index 7d0300025..6a4b30244 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -27,7 +27,7 @@ from ...authorization import Connections from ..._oauth import _FlowStateTag from ..state import TurnState -from .auth_handler import AuthHandler +from .auth_handler import AuthHandlerSettings from ._sign_in_state import _SignInState from ._sign_in_response import _SignInResponse from ._handlers import ( @@ -66,7 +66,7 @@ def __init__( self, storage: Storage, connection_manager: Connections, - auth_handlers: dict[str, AuthHandler] | None = None, + auth_handlers: dict[str, AuthHandlerSettings] | None = None, auto_sign_in: bool = False, use_cache: bool = False, **kwargs, @@ -82,7 +82,7 @@ def __init__( :param connection_manager: The connection manager for OAuth providers. :type connection_manager: :class:`microsoft_agents.hosting.core.authorization.Connections` :param auth_handlers: Configuration for OAuth providers. - :type auth_handlers: dict[str, :class:`microsoft_agents.hosting.core.app.oauth.auth_handler.AuthHandler`], Optional + :type auth_handlers: dict[str, :class:`microsoft_agents.hosting.core.app.oauth.auth_handler.AuthHandlerSettings`], Optional :raises ValueError: When storage is None or no auth handlers provided. """ if not storage: @@ -107,9 +107,9 @@ def __init__( # get from config handlers_config: dict[str, dict] = auth_configuration.get("HANDLERS") or {} auth_handlers = { - handler_name: AuthHandler( + handler_name: AuthHandlerSettings( name=handler_name, - auth_type=config.get("TYPE", ""), + auth_type=config.get("TYPE", None), **config.get("SETTINGS", {}), ) for handler_name, config in handlers_config.items() @@ -132,17 +132,17 @@ def _init_handlers(self) -> None: it initializes an instance of each variant that is referenced. :param auth_handlers: A dictionary of auth handler configurations. - :type auth_handlers: dict[str, :class:`microsoft_agents.hosting.core.app.oauth.auth_handler.AuthHandler`] + :type auth_handlers: dict[str, :class:`microsoft_agents.hosting.core.app.oauth.auth_handler.AuthHandlerSettings`] """ - for name, auth_handler in self._handler_settings.items(): - auth_type = auth_handler.auth_type + for name, settings in self._handler_settings.items(): + auth_type = settings.auth_type if auth_type not in AUTHORIZATION_TYPE_MAP: raise ValueError(f"Auth type {auth_type} not recognized.") self._handlers[name] = AUTHORIZATION_TYPE_MAP[auth_type]( storage=self._storage, connection_manager=self._connection_manager, - auth_handler=auth_handler, + settings=settings, ) @property @@ -429,13 +429,13 @@ async def exchange_token( :param context: The context object for the current turn. :type context: :class:`microsoft_agents.hosting.core.turn_context.TurnContext` :param scopes: The scopes to request during the token exchange or refresh. Defaults - to the list given in the AuthHandler configuration if None. + to the list given in the AuthHandlerSettings configuration if None. :type scopes: list[str] | None :param auth_handler_id: The ID of the auth handler to exchange or refresh the token for. If None, the default handler will be used. :type auth_handler_id: str | None :param exchange_connection: The name of the connection to use for token exchange. If None, - the connection defined in the AuthHandler configuration will be used. + the connection defined in the AuthHandlerSettings configuration will be used. :type exchange_connection: str | None :return: The token response from the OAuth provider. :rtype: :class:`microsoft_agents.activity.TokenResponse` @@ -483,13 +483,13 @@ def exchange_token_as_token_credential( :param context: The context object for the current turn. :type context: :class:`microsoft_agents.hosting.core.turn_context.TurnContext` :param scopes: The scopes to request during the token exchange or refresh. Defaults - to the list given in the AuthHandler configuration if None. + to the list given in the AuthHandlerSettings configuration if None. :type scopes: list[str] | None :param auth_handler_id: The ID of the auth handler to exchange or refresh the token for. If None, the default handler will be used. :type auth_handler_id: str | None :param exchange_connection: The name of the connection to use for token exchange. If None, - the connection defined in the AuthHandler configuration will be used. + the connection defined in the AuthHandlerSettings configuration will be used. :type exchange_connection: str | None :return: An instance of `AsyncTokenCredential`. :rtype: :class:`azure.core.credentials_async.AsyncTokenCredential` diff --git a/tests/_common/data/auth_test_data.py b/tests/_common/data/auth_test_data.py index 698cc1f5a..1f61be260 100644 --- a/tests/_common/data/auth_test_data.py +++ b/tests/_common/data/auth_test_data.py @@ -1,13 +1,13 @@ -from microsoft_agents.hosting.core import AuthHandler +from microsoft_agents.hosting.core import AuthHandlerSettings def create_test_auth_handler( name: str, obo: bool = False, title: str = None, text: str = None -) -> AuthHandler: +) -> AuthHandlerSettings: """ - Creates a test AuthHandler instance with standardized connection names. + Creates a test AuthHandlerSettings instance with standardized connection names. - This helper function simplifies the creation of AuthHandler objects for testing + This helper function simplifies the creation of AuthHandlerSettings objects for testing by automatically generating connection names based on the provided name and optionally including On-Behalf-Of (OBO) connection configuration. @@ -18,9 +18,9 @@ def create_test_auth_handler( text: Optional descriptive text for the auth handler Returns: - AuthHandler: Configured auth handler instance with test-friendly connection names + AuthHandlerSettings: Configured auth handler instance with test-friendly connection names """ - return AuthHandler( + return AuthHandlerSettings( name, abs_oauth_connection_name=f"{name}-abs-connection", obo_connection_name=f"{name}-obo-connection" if obo else None, @@ -32,9 +32,9 @@ def create_test_auth_handler( class AUTH_TEST_DATA: def __init__(self): - self.auth_handler: AuthHandler = create_test_auth_handler("graph") + self.auth_handler: AuthHandlerSettings = create_test_auth_handler("graph") - self.auth_handlers: dict[str, AuthHandler] = { + self.auth_handlers: dict[str, AuthHandlerSettings] = { "graph": create_test_auth_handler("graph"), "github": create_test_auth_handler("github", obo=True), "slack": create_test_auth_handler("slack", obo=True), diff --git a/tests/_common/data/default_test_values.py b/tests/_common/data/default_test_values.py index 98701ef3c..cbf5c2ca8 100644 --- a/tests/_common/data/default_test_values.py +++ b/tests/_common/data/default_test_values.py @@ -1,5 +1,5 @@ from microsoft_agents.hosting.core import ( - AuthHandler, + AuthHandlerSettings, ) @@ -40,4 +40,4 @@ def __init__(self): self.missing_abs_oauth_connection_name = "missing_connection_name" - self.auth_handlers = [AuthHandler()] + self.auth_handlers = [AuthHandlerSettings()] diff --git a/tests/_common/testing_objects/testing_authorization.py b/tests/_common/testing_objects/testing_authorization.py index 6e962aa63..d9fcb6da9 100644 --- a/tests/_common/testing_objects/testing_authorization.py +++ b/tests/_common/testing_objects/testing_authorization.py @@ -2,7 +2,11 @@ from microsoft_agents.activity import TokenResponse -from microsoft_agents.hosting.core import Authorization, AuthHandler, MemoryStorage +from microsoft_agents.hosting.core import ( + Authorization, + AuthHandlerSettings, + MemoryStorage, +) from .testing_connection_manager import TestingConnectionManager @@ -22,7 +26,7 @@ class TestingAuthorization(Authorization): def __init__( self, mocker, - auth_handlers: dict[str, AuthHandler], + auth_handlers: dict[str, AuthHandlerSettings], token: Union[str, None] = "default", flow_started=False, sign_in_failed=False, @@ -35,7 +39,7 @@ def __init__( mock OAuth flows. Args: - auth_handlers: Dictionary mapping handler names to AuthHandler instances + auth_handlers: Dictionary mapping handler names to AuthHandlerSettings instances token: Token value to use in mock responses. "default" uses auto-generated tokens, None simulates no token available, or provide custom jwt token string flow_started: Simulate OAuth flows that have already started diff --git a/tests/hosting_core/app/_oauth/_handlers/test_agentic_user_authorization.py b/tests/hosting_core/app/_oauth/_handlers/test_agentic_user_authorization.py index dec38b526..5c61cb4a6 100644 --- a/tests/hosting_core/app/_oauth/_handlers/test_agentic_user_authorization.py +++ b/tests/hosting_core/app/_oauth/_handlers/test_agentic_user_authorization.py @@ -45,17 +45,17 @@ def connection_manager(self, mocker): return MsalConnectionManager(**AGENTIC_ENV_DICT) @pytest.fixture - def auth_handler_settings(self): + def settings(self): return AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][ DEFAULTS.agentic_auth_handler_id ]["SETTINGS"] @pytest.fixture - def agentic_auth(self, storage, connection_manager, auth_handler_settings): + def agentic_auth(self, storage, connection_manager, settings): return AgenticUserAuthorization( storage, connection_manager, - auth_handler_settings=auth_handler_settings, + settings=settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id, ) @@ -136,7 +136,7 @@ async def test_get_agentic_user_token_agentic_no_user_id( @pytest.mark.asyncio async def test_get_agentic_instance_token_is_agentic( - self, mocker, agentic_role, agentic_auth, auth_handler_settings + self, mocker, agentic_role, agentic_auth, settings ): mock_provider = self.mock_provider(mocker, instance_token=DEFAULTS.token) connection_manager = mocker.Mock(spec=MsalConnectionManager) @@ -145,7 +145,7 @@ async def test_get_agentic_instance_token_is_agentic( agentic_auth = AgenticUserAuthorization( MemoryStorage(), connection_manager, - auth_handler_settings=auth_handler_settings, + settings=settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id, ) @@ -167,7 +167,7 @@ async def test_get_agentic_instance_token_is_agentic( @pytest.mark.asyncio async def test_get_agentic_user_token_is_agentic( - self, mocker, agentic_role, agentic_auth, auth_handler_settings + self, mocker, agentic_role, agentic_auth, settings ): mock_provider = self.mock_provider(mocker, user_token=DEFAULTS.token) @@ -177,7 +177,7 @@ async def test_get_agentic_user_token_is_agentic( agentic_auth = AgenticUserAuthorization( MemoryStorage(), connection_manager, - auth_handler_settings=auth_handler_settings, + settings=settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id, ) @@ -212,7 +212,7 @@ async def test_sign_in_success( scopes_list, agentic_role, expected_scopes_list, - auth_handler_settings, + settings, ): mock_provider = self.mock_provider(mocker, user_token="my_token") @@ -222,7 +222,7 @@ async def test_sign_in_success( agentic_auth = AgenticUserAuthorization( MemoryStorage(), connection_manager, - auth_handler_settings=auth_handler_settings, + settings=settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id, ) activity = Activity( @@ -260,7 +260,7 @@ async def test_sign_in_failure( scopes_list, agentic_role, expected_scopes_list, - auth_handler_settings, + settings, ): mock_provider = self.mock_provider(mocker, user_token=None) @@ -270,7 +270,7 @@ async def test_sign_in_failure( agentic_auth = AgenticUserAuthorization( MemoryStorage(), connection_manager, - auth_handler_settings=auth_handler_settings, + settings=settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id, ) activity = Activity( @@ -308,7 +308,7 @@ async def test_get_refreshed_token_success( scopes_list, agentic_role, expected_scopes_list, - auth_handler_settings, + settings, ): mock_provider = self.mock_provider(mocker, user_token="my_token") @@ -318,7 +318,7 @@ async def test_get_refreshed_token_success( agentic_auth = AgenticUserAuthorization( MemoryStorage(), connection_manager, - auth_handler_settings=auth_handler_settings, + settings=settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id, ) activity = Activity( @@ -357,7 +357,7 @@ async def test_get_refreshed_token_failure( scopes_list, agentic_role, expected_scopes_list, - auth_handler_settings, + settings, ): mock_provider = self.mock_provider(mocker, user_token=None) @@ -367,7 +367,7 @@ async def test_get_refreshed_token_failure( agentic_auth = AgenticUserAuthorization( MemoryStorage(), connection_manager, - auth_handler_settings=auth_handler_settings, + settings=settings, auth_handler_id=DEFAULTS.agentic_auth_handler_id, ) activity = Activity( diff --git a/tests/hosting_core/app/_oauth/_handlers/test_connector_user_authorization.py b/tests/hosting_core/app/_oauth/_handlers/test_connector_user_authorization.py index f66f46bb8..9564d0c9f 100644 --- a/tests/hosting_core/app/_oauth/_handlers/test_connector_user_authorization.py +++ b/tests/hosting_core/app/_oauth/_handlers/test_connector_user_authorization.py @@ -97,19 +97,17 @@ def connection_manager(self): return MsalConnectionManager(**AGENTIC_ENV_DICT) @pytest.fixture - def auth_handler_settings(self): + def settings(self): return AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][ DEFAULTS.auth_handler_id ]["SETTINGS"] @pytest.fixture - def connector_authorization( - self, connection_manager, storage, auth_handler_settings - ): + def connector_authorization(self, connection_manager, storage, settings): return ConnectorUserAuthorization( storage, connection_manager, - auth_handler_settings=auth_handler_settings, + settings=settings, auth_handler_id=DEFAULTS.auth_handler_id, ) diff --git a/tests/hosting_core/app/_oauth/_handlers/test_user_authorization.py b/tests/hosting_core/app/_oauth/_handlers/test_user_authorization.py index a7e805623..373d42a1a 100644 --- a/tests/hosting_core/app/_oauth/_handlers/test_user_authorization.py +++ b/tests/hosting_core/app/_oauth/_handlers/test_user_authorization.py @@ -114,17 +114,17 @@ def auth_handlers(self): return AUTH_TEST_DATA().auth_handlers @pytest.fixture - def auth_handler_settings(self): + def settings(self): return AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][ DEFAULTS.auth_handler_id ]["SETTINGS"] @pytest.fixture - def user_authorization(self, connection_manager, storage, auth_handler_settings): + def user_authorization(self, connection_manager, storage, settings): return MyUserAuthorization( storage, connection_manager, - auth_handler_settings=auth_handler_settings, + settings=settings, auth_handler_id=DEFAULTS.auth_handler_id, ) diff --git a/tests/hosting_core/app/_oauth/test_auth_handler.py b/tests/hosting_core/app/_oauth/test_auth_handler.py index b362891e4..b6ead21b0 100644 --- a/tests/hosting_core/app/_oauth/test_auth_handler.py +++ b/tests/hosting_core/app/_oauth/test_auth_handler.py @@ -1,6 +1,17 @@ +from contextlib import nullcontext + import pytest -from microsoft_agents.hosting.core import AuthHandler +from microsoft_agents.hosting.core import ( + AuthHandler, + AuthHandlerSettings, + MemoryStorage, +) +from microsoft_agents.hosting.core.app.oauth import ( + AgenticUserAuthorization, + ConnectorUserAuthorization, + _UserAuthorization, +) from tests._common.data import ( DEFAULT_TEST_VALUES, @@ -13,7 +24,90 @@ AGENTIC_ENV_DICT = AGENTIC_TEST_ENV_DICT() -class TestAuthHandler: +class TestAuthHandlerSettings: + def test_default_auth_type(self): + assert AuthHandlerSettings().auth_type == "userauthorization" + + @pytest.mark.parametrize("key_style", ["upper", "lower", "snake"]) + def test_configuration_aliases(self, key_style): + configuration = { + "NAME": "SSO", + "TYPE": "AgenticUserAuthorization", + "AZUREBOTOAUTHCONNECTIONNAME": "sso", + "OBOCONNECTIONNAME": "obo", + "SCOPES": "User.Read Mail.Read", + } + if key_style != "upper": + configuration = {k.lower(): v for k, v in configuration.items()} + if key_style == "snake": + configuration["auth_type"] = configuration.pop("type") + configuration["abs_oauth_connection_name"] = configuration.pop( + "azurebotoauthconnectionname" + ) + configuration["obo_connection_name"] = configuration.pop( + "oboconnectionname" + ) + configuration["scopes"] = ["User.Read", "Mail.Read"] + settings = AuthHandlerSettings(**configuration) + + assert settings.name == "SSO" + assert settings.auth_type == "agenticuserauthorization" + assert settings.abs_oauth_connection_name == "sso" + assert settings.obo_connection_name == "obo" + assert settings.scopes == ["User.Read", "Mail.Read"] + + copied = AuthHandlerSettings(**settings._to_dict()) + assert copied._to_dict() == settings._to_dict() + + @pytest.mark.parametrize("scopes_key", ["scopes", "SCOPES"]) + def test_scopes_are_copied(self, scopes_key): + scopes = ["User.Read"] + settings = AuthHandlerSettings(**{scopes_key: scopes}) + + scopes.append("Mail.Read") + + assert settings.scopes == ["User.Read"] + + def test_kwargs_returns_a_copy(self): + settings = AuthHandlerSettings(CUSTOM_SETTING="custom") + extras = settings.kwargs + + extras["custom_setting"] = "changed" + extras["new_setting"] = "new" + + assert settings.kwargs == {"custom_setting": "custom"} + + @pytest.mark.parametrize("empty_value", [None, ""]) + def test_empty_arguments_use_configuration(self, empty_value): + settings = AuthHandlerSettings( + name=empty_value, + title=empty_value, + text=empty_value, + abs_oauth_connection_name=empty_value, + obo_connection_name=empty_value, + auth_type=empty_value, + scopes=None if empty_value is None else [], + NAME="SSO", + TITLE="Sign in", + TEXT="Please sign in", + AZUREBOTOAUTHCONNECTIONNAME="sso", + OBOCONNECTIONNAME="obo", + TYPE="AgenticUserAuthorization", + SCOPES="User.Read Mail.Read", + ALT_BLUEPRINT_NAME="blueprint", + CUSTOM_SETTING="custom", + ) + + assert settings.name == "SSO" + assert settings.title == "Sign in" + assert settings.text == "Please sign in" + assert settings.abs_oauth_connection_name == "sso" + assert settings.obo_connection_name == "obo" + assert settings.auth_type == "agenticuserauthorization" + assert settings.scopes == ["User.Read", "Mail.Read"] + assert settings._alt_blueprint_name == "blueprint" + assert settings.kwargs == {"custom_setting": "custom"} + @pytest.fixture def auth_setting(self): return ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][ @@ -27,7 +121,7 @@ def agentic_auth_setting(self): ]["SETTINGS"] def test_init(self, auth_setting): - auth_handler = AuthHandler(DEFAULTS.auth_handler_id, **auth_setting) + auth_handler = AuthHandlerSettings(DEFAULTS.auth_handler_id, **auth_setting) assert auth_handler.name == DEFAULTS.auth_handler_id assert auth_handler.title == DEFAULTS.auth_handler_title assert auth_handler.text == DEFAULTS.auth_handler_text @@ -37,7 +131,7 @@ def test_init(self, auth_setting): ) def test_init_agentic(self, agentic_auth_setting): - auth_handler = AuthHandler( + auth_handler = AuthHandlerSettings( DEFAULTS.agentic_auth_handler_id, **agentic_auth_setting ) assert auth_handler.name == DEFAULTS.agentic_auth_handler_id @@ -49,3 +143,242 @@ def test_init_agentic(self, agentic_auth_setting): auth_handler.abs_oauth_connection_name == DEFAULTS.agentic_abs_oauth_connection_name ) + + @pytest.mark.parametrize("scopes", [[], ["User.Read"], ["User.Read", "Mail.Read"]]) + def test_settings_round_trip(self, scopes): + settings = AuthHandlerSettings( + name="SSO", + title="Chosen title", + TITLE="Fallback title", + scopes=scopes, + SCOPES=" ".join(scopes), + ALT_BLUEPRINT_NAME="blueprint", + CUSTOM_SETTING="custom", + ) + + copied = AuthHandlerSettings(**settings._to_dict()) + + assert copied.name == "SSO" + assert copied.title == "Chosen title" + assert copied.scopes == scopes + assert copied.scopes is not settings.scopes + assert copied._alt_blueprint_name == "blueprint" + assert copied.kwargs["custom_setting"] == "custom" + + def test_legacy_auth_handler(self, auth_setting): + with pytest.warns(DeprecationWarning, match="AuthHandler is deprecated"): + legacy = AuthHandler(DEFAULTS.auth_handler_id, **auth_setting) + + settings = AuthHandlerSettings(DEFAULTS.auth_handler_id, **auth_setting) + assert isinstance(legacy, AuthHandlerSettings) + assert legacy._to_dict() == settings._to_dict() + + +class TestAuthorizationHandlerSettings: + @pytest.fixture( + params=[ + _UserAuthorization, + AgenticUserAuthorization, + ConnectorUserAuthorization, + ] + ) + def handler_type(self, request): + return request.param + + @pytest.fixture + def configuration(self): + return { + "NAME": "SSO", + "TITLE": "Sign in", + "TEXT": "Please sign in", + "AZUREBOTOAUTHCONNECTIONNAME": "sso", + "OBOCONNECTIONNAME": "obo", + "TYPE": "AgenticUserAuthorization", + "SCOPES": "User.Read Mail.Read", + "ALT_BLUEPRINT_NAME": "blueprint", + "CUSTOM_SETTING": "custom", + } + + @pytest.fixture + def settings(self, configuration): + return AuthHandlerSettings(**configuration) + + @pytest.mark.parametrize("settings_kind", ["dictionary", "object"]) + def test_init_with_settings( + self, mocker, handler_type, configuration, settings, settings_kind + ): + storage = MemoryStorage() + connections = mocker.Mock() + source = configuration if settings_kind == "dictionary" else settings + + handler = handler_type(storage, connections, settings=source) + + assert handler._storage is storage + assert handler._connection_manager is connections + assert handler._id == "SSO" + assert handler._handler._to_dict() == settings._to_dict() + assert handler._handler.scopes == ["User.Read", "Mail.Read"] + if settings_kind == "object": + assert handler._handler is settings + if isinstance(handler, AgenticUserAuthorization): + assert handler._alt_blueprint_name == "blueprint" + + @pytest.mark.parametrize( + "settings_kind", + ["dictionary", "object", "deprecated_dictionary", "deprecated_object"], + ) + def test_handler_kwargs_do_not_modify_settings( + self, mocker, handler_type, configuration, settings, settings_kind + ): + source = configuration if "dictionary" in settings_kind else settings + argument = { + "deprecated_dictionary": "auth_handler_settings", + "deprecated_object": "auth_handler", + }.get(settings_kind, "settings") + + warning = ( + pytest.warns(DeprecationWarning, match="Please use settings instead") + if settings_kind.startswith("deprecated") + else nullcontext() + ) + with warning: + handler = handler_type( + MemoryStorage(), + mocker.Mock(), + **{argument: source}, + title="New title", + text="New text", + scopes=["Files.Read"], + abs_oauth_connection_name="new-sso", + obo_connection_name="new-obo", + auth_type="ConnectorUserAuthorization", + alt_blueprint_name="new-blueprint", + CUSTOM_SETTING="other", + OTHER_SETTING="ignored", + ) + + assert handler._handler.title == "Sign in" + assert handler._handler.text == "Please sign in" + assert handler._handler.scopes == ["User.Read", "Mail.Read"] + assert handler._handler.abs_oauth_connection_name == "sso" + assert handler._handler.obo_connection_name == "obo" + assert handler._handler.auth_type == "agenticuserauthorization" + assert handler._handler._alt_blueprint_name == "blueprint" + assert handler._handler.kwargs["custom_setting"] == "custom" + assert "other_setting" not in handler._handler.kwargs + assert settings.title == "Sign in" + assert settings.scopes == ["User.Read", "Mail.Read"] + assert configuration["TITLE"] == "Sign in" + assert configuration["SCOPES"] == "User.Read Mail.Read" + + @pytest.mark.parametrize( + "settings_kind", + ["dictionary", "object", "deprecated_dictionary", "deprecated_object"], + ) + def test_missing_optional_settings(self, mocker, handler_type, settings_kind): + configuration = {"NAME": "SSO"} + source = ( + configuration + if "dictionary" in settings_kind + else AuthHandlerSettings(**configuration) + ) + argument = { + "deprecated_dictionary": "auth_handler_settings", + "deprecated_object": "auth_handler", + }.get(settings_kind, "settings") + + warning = ( + pytest.warns(DeprecationWarning, match="Please use settings instead") + if settings_kind.startswith("deprecated") + else nullcontext() + ) + with warning: + handler = handler_type( + MemoryStorage(), + mocker.Mock(), + **{argument: source}, + ) + + assert handler._id == "SSO" + assert handler._handler.title == "" + assert handler._handler.text == "" + assert handler._handler.abs_oauth_connection_name == "" + assert handler._handler.obo_connection_name == "" + assert handler._handler.auth_type == "userauthorization" + assert handler._handler.scopes == [] + assert handler._handler._alt_blueprint_name is None + assert handler._handler.kwargs == {} + + @pytest.mark.parametrize("argument", ["settings", "auth_handler"]) + def test_modified_settings_object_is_preserved( + self, mocker, handler_type, settings, argument + ): + settings.abs_oauth_connection_name = "" + settings.obo_connection_name = "" + settings.scopes = [] + + warning = ( + pytest.warns(DeprecationWarning, match="Please use settings instead") + if argument == "auth_handler" + else nullcontext() + ) + with warning: + handler = handler_type( + MemoryStorage(), mocker.Mock(), **{argument: settings} + ) + + assert handler._handler is settings + assert handler._handler.abs_oauth_connection_name == "" + assert handler._handler.obo_connection_name == "" + assert handler._handler.scopes == [] + + @pytest.mark.parametrize("argument", ["auth_handler", "auth_handler_settings"]) + def test_legacy_keyword_arguments( + self, mocker, handler_type, configuration, settings, argument + ): + source = settings if argument == "auth_handler" else configuration + + with pytest.warns(DeprecationWarning, match="Please use settings instead"): + handler = handler_type(MemoryStorage(), mocker.Mock(), **{argument: source}) + + assert handler._id == "SSO" + assert handler._handler._to_dict() == settings._to_dict() + assert handler._handler.scopes == ["User.Read", "Mail.Read"] + + @pytest.mark.parametrize("argument", ["positional", "auth_handler"]) + def test_legacy_auth_handler_instance( + self, mocker, handler_type, configuration, argument + ): + with pytest.warns(DeprecationWarning, match="AuthHandler is deprecated"): + legacy = AuthHandler(**configuration) + + if argument == "positional": + handler = handler_type(MemoryStorage(), mocker.Mock(), legacy) + else: + with pytest.warns(DeprecationWarning, match="Please use settings instead"): + handler = handler_type( + MemoryStorage(), mocker.Mock(), auth_handler=legacy + ) + + assert handler._id == "SSO" + assert handler._handler is legacy + assert handler._handler._to_dict() == legacy._to_dict() + assert handler._handler.scopes == ["User.Read", "Mail.Read"] + + def test_legacy_dictionary_with_explicit_id( + self, mocker, handler_type, configuration + ): + configuration.pop("NAME") + + with pytest.warns(DeprecationWarning, match="Please use settings instead"): + handler = handler_type( + MemoryStorage(), + mocker.Mock(), + auth_handler_settings=configuration, + auth_handler_id="legacy-id", + ) + + assert handler._id == "legacy-id" + assert handler._handler.abs_oauth_connection_name == "sso" + assert handler._handler.obo_connection_name == "obo" + assert handler._handler.scopes == ["User.Read", "Mail.Read"] diff --git a/tests/hosting_core/app/_oauth/test_authorization.py b/tests/hosting_core/app/_oauth/test_authorization.py index 50f603daa..74b773153 100644 --- a/tests/hosting_core/app/_oauth/test_authorization.py +++ b/tests/hosting_core/app/_oauth/test_authorization.py @@ -17,12 +17,14 @@ _UserAuthorization, Authorization, AgenticUserAuthorization, + ConnectorUserAuthorization, ) from microsoft_agents.hosting.core._oauth import _FlowStateTag from microsoft_agents.hosting.core import ( AuthHandler, + AuthHandlerSettings, MemoryStorage, ) @@ -151,6 +153,37 @@ def auth_handler_id(self, request): class TestAuthorizationSetup(TestEnv): + @pytest.mark.parametrize( + "auth_type, handler_type", + [ + ("UserAuthorization", _UserAuthorization), + ("AgenticUserAuthorization", AgenticUserAuthorization), + ("ConnectorUserAuthorization", ConnectorUserAuthorization), + ], + ) + def test_legacy_auth_handler_configuration( + self, connection_manager, storage, auth_type, handler_type + ): + with pytest.warns(DeprecationWarning, match="AuthHandler is deprecated"): + settings = AuthHandler( + name="legacy", + auth_type=auth_type, + abs_oauth_connection_name="sso", + obo_connection_name="obo", + SCOPES="User.Read Mail.Read", + ) + + auth = Authorization( + storage, connection_manager, auth_handlers={"legacy": settings} + ) + handler = auth._resolve_handler("legacy") + + assert isinstance(handler, handler_type) + assert handler._id == "legacy" + assert handler._handler.abs_oauth_connection_name == "sso" + assert handler._handler.obo_connection_name == "obo" + assert handler._handler.scopes == ["User.Read", "Mail.Read"] + def test_init_user_auth(self, connection_manager, storage, env_dict): auth = Authorization(storage, connection_manager, **env_dict) assert auth._resolve_handler(DEFAULTS.auth_handler_id) is not None @@ -179,9 +212,15 @@ def test_resolve_handler(self, connection_manager, storage, auth_handler_id): handler_config = AGENTIC_ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"][ "HANDLERS" ][auth_handler_id] - auth._resolve_handler(auth_handler_id) == AuthHandler( - auth_handler_id, **handler_config + settings = AuthHandlerSettings( + name=auth_handler_id, + auth_type=handler_config.get("TYPE"), + **handler_config["SETTINGS"], ) + handler = auth._resolve_handler(auth_handler_id) + assert handler._id == auth_handler_id + assert handler._handler._to_dict() == settings._to_dict() + assert handler._handler.scopes == settings.scopes def test_connection_manager_property(self, connection_manager, storage): auth = Authorization(storage, connection_manager, **ENV_DICT)