From 5db1f59ccf7658e286d53b0a1ce72bc22d1e8af4 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 31 Aug 2026 10:08:35 -0700 Subject: [PATCH 1/8] Authorization TokenCredential-returning variants --- .../entra_auth_sidecar/sidecar_auth.py | 17 +++ .../sidecar_token_credential.py | 66 +++++++++++ .../authentication/msal/msal_auth.py | 13 +++ .../msal/msal_token_credential.py | 9 +- .../hosting/core/app/oauth/authorization.py | 110 ++++++++++++++---- .../hosting/core/authorization/_helpers.py | 58 +++++++++ .../access_token_provider_base.py | 11 ++ .../authorization/sdk_token_credential.py | 0 .../microsoft-agents-hosting-core/setup.py | 1 + 9 files changed, 261 insertions(+), 24 deletions(-) create mode 100644 libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_token_credential.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_helpers.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/sdk_token_credential.py diff --git a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_auth.py b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_auth.py index 89e13c0cf..f4ac8d656 100644 --- a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_auth.py +++ b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_auth.py @@ -12,6 +12,8 @@ AgentAuthConfiguration, ) +from azure.core.credentials_async import AsyncTokenCredential + from ._models import SidecarConnectionSettings, SidecarRequestOptions from ._token_expiry import SidecarTokenExpiry from .errors.error_resources import SidecarAuthErrorResources as _Errors @@ -111,6 +113,21 @@ async def get_access_token( ) return await self._get_cached_token(self._service_name, options) + async def get_token_credential(self) -> AsyncTokenCredential: + """Gets the token credential for the access token provider. + + :return: The token credential. + :rtype: AsyncTokenCredential + """ + from .sidecar_token_credential import SidecarTokenCredential + + return SidecarTokenCredential(self._configuration, provider=self) + + async def acquire_token_on_behalf_of( + self, scopes: list[str], user_assertion: str + ) -> str: + raise NotImplementedError("acquire_token_on_behalf_of is not implemented.") + async def get_agentic_application_token( self, tenant_id: str, agent_app_instance_id: str ) -> str | None: diff --git a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_token_credential.py b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_token_credential.py new file mode 100644 index 000000000..88d8fdb03 --- /dev/null +++ b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_token_credential.py @@ -0,0 +1,66 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging + +from azure.core.credentials import AccessToken +from azure.core.credentials_async import AsyncTokenCredential + +from microsoft_agents.hosting.core import AgentAuthConfiguration + +from .sidecar_auth import SidecarAuth +from ._token_expiry import SidecarTokenExpiry + +logger = logging.getLogger(__name__) + + +def _get_resource(scope: str) -> str: + """Extracts the resource by removing a trailing '/.default' from the scope. + + :param scope: The scope string. + :return: The extracted resource string. + :rtype: str + """ + return scope.removesuffix("/.default") + + +class SidecarTokenCredential(AsyncTokenCredential): + """Provides an asynchronous Azure Core token credential using the Sidecar.""" + + def __init__( + self, + config: AgentAuthConfiguration, + *, + provider: SidecarAuth | None = None, + ): + """Initializes the SidecarTokenCredential with the given configuration. + + :param config: The agent authentication configuration. + :type config: :class:`microsoft_agents.hosting.core.AgentAuthConfiguration` + """ + self._config = config + self._provider: SidecarAuth | None = provider + + async def get_token(self, *scopes: str, **kwargs) -> AccessToken: + """Acquire an access token for the specified scopes. + + :param scopes: The scopes for which the access token is requested. + :param kwargs: Additional keyword arguments. + + :return: The acquired access token. + :rtype: AccessToken + """ + + logger.debug("get_token scope=%s", scopes) + + if not scopes: + raise ValueError("At least one scope must be provided.") + + if not self._provider: + self._provider = SidecarAuth(self._config) + + resource = _get_resource(scopes[0]) + + token = await self._provider.get_access_token(resource, list(scopes)) + + return AccessToken(token=token, expires_on=SidecarTokenExpiry.resolve(token)) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 74edf7c19..79ec76a27 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -17,7 +17,10 @@ SystemAssignedManagedIdentity, TokenCache, ) + from azure.core.credentials import AccessToken +from azure.core.credentials_async import AsyncTokenCredential + from requests import Session from microsoft_agents.activity._utils import _DeferredString @@ -86,6 +89,16 @@ async def get_access_token( access_token = await self._get_access_token(resource_url, scopes, force_refresh) return access_token.token + async def get_token_credential(self) -> AsyncTokenCredential: + """Gets the token credential for the access token provider. + + :return: The token credential. + :rtype: AsyncTokenCredential + """ + from .msal_token_credential import MsalTokenCredential + + return MsalTokenCredential(self._msal_configuration, provider=self) + async def _get_access_token( self, resource_url: str, scopes: list[str], force_refresh: bool = False ) -> AccessToken: diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_token_credential.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_token_credential.py index 933eebd04..7cd4b5866 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_token_credential.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_token_credential.py @@ -26,14 +26,19 @@ def _get_resource(scope: str) -> str: class MsalTokenCredential(AsyncTokenCredential): """Provides an asynchronous Azure Core token credential using MSAL.""" - def __init__(self, config: AgentAuthConfiguration): + def __init__( + self, + config: AgentAuthConfiguration, + *, + provider: MsalAuth | None = None, + ): """Initializes the MsalTokenCredential with the given configuration. :param config: The agent authentication configuration. :type config: :class:`microsoft_agents.hosting.core.AgentAuthConfiguration` """ self._config = config - self._provider: MsalAuth | None = None + self._provider: MsalAuth | None = provider async def get_token(self, *scopes: str, **kwargs) -> AccessToken: """Acquire an access token for the specified scopes. 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 bd40288b4..04b487643 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 @@ -4,9 +4,11 @@ """ import logging -from typing import Optional, Callable, Awaitable, cast +from typing import Optional, Callable, Awaitable, cast, overload, Literal from dataclasses import dataclass +from azure.core.credentials_async import AsyncTokenCredential + from microsoft_agents.activity import ( Activity, Channels, @@ -16,6 +18,10 @@ ) from microsoft_agents.activity.activity_types import ActivityTypes +from microsoft_agents.hosting.core.authorization._helpers import ( + _CallableTokenCredential, +) + from ...turn_context import TurnContext from ...storage import Storage from ...authorization import Connections @@ -60,7 +66,7 @@ def __init__( self, storage: Storage, connection_manager: Connections, - auth_handlers: Optional[dict[str, AuthHandler]] = None, + auth_handlers: dict[str, AuthHandler] | None = None, auto_sign_in: bool = False, use_cache: bool = False, **kwargs, @@ -86,10 +92,10 @@ def __init__( self._connection_manager = connection_manager self._sign_in_success_handler: Optional[ - Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]] + Callable[[TurnContext, TurnState, str | None], Awaitable[None]] ] = None self._sign_in_failure_handler: Optional[ - Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]] + Callable[[TurnContext, TurnState, str | None], Awaitable[None]] ] = None self._handlers = {} @@ -165,13 +171,13 @@ def _sign_in_state_key(context: TurnContext) -> str: """ return f"auth:_SignInState:{context.activity.channel_id}:{context.activity.from_property.id}" - async def _load_sign_in_state(self, context: TurnContext) -> Optional[_SignInState]: + async def _load_sign_in_state(self, context: TurnContext) -> _SignInState | None: """Load the sign-in state from storage for the given context. :param context: The turn context for the current turn of conversation. :type context: :class:`microsoft_agents.hosting.core.turn_context.TurnContext` :return: The sign-in state if found, None otherwise. - :rtype: Optional[:class:`microsoft_agents.hosting.core.app.oauth._sign_in_state._SignInState`] + :rtype: :class:`microsoft_agents.hosting.core.app.oauth._sign_in_state._SignInState` | None """ key = self._sign_in_state_key(context) return (await self._storage.read([key], target_cls=_SignInState)).get(key) @@ -205,9 +211,9 @@ def _cache_key(context: TurnContext, handler_id: str) -> str: @staticmethod def _get_cached_token( context: TurnContext, handler_id: str - ) -> Optional[TokenResponse]: + ) -> TokenResponse | None: key = Authorization._cache_key(context, handler_id) - return cast(Optional[TokenResponse], context.turn_state.get(key)) + return cast(TokenResponse | None, context.turn_state.get(key)) @staticmethod def _cache_token( @@ -241,7 +247,7 @@ async def _start_or_continue_sign_in( self, context: TurnContext, state: TurnState, - auth_handler_id: Optional[str] = None, + auth_handler_id: str | None = None, ) -> _SignInResponse: """Start or continue the sign-in process for the user with the given auth handler. @@ -303,14 +309,14 @@ async def _start_or_continue_sign_in( return sign_in_response async def sign_out( - self, context: TurnContext, auth_handler_id: Optional[str] = None + self, context: TurnContext, auth_handler_id: str | None = None ) -> None: """Attempts to sign out the user from a specified auth handler or the default handler. :param context: The turn context for the current turn of conversation. :type context: :class:`microsoft_agents.hosting.core.turn_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 :return: None """ auth_handler_id = auth_handler_id or self._default_handler_id @@ -372,7 +378,9 @@ async def _on_turn_auth_intercept( ) async def get_token( - self, context: TurnContext, auth_handler_id: Optional[str] = None + self, + context: TurnContext, + auth_handler_id: str | None = None, ) -> TokenResponse: """Gets the token for a specific auth handler or the default handler. @@ -387,12 +395,34 @@ async def get_token( """ return await self.exchange_token(context, auth_handler_id=auth_handler_id) + def get_token_as_token_credential( + self, + context: TurnContext, + auth_handler_id: str | None = None, + ) -> AsyncTokenCredential: + """Gets the token as an AsyncTokenCredential for a specific auth handler or the default handler. + + :param context: The context object for the current turn. + :type context: :class:`microsoft_agents.hosting.core.turn_context.TurnContext` + :param auth_handler_id: The ID of the auth handler to get the token for. + :type auth_handler_id: str + :return: An AsyncTokenCredential for the specified auth handler or the default handler. + :rtype: :class:`microsoft_agents.hosting.core.app.oauth.authorization.AsyncTokenCredential` + """ + + async def func(*scopes: str, **kwargs) -> TokenResponse: + return await self.exchange_token( + context, auth_handler_id=auth_handler_id, scopes=list(scopes), **kwargs + ) + + return _CallableTokenCredential(func) + async def exchange_token( self, context: TurnContext, - scopes: Optional[list[str]] = None, - auth_handler_id: Optional[str] = None, - exchange_connection: Optional[str] = None, + scopes: list[str] | None = None, + auth_handler_id: str | None = None, + exchange_connection: str | None = None, ) -> TokenResponse: """Exchanges or refreshes the token for a specific auth handler or the default handler. @@ -400,13 +430,13 @@ async def exchange_token( :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. - :type scopes: Optional[list[str]] + :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: Optional[str] + :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. - :type exchange_connection: Optional[str] + :type exchange_connection: str | None :return: The token response from the OAuth provider. :rtype: :class:`microsoft_agents.activity.TokenResponse` :raises ValueError: If the specified auth handler ID is not recognized or not configured. @@ -441,26 +471,62 @@ async def exchange_token( return res return TokenResponse() + def exchange_token_as_token_credential( + self, + context: TurnContext, + scopes: list[str] | None = None, + auth_handler_id: str | None = None, + exchange_connection: str | None = None, + ) -> AsyncTokenCredential: + """Gets a token credential that exchanges or refreshes the token for a specific auth handler or the default handler. + + :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. + :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. + :type exchange_connection: str | None + :return: An instance of `AsyncTokenCredential`. + :rtype: :class:`azure.core.credentials_async.AsyncTokenCredential` + :raises ValueError: If the specified auth handler ID is not recognized or not configured. + """ + + async def func(*new_scopes: str, **kwargs) -> TokenResponse: + all_scopes = list(set(scopes or [] + list(new_scopes))) + return await self.exchange_token( + context, + scopes=all_scopes, + auth_handler_id=auth_handler_id, + exchange_connection=exchange_connection, + ) + + return _CallableTokenCredential(func) + def on_sign_in_success( self, - handler: Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]], + handler: Callable[[TurnContext, TurnState, str | None], Awaitable[None]], ) -> None: """ Sets a handler to be called when sign-in is successfully completed. :param handler: The handler function to call on successful sign-in. - :type handler: Callable[[:class:`microsoft_agents.hosting.core.turn_context.TurnContext`, :class:`microsoft_agents.hosting.core.app.state.turn_state.TurnState`, Optional[str]], Awaitable[None]] + :type handler: Callable[[:class:`microsoft_agents.hosting.core.turn_context.TurnContext`, :class:`microsoft_agents.hosting.core.app.state.turn_state.TurnState`, str | None], Awaitable[None]] """ self._sign_in_success_handler = handler def on_sign_in_failure( self, - handler: Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]], + handler: Callable[[TurnContext, TurnState, str | None], Awaitable[None]], ) -> None: """ Sets a handler to be called when sign-in fails. :param handler: The handler function to call on sign-in failure. - :type handler: Callable[[:class:`microsoft_agents.hosting.core.turn_context.TurnContext`, :class:`microsoft_agents.hosting.core.app.state.turn_state.TurnState`, Optional[str]], Awaitable[None]] + :type handler: Callable[[:class:`microsoft_agents.hosting.core.turn_context.TurnContext`, :class:`microsoft_agents.hosting.core.app.state.turn_state.TurnState`, str | None], Awaitable[None]] """ self._sign_in_failure_handler = handler diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_helpers.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_helpers.py new file mode 100644 index 000000000..04e323283 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_helpers.py @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import datetime +from typing import Protocol + +from azure.core.credentials_async import AsyncTokenCredential +from azure.core.credentials import AccessToken + +from microsoft_agents.activity import TokenResponse + + +class _TokenRetrieverProtocol(Protocol): + async def __call__( + self, *scopes: str, **kwargs + ) -> TokenResponse | AccessToken | None: ... + + +def _access_token_from_token_response(token_response: TokenResponse) -> AccessToken: + """Convert a `TokenResponse` to an `AccessToken`. + + :param token_response: An instance of `TokenResponse` to convert. + :return: An instance of `AccessToken`. + """ + if not token_response: + raise ValueError("Failed to retrieve token") + + expires_on: int = 0 + if token_response.expiration: + dt = datetime.datetime.fromisoformat(token_response.expiration) + expires_on = int(dt.replace(tzinfo=datetime.timezone.utc).timestamp()) + + return AccessToken(token=token_response.token, expires_on=expires_on) + + +class _CallableTokenCredential(AsyncTokenCredential): + """A wrapper class that implements `AsyncTokenCredential` using a callable to retrieve tokens.""" + + def __init__(self, get_token_callable: _TokenRetrieverProtocol): + """Initialize the `_CallableTokenCredential` with a token retriever callable. + + :param get_token_callable: A callable that retrieves tokens. + """ + self._get_token_callable = get_token_callable + + async def get_token(self, *scopes: str, **kwargs) -> AccessToken: + """Get an access token using the provided callable. + + :param scopes: The scopes for which the access token is requested. + :param kwargs: Additional keyword arguments to pass to the token retriever callable. + :return: An instance of `AccessToken`. + """ + res = await self._get_token_callable(*scopes, **kwargs) + if not res: + raise ValueError("Failed to retrieve token") + if isinstance(res, AccessToken): + return res + return _access_token_from_token_response(res) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py index 5cd29e296..c113b814e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py @@ -4,6 +4,8 @@ from typing import Protocol from abc import abstractmethod +from azure.core.credentials_async import AsyncTokenCredential + from .agent_auth_configuration import AgentAuthConfiguration @@ -33,6 +35,15 @@ async def get_access_token( """ pass + @abstractmethod + async def get_token_credential(self) -> AsyncTokenCredential: + """ + Get the token credential for the access token provider. + + :return: The token credential. + """ + pass + async def acquire_token_on_behalf_of( self, scopes: list[str], user_assertion: str ) -> str: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/sdk_token_credential.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/sdk_token_credential.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/microsoft-agents-hosting-core/setup.py b/libraries/microsoft-agents-hosting-core/setup.py index 6703d8559..c5734fbd9 100644 --- a/libraries/microsoft-agents-hosting-core/setup.py +++ b/libraries/microsoft-agents-hosting-core/setup.py @@ -20,5 +20,6 @@ "opentelemetry-sdk>=1.27.0", "aiohttp>=3.11.11", "yarl>=1.17.0,<2.0", + "azure.core", ], ) From ac9cbcf64b38ead7f81cc9400e251b0a3fad4e1e Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 31 Aug 2026 11:54:39 -0700 Subject: [PATCH 2/8] Small improvements --- .../entra_auth_sidecar/sidecar_auth.py | 2 +- .../authentication/msal/msal_auth.py | 2 +- .../hosting/core/app/oauth/authorization.py | 9 ++- .../hosting/core/authorization/_helpers.py | 11 ++- .../access_token_provider_base.py | 2 +- .../authorization/anonymous_token_provider.py | 12 +++ .../authorization/sdk_token_credential.py | 0 .../microsoft-agents-hosting-core/setup.py | 2 +- .../testing_objects/testing_token_provider.py | 14 ++++ .../app/_oauth/test_authorization.py | 65 +++++++++++++++ .../test_anonymous_token_provider.py | 17 ++++ .../authorization/test_helpers.py | 81 +++++++++++++++++++ tests/hosting_core/test_connection_manager.py | 23 ++++++ 13 files changed, 230 insertions(+), 10 deletions(-) delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/sdk_token_credential.py create mode 100644 tests/hosting_core/authorization/test_anonymous_token_provider.py create mode 100644 tests/hosting_core/authorization/test_helpers.py diff --git a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_auth.py b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_auth.py index f4ac8d656..827381e83 100644 --- a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_auth.py +++ b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_auth.py @@ -113,7 +113,7 @@ async def get_access_token( ) return await self._get_cached_token(self._service_name, options) - async def get_token_credential(self) -> AsyncTokenCredential: + def get_token_credential(self) -> AsyncTokenCredential: """Gets the token credential for the access token provider. :return: The token credential. diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 79ec76a27..17ad6d9e1 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -89,7 +89,7 @@ async def get_access_token( access_token = await self._get_access_token(resource_url, scopes, force_refresh) return access_token.token - async def get_token_credential(self) -> AsyncTokenCredential: + def get_token_credential(self) -> AsyncTokenCredential: """Gets the token credential for the access token provider. :return: The token credential. 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 04b487643..330947d1b 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 @@ -410,9 +410,9 @@ def get_token_as_token_credential( :rtype: :class:`microsoft_agents.hosting.core.app.oauth.authorization.AsyncTokenCredential` """ - async def func(*scopes: str, **kwargs) -> TokenResponse: + async def func(*scopes: str, **_kwargs) -> TokenResponse: return await self.exchange_token( - context, auth_handler_id=auth_handler_id, scopes=list(scopes), **kwargs + context, auth_handler_id=auth_handler_id, scopes=list(scopes) ) return _CallableTokenCredential(func) @@ -496,8 +496,9 @@ def exchange_token_as_token_credential( :raises ValueError: If the specified auth handler ID is not recognized or not configured. """ - async def func(*new_scopes: str, **kwargs) -> TokenResponse: - all_scopes = list(set(scopes or [] + list(new_scopes))) + async def func(*new_scopes: str, **_kwargs) -> TokenResponse: + prev_scopes: list[str] = scopes or [] + all_scopes = list(dict.fromkeys([*prev_scopes, *new_scopes])) return await self.exchange_token( context, scopes=all_scopes, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_helpers.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_helpers.py index 04e323283..bb3d0095b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_helpers.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_helpers.py @@ -27,8 +27,15 @@ def _access_token_from_token_response(token_response: TokenResponse) -> AccessTo expires_on: int = 0 if token_response.expiration: - dt = datetime.datetime.fromisoformat(token_response.expiration) - expires_on = int(dt.replace(tzinfo=datetime.timezone.utc).timestamp()) + exp = token_response.expiration + if exp.endswith("Z"): + exp = exp[:-1] + "+00:00" + dt = datetime.datetime.fromisoformat(exp) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=datetime.timezone.utc) + else: + dt = dt.astimezone(datetime.timezone.utc) + expires_on = int(dt.timestamp()) return AccessToken(token=token_response.token, expires_on=expires_on) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py index c113b814e..250c9c999 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py @@ -36,7 +36,7 @@ async def get_access_token( pass @abstractmethod - async def get_token_credential(self) -> AsyncTokenCredential: + def get_token_credential(self) -> AsyncTokenCredential: """ Get the token credential for the access token provider. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py index 801e8a9a0..8be0fb2cf 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py @@ -3,8 +3,14 @@ from typing import Optional +from azure.core.credentials import AccessToken +from azure.core.credentials_async import AsyncTokenCredential + from .access_token_provider_base import AccessTokenProviderBase from .agent_auth_configuration import AgentAuthConfiguration +from ._helpers import _CallableTokenCredential + +_ANONYMOUS_TOKEN_EXPIRATION = 2**31 - 1 class AnonymousTokenProvider(AccessTokenProviderBase): @@ -26,6 +32,12 @@ async def get_access_token( ) -> str: return "" + def get_token_credential(self) -> AsyncTokenCredential: + async def get_token(*scopes: str, **kwargs) -> AccessToken: + return AccessToken("", _ANONYMOUS_TOKEN_EXPIRATION) + + return _CallableTokenCredential(get_token) + async def acquire_token_on_behalf_of( self, scopes: list[str], user_assertion: str ) -> str: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/sdk_token_credential.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/sdk_token_credential.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/libraries/microsoft-agents-hosting-core/setup.py b/libraries/microsoft-agents-hosting-core/setup.py index c5734fbd9..56079d3ee 100644 --- a/libraries/microsoft-agents-hosting-core/setup.py +++ b/libraries/microsoft-agents-hosting-core/setup.py @@ -20,6 +20,6 @@ "opentelemetry-sdk>=1.27.0", "aiohttp>=3.11.11", "yarl>=1.17.0,<2.0", - "azure.core", + "azure-core", ], ) diff --git a/tests/_common/testing_objects/testing_token_provider.py b/tests/_common/testing_objects/testing_token_provider.py index 9e3f1c1d1..c3dcca122 100644 --- a/tests/_common/testing_objects/testing_token_provider.py +++ b/tests/_common/testing_objects/testing_token_provider.py @@ -2,6 +2,14 @@ AccessTokenProviderBase, AgentAuthConfiguration, ) +from azure.core.credentials import AccessToken +from azure.core.credentials_async import AsyncTokenCredential + +from microsoft_agents.hosting.core.authorization._helpers import ( + _CallableTokenCredential, +) + +_TEST_TOKEN_EXPIRATION = 2**31 - 1 class TestingTokenProvider(AccessTokenProviderBase): @@ -46,6 +54,12 @@ async def get_access_token( """ return f"{self.name}-token" + def get_token_credential(self) -> AsyncTokenCredential: + async def get_token(*scopes: str, **kwargs) -> AccessToken: + return AccessToken(f"{self.name}-token", _TEST_TOKEN_EXPIRATION) + + return _CallableTokenCredential(get_token) + async def acquire_token_on_behalf_of( self, scopes: list[str], user_assertion: str ) -> str: diff --git a/tests/hosting_core/app/_oauth/test_authorization.py b/tests/hosting_core/app/_oauth/test_authorization.py index 13238e8fa..50f603daa 100644 --- a/tests/hosting_core/app/_oauth/test_authorization.py +++ b/tests/hosting_core/app/_oauth/test_authorization.py @@ -560,6 +560,37 @@ async def test_get_token( assert sign_in_state_eq(initial_state, final_state) assert context.turn_state == expected_turn_state + @pytest.mark.asyncio + async def test_get_token_as_token_credential(self, mocker, authorization, context): + token_response = TokenResponse( + token=DEFAULTS.token, + expiration="2030-01-01T00:00:00Z", + ) + exchange_token = mocker.patch.object( + authorization, + "exchange_token", + new=mocker.AsyncMock(return_value=token_response), + ) + + credential = authorization.get_token_as_token_credential( + context, + auth_handler_id=DEFAULTS.auth_handler_id, + ) + token = await credential.get_token( + "scope1", + "scope2", + claims="claims", + tenant_id="tenant", + enable_cae=True, + ) + + assert token.token == DEFAULTS.token + exchange_token.assert_awaited_once_with( + context, + auth_handler_id=DEFAULTS.auth_handler_id, + scopes=["scope1", "scope2"], + ) + @pytest.mark.asyncio @pytest.mark.parametrize( "initial_state, initial_cache, handler_id, refreshed, refresh_token", @@ -636,6 +667,40 @@ async def test_exchange_token( assert sign_in_state_eq(initial_state, final_state) assert context.turn_state == expected_turn_state + @pytest.mark.asyncio + async def test_exchange_token_as_token_credential( + self, mocker, authorization, context + ): + token_response = TokenResponse( + token=DEFAULTS.token, + expiration="2030-01-01T00:00:00Z", + ) + exchange_token = mocker.patch.object( + authorization, + "exchange_token", + new=mocker.AsyncMock(return_value=token_response), + ) + + credential = authorization.exchange_token_as_token_credential( + context, + scopes=["configured", "shared"], + auth_handler_id=DEFAULTS.auth_handler_id, + exchange_connection="connection", + ) + token = await credential.get_token( + "shared", + "requested", + claims="claims", + ) + + assert token.token == DEFAULTS.token + exchange_token.assert_awaited_once_with( + context, + scopes=["configured", "shared", "requested"], + auth_handler_id=DEFAULTS.auth_handler_id, + exchange_connection="connection", + ) + @pytest.mark.asyncio async def test_on_turn_auth_intercept_no_intercept( self, storage, authorization, context diff --git a/tests/hosting_core/authorization/test_anonymous_token_provider.py b/tests/hosting_core/authorization/test_anonymous_token_provider.py new file mode 100644 index 000000000..53e602c64 --- /dev/null +++ b/tests/hosting_core/authorization/test_anonymous_token_provider.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +from azure.core.credentials import AccessToken + +from microsoft_agents.hosting.core import AnonymousTokenProvider + + +@pytest.mark.asyncio +async def test_get_token_credential_is_synchronous(): + provider = AnonymousTokenProvider() + + credential = provider.get_token_credential() + token = await credential.get_token("scope") + + assert token == AccessToken("", 2**31 - 1) diff --git a/tests/hosting_core/authorization/test_helpers.py b/tests/hosting_core/authorization/test_helpers.py new file mode 100644 index 000000000..7812775ef --- /dev/null +++ b/tests/hosting_core/authorization/test_helpers.py @@ -0,0 +1,81 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import datetime + +import pytest +from azure.core.credentials import AccessToken + +from microsoft_agents.activity import TokenResponse +from microsoft_agents.hosting.core.authorization._helpers import ( + _CallableTokenCredential, + _access_token_from_token_response, +) + + +@pytest.mark.parametrize( + "expiration, expected", + [ + (None, 0), + ("2030-01-01T00:00:00Z", 1893456000), + ("2030-01-01T00:00:00", 1893456000), + ("2030-01-01T02:00:00+02:00", 1893456000), + ], +) +def test_access_token_from_token_response_expiration(expiration, expected): + response = TokenResponse(token="token", expiration=expiration) + + token = _access_token_from_token_response(response) + + assert token == AccessToken("token", expected) + + +def test_access_token_from_token_response_rejects_missing_token(): + with pytest.raises(ValueError, match="Failed to retrieve token"): + _access_token_from_token_response(TokenResponse()) + + +def test_access_token_from_token_response_rejects_invalid_expiration(): + with pytest.raises(ValueError): + _access_token_from_token_response( + TokenResponse(token="token", expiration="not-a-date") + ) + + +@pytest.mark.asyncio +async def test_callable_token_credential_returns_access_token_unchanged(mocker): + expected = AccessToken("token", 123) + retriever = mocker.AsyncMock(return_value=expected) + credential = _CallableTokenCredential(retriever) + + token = await credential.get_token("scope", claims="claims") + + assert token is expected + retriever.assert_awaited_once_with("scope", claims="claims") + + +@pytest.mark.asyncio +async def test_callable_token_credential_converts_token_response(): + async def retrieve_token(*scopes: str, **kwargs): + return TokenResponse( + token="token", + expiration="2030-01-01T02:00:00+02:00", + ) + + credential = _CallableTokenCredential(retrieve_token) + + token = await credential.get_token("scope") + + assert token == AccessToken("token", 1893456000) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("result", [None, TokenResponse()]) +async def test_callable_token_credential_rejects_missing_token(result): + async def retrieve_token(*scopes: str, **kwargs): + return result + + credential = _CallableTokenCredential(retrieve_token) + + with pytest.raises(ValueError, match="Failed to retrieve token"): + await credential.get_token("scope") diff --git a/tests/hosting_core/test_connection_manager.py b/tests/hosting_core/test_connection_manager.py index 9fb41f15c..12d9455fe 100644 --- a/tests/hosting_core/test_connection_manager.py +++ b/tests/hosting_core/test_connection_manager.py @@ -2,6 +2,8 @@ # Licensed under the MIT License. import pytest +from azure.core.credentials import AccessToken +from azure.core.credentials_async import AsyncTokenCredential from microsoft_agents.activity import Activity, ChannelAccount, RoleTypes from microsoft_agents.hosting.core import ( @@ -10,6 +12,11 @@ ClaimsIdentity, ConnectionManager, ) +from microsoft_agents.hosting.core.authorization._helpers import ( + _CallableTokenCredential, +) + +_TEST_TOKEN_EXPIRATION = 2**31 - 1 class FakeProvider(AccessTokenProviderBase): @@ -25,6 +32,22 @@ def configuration(self) -> AgentAuthConfiguration: async def get_access_token(self, resource_url, scopes, force_refresh=False): return "fake-token" + def get_token_credential(self) -> AsyncTokenCredential: + async def get_token(*scopes: str, **kwargs) -> AccessToken: + return AccessToken("fake-token", _TEST_TOKEN_EXPIRATION) + + return _CallableTokenCredential(get_token) + + +@pytest.mark.asyncio +async def test_fake_provider_get_token_credential_is_synchronous(): + provider = FakeProvider(AgentAuthConfiguration()) + + credential = provider.get_token_credential() + token = await credential.get_token("scope") + + assert token == AccessToken("fake-token", _TEST_TOKEN_EXPIRATION) + ENV_CONFIG = { "CONNECTIONS": { From 2be89b2bd2a1b3348aaab979bc554e67b7b5c31c Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 31 Aug 2026 12:03:30 -0700 Subject: [PATCH 3/8] Test improvements and changelog --- changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.md b/changelog.md index e20fdd34f..efc828dbc 100644 --- a/changelog.md +++ b/changelog.md @@ -6,6 +6,7 @@ ## New Models & APIs - **MSAL Token Credential**: Added `MsalTokenCredential`, an Azure Core-compatible asynchronous token credential backed by MSAL, for authenticating Azure SDK clients that accept an `AsyncTokenCredential`. +- **Authorization Token Credentials**: Added `AsyncTokenCredential` access through `AccessTokenProviderBase` and `Authorization`, including user-token refresh and exchange flows. --- From fcf562e4e7ed8ca191d250f78e2b9d5171399d13 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 31 Aug 2026 13:44:41 -0700 Subject: [PATCH 4/8] Fixing broken test --- tests/hosting_core/authorization/test_helpers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/hosting_core/authorization/test_helpers.py b/tests/hosting_core/authorization/test_helpers.py index 7812775ef..9011f5ebf 100644 --- a/tests/hosting_core/authorization/test_helpers.py +++ b/tests/hosting_core/authorization/test_helpers.py @@ -23,7 +23,10 @@ ], ) def test_access_token_from_token_response_expiration(expiration, expected): - response = TokenResponse(token="token", expiration=expiration) + response = TokenResponse( + token="token", + **({"expiration": expiration} if expiration is not None else {}), + ) token = _access_token_from_token_response(response) From 61d3515d0ab9b2fce344cf258b7bb428ced5156b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Brand=C3=A3o?= Date: Mon, 31 Aug 2026 13:44:58 -0700 Subject: [PATCH 5/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/hosting_core/authorization/test_helpers.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/hosting_core/authorization/test_helpers.py b/tests/hosting_core/authorization/test_helpers.py index 9011f5ebf..5387dbafa 100644 --- a/tests/hosting_core/authorization/test_helpers.py +++ b/tests/hosting_core/authorization/test_helpers.py @@ -1,8 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import datetime - import pytest from azure.core.credentials import AccessToken From 6bfd41a4c1bb44c55fc4a0e2cca99a5fd6c725b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Brand=C3=A3o?= Date: Mon, 31 Aug 2026 13:46:03 -0700 Subject: [PATCH 6/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../microsoft_agents/hosting/core/app/oauth/authorization.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 330947d1b..75d5e159e 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 @@ -405,10 +405,9 @@ def get_token_as_token_credential( :param context: The context object for the current turn. :type context: :class:`microsoft_agents.hosting.core.turn_context.TurnContext` :param auth_handler_id: The ID of the auth handler to get the token for. - :type auth_handler_id: str + :type auth_handler_id: str | None :return: An AsyncTokenCredential for the specified auth handler or the default handler. - :rtype: :class:`microsoft_agents.hosting.core.app.oauth.authorization.AsyncTokenCredential` - """ + :rtype: :class:`azure.core.credentials_async.AsyncTokenCredential` async def func(*scopes: str, **_kwargs) -> TokenResponse: return await self.exchange_token( From afc0c61840feb65b10686849f8423b9cd8a579e6 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 31 Aug 2026 13:56:48 -0700 Subject: [PATCH 7/8] Another commit --- .../microsoft_agents/hosting/core/app/oauth/authorization.py | 1 + 1 file changed, 1 insertion(+) 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 75d5e159e..047150ff0 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 @@ -408,6 +408,7 @@ def get_token_as_token_credential( :type auth_handler_id: str | None :return: An AsyncTokenCredential for the specified auth handler or the default handler. :rtype: :class:`azure.core.credentials_async.AsyncTokenCredential` + """ async def func(*scopes: str, **_kwargs) -> TokenResponse: return await self.exchange_token( From a0d925ec4012c97cd6cb638845ae34b8b6f9f566 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 31 Aug 2026 14:20:54 -0700 Subject: [PATCH 8/8] removing unused imports --- .../microsoft_agents/hosting/core/app/oauth/authorization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 047150ff0..a8702fdbd 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 @@ -4,7 +4,7 @@ """ import logging -from typing import Optional, Callable, Awaitable, cast, overload, Literal +from typing import Optional, Callable, Awaitable, cast from dataclasses import dataclass from azure.core.credentials_async import AsyncTokenCredential