Teams adapter: `getUser()` requests its Graph token for the bot's own tenant, so users in other tenants resolve to `null`
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 2.4k
- Forks
- 314
- Avg merge
- 1d 18h
- Merged PRs (30d)
- 63
Description
Bug Description
TeamsAdapter.getUser(userId) looks the user up with this.app.graph.call(users.get, { "user-id": aadObjectId }). app.graph is the Teams SDK's app-level Graph client, which acquires its token with getAppGraphToken() and no tenant argument. The Teams SDK token manager resolves the tenant as tenantId || credentials.tenantId || "common", so the token is issued by the tenant passed as appTenantId. A user who belongs to any other tenant does not exist in that tenant's directory. Graph answers 404 Request_ResourceNotFound, the adapter logs Failed to fetch user info from Graph API, and getUser returns null.
Our bot serves users in several Microsoft 365 tenants from one registration: a multi-tenant Entra app registration (signInAudience: AzureADMultipleOrgs) with a single-tenant Azure Bot resource in tenant A, so the adapter is configured with appType: "SingleTenant" and appTenantId: tenant A (details under Additional context). Messaging works across tenants in this setup: a tenant B user DMs the bot and the reply is delivered. However getUser() works only for tenant A users.
Steps to Reproduce
- In tenant A, create an Entra app registration with
signInAudience: AzureADMultipleOrgsand a single-tenant Azure Bot resource (MicrosoftAppType=SingleTenant,MicrosoftAppTenantId= tenant A). Grant theUser.Read.Allapplication permission and admin-consent it in tenant A. - Configure the adapter with
appType: "SingleTenant"andappTenantId= tenant A (code sample below). - Install the Teams app package in tenant B. Have a tenant B admin grant consent for
User.Read.Allthroughhttps://login.microsoftonline.com/{tenantB}/adminconsent?client_id={appId}. - As a tenant B user, DM the bot. The adapter caches
teams:aadObjectId:{userId}andteams:tenantId:{userId}, and the bot's reply is delivered. - Call
bot.getUser(message.author)(oradapter.getUser(userId)) for that user.
Expected Behavior
getUser returns the tenant B user's UserInfo (email, display name, UPN), as it does for a tenant A user.
Actual Behavior
getUser returns null and logs:
Failed to fetch user info from Graph API { userId: "29:…", error: … 404 Request_ResourceNotFound … }
This happens even with tenant B's admin consent for User.Read.All in place, because the token is never requested from tenant B.
With the workaround below, which requests the token from the cached tenant, the same lookup for the same user returns 200 once tenant B has consented. That isolates the failure to which tenant issues the token.
Code Sample
class CrossTenantTeamsAdapter extends TeamsAdapter {
override async getUser(userId: string): Promise<UserInfo | null> {
const state = this.chat?.getState();
if (!state) return null;
const aadObjectId = await state.get<string>(`teams:aadObjectId:${userId}`);
if (!aadObjectId) return null;
const tenantId = await state.get<string>(`teams:tenantId:${userId}`);
if (!tenantId) return super.getUser(userId); // No cached tenant for this user; fall back to home-tenant lookup
const { data } = await this.app.client.get<GraphUser>(
`https://graph.microsoft.com/v1.0/users/${encodeURIComponent(aadObjectId)}?$select=mail,displayName,userPrincipalName`,
{ token: async () => (await this.app.tokenManager.getGraphToken(tenantId)) ?? undefined },
);
return {
avatarUrl: undefined,
email: data.mail ?? undefined,
fullName: data.displayName ?? aadObjectId,
isBot: false,
userId,
userName: data.userPrincipalName ?? data.displayName ?? userId,
};
}
}
Chat SDK Version
4.30.0
Node.js Version
22.21.1
Platform Adapter
Microsoft Teams, Slack
Operating System
Linux
Additional Context
Why appType: "SingleTenant" with users in several tenants.
- Microsoft stopped allowing new multi-tenant Azure Bot resources after 2025-07-31, so
SingleTenant(or a managed identity) is the only bot type that can be created today. The Teams CLI also registers a single-tenant bot. - Whether the app can be installed and consented in other tenants is controlled by the Entra app registration's
signInAudience(AzureADMultipleOrgs), which is independent of the Azure Bot resource's app type. - In the adapter,
appTypehas one effect (toAppOptions): for"SingleTenant"it passesappTenantIdto the Teams SDK astenantId, and for"MultiTenant"it passes nothing. It has to match the bot resource, or the Connector token comes from the wrong authority and replies fail with 401, so switching to"MultiTenant"is not available to us. It would not fixgetUsereither: the Graph authority would become/common, which the client-credentials flow does not accept.
Use case. A help-desk bot installed into customer tenants from one app registration. It resolves the sender's email on inbound messages, modal submits, and card-button actions to match the sender to a contact in the ticketing system. All three paths call bot.getUser.
Status in 4.40.0. We have not run 4.40.0 live. The following comes from reading the published 4.31–4.40 packages and the packages/adapter-teams history, and is offered as input on possible approaches.
- #860 (released in 4.39.0) moved the inbound-message path off Graph:
handleMessageActivitynow resolves the sender throughctx.api.conversations.getMemberByIdin a privategetIncomingUser, which needs no Graph permission. Its description notes that explicitgetUser()lookups remain Graph-backed.getUser→getUserByAadObjectIdstill callsthis.app.graph.call(users.get, …)with the same app-level token, so this report still applies to explicitgetUser()calls. getIncomingUserhas one call site. The Action.Submit, adaptive-card-action, and dialog open/submit handlers buildauthorfromactivity.from.nameand look no user up, so a handler on those paths that needs the sender's email has to callgetUser.getIncomingUserandgetUserByAadObjectIdshare theteams:userInfo:{aadObjectId}cache (one hour on success, five minutes for theunresolvablesentinel). From the code,getUserfor a tenant B user would succeed while a Connector-written entry is live and fail after it expires, so the failure would look intermittent. A user who only clicks card buttons never refreshes the entry.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with TeamsAdapter.getUser and getUserByAadObjectId in packages/adapter-teams, then compare their app-level Graph token use with getIncomingUser and the tenant-aware workaround described here. Trace the cached teams:tenantId entry and token-manager calls; done means explicit getUser lookups resolve users from other consented tenants without breaking the home-tenant fallback or existing cache behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- azure, typescript
- Domain
- api, authentication, backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100