diff --git a/examples/agent365/src/main.py b/examples/agent365/src/main.py index 358f108c4..153e79f14 100644 --- a/examples/agent365/src/main.py +++ b/examples/agent365/src/main.py @@ -7,7 +7,7 @@ import os from dotenv import load_dotenv -from microsoft_teams.api import ClientCredentials +from microsoft_teams.api import AgenticIdentity, ClientCredentials from microsoft_teams.apps.token_manager import AGENT_BOT_API_SCOPE, TokenManager @@ -26,8 +26,7 @@ async def main(): blueprint_client_id = get_required_env("AGENT365_BLUEPRINT_CLIENT_ID") blueprint_client_secret = get_required_env("AGENT365_BLUEPRINT_CLIENT_SECRET") agentic_app_id = get_required_env("AGENT365_AGENTIC_APP_ID") - agentic_user_id = os.getenv("AGENT365_AGENTIC_USER_ID") - agentic_user_upn = os.getenv("AGENT365_AGENTIC_USER_UPN") + agentic_user_id = get_required_env("AGENT365_AGENTIC_USER_ID") scope = os.getenv("AGENT365_SCOPE", AGENT_BOT_API_SCOPE) credentials = ClientCredentials( @@ -38,11 +37,8 @@ async def main(): token_manager = TokenManager(credentials=credentials) token = await token_manager.get_agentic_token( - tenant_id, - agentic_app_id, scope, - agentic_user_id=agentic_user_id, - agentic_user_upn=agentic_user_upn, + AgenticIdentity(agentic_app_id=agentic_app_id, agentic_user_id=agentic_user_id, tenant_id=tenant_id), ) print(f"Acquired agent user token for {scope}") diff --git a/packages/api/src/microsoft_teams/api/auth/__init__.py b/packages/api/src/microsoft_teams/api/auth/__init__.py index 10f62a86b..ff54c02eb 100644 --- a/packages/api/src/microsoft_teams/api/auth/__init__.py +++ b/packages/api/src/microsoft_teams/api/auth/__init__.py @@ -10,11 +10,13 @@ ) from .cloud_environment import from_name as config_from_cloud_name from .credentials import ( + BasicTokenProvider, ClientCredentials, Credentials, FederatedIdentityCredentials, ManagedIdentityCredentials, TokenCredentials, + TokenProvider, ) from .json_web_token import JsonWebToken, JsonWebTokenPayload from .token import TokenProtocol @@ -23,12 +25,14 @@ "CallerIds", "CallerType", "CloudEnvironment", + "BasicTokenProvider", "ClientCredentials", "config_from_cloud_name", "Credentials", "FederatedIdentityCredentials", "ManagedIdentityCredentials", "TokenCredentials", + "TokenProvider", "TokenProtocol", "JsonWebToken", "JsonWebTokenPayload", diff --git a/packages/api/src/microsoft_teams/api/auth/cloud_environment.py b/packages/api/src/microsoft_teams/api/auth/cloud_environment.py index 4821ca5b8..ef7ac2ed0 100644 --- a/packages/api/src/microsoft_teams/api/auth/cloud_environment.py +++ b/packages/api/src/microsoft_teams/api/auth/cloud_environment.py @@ -21,6 +21,8 @@ class CloudEnvironment: """The default multi-tenant login tenant (e.g. "botframework.com").""" bot_scope: str """The Bot Framework OAuth scope (e.g. "https://api.botframework.com/.default").""" + agentic_bot_scope: str + """The Teams Bot API scope for Agent ID user-token calls.""" token_service_url: str """The Bot Framework token service base URL (e.g. "https://token.botframework.com").""" openid_metadata_url: str @@ -35,6 +37,7 @@ class CloudEnvironment: login_endpoint="https://login.microsoftonline.com", login_tenant="botframework.com", bot_scope="https://api.botframework.com/.default", + agentic_bot_scope="https://botapi.skype.com/.default", token_service_url="https://token.botframework.com", openid_metadata_url="https://login.botframework.com/v1/.well-known/openidconfiguration", token_issuer="https://api.botframework.com", @@ -46,6 +49,8 @@ class CloudEnvironment: login_endpoint="https://login.microsoftonline.us", login_tenant="MicrosoftServices.onmicrosoft.us", bot_scope="https://api.botframework.us/.default", + # TODO: confirm Agent ID Bot API scope for this cloud before enabling sovereign Agent ID scenarios. + agentic_bot_scope="https://botapi.skype.com/.default", token_service_url="https://tokengcch.botframework.azure.us", openid_metadata_url="https://login.botframework.azure.us/v1/.well-known/openidconfiguration", token_issuer="https://api.botframework.us", @@ -57,6 +62,8 @@ class CloudEnvironment: login_endpoint="https://login.microsoftonline.us", login_tenant="MicrosoftServices.onmicrosoft.us", bot_scope="https://api.botframework.us/.default", + # TODO: confirm Agent ID Bot API scope for this cloud before enabling sovereign Agent ID scenarios. + agentic_bot_scope="https://botapi.skype.com/.default", token_service_url="https://apiDoD.botframework.azure.us", openid_metadata_url="https://login.botframework.azure.us/v1/.well-known/openidconfiguration", token_issuer="https://api.botframework.us", @@ -68,6 +75,8 @@ class CloudEnvironment: login_endpoint="https://login.partner.microsoftonline.cn", login_tenant="microsoftservices.partner.onmschina.cn", bot_scope="https://api.botframework.azure.cn/.default", + # TODO: confirm Agent ID Bot API scope for this cloud before enabling China cloud Agent ID scenarios. + agentic_bot_scope="https://botapi.skype.com/.default", token_service_url="https://token.botframework.azure.cn", openid_metadata_url="https://login.botframework.azure.cn/v1/.well-known/openidconfiguration", token_issuer="https://api.botframework.azure.cn", diff --git a/packages/api/src/microsoft_teams/api/auth/credentials.py b/packages/api/src/microsoft_teams/api/auth/credentials.py index 471c7952c..5d3b4c3fc 100644 --- a/packages/api/src/microsoft_teams/api/auth/credentials.py +++ b/packages/api/src/microsoft_teams/api/auth/credentials.py @@ -3,9 +3,34 @@ Licensed under the MIT License. """ -from typing import Awaitable, Callable, Literal, Optional, Union +from typing import Awaitable, Callable, Literal, Optional, Protocol, TypeAlias, Union, runtime_checkable -from ..models import CustomBaseModel +from ..models import AgenticIdentity, CustomBaseModel + +TokenScope: TypeAlias = Union[str, list[str]] +TokenResult: TypeAlias = Union[str, Awaitable[str]] +BasicTokenProvider: TypeAlias = Callable[[TokenScope, Optional[str]], TokenResult] +_PositionalAgenticTokenProvider: TypeAlias = Callable[ + [TokenScope, Optional[str], Optional[AgenticIdentity]], TokenResult +] + + +@runtime_checkable +class _KeywordAgenticTokenProvider(Protocol): + def __call__( + self, + scope: TokenScope, + tenant_id: Optional[str], + *, + agentic_identity: Optional[AgenticIdentity] = None, + ) -> TokenResult: ... + + +TokenProvider: TypeAlias = Union[ + BasicTokenProvider, + _PositionalAgenticTokenProvider, + _KeywordAgenticTokenProvider, +] class ClientCredentials(CustomBaseModel): @@ -36,8 +61,8 @@ class TokenCredentials(CustomBaseModel): """ The tenant ID. """ - # (scope: string | string[], tenantId?: string) => string | Promise - token: Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]] + # (scope: string | string[], tenantId?: string, agenticIdentity?: AgenticIdentity) => string | Promise + token: TokenProvider """ The token function. """ diff --git a/packages/api/src/microsoft_teams/api/clients/_auth_provider_interceptor.py b/packages/api/src/microsoft_teams/api/clients/_auth_provider_interceptor.py new file mode 100644 index 000000000..2f97d2e2e --- /dev/null +++ b/packages/api/src/microsoft_teams/api/clients/_auth_provider_interceptor.py @@ -0,0 +1,54 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +import logging +from typing import Awaitable, Protocol, cast + +from microsoft_teams.common import InterceptorRequestContext, resolve_token +from microsoft_teams.common.http.client_token import StringLike + +from ..models.agentic_identity import AgenticIdentity + + +class AuthProvider(Protocol): + def token( + self, *, scope: str | None = None, agentic_identity: AgenticIdentity | None = None + ) -> str | StringLike | None | Awaitable[str | StringLike | None]: ... + + +AGENTIC_IDENTITY_EXTENSION = "microsoft_teams.agentic_identity" +logger = logging.getLogger(__name__) + + +class AuthProviderInterceptor: + """Adds an auth-provider token when a request has no Authorization header.""" + + def __init__( + self, + auth_provider: AuthProvider, + *, + default_agentic_identity: AgenticIdentity | None = None, + ) -> None: + self._auth_provider = auth_provider + self._default_agentic_identity = default_agentic_identity + + async def request(self, ctx: InterceptorRequestContext) -> None: + if "Authorization" in ctx.request.headers: + return + + request_agentic_identity = cast(AgenticIdentity | None, ctx.request.extensions.get(AGENTIC_IDENTITY_EXTENSION)) + agentic_identity = request_agentic_identity or self._default_agentic_identity + token = await resolve_token(lambda: self._auth_provider.token(agentic_identity=agentic_identity)) + if token is None: + return + + token = token.strip() + if not token: + logger.warning("Auth provider returned an empty token; skipping Authorization header.") + return + + ctx.request.headers["Authorization"] = f"Bearer {token}" diff --git a/packages/api/src/microsoft_teams/api/clients/api_client.py b/packages/api/src/microsoft_teams/api/clients/api_client.py index 2da68c7d5..bbc9b69a5 100644 --- a/packages/api/src/microsoft_teams/api/clients/api_client.py +++ b/packages/api/src/microsoft_teams/api/clients/api_client.py @@ -5,12 +5,15 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Union +from typing import Optional, Union, cast from microsoft_teams.common import Client as HttpClient -from microsoft_teams.common import ClientOptions +from microsoft_teams.common import ClientOptions, Interceptor -from .api_client_settings import ApiClientSettings +from ..auth.cloud_environment import PUBLIC, CloudEnvironment +from ..models import AgenticIdentity +from ._auth_provider_interceptor import AuthProvider, AuthProviderInterceptor +from .api_client_settings import ApiClientSettings, merge_api_client_settings from .base_client import BaseClient from .bot import BotClient from .conversation import ConversationClient @@ -19,9 +22,6 @@ from .team import TeamClient from .user import UserClient -if TYPE_CHECKING: - from ..auth.cloud_environment import CloudEnvironment - class ApiClient(BaseClient): """Unified client for Microsoft Teams API operations.""" @@ -32,6 +32,9 @@ def __init__( options: Optional[Union[HttpClient, ClientOptions]] = None, api_client_settings: Optional[ApiClientSettings] = None, cloud: Optional[CloudEnvironment] = None, + *, + auth_provider: Optional[AuthProvider] = None, + agentic_identity: Optional[AgenticIdentity] = None, ) -> None: """Initialize the unified Teams API client. @@ -41,12 +44,17 @@ def __init__( api_client_settings: Optional API client settings. cloud: Optional cloud environment for sovereign cloud support. """ - super().__init__(options, api_client_settings) + self._cloud = cloud or PUBLIC + merged_settings = merge_api_client_settings(api_client_settings, self._cloud) + super().__init__(options, merged_settings) self.service_url = service_url.rstrip("/") + self._auth_provider = auth_provider + self._default_agentic_identity = agentic_identity + self._apply_auth_provider_interceptor() # Initialize all client types - self.bots = BotClient(self._http, self._api_client_settings, cloud=cloud) - self.users = UserClient(self._http, self._api_client_settings) + self.bots = BotClient(self._http, self._api_client_settings, cloud=self._cloud) + self.users = UserClient(self._http, self._api_client_settings, cloud=self._cloud) self.conversations = ConversationClient(self.service_url, self._http, self._api_client_settings) self.teams = TeamClient(self.service_url, self._http, self._api_client_settings) self.meetings = MeetingClient(self.service_url, self._http, self._api_client_settings) @@ -59,6 +67,23 @@ def reactions(self) -> ReactionClient: self._reactions = ReactionClient(self.service_url, self._http, self._api_client_settings) return self._reactions + def _apply_auth_provider_interceptor(self) -> None: + if self._auth_provider is None: + return + + if any(isinstance(interceptor, AuthProviderInterceptor) for interceptor in self._http.interceptors): + return + + self._http.use_interceptor( + cast( + Interceptor, + AuthProviderInterceptor( + self._auth_provider, + default_agentic_identity=self._default_agentic_identity, + ), + ) + ) + @property def http(self) -> HttpClient: """Get the HTTP client instance.""" @@ -67,11 +92,12 @@ def http(self) -> HttpClient: @http.setter def http(self, value: HttpClient) -> None: """Set the HTTP client instance and propagate to all sub-clients.""" - self.bots.http = value - self.conversations.http = value - self.users.http = value - self.teams.http = value - self.meetings.http = value - if self._reactions is not None: - self._reactions.http = value self._http = value + self._apply_auth_provider_interceptor() + self.bots.http = self._http + self.conversations.http = self._http + self.users.http = self._http + self.teams.http = self._http + self.meetings.http = self._http + if self._reactions is not None: + self._reactions.http = self._http diff --git a/packages/api/src/microsoft_teams/api/clients/base_client.py b/packages/api/src/microsoft_teams/api/clients/base_client.py index bc5ccd8f2..4e52406fd 100644 --- a/packages/api/src/microsoft_teams/api/clients/base_client.py +++ b/packages/api/src/microsoft_teams/api/clients/base_client.py @@ -5,7 +5,7 @@ from typing import Optional, Union -from microsoft_teams.common.http import Client, ClientOptions +from microsoft_teams.common import Client, ClientOptions from .api_client_settings import ApiClientSettings, merge_api_client_settings diff --git a/packages/api/src/microsoft_teams/api/clients/bot/client.py b/packages/api/src/microsoft_teams/api/clients/bot/client.py index c4e170ae4..efacd2ec0 100644 --- a/packages/api/src/microsoft_teams/api/clients/bot/client.py +++ b/packages/api/src/microsoft_teams/api/clients/bot/client.py @@ -5,18 +5,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Union +from typing import Optional, Union from microsoft_teams.common.http import Client, ClientOptions -from ..api_client_settings import ApiClientSettings +from ...auth.cloud_environment import PUBLIC, CloudEnvironment +from ..api_client_settings import ApiClientSettings, merge_api_client_settings from ..base_client import BaseClient from .sign_in_client import BotSignInClient from .token_client import BotTokenClient -if TYPE_CHECKING: - from ...auth.cloud_environment import CloudEnvironment - class BotClient(BaseClient): """Client for managing bot operations.""" @@ -34,9 +32,11 @@ def __init__( api_client_settings: Optional API client settings. cloud: Optional cloud environment for sovereign cloud support. """ - super().__init__(options, api_client_settings) - self.token = BotTokenClient(self.http, self._api_client_settings, cloud=cloud) - self.sign_in = BotSignInClient(self.http, self._api_client_settings) + self._cloud = cloud or PUBLIC + merged_settings = merge_api_client_settings(api_client_settings, self._cloud) + super().__init__(options, merged_settings) + self.token = BotTokenClient(self.http, self._api_client_settings, cloud=self._cloud) + self.sign_in = BotSignInClient(self.http, self._api_client_settings, cloud=self._cloud) @property def http(self) -> Client: diff --git a/packages/api/src/microsoft_teams/api/clients/bot/sign_in_client.py b/packages/api/src/microsoft_teams/api/clients/bot/sign_in_client.py index a6c65ae54..1251b24c8 100644 --- a/packages/api/src/microsoft_teams/api/clients/bot/sign_in_client.py +++ b/packages/api/src/microsoft_teams/api/clients/bot/sign_in_client.py @@ -3,10 +3,13 @@ Licensed under the MIT License. """ +from __future__ import annotations + from typing import Optional, Union, cast from microsoft_teams.common.http import Client, ClientOptions +from ...auth.cloud_environment import PUBLIC, CloudEnvironment from ...models import SignInUrlResponse from ..api_client_settings import ApiClientSettings, merge_api_client_settings from ..base_client import BaseClient @@ -26,6 +29,7 @@ def __init__( self, options: Union[Client, ClientOptions, None] = None, api_client_settings: Optional[ApiClientSettings] = None, + cloud: Optional[CloudEnvironment] = None, ) -> None: """Initialize the bot sign-in client. @@ -33,8 +37,9 @@ def __init__( options: Optional Client or ClientOptions instance. api_client_settings: Optional API client settings. """ - super().__init__(options) - self._api_client_settings = merge_api_client_settings(api_client_settings) + self._cloud = cloud or PUBLIC + merged_settings = merge_api_client_settings(api_client_settings, self._cloud) + super().__init__(options, merged_settings) async def get_url(self, params: GetBotSignInUrlParams) -> str: """Get a sign-in URL. diff --git a/packages/api/src/microsoft_teams/api/clients/bot/token_client.py b/packages/api/src/microsoft_teams/api/clients/bot/token_client.py index d2479e780..2865792ca 100644 --- a/packages/api/src/microsoft_teams/api/clients/bot/token_client.py +++ b/packages/api/src/microsoft_teams/api/clients/bot/token_client.py @@ -6,7 +6,7 @@ from __future__ import annotations import inspect -from typing import TYPE_CHECKING, Literal, Optional, Union +from typing import TYPE_CHECKING, Any, Awaitable, Literal, Optional, Union, cast from microsoft_teams.api.auth.credentials import ClientCredentials from microsoft_teams.common.http import Client, ClientOptions @@ -44,7 +44,11 @@ class GetBotTokenResponse(BaseModel): class BotTokenClient(BaseClient): - """Client for managing bot tokens.""" + """Deprecated client for managing bot tokens. + + Token minting for Teams apps now happens through the MSAL-backed TokenManager + in microsoft-teams-apps. + """ def __init__( self, @@ -59,9 +63,9 @@ def __init__( api_client_settings: Optional API client settings. cloud: Optional cloud environment for sovereign cloud support. """ - super().__init__(options) self._cloud = cloud or PUBLIC - self._api_client_settings = merge_api_client_settings(api_client_settings, self._cloud) + merged_settings = merge_api_client_settings(api_client_settings, self._cloud) + super().__init__(options, merged_settings) async def get(self, credentials: Credentials) -> GetBotTokenResponse: """Get a bot token. @@ -73,10 +77,7 @@ async def get(self, credentials: Credentials) -> GetBotTokenResponse: The bot token response. """ if isinstance(credentials, TokenCredentials): - token = credentials.token( - self._cloud.bot_scope, - credentials.tenant_id, - ) + token = self._call_token_provider(credentials, self._cloud.bot_scope) if inspect.isawaitable(token): token = await token @@ -114,10 +115,7 @@ async def get_graph(self, credentials: Credentials) -> GetBotTokenResponse: The bot token response. """ if isinstance(credentials, TokenCredentials): - token = credentials.token( - self._cloud.graph_scope, - credentials.tenant_id, - ) + token = self._call_token_provider(credentials, self._cloud.graph_scope) if inspect.isawaitable(token): token = await token @@ -144,3 +142,30 @@ async def get_graph(self, credentials: Credentials) -> GetBotTokenResponse: ) return GetBotTokenResponse.model_validate(res.json()) + + def _call_token_provider(self, credentials: TokenCredentials, scope: str) -> str | Awaitable[str]: + token_provider = cast(Any, credentials.token) + try: + parameters = list(inspect.signature(token_provider).parameters.values()) + except (TypeError, ValueError): + return cast(str | Awaitable[str], token_provider(scope, credentials.tenant_id)) + + accepts_agentic_identity = any( + parameter.kind == inspect.Parameter.VAR_KEYWORD or parameter.name == "agentic_identity" + for parameter in parameters + ) + if accepts_agentic_identity: + return cast(str | Awaitable[str], token_provider(scope, credentials.tenant_id, agentic_identity=None)) + + positional_parameters = [ + parameter + for parameter in parameters + if parameter.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + ] + required_positional_parameters = [ + parameter for parameter in positional_parameters if parameter.default is inspect.Parameter.empty + ] + if len(required_positional_parameters) >= 3: + return cast(str | Awaitable[str], token_provider(scope, credentials.tenant_id, None)) + + return cast(str | Awaitable[str], token_provider(scope, credentials.tenant_id)) diff --git a/packages/api/src/microsoft_teams/api/clients/user/client.py b/packages/api/src/microsoft_teams/api/clients/user/client.py index f0177fe76..2881a7729 100644 --- a/packages/api/src/microsoft_teams/api/clients/user/client.py +++ b/packages/api/src/microsoft_teams/api/clients/user/client.py @@ -3,11 +3,14 @@ Licensed under the MIT License. """ +from __future__ import annotations + from typing import Optional, Union from microsoft_teams.common.http import Client, ClientOptions -from ..api_client_settings import ApiClientSettings +from ...auth.cloud_environment import PUBLIC, CloudEnvironment +from ..api_client_settings import ApiClientSettings, merge_api_client_settings from ..base_client import BaseClient from .token_client import UserTokenClient @@ -19,6 +22,8 @@ def __init__( self, options: Optional[Union[Client, ClientOptions]] = None, api_client_settings: Optional[ApiClientSettings] = None, + *, + cloud: Optional[CloudEnvironment] = None, ) -> None: """ Initialize the UserClient. @@ -27,8 +32,10 @@ def __init__( options: Optional Client or ClientOptions instance. If not provided, a default Client will be created. api_client_settings: Optional API client settings. """ - super().__init__(options, api_client_settings) - self.token = UserTokenClient(self.http, self._api_client_settings) + self._cloud = cloud or PUBLIC + merged_settings = merge_api_client_settings(api_client_settings, self._cloud) + super().__init__(options, merged_settings) + self.token = UserTokenClient(self.http, self._api_client_settings, cloud=self._cloud) @property def http(self) -> Client: diff --git a/packages/api/src/microsoft_teams/api/clients/user/token_client.py b/packages/api/src/microsoft_teams/api/clients/user/token_client.py index acab73ba3..473496c45 100644 --- a/packages/api/src/microsoft_teams/api/clients/user/token_client.py +++ b/packages/api/src/microsoft_teams/api/clients/user/token_client.py @@ -3,10 +3,13 @@ Licensed under the MIT License. """ +from __future__ import annotations + from typing import Dict, List, Optional, Union from microsoft_teams.common.http import Client, ClientOptions +from ...auth.cloud_environment import PUBLIC, CloudEnvironment from ...models import TokenResponse, TokenStatus from ..api_client_settings import ApiClientSettings, merge_api_client_settings from ..base_client import BaseClient @@ -35,6 +38,8 @@ def __init__( self, options: Optional[Union[Client, ClientOptions]] = None, api_client_settings: Optional[ApiClientSettings] = None, + *, + cloud: Optional[CloudEnvironment] = None, ) -> None: """ Initialize the UserTokenClient. @@ -43,8 +48,9 @@ def __init__( options: Optional Client or ClientOptions instance. If not provided, a default Client will be created. api_client_settings: Optional API client settings. """ - super().__init__(options) - self._api_client_settings = merge_api_client_settings(api_client_settings) + self._cloud = cloud or PUBLIC + merged_settings = merge_api_client_settings(api_client_settings, self._cloud) + super().__init__(options, merged_settings) async def get(self, params: GetUserTokenParams) -> TokenResponse: """ diff --git a/packages/api/src/microsoft_teams/api/models/__init__.py b/packages/api/src/microsoft_teams/api/models/__init__.py index df7e229fd..b00644124 100644 --- a/packages/api/src/microsoft_teams/api/models/__init__.py +++ b/packages/api/src/microsoft_teams/api/models/__init__.py @@ -27,6 +27,7 @@ from .activity import Activity as ActivityBase from .activity import ActivityInput as ActivityInputBase from .adaptive_card import * # noqa: F403 +from .agentic_identity import AgenticIdentity from .app_based_link_query import AppBasedLinkQuery from .attachment import * # noqa: F403 from .cache_info import CacheInfo @@ -75,6 +76,7 @@ "Action", "ActivityBase", "ActivityInputBase", + "AgenticIdentity", "AppBasedLinkQuery", "CacheInfo", "ChannelID", diff --git a/packages/api/src/microsoft_teams/api/models/account.py b/packages/api/src/microsoft_teams/api/models/account.py index 9c58c8f2b..33fad58e0 100644 --- a/packages/api/src/microsoft_teams/api/models/account.py +++ b/packages/api/src/microsoft_teams/api/models/account.py @@ -7,9 +7,11 @@ from pydantic import AliasChoices, Field +from .agentic_identity import AgenticIdentity from .custom_base_model import CustomBaseModel AccountType = Literal["person", "tag", "channel", "team", "bot"] +AccountRole = Literal["agenticUser"] class Account(CustomBaseModel): @@ -44,6 +46,30 @@ class Account(CustomBaseModel): """ The name of the account. """ + role: Optional[AccountRole | str] = None + """The role of the account in the activity.""" + agentic_user_id: Optional[str] = None + """The Agent ID user-shaped identity object ID.""" + agentic_app_id: Optional[str] = None + """The Agent ID app/client ID for the concrete agent identity.""" + agentic_app_blueprint_id: Optional[str] = None + """The Agent ID blueprint app/client ID.""" + callback_uri: Optional[str] = None + """The callback URI associated with the agent identity.""" + tenant_id: Optional[str] = None + """The tenant ID associated with the account.""" + + @property + def agentic_identity(self) -> Optional[AgenticIdentity]: + if self.agentic_app_id is None or self.agentic_user_id is None: + return None + + return AgenticIdentity( + agentic_app_id=self.agentic_app_id, + agentic_user_id=self.agentic_user_id, + tenant_id=self.tenant_id, + agentic_app_blueprint_id=self.agentic_app_blueprint_id, + ) class TeamsChannelAccount(CustomBaseModel): diff --git a/packages/api/src/microsoft_teams/api/models/agentic_identity.py b/packages/api/src/microsoft_teams/api/models/agentic_identity.py new file mode 100644 index 000000000..dcf893614 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/models/agentic_identity.py @@ -0,0 +1,19 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AgenticIdentity: + """Identifies an Agent ID user-shaped identity and its backing agent app.""" + + agentic_app_id: str + agentic_user_id: str + tenant_id: str | None = None + agentic_app_blueprint_id: str | None = None + + +__all__ = ["AgenticIdentity"] diff --git a/packages/api/tests/unit/test_base_client.py b/packages/api/tests/unit/test_base_client.py new file mode 100644 index 000000000..8f259cbd1 --- /dev/null +++ b/packages/api/tests/unit/test_base_client.py @@ -0,0 +1,224 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import logging +from typing import cast + +import httpx +import pytest +from microsoft_teams.api.auth.cloud_environment import US_GOV +from microsoft_teams.api.clients import ApiClient +from microsoft_teams.api.clients._auth_provider_interceptor import AGENTIC_IDENTITY_EXTENSION, AuthProviderInterceptor +from microsoft_teams.api.clients.base_client import BaseClient +from microsoft_teams.api.models import AgenticIdentity +from microsoft_teams.common import Client, ClientOptions, Interceptor, Token + + +class RequestRecorder: + def __init__(self): + self.requests: list[httpx.Request] = [] + + def handler(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + return httpx.Response(200, json={"ok": True}, headers={"content-type": "application/json"}) + + @property + def last_request(self) -> httpx.Request: + return self.requests[-1] + + +class RecordingAuthProvider: + def __init__(self, token_value: str | None = "auth-provider-token"): + self._token_value = token_value + self.calls: list[tuple[str | None, AgenticIdentity | None]] = [] + + def token( + self, + *, + scope: str | None = None, + agentic_identity: AgenticIdentity | None = None, + ) -> str | None: + self.calls.append((scope, agentic_identity)) + return self._token_value + + +class HarnessClient(BaseClient): + async def post_resource( + self, + *, + token: Token | None = None, + agentic_identity: AgenticIdentity | None = None, + headers: dict[str, str] | None = None, + ) -> httpx.Response: + return await self.http.post( + "/resource", + json={"ok": True}, + headers=headers, + token=token, + extensions={AGENTIC_IDENTITY_EXTENSION: agentic_identity}, + ) + + +def create_client(*, default_token: Token | None = None) -> tuple[Client, RequestRecorder]: + recorder = RequestRecorder() + client = Client(ClientOptions(base_url="https://mock.api.com", token=default_token)) + client.http._transport = httpx.MockTransport(recorder.handler) + return client, recorder + + +def use_auth_provider( + http_client: Client, + auth_provider: RecordingAuthProvider, + default_agentic_identity: AgenticIdentity | None = None, +) -> None: + http_client.use_interceptor( + cast(Interceptor, AuthProviderInterceptor(auth_provider, default_agentic_identity=default_agentic_identity)) + ) + + +def test_api_client_adds_auth_provider_interceptor_once_for_shared_http_client(): + http_client, _ = create_client() + auth_provider = RecordingAuthProvider() + + ApiClient("https://test.service.url", http_client, auth_provider=auth_provider) + ApiClient("https://test.service.url", http_client, auth_provider=auth_provider) + + assert len(http_client.http.event_hooks["request"]) == 1 + + +def test_api_client_uses_cloud_token_service_url_for_default_settings(): + client = ApiClient("https://test.service.url", cloud=US_GOV) + + assert client._api_client_settings.oauth_url == US_GOV.token_service_url + + +@pytest.mark.asyncio +async def test_explicit_request_token_wins_over_auth_provider_and_http_client_token(): + http_client, recorder = create_client(default_token="http-client-token") + auth_provider = RecordingAuthProvider() + use_auth_provider(http_client, auth_provider) + client = HarnessClient(http_client) + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + + await client.post_resource(token="explicit-token", agentic_identity=identity) + + assert auth_provider.calls == [] + assert recorder.last_request.headers["authorization"] == "Bearer explicit-token" + + +@pytest.mark.asyncio +async def test_explicit_authorization_header_wins_over_auth_provider(): + http_client, recorder = create_client() + auth_provider = RecordingAuthProvider() + use_auth_provider(http_client, auth_provider) + client = HarnessClient(http_client) + + await client.post_resource(headers={"Authorization": "Bearer explicit-header-token"}) + + assert auth_provider.calls == [] + assert recorder.last_request.headers["authorization"] == "Bearer explicit-header-token" + + +@pytest.mark.asyncio +async def test_http_client_token_wins_over_auth_provider_token(): + http_client, recorder = create_client(default_token="http-client-token") + auth_provider = RecordingAuthProvider() + use_auth_provider(http_client, auth_provider) + client = HarnessClient(http_client) + + await client.post_resource() + + assert auth_provider.calls == [] + assert recorder.last_request.headers["authorization"] == "Bearer http-client-token" + + +@pytest.mark.asyncio +async def test_auth_provider_token_is_used_when_request_has_no_auth(): + http_client, recorder = create_client() + auth_provider = RecordingAuthProvider() + use_auth_provider(http_client, auth_provider) + client = HarnessClient(http_client) + + await client.post_resource() + + assert auth_provider.calls == [(None, None)] + assert recorder.last_request.headers["authorization"] == "Bearer auth-provider-token" + + +@pytest.mark.asyncio +async def test_no_authorization_is_added_when_auth_provider_returns_none(): + http_client, recorder = create_client() + auth_provider = RecordingAuthProvider(token_value=None) + use_auth_provider(http_client, auth_provider) + client = HarnessClient(http_client) + + await client.post_resource() + + assert auth_provider.calls == [(None, None)] + assert "authorization" not in recorder.last_request.headers + + +@pytest.mark.asyncio +async def test_no_authorization_is_added_when_auth_provider_returns_blank_token(caplog): + http_client, recorder = create_client() + auth_provider = RecordingAuthProvider(token_value=" ") + use_auth_provider(http_client, auth_provider) + client = HarnessClient(http_client) + + with caplog.at_level(logging.WARNING): + await client.post_resource() + + assert auth_provider.calls == [(None, None)] + assert "authorization" not in recorder.last_request.headers + assert "Auth provider returned an empty token" in caplog.text + + +@pytest.mark.asyncio +async def test_http_client_token_is_used_when_no_auth_provider(): + http_client, recorder = create_client(default_token="http-client-token") + client = HarnessClient(http_client) + + await client.post_resource() + + assert recorder.last_request.headers["authorization"] == "Bearer http-client-token" + + +@pytest.mark.asyncio +async def test_agentic_identity_is_passed_to_auth_provider_interceptor(): + http_client, recorder = create_client() + auth_provider = RecordingAuthProvider(token_value="agentic-token") + use_auth_provider(http_client, auth_provider) + client = HarnessClient(http_client) + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + + await client.post_resource(agentic_identity=identity) + + assert auth_provider.calls == [(None, identity)] + assert recorder.last_request.headers["authorization"] == "Bearer agentic-token" + + +@pytest.mark.asyncio +async def test_default_agentic_identity_is_passed_to_auth_provider_interceptor(): + http_client, recorder = create_client() + auth_provider = RecordingAuthProvider(token_value="agentic-token") + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + use_auth_provider(http_client, auth_provider, default_agentic_identity=identity) + client = HarnessClient(http_client) + + await client.post_resource() + + assert auth_provider.calls == [(None, identity)] + assert recorder.last_request.headers["authorization"] == "Bearer agentic-token" + + +@pytest.mark.asyncio +async def test_agentic_identity_without_auth_provider_uses_http_client_token(): + http_client, recorder = create_client(default_token="http-client-token") + client = HarnessClient(http_client) + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + + await client.post_resource(agentic_identity=identity) + + assert recorder.last_request.headers["authorization"] == "Bearer http-client-token" diff --git a/packages/api/tests/unit/test_bot_client.py b/packages/api/tests/unit/test_bot_client.py index 3e2b17c28..c6a2e9ce4 100644 --- a/packages/api/tests/unit/test_bot_client.py +++ b/packages/api/tests/unit/test_bot_client.py @@ -5,7 +5,14 @@ # pyright: basic import pytest -from microsoft_teams.api import ApiClientSettings, BotClient, GetBotSignInResourceParams, GetBotSignInUrlParams +from microsoft_teams.api import ( + ApiClient, + ApiClientSettings, + BotClient, + GetBotSignInResourceParams, + GetBotSignInUrlParams, +) +from microsoft_teams.api.auth.credentials import TokenCredentials from microsoft_teams.common.http import Client, ClientOptions @@ -43,6 +50,57 @@ async def test_bot_token_get_graph_with_token_credentials(self, mock_http_client assert response.access_token is not None assert response.expires_in == -1 + @pytest.mark.asyncio + async def test_bot_token_get_with_uninspectable_token_provider_signature(self, mock_http_client): + from unittest.mock import patch + + calls = [] + + def token_provider(scope, tenant_id): + calls.append((scope, tenant_id)) + return "token" + + credentials = TokenCredentials(client_id="client-id", tenant_id="tenant-id", token=token_provider) + client = BotClient(mock_http_client) + + with patch("inspect.signature", side_effect=ValueError("no signature")): + response = await client.token.get(credentials) + + assert response.access_token == "token" + assert calls == [("https://api.botframework.com/.default", "tenant-id")] + + @pytest.mark.asyncio + async def test_bot_token_get_with_positional_agentic_identity_provider(self, mock_http_client): + calls = [] + + def token_provider(scope, tenant_id, agentic_identity): + calls.append((scope, tenant_id, agentic_identity)) + return "token" + + credentials = TokenCredentials(client_id="client-id", tenant_id="tenant-id", token=token_provider) + client = BotClient(mock_http_client) + + response = await client.token.get(credentials) + + assert response.access_token == "token" + assert calls == [("https://api.botframework.com/.default", "tenant-id", None)] + + @pytest.mark.asyncio + async def test_bot_token_get_with_optional_third_argument_uses_default(self, mock_http_client): + calls = [] + + def token_provider(scope, tenant_id, timeout=30): + calls.append((scope, tenant_id, timeout)) + return "token" + + credentials = TokenCredentials(client_id="client-id", tenant_id="tenant-id", token=token_provider) + client = BotClient(mock_http_client) + + response = await client.token.get(credentials) + + assert response.access_token == "token" + assert calls == [("https://api.botframework.com/.default", "tenant-id", 30)] + @pytest.mark.asyncio async def test_bot_sign_in_get_url(self, mock_http_client): client = BotClient(mock_http_client) @@ -65,6 +123,24 @@ async def test_bot_sign_in_get_resource(self, mock_http_client): assert response.sign_in_link.startswith("http") assert response.token_exchange_resource is not None + @pytest.mark.asyncio + async def test_bot_sign_in_uses_auth_provider_for_bot_token(self, request_capture): + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "bot-token" + + client = ApiClient("https://test.service.url", request_capture, auth_provider=TestAuthProvider()) + params = GetBotSignInResourceParams(state="test_state", code_challenge="test_challenge") + + await client.bots.sign_in.get_resource(params) + + assert calls == [(None, None)] + request = request_capture._capture.last_request + assert request.headers["authorization"] == "Bearer bot-token" + @pytest.mark.unit class TestBotClientHttpClientSharing: @@ -106,6 +182,12 @@ def test_bot_token_client_receives_cloud(self): assert client.token._cloud.bot_scope == "https://api.botframework.us/.default" assert client.token._cloud.login_endpoint == "https://login.microsoftonline.us" + def test_bot_sign_in_client_uses_cloud_token_service_url(self): + from microsoft_teams.api.auth.cloud_environment import US_GOV + + client = BotClient(cloud=US_GOV) + assert client.sign_in._api_client_settings.oauth_url == US_GOV.token_service_url + def test_bot_token_client_defaults_to_public(self): from microsoft_teams.api.auth.cloud_environment import PUBLIC diff --git a/packages/api/tests/unit/test_user_client.py b/packages/api/tests/unit/test_user_client.py index cfbfee5ef..99b17f8ee 100644 --- a/packages/api/tests/unit/test_user_client.py +++ b/packages/api/tests/unit/test_user_client.py @@ -7,6 +7,7 @@ import pytest from microsoft_teams.api import ( + ApiClient, ApiClientSettings, ExchangeUserTokenParams, GetUserAADTokenParams, @@ -37,6 +38,26 @@ async def test_user_token_get(self, mock_http_client): assert response.token == "mock_access_token_123" assert response.connection_name == "test_connection" + @pytest.mark.asyncio + async def test_user_token_get_uses_auth_provider_for_bot_token(self, mock_http_client): + calls = [] + + class TestAuthProvider: + def token(self, *, scope=None, agentic_identity=None): + calls.append((scope, agentic_identity)) + return "bot-token" + + client = ApiClient("https://test.service.url", mock_http_client, auth_provider=TestAuthProvider()).users + params = GetUserTokenParams( + user_id="test_user_id", + connection_name="test_connection", + channel_id="test_channel_id", + ) + + await client.token.get(params) + + assert calls == [(None, None)] + @pytest.mark.asyncio async def test_user_token_get_aad(self, mock_http_client): client = UserClient(mock_http_client) @@ -126,6 +147,15 @@ def test_http_client_update_propagates(self, mock_http_client): assert client.token.http == new_http_client +@pytest.mark.unit +class TestUserClientSovereignCloud: + def test_user_token_client_uses_cloud_token_service_url(self): + from microsoft_teams.api.auth.cloud_environment import US_GOV + + client = UserClient(cloud=US_GOV) + assert client.token._api_client_settings.oauth_url == US_GOV.token_service_url + + @pytest.mark.unit class TestUserClientRegionalEndpoints: @pytest.mark.asyncio diff --git a/packages/apps/pyproject.toml b/packages/apps/pyproject.toml index 6a2b33c7f..336c07c54 100644 --- a/packages/apps/pyproject.toml +++ b/packages/apps/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "microsoft-teams-api", "microsoft-teams-common", "uvicorn>=0.34.3", - "cryptography>=3.4.0", + "cryptography>=48.0.1", "pyjwt[crypto]>=2.12.0", "dependency-injector>=4.48.1", "msal>=1.37.0", diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index c8e93be73..5da9a8a70 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -42,6 +42,7 @@ from .app_process import ActivityProcessor from .auth import TokenValidator from .auth.remote_function_jwt_middleware import validate_remote_function_request +from .auth_provider import AppAuthProvider from .container import Container from .contexts.function_context import FunctionContext from .events import ( @@ -100,6 +101,7 @@ def __init__(self, **options: Unpack[AppOptions]): credentials=self.credentials, cloud=self.cloud, ) + self._auth_provider = AppAuthProvider(self._token_manager, self.cloud) self.container = Container() self.container.set_provider("storage", providers.Object(self.storage)) @@ -110,9 +112,10 @@ def __init__(self, **options: Unpack[AppOptions]): self.api = ApiClient( service_url, - self.http_client.clone(ClientOptions(token=self._get_bot_token)), + self.http_client.clone(), self.options.api_client_settings, cloud=self.cloud, + auth_provider=self._auth_provider, ) plugins: List[PluginBase] = list(self.options.plugins) diff --git a/packages/apps/src/microsoft_teams/apps/auth_provider.py b/packages/apps/src/microsoft_teams/apps/auth_provider.py new file mode 100644 index 000000000..69cc65a1b --- /dev/null +++ b/packages/apps/src/microsoft_teams/apps/auth_provider.py @@ -0,0 +1,35 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from microsoft_teams.api import AgenticIdentity, TokenProtocol +from microsoft_teams.api.auth.cloud_environment import CloudEnvironment + +from .token_manager import TokenManager + + +class AppAuthProvider: + """Provides app and agentic tokens for Teams API clients.""" + + def __init__(self, token_manager: TokenManager, cloud: CloudEnvironment): + self._token_manager = token_manager + self._cloud = cloud + + async def token( + self, *, scope: str | None = None, agentic_identity: AgenticIdentity | None = None + ) -> TokenProtocol | None: + if agentic_identity is None: + return await self._token_manager.get_app_token( + scope or self._cloud.bot_scope, + caller_name="token", + ) + + return await self._token_manager.get_agentic_token( + scope or self._cloud.agentic_bot_scope, + agentic_identity, + caller_name="token", + ) + + +__all__ = ["AppAuthProvider"] diff --git a/packages/apps/src/microsoft_teams/apps/options.py b/packages/apps/src/microsoft_teams/apps/options.py index c9d7a363f..b3bf46228 100644 --- a/packages/apps/src/microsoft_teams/apps/options.py +++ b/packages/apps/src/microsoft_teams/apps/options.py @@ -6,10 +6,11 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Awaitable, Callable, List, Optional, TypedDict, Union, cast +from typing import Any, List, Optional, TypedDict, Union, cast from microsoft_teams.api import ApiClientSettings from microsoft_teams.api.auth.cloud_environment import CloudEnvironment +from microsoft_teams.api.auth.credentials import TokenProvider from microsoft_teams.common import Client, ClientOptions, Storage from typing_extensions import Unpack @@ -30,7 +31,7 @@ class AppOptions(TypedDict, total=False): """Application ID URI from the Azure portal. Used for user authentication. Matches webApplicationInfo.resource in the app manifest.""" # Custom token provider function - token: Optional[Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]]] + token: Optional[TokenProvider] """Custom token provider function. If provided with client_id (no client_secret), uses TokenCredentials.""" # Managed identity configuration (used when client_id provided without client_secret or token) @@ -111,7 +112,7 @@ class InternalAppOptions: application_id_uri: Optional[str] = None """Application ID URI from the Azure portal. Used for user authentication. Matches webApplicationInfo.resource in the app manifest.""" - token: Optional[Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]]] = None + token: Optional[TokenProvider] = None """Custom token provider function. If provided with client_id (no client_secret), uses TokenCredentials.""" managed_identity_client_id: Optional[str] = None """ diff --git a/packages/apps/src/microsoft_teams/apps/token_manager.py b/packages/apps/src/microsoft_teams/apps/token_manager.py index 18eb00f30..9da102ece 100644 --- a/packages/apps/src/microsoft_teams/apps/token_manager.py +++ b/packages/apps/src/microsoft_teams/apps/token_manager.py @@ -5,11 +5,12 @@ import asyncio import logging -from inspect import isawaitable -from typing import Any, Callable, Optional +from inspect import Parameter, isawaitable, signature +from typing import Any, Awaitable, Callable, Optional, cast import requests from microsoft_teams.api import ( + AgenticIdentity, ClientCredentials, Credentials, JsonWebToken, @@ -52,8 +53,24 @@ def __init__( async def get_bot_token(self) -> Optional[TokenProtocol]: """Refresh the bot authentication token.""" + return await self.get_app_token(self._cloud.bot_scope, default_tenant_id=self._cloud.login_tenant) + + async def get_app_token( + self, + scope: str, + tenant_id: Optional[str] = None, + *, + default_tenant_id: str | None = None, + caller_name: str | None = None, + ) -> Optional[TokenProtocol]: + """Get an app token for the requested scope.""" + resolved_tenant_id = self._resolve_tenant_id(tenant_id, default_tenant_id or self._cloud.login_tenant) + if resolved_tenant_id is None: + raise ValueError("tenant_id is required to get an app token") return await self._get_token( - self._cloud.bot_scope, tenant_id=self._resolve_tenant_id(None, self._cloud.login_tenant) + scope, + tenant_id=resolved_tenant_id, + caller_name=caller_name, ) async def get_graph_token(self, tenant_id: Optional[str] = None) -> Optional[TokenProtocol]: @@ -67,38 +84,42 @@ async def get_graph_token(self, tenant_id: Optional[str] = None) -> Optional[Tok Returns: The graph token or None if not available """ - return await self._get_token( - self._cloud.graph_scope, tenant_id=self._resolve_tenant_id(tenant_id, DEFAULT_TENANT_FOR_GRAPH_TOKEN) + return await self.get_app_token( + self._cloud.graph_scope, + tenant_id=tenant_id, + default_tenant_id=DEFAULT_TENANT_FOR_GRAPH_TOKEN, ) async def get_agentic_token( self, - tenant_id: str, - agentic_app_id: str, scope: str, + agentic_identity: AgenticIdentity, *, - agentic_user_id: str | None = None, - agentic_user_upn: str | None = None, caller_name: str | None = None, ) -> Optional[TokenProtocol]: """Get a resource token for an agentic identity acting through its agentic user.""" - if not agentic_user_id and not agentic_user_upn: - raise ValueError("Either agentic_user_id or agentic_user_upn must be provided") - if agentic_user_id and agentic_user_upn: - raise ValueError("agentic_user_id and agentic_user_upn are mutually exclusive") + if not agentic_identity.agentic_user_id: + raise ValueError("agentic_identity.agentic_user_id is required to get an agentic token") if self._credentials is None: if caller_name: logger.debug(f"No credentials provided for {caller_name}") return None + tenant_id = self._resolve_tenant_id(agentic_identity.tenant_id, None) + if tenant_id is None: + raise ValueError("tenant_id is required to get an agentic token") + credentials = self._credentials + if isinstance(credentials, TokenCredentials): + return await self._get_token_with_token_provider(credentials, scope, tenant_id, agentic_identity) + if not isinstance(credentials, ClientCredentials): raise ValueError("Agent user tokens require ClientCredentials") confidential_client = self._get_confidential_client(credentials, tenant_id) def get_t1_assertion(_context: dict[str, Any]) -> str: t1_raw: dict[str, Any] = confidential_client.acquire_token_for_client( - [TOKEN_EXCHANGE_SCOPE], fmi_path=agentic_app_id + [TOKEN_EXCHANGE_SCOPE], fmi_path=agentic_identity.agentic_app_id ) return self._get_access_token_or_raise(t1_raw, "Agent token exchange step 1 failed") @@ -106,7 +127,7 @@ def get_t1_assertion(_context: dict[str, Any]) -> str: # Identity assertion from step 1 as its client assertion for the next exchanges. t2_confidential_client = self._get_agent_identity_client( tenant_id, - agentic_app_id, + agentic_identity.agentic_app_id, get_t1_assertion, ) @@ -120,8 +141,8 @@ def get_t1_assertion(_context: dict[str, Any]) -> str: lambda: t2_confidential_client.acquire_token_by_user_federated_identity_credential( [scope], assertion=t2, - user_object_id=agentic_user_id, - username=agentic_user_upn, + user_object_id=agentic_identity.agentic_user_id, + username=None, data={"requested_token_use": "on_behalf_of"}, ) ) @@ -227,9 +248,10 @@ async def _get_token_with_token_provider( credentials: TokenCredentials, scope: str, tenant_id: str, + agentic_identity: AgenticIdentity | None = None, ) -> TokenProtocol: """Get token using custom token provider function.""" - token = credentials.token(scope, tenant_id) + token = self._call_token_provider(credentials, scope, tenant_id, agentic_identity) if isawaitable(token): access_token = await token @@ -238,6 +260,45 @@ async def _get_token_with_token_provider( return JsonWebToken(access_token) + def _call_token_provider( + self, + credentials: TokenCredentials, + scope: str, + tenant_id: str, + agentic_identity: AgenticIdentity | None = None, + ) -> str | Awaitable[str]: + token_provider = cast(Any, credentials.token) + try: + parameters = list(signature(token_provider).parameters.values()) + except (TypeError, ValueError) as error: + if agentic_identity is not None: + raise ValueError("Token provider must accept agentic_identity to mint agentic tokens") from error + return cast(str | Awaitable[str], token_provider(scope, tenant_id)) + + accepts_agentic_identity = any( + parameter.kind == Parameter.VAR_KEYWORD or parameter.name == "agentic_identity" for parameter in parameters + ) + if accepts_agentic_identity: + return cast(str | Awaitable[str], token_provider(scope, tenant_id, agentic_identity=agentic_identity)) + + positional_parameters = [ + parameter + for parameter in parameters + if parameter.kind in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD) + ] + required_positional_parameters = [ + parameter for parameter in positional_parameters if parameter.default is Parameter.empty + ] + if len(positional_parameters) >= 3 and ( + agentic_identity is not None or len(required_positional_parameters) >= 3 + ): + return cast(str | Awaitable[str], token_provider(scope, tenant_id, agentic_identity)) + + if agentic_identity is not None: + raise ValueError("Token provider must accept agentic_identity to mint agentic tokens") + + return cast(str | Awaitable[str], token_provider(scope, tenant_id)) + def _handle_token_response(self, token_res: dict[str, Any], error_prefix: str = "") -> TokenProtocol: """Handle token response from MSAL client.""" if token_res.get("access_token", None): @@ -332,5 +393,5 @@ def _get_managed_identity_client( ) return self._managed_identity_client - def _resolve_tenant_id(self, tenant_id: str | None, default_tenant_id: str): - return tenant_id or (self._credentials.tenant_id if self._credentials else False) or default_tenant_id + def _resolve_tenant_id(self, tenant_id: str | None, default_tenant_id: str | None) -> str | None: + return tenant_id or (self._credentials.tenant_id if self._credentials else None) or default_tenant_id diff --git a/packages/apps/tests/test_token_manager.py b/packages/apps/tests/test_token_manager.py index a60e54030..ac24f7034 100644 --- a/packages/apps/tests/test_token_manager.py +++ b/packages/apps/tests/test_token_manager.py @@ -6,16 +6,19 @@ # pyright: basic from typing import Literal, cast -from unittest.mock import MagicMock, create_autospec, patch +from unittest.mock import AsyncMock, MagicMock, create_autospec, patch import pytest from microsoft_teams.api import ( + AgenticIdentity, ClientCredentials, FederatedIdentityCredentials, JsonWebToken, ManagedIdentityCredentials, ) +from microsoft_teams.api.auth.cloud_environment import PUBLIC from microsoft_teams.api.auth.credentials import TokenCredentials +from microsoft_teams.apps.auth_provider import AppAuthProvider from microsoft_teams.apps.token_manager import AGENT_BOT_API_SCOPE, TOKEN_EXCHANGE_SCOPE, TokenManager from msal import ManagedIdentityClient # pyright: ignore[reportMissingTypeStubs] @@ -53,10 +56,8 @@ async def test_get_agentic_token_uses_agent_identity_flow(self): manager = TokenManager(credentials=mock_credentials) token = await manager.get_agentic_token( - "tenant-id", - "agentic-app-id", AGENT_BOT_API_SCOPE, - agentic_user_id="agentic-user-id", + AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id"), ) assert token is not None @@ -104,14 +105,194 @@ async def test_get_agentic_token_caches_agent_identity_client(self): manager = TokenManager(credentials=mock_credentials) await manager.get_agentic_token( - "tenant-id", "agentic-app-id", AGENT_BOT_API_SCOPE, agentic_user_id="agentic-user-id" + AGENT_BOT_API_SCOPE, AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") ) await manager.get_agentic_token( - "tenant-id", "agentic-app-id", AGENT_BOT_API_SCOPE, agentic_user_id="agentic-user-id" + AGENT_BOT_API_SCOPE, AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") ) assert mock_confidential_app.call_count == 2 + @pytest.mark.asyncio + async def test_get_agentic_token_with_token_credentials_passes_agentic_identity(self): + calls = [] + + async def token_provider(scope: str, tenant_id: str | None, *, agentic_identity: AgenticIdentity | None): + calls.append((scope, tenant_id, agentic_identity)) + return VALID_TEST_TOKEN + + credentials = TokenCredentials(client_id="blueprint-client-id", token=token_provider, tenant_id="tenant-id") + manager = TokenManager(credentials=credentials) + + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + token = await manager.get_agentic_token(AGENT_BOT_API_SCOPE, identity) + + assert token is not None + assert str(token) == VALID_TEST_TOKEN + assert calls == [(AGENT_BOT_API_SCOPE, "tenant-id", identity)] + + @pytest.mark.asyncio + async def test_get_agentic_token_with_token_credentials_accepts_positional_identity(self): + calls = [] + + def token_provider(scope: str, tenant_id: str | None, identity: AgenticIdentity | None): + calls.append((scope, tenant_id, identity)) + return VALID_TEST_TOKEN + + credentials = TokenCredentials(client_id="blueprint-client-id", token=token_provider, tenant_id="tenant-id") + manager = TokenManager(credentials=credentials) + + identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + token = await manager.get_agentic_token(AGENT_BOT_API_SCOPE, identity) + + assert token is not None + assert str(token) == VALID_TEST_TOKEN + assert calls == [(AGENT_BOT_API_SCOPE, "tenant-id", identity)] + + @pytest.mark.asyncio + async def test_get_token_with_required_third_argument_passes_none_for_non_agentic_token(self): + calls = [] + + def token_provider(scope: str, tenant_id: str | None, identity: AgenticIdentity | None): + calls.append((scope, tenant_id, identity)) + return VALID_TEST_TOKEN + + credentials = TokenCredentials(client_id="test-client-id", token=token_provider, tenant_id="tenant-id") + manager = TokenManager(credentials=credentials) + + token = await manager._get_token_with_token_provider(credentials, AGENT_BOT_API_SCOPE, "tenant-id") + + assert str(token) == VALID_TEST_TOKEN + assert calls == [(AGENT_BOT_API_SCOPE, "tenant-id", None)] + + @pytest.mark.asyncio + async def test_get_token_with_optional_third_argument_uses_default_for_non_agentic_token(self): + calls = [] + + def token_provider(scope: str, tenant_id: str | None, timeout: int = 30): + calls.append((scope, tenant_id, timeout)) + return VALID_TEST_TOKEN + + credentials = TokenCredentials(client_id="test-client-id", token=token_provider, tenant_id="tenant-id") + manager = TokenManager(credentials=credentials) + + token = await manager._get_token_with_token_provider(credentials, AGENT_BOT_API_SCOPE, "tenant-id") + + assert str(token) == VALID_TEST_TOKEN + assert calls == [(AGENT_BOT_API_SCOPE, "tenant-id", 30)] + + @pytest.mark.asyncio + async def test_token_provider_uninspectable_signature_uses_legacy_args_without_agentic_identity(self): + calls = [] + + def token_provider(scope: str, tenant_id: str | None): + calls.append((scope, tenant_id)) + return VALID_TEST_TOKEN + + credentials = TokenCredentials(client_id="test-client-id", token=token_provider, tenant_id="tenant-id") + manager = TokenManager(credentials=credentials) + + with patch("microsoft_teams.apps.token_manager.signature", side_effect=ValueError("no signature")): + token = await manager._get_token_with_token_provider(credentials, AGENT_BOT_API_SCOPE, "tenant-id") + + assert str(token) == VALID_TEST_TOKEN + assert calls == [(AGENT_BOT_API_SCOPE, "tenant-id")] + + @pytest.mark.asyncio + async def test_token_provider_uninspectable_signature_rejects_agentic_identity(self): + credentials = TokenCredentials( + client_id="test-client-id", + token=lambda _scope, _tenant_id: VALID_TEST_TOKEN, + tenant_id="tenant-id", + ) + manager = TokenManager(credentials=credentials) + agentic_identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + + with patch("microsoft_teams.apps.token_manager.signature", side_effect=ValueError("no signature")): + with pytest.raises(ValueError, match="Token provider must accept agentic_identity"): + await manager._get_token_with_token_provider( + credentials, AGENT_BOT_API_SCOPE, "tenant-id", agentic_identity + ) + + @pytest.mark.asyncio + async def test_app_auth_provider_uses_app_token_without_agentic_identity(self): + token_manager = MagicMock(spec=TokenManager) + token_manager.get_app_token = AsyncMock(return_value="app-token") + auth_provider = AppAuthProvider(token_manager, PUBLIC) + + token = await auth_provider.token() + + assert token == "app-token" + token_manager.get_app_token.assert_awaited_once_with(PUBLIC.bot_scope, caller_name="token") + token_manager.get_agentic_token.assert_not_called() + + @pytest.mark.asyncio + async def test_app_auth_provider_uses_agentic_token_with_agentic_identity(self): + token_manager = MagicMock(spec=TokenManager) + token_manager.get_agentic_token = AsyncMock(return_value="agentic-token") + auth_provider = AppAuthProvider(token_manager, PUBLIC) + agentic_identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id") + + token = await auth_provider.token(agentic_identity=agentic_identity) + + assert token == "agentic-token" + token_manager.get_agentic_token.assert_awaited_once_with( + AGENT_BOT_API_SCOPE, + agentic_identity, + caller_name="token", + ) + token_manager.get_app_token.assert_not_called() + + @pytest.mark.asyncio + async def test_app_auth_provider_passes_missing_agentic_tenant_to_token_manager(self): + token_manager = MagicMock(spec=TokenManager) + token_manager.get_agentic_token = AsyncMock(return_value="agentic-token") + auth_provider = AppAuthProvider(token_manager, PUBLIC) + agentic_identity = AgenticIdentity("agentic-app-id", "agentic-user-id") + + token = await auth_provider.token(agentic_identity=agentic_identity) + + assert token == "agentic-token" + token_manager.get_agentic_token.assert_awaited_once_with( + AGENT_BOT_API_SCOPE, + agentic_identity, + caller_name="token", + ) + token_manager.get_app_token.assert_not_called() + + @pytest.mark.asyncio + async def test_get_agentic_token_uses_credentials_tenant_when_missing(self): + calls = [] + + async def token_provider(scope: str, tenant_id: str | None, *, agentic_identity: AgenticIdentity | None): + calls.append((scope, tenant_id, agentic_identity)) + return VALID_TEST_TOKEN + + credentials = TokenCredentials( + client_id="blueprint-client-id", token=token_provider, tenant_id="credential-tenant-id" + ) + manager = TokenManager(credentials=credentials) + + identity = AgenticIdentity("agentic-app-id", "agentic-user-id") + token = await manager.get_agentic_token(AGENT_BOT_API_SCOPE, identity) + + assert token is not None + assert calls == [(AGENT_BOT_API_SCOPE, "credential-tenant-id", identity)] + + @pytest.mark.asyncio + async def test_get_agentic_token_requires_tenant_when_missing_from_request_and_credentials(self): + credentials = TokenCredentials( + client_id="blueprint-client-id", + token=lambda _scope, _tenant_id: VALID_TEST_TOKEN, + ) + manager = TokenManager(credentials=credentials) + + with pytest.raises(ValueError, match="tenant_id is required to get an agentic token"): + await manager.get_agentic_token( + AGENT_BOT_API_SCOPE, + AgenticIdentity("agentic-app-id", "agentic-user-id"), + ) + @pytest.mark.asyncio async def test_get_bot_token_success(self): """Test successful bot token retrieval using MSAL.""" diff --git a/packages/common/src/microsoft_teams/common/http/client.py b/packages/common/src/microsoft_teams/common/http/client.py index 5c9305130..868707963 100644 --- a/packages/common/src/microsoft_teams/common/http/client.py +++ b/packages/common/src/microsoft_teams/common/http/client.py @@ -89,7 +89,7 @@ class ClientOptions: headers: Dict[str, str] = field(default_factory=dict[str, str]) timeout: Optional[float] = None token: Optional[Token] = None - interceptors: Optional[List[Interceptor]] = field(default_factory=list[Interceptor]) + interceptors: Optional[List[Interceptor]] = None class Client: @@ -125,6 +125,11 @@ def __init__(self, options: Optional[ClientOptions] = None): ) self._update_event_hooks() + @property + def interceptors(self) -> tuple[Interceptor, ...]: + """Get the registered interceptors.""" + return tuple(self._interceptors) + async def _prepare_headers(self, headers: Optional[Dict[str, str]], token: Optional[Token]) -> Dict[str, str]: """ Merge default and per-request headers, resolve token, and inject Authorization header if needed. diff --git a/packages/common/tests/test_client.py b/packages/common/tests/test_client.py index 529f38bd8..14b6793c6 100644 --- a/packages/common/tests/test_client.py +++ b/packages/common/tests/test_client.py @@ -119,6 +119,36 @@ async def test_clone_merges_options_and_interceptors(mock_transport): assert interceptor2.request_called +def test_interceptors_returns_read_only_copy(): + interceptor1 = DummyAsyncInterceptor() + client = Client(ClientOptions(interceptors=[interceptor1])) + + assert client.interceptors == (interceptor1,) + + +def test_clone_copies_interceptor_list_independently(): + interceptor1 = DummyAsyncInterceptor() + client = Client(ClientOptions(interceptors=[interceptor1])) + + clone = client.clone() + interceptor2 = DummyAsyncInterceptor() + clone.use_interceptor(interceptor2) + + assert client.interceptors == (interceptor1,) + assert clone.interceptors == (interceptor1, interceptor2) + assert client.interceptors is not clone.interceptors + + +def test_clone_can_clear_interceptors_with_empty_override(): + interceptor1 = DummyAsyncInterceptor() + client = Client(ClientOptions(interceptors=[interceptor1])) + + clone = client.clone(ClientOptions(interceptors=[])) + + assert client.interceptors == (interceptor1,) + assert clone.interceptors == () + + @pytest.mark.parametrize( "token,expected", [