Skip to content

Commit 92e0449

Browse files
authored
Use refreshable app tokens (#187)
## Context This fixes #184 which stated that the token wasn't being refreshed after an hour. This was because we weren't refreshing it on use. In typescript, we refresh the token on every [app.process](https://github.com/microsoft/teams.ts/blob/1f2d735ce02d0add1dc308f9e9df28f0c3fb6985/packages/apps/src/app.process.ts#L31). But this actually still leaves _proactive scenarios_ to used an cached, potentially expired, tokens. To remediate this, the token that gets passed to the API is a factory which refreshes the token if the cached token is expired. For the record C# sets the token value to be [refreshable](https://github.com/microsoft/teams.net/blob/19e4df96dac1524ae99d6c06bd4891fa5535ca67/Libraries/Microsoft.Teams.Apps/App.cs#L67C1-L67C46) (just as this PR is attempting to do). ## Changes This PR includes several changes 1. No more graph token manager. Instead we now have a TokenManager which manages all tokens. Soon, this might change to msal doing the changes 2. The app no longer refreshes token on start. But it does it the first time the token is being used. Because of this, the id field is now from the credentials, and the "name" field had to be removed. I don't think this should cause a big deal because name is honestly, not a very well used (or documented) field. **This is a breaking change though**. 3. The token that's being passed around now is an async function that either gets the token from the cache, or refreshes it if it's expired. ## Testing 1. Unit tests 2. Sanity tests to make sure we can send messages etc normally. 3. Tested to make sure app token refreshes automatically after an hour 4. Tested user graph tokens 5. Tested app graph tokens #### PR Dependency Tree * **PR #187** 👈 * **PR #191** * **PR #192** * **PR #193** This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal)
1 parent 32dc328 commit 92e0449

File tree

14 files changed

+401
-506
lines changed

14 files changed

+401
-506
lines changed

packages/apps/src/microsoft/teams/apps/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
from . import auth, contexts, events, plugins
77
from .app import App
8-
from .app_tokens import AppTokens
98
from .auth import * # noqa: F403
109
from .contexts import * # noqa: F403
1110
from .events import * # noqa: F401, F403
@@ -16,7 +15,7 @@
1615
from .routing import ActivityContext
1716

1817
# Combine all exports from submodules
19-
__all__: list[str] = ["App", "AppOptions", "HttpPlugin", "HttpStream", "ActivityContext", "AppTokens"]
18+
__all__: list[str] = ["App", "AppOptions", "HttpPlugin", "HttpStream", "ActivityContext"]
2019
__all__.extend(auth.__all__)
2120
__all__.extend(events.__all__)
2221
__all__.extend(plugins.__all__)

packages/apps/src/microsoft/teams/apps/app.py

Lines changed: 21 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
ConversationAccount,
2222
ConversationReference,
2323
Credentials,
24-
JsonWebToken,
2524
MessageActivityInput,
2625
TokenCredentials,
2726
)
@@ -32,7 +31,6 @@
3231
from .app_oauth import OauthHandlers
3332
from .app_plugins import PluginProcessor
3433
from .app_process import ActivityProcessor
35-
from .app_tokens import AppTokens
3634
from .auth import TokenValidator
3735
from .auth.remote_function_jwt_middleware import remote_function_jwt_validation
3836
from .container import Container
@@ -45,12 +43,12 @@
4543
get_event_type_from_signature,
4644
is_registered_event,
4745
)
48-
from .graph_token_manager import GraphTokenManager
4946
from .http_plugin import HttpPlugin
5047
from .options import AppOptions, InternalAppOptions
5148
from .plugins import PluginBase, PluginStartEvent, get_metadata
5249
from .routing import ActivityHandlerMixin, ActivityRouter
5350
from .routing.activity_context import ActivityContext
51+
from .token_manager import TokenManager
5452

5553
version = importlib.metadata.version("microsoft-teams-apps")
5654

@@ -83,22 +81,26 @@ def __init__(self, **options: Unpack[AppOptions]):
8381
self._events = EventEmitter[EventType]()
8482
self._router = ActivityRouter()
8583

