From 722dd81724bb547a6c924a062948fdfb34515796 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sat, 25 Oct 2025 00:31:43 -0700 Subject: [PATCH 01/15] Remove App Token, use token factories, remove "name", consolidate graph token manager. --- packages/apps/src/microsoft/teams/apps/app.py | 124 ++++------------ .../src/microsoft/teams/apps/app_process.py | 34 ++--- .../src/microsoft/teams/apps/app_tokens.py | 17 --- .../teams/apps/graph_token_manager.py | 64 --------- .../src/microsoft/teams/apps/http_plugin.py | 5 +- .../teams/apps/routing/activity_context.py | 9 +- .../src/microsoft/teams/apps/token_manager.py | 135 ++++++++++++++++++ 7 files changed, 173 insertions(+), 215 deletions(-) delete mode 100644 packages/apps/src/microsoft/teams/apps/app_tokens.py delete mode 100644 packages/apps/src/microsoft/teams/apps/graph_token_manager.py create mode 100644 packages/apps/src/microsoft/teams/apps/token_manager.py diff --git a/packages/apps/src/microsoft/teams/apps/app.py b/packages/apps/src/microsoft/teams/apps/app.py index f82104e2..347b779f 100644 --- a/packages/apps/src/microsoft/teams/apps/app.py +++ b/packages/apps/src/microsoft/teams/apps/app.py @@ -21,7 +21,6 @@ ConversationAccount, ConversationReference, Credentials, - JsonWebToken, MessageActivityInput, TokenCredentials, ) @@ -32,7 +31,6 @@ from .app_oauth import OauthHandlers from .app_plugins import PluginProcessor from .app_process import ActivityProcessor -from .app_tokens import AppTokens from .auth import TokenValidator from .auth.remote_function_jwt_middleware import remote_function_jwt_validation from .container import Container @@ -45,12 +43,12 @@ get_event_type_from_signature, is_registered_event, ) -from .graph_token_manager import GraphTokenManager from .http_plugin import HttpPlugin from .options import AppOptions, InternalAppOptions from .plugins import PluginBase, PluginStartEvent, get_metadata from .routing import ActivityHandlerMixin, ActivityRouter from .routing.activity_context import ActivityContext +from .token_manager import TokenManager version = importlib.metadata.version("microsoft-teams-apps") @@ -83,24 +81,30 @@ def __init__(self, **options: Unpack[AppOptions]): self._events = EventEmitter[EventType]() self._router = ActivityRouter() - self._tokens = AppTokens() self.credentials = self._init_credentials() + self.api = ApiClient( + "https://smba.trafficmanager.net/teams", + self.http_client.clone(ClientOptions(token=self._get_or_refresh_bot_token)), + ) + + # Initialize token manager early so id/name properties work + self._token_manager = TokenManager( + api_client=self.api, + credentials=self.credentials, + event_emitter=self._events, + logger=self.log, + default_connection_name=self.options.default_connection_name, + ) + self.container = Container() self.container.set_provider("id", providers.Object(self.id)) - self.container.set_provider("name", providers.Object(self.name)) self.container.set_provider("credentials", providers.Object(self.credentials)) - self.container.set_provider("bot_token", providers.Callable(lambda: self.tokens.bot)) - self.container.set_provider("graph_token", providers.Callable(lambda: self.tokens.graph)) + self.container.set_provider("bot_token", providers.Factory(self._get_or_refresh_bot_token)) self.container.set_provider("logger", providers.Object(self.log)) self.container.set_provider("storage", providers.Object(self.storage)) self.container.set_provider(self.http_client.__class__.__name__, providers.Factory(lambda: self.http_client)) - self.api = ApiClient( - "https://smba.trafficmanager.net/teams", - self.http_client.clone(ClientOptions(token=lambda: self.tokens.bot)), - ) - plugins: List[PluginBase] = list(self.options.plugins) http_plugin = None @@ -125,11 +129,6 @@ def __init__(self, **options: Unpack[AppOptions]): self._running = False # initialize all event, activity, and plugin processors - self.graph_token_manager = GraphTokenManager( - api_client=self.api, - credentials=self.credentials, - logger=self.log, - ) self.activity_processor = ActivityProcessor( self._router, self.log, @@ -137,8 +136,7 @@ def __init__(self, **options: Unpack[AppOptions]): self.storage, self.options.default_connection_name, self.http_client, - self.tokens, - self.graph_token_manager, + self._token_manager, ) self.event_manager = EventManager(self._events, self.activity_processor) self.activity_processor.event_manager = self.event_manager @@ -169,11 +167,6 @@ def is_running(self) -> bool: """Whether the app is currently running.""" return self._running - @property - def tokens(self) -> AppTokens: - """Current authentication tokens.""" - return self._tokens - @property def logger(self) -> Logger: """The logger instance used by the app.""" @@ -192,16 +185,8 @@ def router(self) -> ActivityRouter: @property def id(self) -> Optional[str]: """The app's ID from tokens.""" - return ( - self._tokens.bot.app_id if self._tokens.bot else self._tokens.graph.app_id if self._tokens.graph else None - ) - - @property - def name(self) -> Optional[str]: - """The app's name from tokens.""" - return getattr(self._tokens.bot, "app_display_name", None) or getattr( - self._tokens.graph, "app_display_name", None - ) + tokens = self._token_manager.tokens + return tokens.bot.app_id if tokens.bot else tokens.graph.app_id if tokens.graph else None async def start(self, port: Optional[int] = None) -> None: """ @@ -220,9 +205,6 @@ async def start(self, port: Optional[int] = None) -> None: self._port = port or int(os.getenv("PORT", "3978")) try: - await self._refresh_tokens(force=True) - self._running = True - for plugin in self.plugins: # Inject the dependencies self._plugin_processor.inject(plugin) @@ -234,6 +216,7 @@ async def on_http_ready() -> None: self.log.info("Teams app started successfully") assert self._port is not None, "Port must be set before emitting start event" self._events.emit("start", StartEvent(port=self._port)) + self._running = True self.http.on_ready_callback = on_http_ready @@ -280,13 +263,13 @@ async def on_http_stopped() -> None: async def send(self, conversation_id: str, activity: str | ActivityParams | AdaptiveCard): """Send an activity proactively.""" - if self.id is None or self.name is None: + if self.id is None: raise ValueError("app not started") conversation_ref = ConversationReference( channel_id="msteams", service_url=self.api.service_url, - bot=Account(id=self.id, name=self.name, role="bot"), + bot=Account(id=self.id, role="bot"), conversation=ConversationAccount(id=conversation_id, conversation_type="personal"), ) @@ -326,65 +309,6 @@ def _init_credentials(self) -> Optional[Credentials]: return None - async def _refresh_tokens(self, force: bool = False) -> None: - """Refresh bot and graph tokens.""" - await asyncio.gather(self._refresh_bot_token(force), self._refresh_graph_token(force), return_exceptions=True) - - async def _refresh_bot_token(self, force: bool = False) -> None: - """Refresh the bot authentication token.""" - if not self.credentials: - self.log.warning("No credentials provided, skipping bot token refresh") - return - - if not force and self._tokens.bot and not self._tokens.bot.is_expired(): - return - - if self._tokens.bot: - self.log.debug("Refreshing bot token") - - try: - token_response = await self.api.bots.token.get(self.credentials) - self._tokens.bot = JsonWebToken(token_response.access_token) - self.log.debug("Bot token refreshed successfully") - - except Exception as error: - self.log.error(f"Failed to refresh bot token: {error}") - - self._events.emit("error", ErrorEvent(error, context={"method": "_refresh_bot_token"})) - raise - - async def _refresh_graph_token(self, force: bool = False) -> None: - """Refresh the Graph API token.""" - if not self.credentials: - self.log.warning("No credentials provided, skipping graph token refresh") - return - - if not force and self._tokens.graph and not self._tokens.graph.is_expired(): - return - - if self._tokens.graph: - self.log.debug("Refreshing graph token") - - try: - # Use GraphTokenManager for tenant-aware token management - tenant_id = self.credentials.tenant_id if self.credentials else None - token = await self.graph_token_manager.get_token(tenant_id) - - if token: - self._tokens.graph = token - self.log.debug("Graph token refreshed successfully") - - # Emit token acquired event - self._events.emit("token", {"type": "graph", "token": self._tokens.graph}) - else: - self.log.debug("Failed to get graph token from GraphTokenManager") - - except Exception as error: - self.log.error(f"Failed to refresh graph token: {error}") - - self._events.emit("error", ErrorEvent(error, context={"method": "_refresh_graph_token"})) - raise - @overload def event(self, func_or_event_type: F) -> F: """Register event handler with auto-detected type from function signature.""" @@ -521,7 +445,6 @@ async def endpoint(req: Request): async def call_next(r: Request) -> Any: ctx = FunctionContext( id=self.id, - name=self.name, api=self.api, http=self.http, log=self.log, @@ -541,3 +464,6 @@ async def call_next(r: Request) -> Any: # Named decoration: @app.func("name") return decorator + + def _get_or_refresh_bot_token(self): + return self._token_manager.refresh_bot_token() diff --git a/packages/apps/src/microsoft/teams/apps/app_process.py b/packages/apps/src/microsoft/teams/apps/app_process.py index cf57e473..0957578f 100644 --- a/packages/apps/src/microsoft/teams/apps/app_process.py +++ b/packages/apps/src/microsoft/teams/apps/app_process.py @@ -11,7 +11,6 @@ ActivityParams, ApiClient, ConversationReference, - GetUserTokenParams, InvokeResponse, SentActivity, TokenProtocol, @@ -22,12 +21,11 @@ if TYPE_CHECKING: from .app_events import EventManager -from .app_tokens import AppTokens from .events import ActivityEvent, ActivityResponseEvent, ActivitySentEvent -from .graph_token_manager import GraphTokenManager from .plugins import PluginActivityEvent, PluginBase, Sender from .routing.activity_context import ActivityContext from .routing.router import ActivityHandler, ActivityRouter +from .token_manager import TokenManager from .utils import extract_tenant_id @@ -42,8 +40,7 @@ def __init__( storage: Union[Storage[str, Any], LocalStorage[Any]], default_connection_name: str, http_client: Client, - token: AppTokens, - graph_token_manager: GraphTokenManager, + token_manager: TokenManager, ) -> None: self.router = router self.logger = logger @@ -51,21 +48,12 @@ def __init__( self.storage = storage self.default_connection_name = default_connection_name self.http_client = http_client - self.tokens = token - self._graph_token_manager = graph_token_manager + self.token_manager = token_manager # This will be set after the EventManager is initialized due to # a circular dependency self.event_manager: Optional["EventManager"] = None - async def _get_or_refresh_graph_token(self, tenant_id: Optional[str] = None) -> Optional[TokenProtocol]: - """Get the current graph token or refresh it if needed.""" - try: - return await self._graph_token_manager.get_token(tenant_id) - except Exception as e: - self.logger.error(f"Failed to get graph token via manager: {e}") - return self.tokens.graph - async def _build_context( self, activity: ActivityBase, @@ -92,29 +80,23 @@ async def _build_context( locale=activity.locale, user=activity.from_, ) - api_client = ApiClient(service_url, self.http_client.clone(ClientOptions(token=self.tokens.bot))) + api_client = ApiClient(service_url, self.http_client.clone(ClientOptions(token=self.token_manager.tokens.bot))) # Check if user is signed in is_signed_in = False user_token: Optional[str] = None try: - user_token_res = await api_client.users.token.get( - GetUserTokenParams( - connection_name=self.default_connection_name, - user_id=activity.from_.id, - channel_id=activity.channel_id, - ) + user_token = await self.token_manager.get_user_token( + channel_id=activity.channel_id, user_id=activity.from_.id ) - user_token = user_token_res.token is_signed_in = True except Exception: # User token not available + self.logger.debug("No user token available") pass tenant_id = extract_tenant_id(activity) - graph_token = await self._get_or_refresh_graph_token(tenant_id) - activityCtx = ActivityContext( activity, self.id or "", @@ -126,7 +108,7 @@ async def _build_context( is_signed_in, self.default_connection_name, sender, - app_token=graph_token, + app_token=lambda: self.token_manager.get_or_refresh_graph_token(tenant_id), ) send = activityCtx.send diff --git a/packages/apps/src/microsoft/teams/apps/app_tokens.py b/packages/apps/src/microsoft/teams/apps/app_tokens.py deleted file mode 100644 index a9a9c1f4..00000000 --- a/packages/apps/src/microsoft/teams/apps/app_tokens.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" - -from dataclasses import dataclass -from typing import Optional - -from microsoft.teams.api.auth.token import TokenProtocol - - -@dataclass -class AppTokens: - """Application tokens for API access.""" - - bot: Optional[TokenProtocol] = None - graph: Optional[TokenProtocol] = None diff --git a/packages/apps/src/microsoft/teams/apps/graph_token_manager.py b/packages/apps/src/microsoft/teams/apps/graph_token_manager.py deleted file mode 100644 index 83c19471..00000000 --- a/packages/apps/src/microsoft/teams/apps/graph_token_manager.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" - -import logging -from typing import Dict, Optional - -from microsoft.teams.api import ApiClient, ClientCredentials, Credentials, JsonWebToken, TokenProtocol - - -class GraphTokenManager: - """Simple token manager for Graph API tokens.""" - - def __init__( - self, - api_client: "ApiClient", - credentials: Optional["Credentials"], - logger: Optional[logging.Logger] = None, - ): - self._api_client = api_client - self._credentials = credentials - - if not logger: - self._logger = logging.getLogger(__name__ + ".GraphTokenManager") - else: - self._logger = logger.getChild("GraphTokenManager") - - self._token_cache: Dict[str, TokenProtocol] = {} - - async def get_token(self, tenant_id: Optional[str] = None) -> Optional[TokenProtocol]: - """Get a Graph token for the specified tenant.""" - if not self._credentials: - return None - - if not tenant_id: - tenant_id = "botframework.com" # Default tenant ID, assuming multi-tenant app - - # Check cache first - cached_token = self._token_cache.get(tenant_id) - if cached_token and not cached_token.is_expired(): - return cached_token - - # Refresh token - try: - tenant_credentials = self._credentials - if isinstance(self._credentials, ClientCredentials): - tenant_credentials = ClientCredentials( - client_id=self._credentials.client_id, - client_secret=self._credentials.client_secret, - tenant_id=tenant_id, - ) - - response = await self._api_client.bots.token.get_graph(tenant_credentials) - token = JsonWebToken(response.access_token) - self._token_cache[tenant_id] = token - - self._logger.debug(f"Refreshed graph token for tenant {tenant_id}") - - return token - - except Exception as e: - self._logger.error(f"Failed to refresh graph token for {tenant_id}: {e}") - return None diff --git a/packages/apps/src/microsoft/teams/apps/http_plugin.py b/packages/apps/src/microsoft/teams/apps/http_plugin.py index ab0c6d40..651cfead 100644 --- a/packages/apps/src/microsoft/teams/apps/http_plugin.py +++ b/packages/apps/src/microsoft/teams/apps/http_plugin.py @@ -24,7 +24,7 @@ TokenProtocol, ) from microsoft.teams.apps.http_stream import HttpStream -from microsoft.teams.common.http.client import Client, ClientOptions +from microsoft.teams.common.http import Client, ClientOptions, Token from microsoft.teams.common.logging import ConsoleLogger from pydantic import BaseModel, ValidationError from starlette.applications import Starlette @@ -60,8 +60,7 @@ class HttpPlugin(Sender): client: Annotated[Client, DependencyMetadata()] - bot_token: Annotated[Optional[Callable[[], TokenProtocol]], DependencyMetadata(optional=True)] - graph_token: Annotated[Optional[Callable[[], TokenProtocol]], DependencyMetadata(optional=True)] + bot_token: Annotated[Optional[Callable[[], Token]], DependencyMetadata(optional=True)] lifespans: list[Lifespan[Starlette]] = [] diff --git a/packages/apps/src/microsoft/teams/apps/routing/activity_context.py b/packages/apps/src/microsoft/teams/apps/routing/activity_context.py index eaf2f651..4095be7c 100644 --- a/packages/apps/src/microsoft/teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft/teams/apps/routing/activity_context.py @@ -26,7 +26,6 @@ TokenExchangeResource, TokenExchangeState, TokenPostResource, - TokenProtocol, ) from microsoft.teams.api.models.attachment.card_attachment import ( OAuthCardAttachment, @@ -35,6 +34,7 @@ from microsoft.teams.api.models.oauth import OAuthCard from microsoft.teams.cards import AdaptiveCard from microsoft.teams.common import Storage +from microsoft.teams.common.http.client_token import Token if TYPE_CHECKING: from msgraph.graph_service_client import GraphServiceClient @@ -46,7 +46,7 @@ SendCallable = Callable[[str | ActivityParams | AdaptiveCard], Awaitable[SentActivity]] -def _get_graph_client(token: TokenProtocol): +def _get_graph_client(token: Token): """Lazy import and call get_graph_client when needed.""" try: from microsoft.teams.graph import get_graph_client @@ -97,7 +97,7 @@ def __init__( is_signed_in: bool, connection_name: str, sender: Sender, - app_token: Optional[TokenProtocol], + app_token: Token, ): self.activity = activity self.app_id = app_id @@ -158,9 +158,6 @@ def app_graph(self) -> "GraphServiceClient": ImportError: If the graph dependencies are not installed. """ - if not self._app_token: - raise ValueError("No app token available for Graph client") - if self._app_graph is None: try: self._app_graph = _get_graph_client(self._app_token) diff --git a/packages/apps/src/microsoft/teams/apps/token_manager.py b/packages/apps/src/microsoft/teams/apps/token_manager.py new file mode 100644 index 00000000..9236dc07 --- /dev/null +++ b/packages/apps/src/microsoft/teams/apps/token_manager.py @@ -0,0 +1,135 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import logging +from types import SimpleNamespace +from typing import Any, Optional + +from microsoft.teams.api import ChannelID, ClientCredentials +from microsoft.teams.api.auth.credentials import Credentials +from microsoft.teams.api.auth.json_web_token import JsonWebToken +from microsoft.teams.api.auth.token import TokenProtocol +from microsoft.teams.api.clients.api_client import ApiClient +from microsoft.teams.api.clients.user.params import GetUserTokenParams +from microsoft.teams.apps.events.registry import EventType +from microsoft.teams.common.events.event_emitter import EventEmitter +from microsoft.teams.common.logging.console import ConsoleLogger +from microsoft.teams.common.storage.local_storage import LocalStorage, LocalStorageOptions + + +class TokenManager: + """Manages authentication tokens for the Teams application.""" + + def __init__( + self, + api_client: ApiClient, + credentials: Optional[Credentials], + event_emitter: EventEmitter[EventType], + logger: Optional[logging.Logger] = None, + default_connection_name: Optional[str] = None, + ): + self._api_client = api_client + self._credentials = credentials + self._events = event_emitter + self._default_connection_name = default_connection_name + + if not logger: + self._logger = ConsoleLogger().create_logger("TokenManager") + else: + self._logger = logger.getChild("TokenManager") + + self._bot_token: Optional[TokenProtocol] = None + + # Key: tenant_id (empty string "" for default app graph token) + self._graph_tokens: LocalStorage[TokenProtocol] = LocalStorage({}, LocalStorageOptions(max=20000)) + + @property + def tokens(self) -> Any: + """Current authentication tokens.""" + return SimpleNamespace( + bot=self._bot_token, + graph=self._graph_tokens.get(""), # Default graph token + ) + + async def refresh_bot_token(self, force: bool = False) -> Optional[TokenProtocol]: + """Refresh the bot authentication token.""" + if not self._credentials: + self._logger.warning("No credentials provided, skipping bot token refresh") + return None + + if not force and self._bot_token and not self._bot_token.is_expired(): + return self._bot_token + + if self._bot_token: + self._logger.debug("Refreshing bot token") + + token_response = await self._api_client.bots.token.get(self._credentials) + self._bot_token = JsonWebToken(token_response.access_token) + self._logger.debug("Bot token refreshed successfully") + return self._bot_token + + async def get_or_refresh_graph_token( + self, tenant_id: Optional[str] = None, force: bool = False + ) -> Optional[TokenProtocol]: + """ + Get or refresh a Graph API token. + + Args: + tenant_id: If provided, gets a tenant-specific token. Otherwise uses app's default. + force: Force refresh even if token is not expired + + Returns: + The graph token or None if not available + """ + if not self._credentials: + self._logger.debug("No credentials provided for graph token refresh") + return None + + # Use empty string as key for default graph token + key = tenant_id or "" + + cached = self._graph_tokens.get(key) + if not force and cached and not cached.is_expired(): + return cached + + creds = self._credentials + if tenant_id and isinstance(self._credentials, ClientCredentials): + creds = ClientCredentials( + client_id=self._credentials.client_id, + client_secret=self._credentials.client_secret, + tenant_id=tenant_id, + ) + + response = await self._api_client.bots.token.get_graph(creds) + token = JsonWebToken(response.access_token) + self._graph_tokens.set(key, token) + + self._logger.debug(f"Refreshed graph token tenant_id={tenant_id}") + + return token + + async def get_user_token(self, channel_id: ChannelID, user_id: str) -> Optional[str]: + """ + Get a user token for the specified channel and user. + + Args: + channel_id: The channel ID + user_id: The user ID + + Returns: + The user token or None if not available + """ + if not self._default_connection_name: + self._logger.warning("No default connection name configured, cannot get user token") + return None + + response = await self._api_client.users.token.get( + GetUserTokenParams( + channel_id=channel_id, + user_id=user_id, + connection_name=self._default_connection_name, + ) + ) + return response.token From c9842583baefa0b8f6db2099195436f4c26142fe Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sat, 25 Oct 2025 08:26:39 -0700 Subject: [PATCH 02/15] Get id from creds --- packages/apps/src/microsoft/teams/apps/app.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/apps/src/microsoft/teams/apps/app.py b/packages/apps/src/microsoft/teams/apps/app.py index 347b779f..764bc063 100644 --- a/packages/apps/src/microsoft/teams/apps/app.py +++ b/packages/apps/src/microsoft/teams/apps/app.py @@ -184,9 +184,10 @@ def router(self) -> ActivityRouter: @property def id(self) -> Optional[str]: - """The app's ID from tokens.""" - tokens = self._token_manager.tokens - return tokens.bot.app_id if tokens.bot else tokens.graph.app_id if tokens.graph else None + """The app's ID from credentials.""" + if not self.credentials: + return None + return self.credentials.client_id async def start(self, port: Optional[int] = None) -> None: """ From 6865730c10dbbdc92de2f74ba5c7ab5a7b0aba6a Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sat, 25 Oct 2025 11:39:51 -0700 Subject: [PATCH 03/15] Refreshable bot token to api client, no event emitter, better accessors in token manager --- packages/apps/src/microsoft/teams/apps/app.py | 1 - .../src/microsoft/teams/apps/app_process.py | 4 +++- .../src/microsoft/teams/apps/token_manager.py | 21 ++++++++----------- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/apps/src/microsoft/teams/apps/app.py b/packages/apps/src/microsoft/teams/apps/app.py index 764bc063..57f0841d 100644 --- a/packages/apps/src/microsoft/teams/apps/app.py +++ b/packages/apps/src/microsoft/teams/apps/app.py @@ -92,7 +92,6 @@ def __init__(self, **options: Unpack[AppOptions]): self._token_manager = TokenManager( api_client=self.api, credentials=self.credentials, - event_emitter=self._events, logger=self.log, default_connection_name=self.options.default_connection_name, ) diff --git a/packages/apps/src/microsoft/teams/apps/app_process.py b/packages/apps/src/microsoft/teams/apps/app_process.py index 0957578f..c37ec9b4 100644 --- a/packages/apps/src/microsoft/teams/apps/app_process.py +++ b/packages/apps/src/microsoft/teams/apps/app_process.py @@ -80,7 +80,9 @@ async def _build_context( locale=activity.locale, user=activity.from_, ) - api_client = ApiClient(service_url, self.http_client.clone(ClientOptions(token=self.token_manager.tokens.bot))) + api_client = ApiClient( + service_url, self.http_client.clone(ClientOptions(token=self.token_manager.refresh_bot_token)) + ) # Check if user is signed in is_signed_in = False diff --git a/packages/apps/src/microsoft/teams/apps/token_manager.py b/packages/apps/src/microsoft/teams/apps/token_manager.py index 9236dc07..6eae60ff 100644 --- a/packages/apps/src/microsoft/teams/apps/token_manager.py +++ b/packages/apps/src/microsoft/teams/apps/token_manager.py @@ -4,8 +4,7 @@ """ import logging -from types import SimpleNamespace -from typing import Any, Optional +from typing import Optional from microsoft.teams.api import ChannelID, ClientCredentials from microsoft.teams.api.auth.credentials import Credentials @@ -13,8 +12,6 @@ from microsoft.teams.api.auth.token import TokenProtocol from microsoft.teams.api.clients.api_client import ApiClient from microsoft.teams.api.clients.user.params import GetUserTokenParams -from microsoft.teams.apps.events.registry import EventType -from microsoft.teams.common.events.event_emitter import EventEmitter from microsoft.teams.common.logging.console import ConsoleLogger from microsoft.teams.common.storage.local_storage import LocalStorage, LocalStorageOptions @@ -26,13 +23,11 @@ def __init__( self, api_client: ApiClient, credentials: Optional[Credentials], - event_emitter: EventEmitter[EventType], logger: Optional[logging.Logger] = None, default_connection_name: Optional[str] = None, ): self._api_client = api_client self._credentials = credentials - self._events = event_emitter self._default_connection_name = default_connection_name if not logger: @@ -46,12 +41,14 @@ def __init__( self._graph_tokens: LocalStorage[TokenProtocol] = LocalStorage({}, LocalStorageOptions(max=20000)) @property - def tokens(self) -> Any: - """Current authentication tokens.""" - return SimpleNamespace( - bot=self._bot_token, - graph=self._graph_tokens.get(""), # Default graph token - ) + def bot_token(self): + return self._bot_token + + def get_tenant_graph_token(self, tenant_id: str | None): + """ + Returns the graph token for a given tenant id. + """ + return self._graph_tokens.get(tenant_id or "") async def refresh_bot_token(self, force: bool = False) -> Optional[TokenProtocol]: """Refresh the bot authentication token.""" From 3d1af80a09591493a3dff89aafeed89eb4fd3a5a Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sat, 25 Oct 2025 11:47:41 -0700 Subject: [PATCH 04/15] Reorder --- packages/apps/src/microsoft/teams/apps/app.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/apps/src/microsoft/teams/apps/app.py b/packages/apps/src/microsoft/teams/apps/app.py index 57f0841d..152674cf 100644 --- a/packages/apps/src/microsoft/teams/apps/app.py +++ b/packages/apps/src/microsoft/teams/apps/app.py @@ -83,12 +83,6 @@ def __init__(self, **options: Unpack[AppOptions]): self.credentials = self._init_credentials() - self.api = ApiClient( - "https://smba.trafficmanager.net/teams", - self.http_client.clone(ClientOptions(token=self._get_or_refresh_bot_token)), - ) - - # Initialize token manager early so id/name properties work self._token_manager = TokenManager( api_client=self.api, credentials=self.credentials, @@ -104,6 +98,11 @@ def __init__(self, **options: Unpack[AppOptions]): self.container.set_provider("storage", providers.Object(self.storage)) self.container.set_provider(self.http_client.__class__.__name__, providers.Factory(lambda: self.http_client)) + self.api = ApiClient( + "https://smba.trafficmanager.net/teams", + self.http_client.clone(ClientOptions(token=self._get_or_refresh_bot_token)), + ) + plugins: List[PluginBase] = list(self.options.plugins) http_plugin = None From d5b753564eba185c7b7ff40bafbed8fbb8e7b49c Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sat, 25 Oct 2025 11:49:10 -0700 Subject: [PATCH 05/15] Remove graph token in http plugin --- packages/apps/src/microsoft/teams/apps/plugins/metadata.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/apps/src/microsoft/teams/apps/plugins/metadata.py b/packages/apps/src/microsoft/teams/apps/plugins/metadata.py index 3e0c9352..98b34a9f 100644 --- a/packages/apps/src/microsoft/teams/apps/plugins/metadata.py +++ b/packages/apps/src/microsoft/teams/apps/plugins/metadata.py @@ -99,12 +99,6 @@ class BotTokenDependencyOptions(DependencyMetadata): optional = True -@dataclass -class GraphTokenDependencyOptions(DependencyMetadata): - name = "graph_token" - optional = True - - @dataclass class LoggerDependencyOptions(DependencyMetadata): name = "logger" @@ -129,7 +123,6 @@ class PluginDependencyOptions(DependencyMetadata): ManifestDependencyOptions, CredentialsDependencyOptions, BotTokenDependencyOptions, - GraphTokenDependencyOptions, LoggerDependencyOptions, StorageDependencyOptions, PluginDependencyOptions, From cec7820fc94a17be3ebfb36179bd30535be11b71 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sat, 25 Oct 2025 12:08:30 -0700 Subject: [PATCH 06/15] Fix pyright --- packages/apps/src/microsoft/teams/apps/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/apps/src/microsoft/teams/apps/__init__.py b/packages/apps/src/microsoft/teams/apps/__init__.py index d7dc4712..2aa32332 100644 --- a/packages/apps/src/microsoft/teams/apps/__init__.py +++ b/packages/apps/src/microsoft/teams/apps/__init__.py @@ -5,7 +5,6 @@ from . import auth, contexts, events, plugins from .app import App -from .app_tokens import AppTokens from .auth import * # noqa: F403 from .contexts import * # noqa: F403 from .events import * # noqa: F401, F403 @@ -16,7 +15,7 @@ from .routing import ActivityContext # Combine all exports from submodules -__all__: list[str] = ["App", "AppOptions", "HttpPlugin", "HttpStream", "ActivityContext", "AppTokens"] +__all__: list[str] = ["App", "AppOptions", "HttpPlugin", "HttpStream", "ActivityContext"] __all__.extend(auth.__all__) __all__.extend(events.__all__) __all__.extend(plugins.__all__) From cbbb8d3cb623dab70d4b0c4e490c043c182fd39c Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sat, 25 Oct 2025 13:18:18 -0700 Subject: [PATCH 07/15] Fix tests --- packages/apps/tests/test_app_process.py | 10 +- .../apps/tests/test_graph_token_manager.py | 271 ------------------ packages/apps/tests/test_token_manager.py | 200 +++++++++++++ 3 files changed, 204 insertions(+), 277 deletions(-) delete mode 100644 packages/apps/tests/test_graph_token_manager.py create mode 100644 packages/apps/tests/test_token_manager.py diff --git a/packages/apps/tests/test_app_process.py b/packages/apps/tests/test_app_process.py index 56819d47..261cf139 100644 --- a/packages/apps/tests/test_app_process.py +++ b/packages/apps/tests/test_app_process.py @@ -8,11 +8,11 @@ import pytest from microsoft.teams.api import Activity, ActivityBase, ConversationReference -from microsoft.teams.apps import ActivityContext, AppTokens, Sender +from microsoft.teams.apps import ActivityContext, Sender from microsoft.teams.apps.app_events import EventManager from microsoft.teams.apps.app_process import ActivityProcessor -from microsoft.teams.apps.graph_token_manager import GraphTokenManager from microsoft.teams.apps.routing.router import ActivityHandler, ActivityRouter +from microsoft.teams.apps.token_manager import TokenManager from microsoft.teams.common import Client, ConsoleLogger, LocalStorage @@ -30,8 +30,7 @@ def activity_processor(self, mock_logger, mock_http_client): """Create an ActivityProcessor instance.""" mock_storage = MagicMock(spec=LocalStorage) mock_activity_router = MagicMock(spec=ActivityRouter) - mock_tokens = MagicMock(spec=AppTokens) - mock_graph_token_manager = MagicMock(spec=GraphTokenManager) + mock_token_manager = MagicMock(spec=TokenManager) return ActivityProcessor( mock_activity_router, mock_logger, @@ -39,8 +38,7 @@ def activity_processor(self, mock_logger, mock_http_client): mock_storage, "default_connection", mock_http_client, - mock_tokens, - mock_graph_token_manager, + mock_token_manager, ) @pytest.mark.asyncio diff --git a/packages/apps/tests/test_graph_token_manager.py b/packages/apps/tests/test_graph_token_manager.py deleted file mode 100644 index 4dfbf175..00000000 --- a/packages/apps/tests/test_graph_token_manager.py +++ /dev/null @@ -1,271 +0,0 @@ -""" -Copyright (c) Microsoft Corporation. All rights reserved. -Licensed under the MIT License. -""" - -from unittest.mock import AsyncMock, MagicMock - -import pytest -from microsoft.teams.api import ClientCredentials, JsonWebToken -from microsoft.teams.apps.graph_token_manager import GraphTokenManager - -# Valid JWT-like token for testing (format: header.payload.signature) -VALID_TEST_TOKEN = ( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." - "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ." - "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" -) -ANOTHER_VALID_TOKEN = ( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." - "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbmUgRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ." - "Twzj7LKlhYUUe2GFRME4WOZdWq2TdayZhWjhBr1r5X4" -) - - -class TestGraphTokenManager: - """Test GraphTokenManager functionality.""" - - def test_initialization(self): - """Test GraphTokenManager initialization.""" - mock_api_client = MagicMock() - mock_credentials = MagicMock() - mock_logger = MagicMock() - - manager = GraphTokenManager( - api_client=mock_api_client, - credentials=mock_credentials, - logger=mock_logger, - ) - - assert manager is not None - # Test successful initialization by verifying the manager was created - - def test_initialization_without_logger(self): - """Test GraphTokenManager initialization without logger.""" - mock_api_client = MagicMock() - mock_credentials = MagicMock() - - manager = GraphTokenManager( - api_client=mock_api_client, - credentials=mock_credentials, - ) - - assert manager is not None - - @pytest.mark.asyncio - async def test_get_token_no_tenant_id(self): - """Test getting token with no tenant_id returns None.""" - mock_api_client = MagicMock() - mock_credentials = MagicMock() - - manager = GraphTokenManager( - api_client=mock_api_client, - credentials=mock_credentials, - ) - - token = await manager.get_token(None) - assert token is None - - @pytest.mark.asyncio - async def test_get_token_no_credentials(self): - """Test getting token with no credentials returns None.""" - mock_api_client = MagicMock() - - manager = GraphTokenManager( - api_client=mock_api_client, - credentials=None, - ) - - token = await manager.get_token("test-tenant") - assert token is None - - @pytest.mark.asyncio - async def test_get_token_success(self): - """Test successful token retrieval.""" - mock_api_client = MagicMock() - mock_token_response = MagicMock() - mock_token_response.access_token = VALID_TEST_TOKEN - mock_api_client.bots.token.get_graph = AsyncMock(return_value=mock_token_response) - - mock_credentials = ClientCredentials( - client_id="test-client-id", - client_secret="test-client-secret", - tenant_id="default-tenant-id", - ) - - mock_logger = MagicMock() - mock_child_logger = MagicMock() - mock_logger.getChild.return_value = mock_child_logger - - manager = GraphTokenManager( - api_client=mock_api_client, - credentials=mock_credentials, - logger=mock_logger, - ) - - token = await manager.get_token("test-tenant") - - assert token is not None - assert isinstance(token, JsonWebToken) - # Verify the API was called - mock_api_client.bots.token.get_graph.assert_called_once() - # Verify child logger was created and debug was called - mock_logger.getChild.assert_called_once_with("GraphTokenManager") - mock_child_logger.debug.assert_called_once() - - # Test that subsequent calls use cache by calling again - token2 = await manager.get_token("test-tenant") - assert token2 == token - # API should still only be called once due to caching - mock_api_client.bots.token.get_graph.assert_called_once() - - @pytest.mark.asyncio - async def test_get_token_from_cache(self): - """Test getting token from cache.""" - mock_api_client = MagicMock() - mock_credentials = MagicMock() - - manager = GraphTokenManager( - api_client=mock_api_client, - credentials=mock_credentials, - ) - - # Set up the API response for initial token - mock_token_response = MagicMock() - mock_token_response.access_token = VALID_TEST_TOKEN - mock_api_client.bots.token.get_graph = AsyncMock(return_value=mock_token_response) - - # First call should hit the API - token1 = await manager.get_token("test-tenant") - assert token1 is not None - assert isinstance(token1, JsonWebToken) - mock_api_client.bots.token.get_graph.assert_called_once() - - # Second call should use cache (API should not be called again) - token2 = await manager.get_token("test-tenant") - assert token2 == token1 # Should be the same cached token - # Still only called once due to caching - mock_api_client.bots.token.get_graph.assert_called_once() - - @pytest.mark.asyncio - async def test_get_token_api_error(self): - """Test token retrieval when API call fails.""" - mock_api_client = MagicMock() - mock_api_client.bots.token.get_graph = AsyncMock(side_effect=Exception("API Error")) - - mock_credentials = ClientCredentials( - client_id="test-client-id", - client_secret="test-client-secret", - tenant_id="default-tenant-id", - ) - - mock_logger = MagicMock() - mock_child_logger = MagicMock() - mock_logger.getChild.return_value = mock_child_logger - - manager = GraphTokenManager( - api_client=mock_api_client, - credentials=mock_credentials, - logger=mock_logger, - ) - - token = await manager.get_token("test-tenant") - - assert token is None - # Verify child logger was created and error was logged - mock_logger.getChild.assert_called_once_with("GraphTokenManager") - mock_child_logger.error.assert_called_once() - - @pytest.mark.asyncio - async def test_get_token_no_logger_on_error(self): - """Test token retrieval error handling without logger.""" - mock_api_client = MagicMock() - mock_api_client.bots.token.get_graph = AsyncMock(side_effect=Exception("API Error")) - - mock_credentials = ClientCredentials( - client_id="test-client-id", - client_secret="test-client-secret", - tenant_id="default-tenant-id", - ) - - manager = GraphTokenManager( - api_client=mock_api_client, - credentials=mock_credentials, - # No logger - ) - - token = await manager.get_token("test-tenant") - - assert token is None - # Should not raise exception even without logger - - @pytest.mark.asyncio - async def test_get_token_expired_cache_refresh(self): - """Test that expired tokens in cache are refreshed.""" - mock_api_client = MagicMock() - mock_token_response = MagicMock() - mock_token_response.access_token = ANOTHER_VALID_TOKEN - mock_api_client.bots.token.get_graph = AsyncMock(return_value=mock_token_response) - - mock_credentials = ClientCredentials( - client_id="test-client-id", - client_secret="test-client-secret", - tenant_id="default-tenant-id", - ) - - manager = GraphTokenManager( - api_client=mock_api_client, - credentials=mock_credentials, - ) - - # First, get a token to populate cache - first_token_response = MagicMock() - first_token_response.access_token = VALID_TEST_TOKEN - mock_api_client.bots.token.get_graph.return_value = first_token_response - - first_token = await manager.get_token("test-tenant") - assert first_token is not None - - # Now simulate the cached token being expired and get a new one - mock_api_client.bots.token.get_graph.return_value = mock_token_response - second_token = await manager.get_token("test-tenant") - - assert second_token is not None - assert isinstance(second_token, JsonWebToken) - # Verify the API was called multiple times (once for each get) - assert mock_api_client.bots.token.get_graph.call_count >= 1 - - @pytest.mark.asyncio - async def test_get_token_creates_tenant_specific_credentials(self): - """Test that tenant-specific credentials are created for the API call.""" - mock_api_client = MagicMock() - mock_token_response = MagicMock() - mock_token_response.access_token = VALID_TEST_TOKEN - mock_api_client.bots.token.get_graph = AsyncMock(return_value=mock_token_response) - - original_credentials = ClientCredentials( - client_id="test-client-id", - client_secret="test-client-secret", - tenant_id="original-tenant-id", - ) - - manager = GraphTokenManager( - api_client=mock_api_client, - credentials=original_credentials, - ) - - token = await manager.get_token("different-tenant-id") - - assert token is not None - # Verify the API was called - mock_api_client.bots.token.get_graph.assert_called_once() - - # Get the credentials that were passed to the API - call_args = mock_api_client.bots.token.get_graph.call_args - passed_credentials = call_args[0][0] # First positional argument - - # Verify it's a ClientCredentials with the correct tenant - assert isinstance(passed_credentials, ClientCredentials) - assert passed_credentials.client_id == "test-client-id" - assert passed_credentials.client_secret == "test-client-secret" - assert passed_credentials.tenant_id == "different-tenant-id" diff --git a/packages/apps/tests/test_token_manager.py b/packages/apps/tests/test_token_manager.py new file mode 100644 index 00000000..a3f6d9d6 --- /dev/null +++ b/packages/apps/tests/test_token_manager.py @@ -0,0 +1,200 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from microsoft.teams.api import ClientCredentials, JsonWebToken +from microsoft.teams.apps.token_manager import TokenManager + +# Valid JWT-like token for testing (format: header.payload.signature) +VALID_TEST_TOKEN = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ." + "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" +) + + +class TestTokenManager: + """Test TokenManager functionality.""" + + @pytest.mark.asyncio + async def test_refresh_bot_token_success(self): + """Test successful bot token refresh, caching, and expiration refresh.""" + mock_api_client = MagicMock() + + # First token response + mock_token_response1 = MagicMock() + mock_token_response1.access_token = VALID_TEST_TOKEN + + # Second token response for expired token + mock_token_response2 = MagicMock() + mock_token_response2.access_token = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJzdWIiOiI5ODc2NTQzMjEwIiwibmFtZSI6IkphbmUgRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ." + "Twzj7LKlhYUUe2GFRME4WOZdWq2TdayZhWjhBr1r5X4" + ) + + # Third token response for force refresh + mock_token_response3 = MagicMock() + mock_token_response3.access_token = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJzdWIiOiIxMTExMTExMTExIiwibmFtZSI6IkZvcmNlIFJlZnJlc2giLCJpYXQiOjE1MTYyMzkwMjJ9." + "dQw4w9WgXcQ" + ) + + mock_api_client.bots.token.get = AsyncMock( + side_effect=[mock_token_response1, mock_token_response2, mock_token_response3] + ) + + mock_credentials = ClientCredentials( + client_id="test-client-id", + client_secret="test-client-secret", + tenant_id="test-tenant-id", + ) + + manager = TokenManager( + api_client=mock_api_client, + credentials=mock_credentials, + ) + + # First call + token1 = await manager.refresh_bot_token() + assert token1 is not None + assert isinstance(token1, JsonWebToken) + assert manager.bot_token == token1 + + # Second call should use cache + token2 = await manager.refresh_bot_token() + assert token2 == token1 + mock_api_client.bots.token.get.assert_called_once() + + # Mock the token as expired + token1.is_expired = MagicMock(return_value=True) + + # Third call should refresh because token is expired + token3 = await manager.refresh_bot_token() + assert token3 is not None + assert token3 != token1 # New token + assert mock_api_client.bots.token.get.call_count == 2 + + # Force refresh even if not expired + token3.is_expired = MagicMock(return_value=False) + token4 = await manager.refresh_bot_token(force=True) + assert token4 is not None + assert mock_api_client.bots.token.get.call_count == 3 + + @pytest.mark.asyncio + async def test_refresh_bot_token_no_credentials(self): + """Test refreshing bot token with no credentials returns None.""" + mock_api_client = MagicMock() + + manager = TokenManager( + api_client=mock_api_client, + credentials=None, + ) + + token = await manager.refresh_bot_token() + assert token is None + + @pytest.mark.asyncio + async def test_get_or_refresh_graph_token_default(self): + """Test getting default graph token with caching and expiration refresh.""" + mock_api_client = MagicMock() + + # First token response + mock_token_response1 = MagicMock() + mock_token_response1.access_token = VALID_TEST_TOKEN + + # Second token response for expired token + mock_token_response2 = MagicMock() + mock_token_response2.access_token = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJzdWIiOiI5ODc2NTQzMjEwIiwibmFtZSI6IkphbmUgRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ." + "Twzj7LKlhYUUe2GFRME4WOZdWq2TdayZhWjhBr1r5X4" + ) + + mock_api_client.bots.token.get_graph = AsyncMock(side_effect=[mock_token_response1, mock_token_response2]) + + mock_credentials = ClientCredentials( + client_id="test-client-id", + client_secret="test-client-secret", + tenant_id="default-tenant-id", + ) + + manager = TokenManager( + api_client=mock_api_client, + credentials=mock_credentials, + ) + + token1 = await manager.get_or_refresh_graph_token() + + assert token1 is not None + assert isinstance(token1, JsonWebToken) + + # Verify it's cached + token2 = await manager.get_or_refresh_graph_token() + assert token2 == token1 + mock_api_client.bots.token.get_graph.assert_called_once() + + # Mock the token as expired + token1.is_expired = MagicMock(return_value=True) + + # Third call should refresh because token is expired + token3 = await manager.get_or_refresh_graph_token() + assert token3 is not None + assert token3 != token1 # New token + assert mock_api_client.bots.token.get_graph.call_count == 2 + + @pytest.mark.asyncio + async def test_get_or_refresh_graph_token_with_tenant(self): + """Test getting tenant-specific graph token.""" + mock_api_client = MagicMock() + mock_token_response = MagicMock() + mock_token_response.access_token = VALID_TEST_TOKEN + mock_api_client.bots.token.get_graph = AsyncMock(return_value=mock_token_response) + + original_credentials = ClientCredentials( + client_id="test-client-id", + client_secret="test-client-secret", + tenant_id="original-tenant-id", + ) + + manager = TokenManager( + api_client=mock_api_client, + credentials=original_credentials, + ) + + token = await manager.get_or_refresh_graph_token("different-tenant-id") + + assert token is not None + mock_api_client.bots.token.get_graph.assert_called_once() + + # Verify tenant-specific credentials were created + call_args = mock_api_client.bots.token.get_graph.call_args + passed_credentials = call_args[0][0] + assert isinstance(passed_credentials, ClientCredentials) + assert passed_credentials.tenant_id == "different-tenant-id" + + @pytest.mark.asyncio + async def test_get_user_token_success(self): + """Test successful user token retrieval.""" + mock_api_client = MagicMock() + mock_token_response = MagicMock() + mock_token_response.token = "user-token-value" + mock_api_client.users.token.get = AsyncMock(return_value=mock_token_response) + + mock_credentials = MagicMock() + + manager = TokenManager( + api_client=mock_api_client, + credentials=mock_credentials, + default_connection_name="test-connection", + ) + + token = await manager.get_user_token("msteams", "user-123") + + assert token == "user-token-value" + mock_api_client.users.token.get.assert_called_once() From 593ca57a6db844ff87ccde049ea3a1e5fa5cde9a Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sat, 25 Oct 2025 13:20:10 -0700 Subject: [PATCH 08/15] bot_token -> cached_bot_token --- packages/apps/src/microsoft/teams/apps/token_manager.py | 2 +- packages/apps/tests/test_token_manager.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/apps/src/microsoft/teams/apps/token_manager.py b/packages/apps/src/microsoft/teams/apps/token_manager.py index 6eae60ff..677b25b3 100644 --- a/packages/apps/src/microsoft/teams/apps/token_manager.py +++ b/packages/apps/src/microsoft/teams/apps/token_manager.py @@ -41,7 +41,7 @@ def __init__( self._graph_tokens: LocalStorage[TokenProtocol] = LocalStorage({}, LocalStorageOptions(max=20000)) @property - def bot_token(self): + def cached_bot_token(self): return self._bot_token def get_tenant_graph_token(self, tenant_id: str | None): diff --git a/packages/apps/tests/test_token_manager.py b/packages/apps/tests/test_token_manager.py index a3f6d9d6..a66a0f1f 100644 --- a/packages/apps/tests/test_token_manager.py +++ b/packages/apps/tests/test_token_manager.py @@ -64,7 +64,7 @@ async def test_refresh_bot_token_success(self): token1 = await manager.refresh_bot_token() assert token1 is not None assert isinstance(token1, JsonWebToken) - assert manager.bot_token == token1 + assert manager.cached_bot_token == token1 # Second call should use cache token2 = await manager.refresh_bot_token() From 4ae745ff6c2b4cd18d735b7fca5a0df7a5a00f5a Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sat, 25 Oct 2025 21:12:58 -0700 Subject: [PATCH 09/15] Fixes --- packages/apps/src/microsoft/teams/apps/app.py | 6 +- .../src/microsoft/teams/apps/token_manager.py | 30 +-- packages/apps/tests/test_app.py | 29 +-- packages/apps/tests/test_token_manager.py | 199 ++++++++++-------- 4 files changed, 148 insertions(+), 116 deletions(-) diff --git a/packages/apps/src/microsoft/teams/apps/app.py b/packages/apps/src/microsoft/teams/apps/app.py index 152674cf..ccb300b2 100644 --- a/packages/apps/src/microsoft/teams/apps/app.py +++ b/packages/apps/src/microsoft/teams/apps/app.py @@ -84,7 +84,7 @@ def __init__(self, **options: Unpack[AppOptions]): self.credentials = self._init_credentials() self._token_manager = TokenManager( - api_client=self.api, + http_client=self.http_client, credentials=self.credentials, logger=self.log, default_connection_name=self.options.default_connection_name, @@ -464,5 +464,5 @@ async def call_next(r: Request) -> Any: # Named decoration: @app.func("name") return decorator - def _get_or_refresh_bot_token(self): - return self._token_manager.refresh_bot_token() + async def _get_or_refresh_bot_token(self): + return await self._token_manager.refresh_bot_token() diff --git a/packages/apps/src/microsoft/teams/apps/token_manager.py b/packages/apps/src/microsoft/teams/apps/token_manager.py index 677b25b3..9126855f 100644 --- a/packages/apps/src/microsoft/teams/apps/token_manager.py +++ b/packages/apps/src/microsoft/teams/apps/token_manager.py @@ -6,14 +6,17 @@ import logging from typing import Optional -from microsoft.teams.api import ChannelID, ClientCredentials -from microsoft.teams.api.auth.credentials import Credentials -from microsoft.teams.api.auth.json_web_token import JsonWebToken -from microsoft.teams.api.auth.token import TokenProtocol -from microsoft.teams.api.clients.api_client import ApiClient -from microsoft.teams.api.clients.user.params import GetUserTokenParams -from microsoft.teams.common.logging.console import ConsoleLogger -from microsoft.teams.common.storage.local_storage import LocalStorage, LocalStorageOptions +from microsoft.teams.api import ( + BotTokenClient, + ChannelID, + ClientCredentials, + Credentials, + GetUserTokenParams, + JsonWebToken, + TokenProtocol, + UserTokenClient, +) +from microsoft.teams.common import Client, ConsoleLogger, LocalStorage, LocalStorageOptions class TokenManager: @@ -21,12 +24,13 @@ class TokenManager: def __init__( self, - api_client: ApiClient, + http_client: Client, credentials: Optional[Credentials], logger: Optional[logging.Logger] = None, default_connection_name: Optional[str] = None, ): - self._api_client = api_client + self._bot_token_client = BotTokenClient(http_client.clone()) + self._user_token_client = UserTokenClient(http_client.clone()) self._credentials = credentials self._default_connection_name = default_connection_name @@ -62,7 +66,7 @@ async def refresh_bot_token(self, force: bool = False) -> Optional[TokenProtocol if self._bot_token: self._logger.debug("Refreshing bot token") - token_response = await self._api_client.bots.token.get(self._credentials) + token_response = await self._bot_token_client.get(self._credentials) self._bot_token = JsonWebToken(token_response.access_token) self._logger.debug("Bot token refreshed successfully") return self._bot_token @@ -99,7 +103,7 @@ async def get_or_refresh_graph_token( tenant_id=tenant_id, ) - response = await self._api_client.bots.token.get_graph(creds) + response = await self._bot_token_client.get_graph(creds) token = JsonWebToken(response.access_token) self._graph_tokens.set(key, token) @@ -122,7 +126,7 @@ async def get_user_token(self, channel_id: ChannelID, user_id: str) -> Optional[ self._logger.warning("No default connection name configured, cannot get user token") return None - response = await self._api_client.users.token.get( + response = await self._user_token_client.get( GetUserTokenParams( channel_id=channel_id, user_id=user_id, diff --git a/packages/apps/tests/test_app.py b/packages/apps/tests/test_app.py index 6c9b40c9..134c0151 100644 --- a/packages/apps/tests/test_app.py +++ b/packages/apps/tests/test_app.py @@ -131,11 +131,14 @@ def test_app_starts_successfully(self, basic_options): @pytest.mark.asyncio async def test_app_lifecycle_start_stop(self, app_with_options): """Test basic app lifecycle: start and stop.""" + # Mock the underlying HTTP server to avoid actual server startup - with ( - patch.object(app_with_options, "_refresh_tokens", new_callable=AsyncMock), - patch.object(app_with_options.http, "on_start", new_callable=AsyncMock), - ): + async def mock_on_start(event): + # Simulate the HTTP plugin calling the ready callback + if app_with_options.http.on_ready_callback: + await app_with_options.http.on_ready_callback() + + with patch.object(app_with_options.http, "on_start", new_callable=AsyncMock, side_effect=mock_on_start): # Test start start_task = asyncio.create_task(app_with_options.start(3978)) await asyncio.sleep(0.1) @@ -507,16 +510,18 @@ def get_token(scope, tenant_id=None): options = AppOptions(client_id="test-client-123", token=get_token) - app = App(**options) + # Mock environment variables to ensure they don't interfere + with patch.dict("os.environ", {"CLIENT_ID": "", "CLIENT_SECRET": "", "TENANT_ID": ""}, clear=False): + app = App(**options) - assert app.credentials is not None - assert type(app.credentials) is TokenCredentials - assert app.credentials.client_id == "test-client-123" - assert callable(app.credentials.token) + assert app.credentials is not None + assert type(app.credentials) is TokenCredentials + assert app.credentials.client_id == "test-client-123" + assert callable(app.credentials.token) - res = await app.api.bots.token.get(app.credentials) - assert token_called is True - assert res.access_token == "test.jwt.token" + res = await app.api.bots.token.get(app.credentials) + assert token_called is True + assert res.access_token == "test.jwt.token" def test_middleware_registration(self, app_with_options: App) -> None: """Test that middleware is registered correctly using app.use().""" diff --git a/packages/apps/tests/test_token_manager.py b/packages/apps/tests/test_token_manager.py index a66a0f1f..77b99a60 100644 --- a/packages/apps/tests/test_token_manager.py +++ b/packages/apps/tests/test_token_manager.py @@ -3,11 +3,12 @@ Licensed under the MIT License. """ -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest from microsoft.teams.api import ClientCredentials, JsonWebToken from microsoft.teams.apps.token_manager import TokenManager +from microsoft.teams.common import Client # Valid JWT-like token for testing (format: header.payload.signature) VALID_TEST_TOKEN = ( @@ -23,8 +24,6 @@ class TestTokenManager: @pytest.mark.asyncio async def test_refresh_bot_token_success(self): """Test successful bot token refresh, caching, and expiration refresh.""" - mock_api_client = MagicMock() - # First token response mock_token_response1 = MagicMock() mock_token_response1.access_token = VALID_TEST_TOKEN @@ -45,65 +44,71 @@ async def test_refresh_bot_token_success(self): "dQw4w9WgXcQ" ) - mock_api_client.bots.token.get = AsyncMock( - side_effect=[mock_token_response1, mock_token_response2, mock_token_response3] - ) - mock_credentials = ClientCredentials( client_id="test-client-id", client_secret="test-client-secret", tenant_id="test-tenant-id", ) - manager = TokenManager( - api_client=mock_api_client, - credentials=mock_credentials, + # Mock the BotTokenClient + mock_bot_token_client = MagicMock() + mock_bot_token_client.get = AsyncMock( + side_effect=[mock_token_response1, mock_token_response2, mock_token_response3] ) - # First call - token1 = await manager.refresh_bot_token() - assert token1 is not None - assert isinstance(token1, JsonWebToken) - assert manager.cached_bot_token == token1 - - # Second call should use cache - token2 = await manager.refresh_bot_token() - assert token2 == token1 - mock_api_client.bots.token.get.assert_called_once() - - # Mock the token as expired - token1.is_expired = MagicMock(return_value=True) - - # Third call should refresh because token is expired - token3 = await manager.refresh_bot_token() - assert token3 is not None - assert token3 != token1 # New token - assert mock_api_client.bots.token.get.call_count == 2 - - # Force refresh even if not expired - token3.is_expired = MagicMock(return_value=False) - token4 = await manager.refresh_bot_token(force=True) - assert token4 is not None - assert mock_api_client.bots.token.get.call_count == 3 + mock_http_client = MagicMock(spec=Client) + mock_http_client.clone = MagicMock(return_value=mock_http_client) + + with patch("microsoft.teams.apps.token_manager.BotTokenClient", return_value=mock_bot_token_client): + manager = TokenManager( + http_client=mock_http_client, + credentials=mock_credentials, + ) + + # First call + token1 = await manager.refresh_bot_token() + assert token1 is not None + assert isinstance(token1, JsonWebToken) + assert manager.cached_bot_token == token1 + + # Second call should use cache + token2 = await manager.refresh_bot_token() + assert token2 == token1 + mock_bot_token_client.get.assert_called_once() + + # Mock the token as expired + token1.is_expired = MagicMock(return_value=True) + + # Third call should refresh because token is expired + token3 = await manager.refresh_bot_token() + assert token3 is not None + assert token3 != token1 # New token + assert mock_bot_token_client.get.call_count == 2 + + # Force refresh even if not expired + token3.is_expired = MagicMock(return_value=False) + token4 = await manager.refresh_bot_token(force=True) + assert token4 is not None + assert mock_bot_token_client.get.call_count == 3 @pytest.mark.asyncio async def test_refresh_bot_token_no_credentials(self): """Test refreshing bot token with no credentials returns None.""" - mock_api_client = MagicMock() + mock_http_client = MagicMock(spec=Client) + mock_http_client.clone = MagicMock(return_value=mock_http_client) - manager = TokenManager( - api_client=mock_api_client, - credentials=None, - ) + with patch("microsoft.teams.apps.token_manager.BotTokenClient"): + manager = TokenManager( + http_client=mock_http_client, + credentials=None, + ) - token = await manager.refresh_bot_token() - assert token is None + token = await manager.refresh_bot_token() + assert token is None @pytest.mark.asyncio async def test_get_or_refresh_graph_token_default(self): """Test getting default graph token with caching and expiration refresh.""" - mock_api_client = MagicMock() - # First token response mock_token_response1 = MagicMock() mock_token_response1.access_token = VALID_TEST_TOKEN @@ -116,45 +121,49 @@ async def test_get_or_refresh_graph_token_default(self): "Twzj7LKlhYUUe2GFRME4WOZdWq2TdayZhWjhBr1r5X4" ) - mock_api_client.bots.token.get_graph = AsyncMock(side_effect=[mock_token_response1, mock_token_response2]) - mock_credentials = ClientCredentials( client_id="test-client-id", client_secret="test-client-secret", tenant_id="default-tenant-id", ) - manager = TokenManager( - api_client=mock_api_client, - credentials=mock_credentials, - ) + # Mock the BotTokenClient + mock_bot_token_client = MagicMock() + mock_bot_token_client.get_graph = AsyncMock(side_effect=[mock_token_response1, mock_token_response2]) + + mock_http_client = MagicMock(spec=Client) + mock_http_client.clone = MagicMock(return_value=mock_http_client) - token1 = await manager.get_or_refresh_graph_token() + with patch("microsoft.teams.apps.token_manager.BotTokenClient", return_value=mock_bot_token_client): + manager = TokenManager( + http_client=mock_http_client, + credentials=mock_credentials, + ) - assert token1 is not None - assert isinstance(token1, JsonWebToken) + token1 = await manager.get_or_refresh_graph_token() - # Verify it's cached - token2 = await manager.get_or_refresh_graph_token() - assert token2 == token1 - mock_api_client.bots.token.get_graph.assert_called_once() + assert token1 is not None + assert isinstance(token1, JsonWebToken) - # Mock the token as expired - token1.is_expired = MagicMock(return_value=True) + # Verify it's cached + token2 = await manager.get_or_refresh_graph_token() + assert token2 == token1 + mock_bot_token_client.get_graph.assert_called_once() - # Third call should refresh because token is expired - token3 = await manager.get_or_refresh_graph_token() - assert token3 is not None - assert token3 != token1 # New token - assert mock_api_client.bots.token.get_graph.call_count == 2 + # Mock the token as expired + token1.is_expired = MagicMock(return_value=True) + + # Third call should refresh because token is expired + token3 = await manager.get_or_refresh_graph_token() + assert token3 is not None + assert token3 != token1 # New token + assert mock_bot_token_client.get_graph.call_count == 2 @pytest.mark.asyncio async def test_get_or_refresh_graph_token_with_tenant(self): """Test getting tenant-specific graph token.""" - mock_api_client = MagicMock() mock_token_response = MagicMock() mock_token_response.access_token = VALID_TEST_TOKEN - mock_api_client.bots.token.get_graph = AsyncMock(return_value=mock_token_response) original_credentials = ClientCredentials( client_id="test-client-id", @@ -162,39 +171,53 @@ async def test_get_or_refresh_graph_token_with_tenant(self): tenant_id="original-tenant-id", ) - manager = TokenManager( - api_client=mock_api_client, - credentials=original_credentials, - ) + # Mock the BotTokenClient + mock_bot_token_client = MagicMock() + mock_bot_token_client.get_graph = AsyncMock(return_value=mock_token_response) - token = await manager.get_or_refresh_graph_token("different-tenant-id") + mock_http_client = MagicMock(spec=Client) + mock_http_client.clone = MagicMock(return_value=mock_http_client) - assert token is not None - mock_api_client.bots.token.get_graph.assert_called_once() + with patch("microsoft.teams.apps.token_manager.BotTokenClient", return_value=mock_bot_token_client): + manager = TokenManager( + http_client=mock_http_client, + credentials=original_credentials, + ) - # Verify tenant-specific credentials were created - call_args = mock_api_client.bots.token.get_graph.call_args - passed_credentials = call_args[0][0] - assert isinstance(passed_credentials, ClientCredentials) - assert passed_credentials.tenant_id == "different-tenant-id" + token = await manager.get_or_refresh_graph_token("different-tenant-id") + + assert token is not None + mock_bot_token_client.get_graph.assert_called_once() + + # Verify tenant-specific credentials were created + call_args = mock_bot_token_client.get_graph.call_args + passed_credentials = call_args[0][0] + assert isinstance(passed_credentials, ClientCredentials) + assert passed_credentials.tenant_id == "different-tenant-id" @pytest.mark.asyncio async def test_get_user_token_success(self): """Test successful user token retrieval.""" - mock_api_client = MagicMock() mock_token_response = MagicMock() mock_token_response.token = "user-token-value" - mock_api_client.users.token.get = AsyncMock(return_value=mock_token_response) mock_credentials = MagicMock() - manager = TokenManager( - api_client=mock_api_client, - credentials=mock_credentials, - default_connection_name="test-connection", - ) + # Mock the UserTokenClient + mock_user_token_client = MagicMock() + mock_user_token_client.get = AsyncMock(return_value=mock_token_response) + + mock_http_client = MagicMock(spec=Client) + mock_http_client.clone = MagicMock(return_value=mock_http_client) + + with patch("microsoft.teams.apps.token_manager.UserTokenClient", return_value=mock_user_token_client): + manager = TokenManager( + http_client=mock_http_client, + credentials=mock_credentials, + default_connection_name="test-connection", + ) - token = await manager.get_user_token("msteams", "user-123") + token = await manager.get_user_token("msteams", "user-123") - assert token == "user-token-value" - mock_api_client.users.token.get.assert_called_once() + assert token == "user-token-value" + mock_user_token_client.get.assert_called_once() From 4e9d57f6b0ac64df24257759856fae026233d257 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sat, 25 Oct 2025 21:48:28 -0700 Subject: [PATCH 10/15] Fix --- packages/apps/src/microsoft/teams/apps/app.py | 6 +++--- packages/apps/src/microsoft/teams/apps/token_manager.py | 7 ++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/apps/src/microsoft/teams/apps/app.py b/packages/apps/src/microsoft/teams/apps/app.py index ccb300b2..8c6e0d51 100644 --- a/packages/apps/src/microsoft/teams/apps/app.py +++ b/packages/apps/src/microsoft/teams/apps/app.py @@ -93,7 +93,7 @@ def __init__(self, **options: Unpack[AppOptions]): self.container = Container() self.container.set_provider("id", providers.Object(self.id)) self.container.set_provider("credentials", providers.Object(self.credentials)) - self.container.set_provider("bot_token", providers.Factory(self._get_or_refresh_bot_token)) + self.container.set_provider("bot_token", providers.Factory(lambda: self._get_or_refresh_bot_token)) self.container.set_provider("logger", providers.Object(self.log)) self.container.set_provider("storage", providers.Object(self.storage)) self.container.set_provider(self.http_client.__class__.__name__, providers.Factory(lambda: self.http_client)) @@ -464,5 +464,5 @@ async def call_next(r: Request) -> Any: # Named decoration: @app.func("name") return decorator - async def _get_or_refresh_bot_token(self): - return await self._token_manager.refresh_bot_token() + def _get_or_refresh_bot_token(self): + return self._token_manager.refresh_bot_token() diff --git a/packages/apps/src/microsoft/teams/apps/token_manager.py b/packages/apps/src/microsoft/teams/apps/token_manager.py index 9126855f..92b6e700 100644 --- a/packages/apps/src/microsoft/teams/apps/token_manager.py +++ b/packages/apps/src/microsoft/teams/apps/token_manager.py @@ -17,6 +17,7 @@ UserTokenClient, ) from microsoft.teams.common import Client, ConsoleLogger, LocalStorage, LocalStorageOptions +from microsoft.teams.common.http.client import ClientOptions class TokenManager: @@ -30,7 +31,9 @@ def __init__( default_connection_name: Optional[str] = None, ): self._bot_token_client = BotTokenClient(http_client.clone()) - self._user_token_client = UserTokenClient(http_client.clone()) + self._user_token_client = UserTokenClient( + http_client.clone(ClientOptions(token=lambda: self.refresh_bot_token(force=False))) + ) self._credentials = credentials self._default_connection_name = default_connection_name @@ -65,6 +68,8 @@ async def refresh_bot_token(self, force: bool = False) -> Optional[TokenProtocol if self._bot_token: self._logger.debug("Refreshing bot token") + else: + self._logger.debug("Retrieving bot token") token_response = await self._bot_token_client.get(self._credentials) self._bot_token = JsonWebToken(token_response.access_token) From de66766ae616ddf0f784b79157f8fe80e8086df9 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sun, 26 Oct 2025 08:25:34 -0700 Subject: [PATCH 11/15] Fix graph test --- packages/apps/tests/test_optional_graph_dependencies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apps/tests/test_optional_graph_dependencies.py b/packages/apps/tests/test_optional_graph_dependencies.py index f6cf0875..7d0dd244 100644 --- a/packages/apps/tests/test_optional_graph_dependencies.py +++ b/packages/apps/tests/test_optional_graph_dependencies.py @@ -128,5 +128,5 @@ def test_app_graph_property_no_token(self) -> None: ) # app_graph should raise ValueError when no app token is available - with pytest.raises(ValueError, match="No app token available for Graph client"): + with pytest.raises(ValueError, match="Token cannot be None"): _ = activity_context.app_graph From 774c96224243b3f0a64d5860496c0b800f943451 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Sun, 26 Oct 2025 08:26:37 -0700 Subject: [PATCH 12/15] Fix test --- packages/apps/tests/test_optional_graph_dependencies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apps/tests/test_optional_graph_dependencies.py b/packages/apps/tests/test_optional_graph_dependencies.py index 7d0dd244..cc78ac1f 100644 --- a/packages/apps/tests/test_optional_graph_dependencies.py +++ b/packages/apps/tests/test_optional_graph_dependencies.py @@ -128,5 +128,5 @@ def test_app_graph_property_no_token(self) -> None: ) # app_graph should raise ValueError when no app token is available - with pytest.raises(ValueError, match="Token cannot be None"): + with pytest.raises(RuntimeError, match="Token cannot be None"): _ = activity_context.app_graph From 041035841c62a7cb98128879a761e3c61b8ed76d Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Mon, 27 Oct 2025 14:38:45 -0700 Subject: [PATCH 13/15] Switch to async (tho not necessarily needed) --- packages/apps/src/microsoft/teams/apps/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/apps/src/microsoft/teams/apps/app.py b/packages/apps/src/microsoft/teams/apps/app.py index 8c6e0d51..50465134 100644 --- a/packages/apps/src/microsoft/teams/apps/app.py +++ b/packages/apps/src/microsoft/teams/apps/app.py @@ -464,5 +464,5 @@ async def call_next(r: Request) -> Any: # Named decoration: @app.func("name") return decorator - def _get_or_refresh_bot_token(self): - return self._token_manager.refresh_bot_token() + async def _get_or_refresh_bot_token(self): + return await self._token_manager.refresh_bot_token() From 5d157fcfbebd2ed8a651763a9b9c5894832038fd Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Tue, 28 Oct 2025 18:32:16 -0700 Subject: [PATCH 14/15] naming --- packages/apps/src/microsoft/teams/apps/app.py | 8 +- .../src/microsoft/teams/apps/app_process.py | 15 +++- .../src/microsoft/teams/apps/token_manager.py | 44 +---------- packages/apps/tests/test_token_manager.py | 77 ++++++++++++------- 4 files changed, 66 insertions(+), 78 deletions(-) diff --git a/packages/apps/src/microsoft/teams/apps/app.py b/packages/apps/src/microsoft/teams/apps/app.py index 50465134..d30e92c2 100644 --- a/packages/apps/src/microsoft/teams/apps/app.py +++ b/packages/apps/src/microsoft/teams/apps/app.py @@ -93,14 +93,14 @@ def __init__(self, **options: Unpack[AppOptions]): self.container = Container() self.container.set_provider("id", providers.Object(self.id)) self.container.set_provider("credentials", providers.Object(self.credentials)) - self.container.set_provider("bot_token", providers.Factory(lambda: self._get_or_refresh_bot_token)) + self.container.set_provider("bot_token", providers.Factory(lambda: self._get_or_get_bot_token)) self.container.set_provider("logger", providers.Object(self.log)) self.container.set_provider("storage", providers.Object(self.storage)) self.container.set_provider(self.http_client.__class__.__name__, providers.Factory(lambda: self.http_client)) self.api = ApiClient( "https://smba.trafficmanager.net/teams", - self.http_client.clone(ClientOptions(token=self._get_or_refresh_bot_token)), + self.http_client.clone(ClientOptions(token=self._get_or_get_bot_token)), ) plugins: List[PluginBase] = list(self.options.plugins) @@ -464,5 +464,5 @@ async def call_next(r: Request) -> Any: # Named decoration: @app.func("name") return decorator - async def _get_or_refresh_bot_token(self): - return await self._token_manager.refresh_bot_token() + async def _get_or_get_bot_token(self): + return await self._token_manager.get_bot_token() diff --git a/packages/apps/src/microsoft/teams/apps/app_process.py b/packages/apps/src/microsoft/teams/apps/app_process.py index c37ec9b4..509b4888 100644 --- a/packages/apps/src/microsoft/teams/apps/app_process.py +++ b/packages/apps/src/microsoft/teams/apps/app_process.py @@ -16,6 +16,7 @@ TokenProtocol, is_invoke_response, ) +from microsoft.teams.api.clients.user.params import GetUserTokenParams from microsoft.teams.cards import AdaptiveCard from microsoft.teams.common import Client, ClientOptions, LocalStorage, Storage @@ -81,16 +82,22 @@ async def _build_context( user=activity.from_, ) api_client = ApiClient( - service_url, self.http_client.clone(ClientOptions(token=self.token_manager.refresh_bot_token)) + service_url, self.http_client.clone(ClientOptions(token=self.token_manager.get_bot_token)) ) # Check if user is signed in is_signed_in = False user_token: Optional[str] = None try: - user_token = await self.token_manager.get_user_token( - channel_id=activity.channel_id, user_id=activity.from_.id + user_token_res = await api_client.users.token.get( + GetUserTokenParams( + channel_id=activity.channel_id, + user_id=activity.from_.id, + connection_name=self.default_connection_name, + ) ) + + user_token = user_token_res.token is_signed_in = True except Exception: # User token not available @@ -110,7 +117,7 @@ async def _build_context( is_signed_in, self.default_connection_name, sender, - app_token=lambda: self.token_manager.get_or_refresh_graph_token(tenant_id), + app_token=lambda: self.token_manager.get_graph_token(tenant_id), ) send = activityCtx.send diff --git a/packages/apps/src/microsoft/teams/apps/token_manager.py b/packages/apps/src/microsoft/teams/apps/token_manager.py index 92b6e700..3ab05beb 100644 --- a/packages/apps/src/microsoft/teams/apps/token_manager.py +++ b/packages/apps/src/microsoft/teams/apps/token_manager.py @@ -8,10 +8,8 @@ from microsoft.teams.api import ( BotTokenClient, - ChannelID, ClientCredentials, Credentials, - GetUserTokenParams, JsonWebToken, TokenProtocol, UserTokenClient, @@ -32,7 +30,7 @@ def __init__( ): self._bot_token_client = BotTokenClient(http_client.clone()) self._user_token_client = UserTokenClient( - http_client.clone(ClientOptions(token=lambda: self.refresh_bot_token(force=False))) + http_client.clone(ClientOptions(token=lambda: self.get_bot_token(force=False))) ) self._credentials = credentials self._default_connection_name = default_connection_name @@ -47,17 +45,7 @@ def __init__( # Key: tenant_id (empty string "" for default app graph token) self._graph_tokens: LocalStorage[TokenProtocol] = LocalStorage({}, LocalStorageOptions(max=20000)) - @property - def cached_bot_token(self): - return self._bot_token - - def get_tenant_graph_token(self, tenant_id: str | None): - """ - Returns the graph token for a given tenant id. - """ - return self._graph_tokens.get(tenant_id or "") - - async def refresh_bot_token(self, force: bool = False) -> Optional[TokenProtocol]: + async def get_bot_token(self, force: bool = False) -> Optional[TokenProtocol]: """Refresh the bot authentication token.""" if not self._credentials: self._logger.warning("No credentials provided, skipping bot token refresh") @@ -76,9 +64,7 @@ async def refresh_bot_token(self, force: bool = False) -> Optional[TokenProtocol self._logger.debug("Bot token refreshed successfully") return self._bot_token - async def get_or_refresh_graph_token( - self, tenant_id: Optional[str] = None, force: bool = False - ) -> Optional[TokenProtocol]: + async def get_graph_token(self, tenant_id: Optional[str] = None, force: bool = False) -> Optional[TokenProtocol]: """ Get or refresh a Graph API token. @@ -115,27 +101,3 @@ async def get_or_refresh_graph_token( self._logger.debug(f"Refreshed graph token tenant_id={tenant_id}") return token - - async def get_user_token(self, channel_id: ChannelID, user_id: str) -> Optional[str]: - """ - Get a user token for the specified channel and user. - - Args: - channel_id: The channel ID - user_id: The user ID - - Returns: - The user token or None if not available - """ - if not self._default_connection_name: - self._logger.warning("No default connection name configured, cannot get user token") - return None - - response = await self._user_token_client.get( - GetUserTokenParams( - channel_id=channel_id, - user_id=user_id, - connection_name=self._default_connection_name, - ) - ) - return response.token diff --git a/packages/apps/tests/test_token_manager.py b/packages/apps/tests/test_token_manager.py index 77b99a60..e46001eb 100644 --- a/packages/apps/tests/test_token_manager.py +++ b/packages/apps/tests/test_token_manager.py @@ -22,7 +22,7 @@ class TestTokenManager: """Test TokenManager functionality.""" @pytest.mark.asyncio - async def test_refresh_bot_token_success(self): + async def test_get_bot_token_success(self): """Test successful bot token refresh, caching, and expiration refresh.""" # First token response mock_token_response1 = MagicMock() @@ -66,33 +66,33 @@ async def test_refresh_bot_token_success(self): ) # First call - token1 = await manager.refresh_bot_token() + token1 = await manager.get_bot_token() assert token1 is not None assert isinstance(token1, JsonWebToken) - assert manager.cached_bot_token == token1 + mock_bot_token_client.get.assert_called_once() - # Second call should use cache - token2 = await manager.refresh_bot_token() + # Second call should use cache (mock should still only be called once) + token2 = await manager.get_bot_token() assert token2 == token1 - mock_bot_token_client.get.assert_called_once() + mock_bot_token_client.get.assert_called_once() # Still only called once due to caching # Mock the token as expired token1.is_expired = MagicMock(return_value=True) # Third call should refresh because token is expired - token3 = await manager.refresh_bot_token() + token3 = await manager.get_bot_token() assert token3 is not None assert token3 != token1 # New token assert mock_bot_token_client.get.call_count == 2 # Force refresh even if not expired token3.is_expired = MagicMock(return_value=False) - token4 = await manager.refresh_bot_token(force=True) + token4 = await manager.get_bot_token(force=True) assert token4 is not None assert mock_bot_token_client.get.call_count == 3 @pytest.mark.asyncio - async def test_refresh_bot_token_no_credentials(self): + async def test_get_bot_token_no_credentials(self): """Test refreshing bot token with no credentials returns None.""" mock_http_client = MagicMock(spec=Client) mock_http_client.clone = MagicMock(return_value=mock_http_client) @@ -103,11 +103,11 @@ async def test_refresh_bot_token_no_credentials(self): credentials=None, ) - token = await manager.refresh_bot_token() + token = await manager.get_bot_token() assert token is None @pytest.mark.asyncio - async def test_get_or_refresh_graph_token_default(self): + async def test_get_graph_token_default(self): """Test getting default graph token with caching and expiration refresh.""" # First token response mock_token_response1 = MagicMock() @@ -140,13 +140,13 @@ async def test_get_or_refresh_graph_token_default(self): credentials=mock_credentials, ) - token1 = await manager.get_or_refresh_graph_token() + token1 = await manager.get_graph_token() assert token1 is not None assert isinstance(token1, JsonWebToken) # Verify it's cached - token2 = await manager.get_or_refresh_graph_token() + token2 = await manager.get_graph_token() assert token2 == token1 mock_bot_token_client.get_graph.assert_called_once() @@ -154,13 +154,13 @@ async def test_get_or_refresh_graph_token_default(self): token1.is_expired = MagicMock(return_value=True) # Third call should refresh because token is expired - token3 = await manager.get_or_refresh_graph_token() + token3 = await manager.get_graph_token() assert token3 is not None assert token3 != token1 # New token assert mock_bot_token_client.get_graph.call_count == 2 @pytest.mark.asyncio - async def test_get_or_refresh_graph_token_with_tenant(self): + async def test_get_graph_token_with_tenant(self): """Test getting tenant-specific graph token.""" mock_token_response = MagicMock() mock_token_response.access_token = VALID_TEST_TOKEN @@ -184,7 +184,7 @@ async def test_get_or_refresh_graph_token_with_tenant(self): credentials=original_credentials, ) - token = await manager.get_or_refresh_graph_token("different-tenant-id") + token = await manager.get_graph_token("different-tenant-id") assert token is not None mock_bot_token_client.get_graph.assert_called_once() @@ -196,28 +196,47 @@ async def test_get_or_refresh_graph_token_with_tenant(self): assert passed_credentials.tenant_id == "different-tenant-id" @pytest.mark.asyncio - async def test_get_user_token_success(self): - """Test successful user token retrieval.""" - mock_token_response = MagicMock() - mock_token_response.token = "user-token-value" + async def test_graph_token_force_refresh(self): + """Test force refreshing graph token even when not expired.""" + mock_token_response1 = MagicMock() + mock_token_response1.access_token = VALID_TEST_TOKEN + + mock_token_response2 = MagicMock() + mock_token_response2.access_token = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJzdWIiOiIxMTExMTExMTExIiwibmFtZSI6IkZvcmNlIFJlZnJlc2giLCJpYXQiOjE1MTYyMzkwMjJ9." + "dQw4w9WgXcQ" + ) - mock_credentials = MagicMock() + mock_credentials = ClientCredentials( + client_id="test-client-id", + client_secret="test-client-secret", + tenant_id="test-tenant-id", + ) - # Mock the UserTokenClient - mock_user_token_client = MagicMock() - mock_user_token_client.get = AsyncMock(return_value=mock_token_response) + mock_bot_token_client = MagicMock() + mock_bot_token_client.get_graph = AsyncMock(side_effect=[mock_token_response1, mock_token_response2]) mock_http_client = MagicMock(spec=Client) mock_http_client.clone = MagicMock(return_value=mock_http_client) - with patch("microsoft.teams.apps.token_manager.UserTokenClient", return_value=mock_user_token_client): + with patch("microsoft.teams.apps.token_manager.BotTokenClient", return_value=mock_bot_token_client): manager = TokenManager( http_client=mock_http_client, credentials=mock_credentials, - default_connection_name="test-connection", ) - token = await manager.get_user_token("msteams", "user-123") + # First call + token1 = await manager.get_graph_token() + assert token1 is not None + mock_bot_token_client.get_graph.assert_called_once() - assert token == "user-token-value" - mock_user_token_client.get.assert_called_once() + # Second call should use cache + token2 = await manager.get_graph_token() + assert token2 == token1 + mock_bot_token_client.get_graph.assert_called_once() # Still only called once + + # Force refresh should call API even if not expired + token3 = await manager.get_graph_token(force=True) + assert token3 is not None + assert mock_bot_token_client.get_graph.call_count == 2 # Now called twice From 2cbd1b611c7a34005ccbd635b7bb0da659342fb8 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Tue, 28 Oct 2025 20:32:40 -0700 Subject: [PATCH 15/15] remove user token client --- packages/apps/src/microsoft/teams/apps/token_manager.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/apps/src/microsoft/teams/apps/token_manager.py b/packages/apps/src/microsoft/teams/apps/token_manager.py index 3ab05beb..d2173a53 100644 --- a/packages/apps/src/microsoft/teams/apps/token_manager.py +++ b/packages/apps/src/microsoft/teams/apps/token_manager.py @@ -12,10 +12,8 @@ Credentials, JsonWebToken, TokenProtocol, - UserTokenClient, ) from microsoft.teams.common import Client, ConsoleLogger, LocalStorage, LocalStorageOptions -from microsoft.teams.common.http.client import ClientOptions class TokenManager: @@ -29,9 +27,6 @@ def __init__( default_connection_name: Optional[str] = None, ): self._bot_token_client = BotTokenClient(http_client.clone()) - self._user_token_client = UserTokenClient( - http_client.clone(ClientOptions(token=lambda: self.get_bot_token(force=False))) - ) self._credentials = credentials self._default_connection_name = default_connection_name