You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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: AzureADMultipleOrgs and a single-tenant Azure Bot resource (MicrosoftAppType=SingleTenant, MicrosoftAppTenantId = tenant A). Grant the User.Read.All application permission and admin-consent it in tenant A.
Configure the adapter with appType: "SingleTenant" and appTenantId = tenant A (code sample below).
Install the Teams app package in tenant B. Have a tenant B admin grant consent for User.Read.All through https://login.microsoftonline.com/{tenantB}/adminconsent?client_id={appId}.
As a tenant B user, DM the bot. The adapter caches teams:aadObjectId:{userId} and teams:tenantId:{userId}, and the bot's reply is delivered.
Call bot.getUser(message.author) (or adapter.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
classCrossTenantTeamsAdapterextendsTeamsAdapter{overrideasyncgetUser(userId: string): Promise<UserInfo|null>{conststate=this.chat?.getState();if(!state)returnnull;constaadObjectId=awaitstate.get<string>(`teams:aadObjectId:${userId}`);if(!aadObjectId)returnnull;consttenantId=awaitstate.get<string>(`teams:tenantId:${userId}`);if(!tenantId)returnsuper.getUser(userId);// No cached tenant for this user; fall back to home-tenant lookupconst{ data }=awaitthis.app.client.get<GraphUser>(`https://graph.microsoft.com/v1.0/users/${encodeURIComponent(aadObjectId)}?$select=mail,displayName,userPrincipalName`,{token: async()=>(awaitthis.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, appType has one effect (toAppOptions): for "SingleTenant" it passes appTenantId to the Teams SDK as tenantId, 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 fix getUser either: 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.
fix(teams): hydrate incoming users without Graph #860 (released in 4.39.0) moved the inbound-message path off Graph: handleMessageActivity now resolves the sender through ctx.api.conversations.getMemberById in a private getIncomingUser, which needs no Graph permission. Its description notes that explicit getUser() lookups remain Graph-backed. getUser → getUserByAadObjectId still calls this.app.graph.call(users.get, …) with the same app-level token, so this report still applies to explicit getUser() calls.
getIncomingUser has one call site. The Action.Submit, adaptive-card-action, and dialog open/submit handlers build author from activity.from.name and look no user up, so a handler on those paths that needs the sender's email has to call getUser.
getIncomingUser and getUserByAadObjectId share the teams:userInfo:{aadObjectId} cache (one hour on success, five minutes for the unresolvable sentinel). From the code, getUser for 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.
Bug Description
TeamsAdapter.getUser(userId)looks the user up withthis.app.graph.call(users.get, { "user-id": aadObjectId }).app.graphis the Teams SDK's app-level Graph client, which acquires its token withgetAppGraphToken()and no tenant argument. The Teams SDK token manager resolves the tenant astenantId || credentials.tenantId || "common", so the token is issued by the tenant passed asappTenantId. A user who belongs to any other tenant does not exist in that tenant's directory. Graph answers404 Request_ResourceNotFound, the adapter logsFailed to fetch user info from Graph API, andgetUserreturnsnull.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 withappType: "SingleTenant"andappTenantId: 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. HowevergetUser()works only for tenant A users.Steps to Reproduce
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.appType: "SingleTenant"andappTenantId= tenant A (code sample below).User.Read.Allthroughhttps://login.microsoftonline.com/{tenantB}/adminconsent?client_id={appId}.teams:aadObjectId:{userId}andteams:tenantId:{userId}, and the bot's reply is delivered.bot.getUser(message.author)(oradapter.getUser(userId)) for that user.Expected Behavior
getUserreturns the tenant B user'sUserInfo(email, display name, UPN), as it does for a tenant A user.Actual Behavior
getUserreturnsnulland logs:This happens even with tenant B's admin consent for
User.Read.Allin 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
200once tenant B has consented. That isolates the failure to which tenant issues the token.Code Sample
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.SingleTenant(or a managed identity) is the only bot type that can be created today. The Teams CLI also registers a single-tenant bot.signInAudience(AzureADMultipleOrgs), which is independent of the Azure Bot resource's app type.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-teamshistory, and is offered as input on possible approaches.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.