86-
self._tokens = AppTokens()
8784
self.credentials = self._init_credentials()
8885

86+
self._token_manager = TokenManager(
87+
http_client=self.http_client,
88+
credentials=self.credentials,
89+
logger=self.log,
90+
default_connection_name=self.options.default_connection_name,
91+
)
92+
8993
self.container = Container()
9094
self.container.set_provider("id", providers.Object(self.id))
91-
self.container.set_provider("name", providers.Object(self.name))
9295
self.container.set_provider("credentials", providers.Object(self.credentials))
93-
self.container.set_provider("bot_token", providers.Callable(lambda: self.tokens.bot))
94-
self.container.set_provider("graph_token", providers.Callable(lambda: self.tokens.graph))
96+
self.container.set_provider("bot_token", providers.Factory(lambda: self._get_or_get_bot_token))
9597
self.container.set_provider("logger", providers.Object(self.log))
9698
self.container.set_provider("storage", providers.Object(self.storage))
9799
self.container.set_provider(self.http_client.__class__.__name__, providers.Factory(lambda: self.http_client))
98100

99101
self.api = ApiClient(
100102
"https://smba.trafficmanager.net/teams",
101-
self.http_client.clone(ClientOptions(token=lambda: self.tokens.bot)),
103+
self.http_client.clone(ClientOptions(token=self._get_or_get_bot_token)),
102104
)
103105

104106
plugins: List[PluginBase] = list(self.options.plugins)
@@ -125,20 +127,14 @@ def __init__(self, **options: Unpack[AppOptions]):
125127
self._running = False
126128

127129
# initialize all event, activity, and plugin processors
128-
self.graph_token_manager = GraphTokenManager(
129-
api_client=self.api,
130-
credentials=self.credentials,
131-
logger=self.log,
132-
)
133130
self.activity_processor = ActivityProcessor(
134131
self._router,
135132
self.log,
136133
self.id,
137134
self.storage,
138135
self.options.default_connection_name,
139136
self.http_client,
140-
self.tokens,
141-
self.graph_token_manager,
137+
self._token_manager,
142138
)
143139
self.event_manager = EventManager(self._events, self.activity_processor)
144140
self.activity_processor.event_manager = self.event_manager
@@ -169,11 +165,6 @@ def is_running(self) -> bool:
169165
"""Whether the app is currently running."""
170166
return self._running
171167

172-
@property
173-
def tokens(self) -> AppTokens:
174-
"""Current authentication tokens."""
175-
return self._tokens
176-
177168
@property
178169
def logger(self) -> Logger:
179170
"""The logger instance used by the app."""
@@ -191,17 +182,10 @@ def router(self) -> ActivityRouter:
191182

192183
@property
193184
def id(self) -> Optional[str]:
194-
"""The app's ID from tokens."""
195-
return (
196-
self._tokens.bot.app_id if self._tokens.bot else self._tokens.graph.app_id if self._tokens.graph else None
197-
)
198-
199-
@property
200-
def name(self) -> Optional[str]:
201-
"""The app's name from tokens."""
202-
return getattr(self._tokens.bot, "app_display_name", None) or getattr(
203-
self._tokens.graph, "app_display_name", None
204-
)
185+
"""The app's ID from credentials."""
186+
if not self.credentials:
187+
return None
188+
return self.credentials.client_id
205189

206190
async def start(self, port: Optional[int] = None) -> None:
207191
"""
@@ -220,9 +204,6 @@ async def start(self, port: Optional[int] = None) -> None:
220204
self._port = port or int(os.getenv("PORT", "3978"))
221205

222206
try:
223-
await self._refresh_tokens(force=True)
224-
self._running = True
225-
226207
for plugin in self.plugins:
227208
# Inject the dependencies
228209
self._plugin_processor.inject(plugin)
@@ -234,6 +215,7 @@ async def on_http_ready() -> None:
234215
self.log.info("Teams app started successfully")
235216
assert self._port is not None, "Port must be set before emitting start event"
236217
self._events.emit("start", StartEvent(port=self._port))
218+
self._running = True
237219

238220
self.http.on_ready_callback = on_http_ready
239221

@@ -280,13 +262,13 @@ async def on_http_stopped() -> None:
280262
async def send(self, conversation_id: str, activity: str | ActivityParams | AdaptiveCard):
281263
"""Send an activity proactively."""
282264

283-
if self.id is None or self.name is None:
265+
if self.id is None:
284266
raise ValueError("app not started")
285267

286268
conversation_ref = ConversationReference(
287269
channel_id="msteams",
288270
service_url=self.api.service_url,
289-
bot=Account(id=self.id, name=self.name, role="bot"),
271+
bot=Account(id=self.id, role="bot"),
290272
conversation=ConversationAccount(id=conversation_id, conversation_type="personal"),
291273
)
292274

@@ -326,65 +308,6 @@ def _init_credentials(self) -> Optional[Credentials]:
326308

327309
return None
328310

329-
async def _refresh_tokens(self, force: bool = False) -> None:
330-
"""Refresh bot and graph tokens."""
331-
await asyncio.gather(self._refresh_bot_token(force), self._refresh_graph_token(force), return_exceptions=True)
332-
333-
async def _refresh_bot_token(self, force: bool = False) -> None:
334-
"""Refresh the bot authentication token."""
335-
if not self.credentials:
336-
self.log.warning("No credentials provided, skipping bot token refresh")
337-
return
338-
339-
if not force and self._tokens.bot and not self._tokens.bot.is_expired():
340-
return
341-
342-
if self._tokens.bot:
343-
self.log.debug("Refreshing bot token")
344-
345-
try:
346-
token_response = await self.api.bots.token.get(self.credentials)
347-
self._tokens.bot = JsonWebToken(token_response.access_token)
348-
self.log.debug("Bot token refreshed successfully")
349-
350-
except Exception as error:
351-
self.log.error(f"Failed to refresh bot token: {error}")
352-
353-
self._events.emit("error", ErrorEvent(error, context={"method": "_refresh_bot_token"}))
354-
raise
355-
356-
async def _refresh_graph_token(self, force: bool = False) -> None:
357-
"""Refresh the Graph API token."""
358-
if not self.credentials:
359-
self.log.warning("No credentials provided, skipping graph token refresh")
360-
return
361-
362-
if not force and self._tokens.graph and not self._tokens.graph.is_expired():
363-
return
364-
365-
if self._tokens.graph:
366-
self.log.debug("Refreshing graph token")
367-
368-
try:
369-
# Use GraphTokenManager for tenant-aware token management
370-
tenant_id = self.credentials.tenant_id if self.credentials else None
371-
token = await self.graph_token_manager.get_token(tenant_id)
372-
373-
if token:
374-
self._tokens.graph = token
375-
self.log.debug("Graph token refreshed successfully")
376-
377-
# Emit token acquired event
378-
self._events.emit("token", {"type": "graph", "token": self._tokens.graph})
379-
else:
380-
self.log.debug("Failed to get graph token from GraphTokenManager")
381-
382-
except Exception as error:
383-
self.log.error(f"Failed to refresh graph token: {error}")
384-
385-
self._events.emit("error", ErrorEvent(error, context={"method": "_refresh_graph_token"}))
386-
raise
387-
388311
@overload
389312
def event(self, func_or_event_type: F) -> F:
390313
"""Register event handler with auto-detected type from function signature."""
@@ -521,7 +444,6 @@ async def endpoint(req: Request):
521444
async def call_next(r: Request) -> Any:
522445
ctx = FunctionContext(
523446
id=self.id,
524-
name=self.name,
525447
api=self.api,
526448
http=self.http,
527449
log=self.log,
@@ -541,3 +463,6 @@ async def call_next(r: Request) -> Any:
541463

542464
# Named decoration: @app.func("name")
543465
return decorator
466+
467+
async def _get_or_get_bot_token(self):
468+
return await self._token_manager.get_bot_token()

packages/apps/src/microsoft/teams/apps/app_process.py

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,22 @@
1111
ActivityParams,
1212
ApiClient,
1313
ConversationReference,
14-
GetUserTokenParams,
1514
InvokeResponse,
1615
SentActivity,
1716
TokenProtocol,
1817
is_invoke_response,
1918
)
19+
from microsoft.teams.api.clients.user.params import GetUserTokenParams
2020
from microsoft.teams.cards import AdaptiveCard
2121
from microsoft.teams.common import Client, ClientOptions, LocalStorage, Storage
2222

2323
if TYPE_CHECKING:
2424
from .app_events import EventManager
25-
from .app_tokens import AppTokens
2625
from .events import ActivityEvent, ActivityResponseEvent, ActivitySentEvent
27-
from .graph_token_manager import GraphTokenManager
2826
from .plugins import PluginActivityEvent, PluginBase, Sender
2927
from .routing.activity_context import ActivityContext
3028
from .routing.router import ActivityHandler, ActivityRouter
29+
from .token_manager import TokenManager
3130
from .utils import extract_tenant_id
3231

3332

@@ -42,30 +41,20 @@ def __init__(
4241
storage: Union[Storage[str, Any], LocalStorage[Any]],
4342
default_connection_name: str,
4443
http_client: Client,
45-
token: AppTokens,
46-
graph_token_manager: GraphTokenManager,
44+
token_manager: TokenManager,
4745
) -> None:
4846
self.router = router
4947
self.logger = logger
5048
self.id = id
5149
self.storage = storage
5250
self.default_connection_name = default_connection_name
5351
self.http_client = http_client
54-
self.tokens = token
55-
self._graph_token_manager = graph_token_manager
52+
self.token_manager = token_manager
5653

5754
# This will be set after the EventManager is initialized due to
5855
# a circular dependency
5956
self.event_manager: Optional["EventManager"] = None
6057

61-
async def _get_or_refresh_graph_token(self, tenant_id: Optional[str] = None) -> Optional[TokenProtocol]:
62-
"""Get the current graph token or refresh it if needed."""
63-
try:
64-
return await self._graph_token_manager.get_token(tenant_id)
65-
except Exception as e:
66-
self.logger.error(f"Failed to get graph token via manager: {e}")
67-
return self.tokens.graph
68-
6958
async def _build_context(
7059
self,
7160
activity: ActivityBase,
@@ -92,29 +81,31 @@ async def _build_context(
9281
locale=activity.locale,
9382
user=activity.from_,
9483
)
95-
api_client = ApiClient(service_url, self.http_client.clone(ClientOptions(token=self.tokens.bot)))
84+
api_client = ApiClient(
85+
service_url, self.http_client.clone(ClientOptions(token=self.token_manager.get_bot_token))
86+
)
9687

9788
# Check if user is signed in
9889
is_signed_in = False
9990
user_token: Optional[str] = None
10091
try:
10192
user_token_res = await api_client.users.token.get(
10293
GetUserTokenParams(
103-
connection_name=self.default_connection_name,
104-
user_id=activity.from_.id,
10594
channel_id=activity.channel_id,
95+
user_id=activity.from_.id,
96+
connection_name=self.default_connection_name,
10697
)
10798
)
99+
108100
user_token = user_token_res.token
109101
is_signed_in = True
110102
except Exception:
111103
# User token not available
104+
self.logger.debug("No user token available")
112105
pass
113106

114107
tenant_id = extract_tenant_id(activity)
115108

116-
graph_token = await self._get_or_refresh_graph_token(tenant_id)
117-
118109
activityCtx = ActivityContext(
119110
activity,
120111
self.id or "",
@@ -126,7 +117,7 @@ async def _build_context(
126117
is_signed_in,
127118
self.default_connection_name,
128119
sender,
129-
app_token=graph_token,
120+
app_token=lambda: self.token_manager.get_graph_token(tenant_id),
130121
)
131122

132123
send = activityCtx.send

packages/apps/src/microsoft/teams/apps/app_tokens.py

Lines changed: 0 additions & 17 deletions
This file was deleted.

0 commit comments

Comments
 (0)