Skip to content

Commit c6dcc0f

Browse files
committed
Support separate Teams Graph tenant
1 parent d8a14e7 commit c6dcc0f

8 files changed

Lines changed: 58 additions & 11 deletions

File tree

docs/channels/msteams.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ The output will show `CLIENT_ID`, `CLIENT_SECRET`, `TENANT_ID`, and a **Teams Ap
9393
appId: "<CLIENT_ID>",
9494
appPassword: "<CLIENT_SECRET>",
9595
tenantId: "<TENANT_ID>",
96+
// Optional: customer tenant for Graph/RSC calls when different from the bot tenant.
97+
// graphTenantId: "<CUSTOMER_TENANT_ID>",
9698
webhook: { port: 3978, path: "/api/messages" },
9799
},
98100
},
@@ -272,6 +274,8 @@ Creation of new multi-tenant bots was deprecated after 2025-07-31. Use **Single
272274
appId: "<APP_ID>",
273275
appPassword: "<APP_PASSWORD>",
274276
tenantId: "<TENANT_ID>",
277+
// Optional: customer tenant for Graph/RSC calls when different from the bot tenant.
278+
// graphTenantId: "<CUSTOMER_TENANT_ID>",
275279
webhook: { port: 3978, path: "/api/messages" },
276280
},
277281
},
@@ -680,6 +684,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
680684

681685
- `channels.msteams.enabled`: enable/disable the channel.
682686
- `channels.msteams.appId`, `channels.msteams.appPassword`, `channels.msteams.tenantId`: bot credentials.
687+
- `channels.msteams.graphTenantId`: optional Microsoft Graph tenant override for customer-tenant/RSC calls when different from the bot tenant.
683688
- `channels.msteams.webhook.port` (default `3978`)
684689
- `channels.msteams.webhook.path` (default `/api/messages`)
685690
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)

docs/gateway/config-channels.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -711,7 +711,7 @@ Microsoft Teams is plugin-backed and configured under `channels.msteams`.
711711
msteams: {
712712
enabled: true,
713713
configWrites: true,
714-
// appId, appPassword, tenantId, webhook, team/channel policies:
714+
// appId, appPassword, tenantId, optional graphTenantId, webhook, team/channel policies:
715715
// see /channels/msteams
716716
},
717717
},

extensions/msteams/src/channel.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,19 @@ describe("msteams config schema", () => {
7575
}
7676
});
7777

78+
it("accepts a separate Graph tenant id", () => {
79+
const res = MSTeamsConfigSchema.safeParse({
80+
tenantId: "bot-tenant-id",
81+
graphTenantId: "customer-tenant-id",
82+
});
83+
84+
expect(res.success).toBe(true);
85+
if (res.success) {
86+
expect(res.data.tenantId).toBe("bot-tenant-id");
87+
expect(res.data.graphTenantId).toBe("customer-tenant-id");
88+
}
89+
});
90+
7891
it("accepts replyStyle at global/team/channel levels", () => {
7992
const res = MSTeamsConfigSchema.safeParse({
8093
replyStyle: "top-level",

extensions/msteams/src/graph.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,25 @@ describe("msteams graph helpers", () => {
300300
expect(getAccessToken).toHaveBeenCalledWith("https://graph.microsoft.com");
301301
});
302302

303+
it("uses graphTenantId for Graph tokens without changing bot credentials", async () => {
304+
mockGraphTokenResolution();
305+
306+
await expect(
307+
resolveGraphToken({
308+
channels: {
309+
msteams: {
310+
graphTenantId: "customer-tenant-id",
311+
},
312+
},
313+
}),
314+
).resolves.toBe("resolved-token");
315+
316+
expect(loadMSTeamsSdkWithAuthMock).toHaveBeenCalledWith({
317+
...mockCredentials,
318+
tenantId: "customer-tenant-id",
319+
});
320+
});
321+
303322
it("fails when credentials or access tokens are unavailable", async () => {
304323
resolveMSTeamsCredentialsMock.mockReturnValue(undefined);
305324
await expectRejectsToThrow(resolveGraphToken({ channels: {} }), "MS Teams credentials missing");

extensions/msteams/src/graph.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ export function escapeOData(value: string): string {
3636
return value.replace(/'/g, "''");
3737
}
3838

39+
function normalizeOptionalTenantId(value: unknown): string | undefined {
40+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
41+
}
42+
3943
async function requestGraph(params: {
4044
token: string;
4145
path: string;
@@ -214,11 +218,14 @@ export async function resolveGraphToken(
214218
if (!creds) {
215219
throw new Error("MS Teams credentials missing");
216220
}
221+
const graphTenantId = normalizeOptionalTenantId(msteamsCfg?.graphTenantId) ?? creds.tenantId;
222+
const graphCreds =
223+
graphTenantId === creds.tenantId ? creds : { ...creds, tenantId: graphTenantId };
217224

218225
// Try delegated token if requested and configured
219226
if (options?.preferDelegated && msteamsCfg?.delegatedAuth?.enabled && creds.type === "secret") {
220227
const delegated = await resolveDelegatedAccessToken({
221-
tenantId: creds.tenantId,
228+
tenantId: graphTenantId,
222229
clientId: creds.appId,
223230
clientSecret: creds.appPassword,
224231
});
@@ -228,7 +235,7 @@ export async function resolveGraphToken(
228235
// Fall through to app-only token
229236
}
230237

231-
const { app } = await loadMSTeamsSdkWithAuth(creds);
238+
const { app } = await loadMSTeamsSdkWithAuth(graphCreds);
232239
const tokenProvider = createMSTeamsTokenProvider(app);
233240
const graphTokenValue = await tokenProvider.getAccessToken("https://graph.microsoft.com");
234241
const accessToken = readAccessToken(graphTokenValue);

src/config/bundled-channel-config-metadata.generated.ts

Lines changed: 8 additions & 8 deletions
Large diffs are not rendered by default.

src/config/types.msteams.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ export type MSTeamsConfig = {
9595
appPassword?: SecretInput;
9696
/** Azure AD Tenant ID (for single-tenant bots). */
9797
tenantId?: string;
98+
/** Optional customer tenant ID for Microsoft Graph app-only calls when it differs from the bot tenant. */
99+
graphTenantId?: string;
98100
/**
99101
* Authentication type.
100102
* - `"secret"` (default): uses `appPassword` (client secret).

src/config/zod-schema.providers-core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1514,6 +1514,7 @@ export const MSTeamsConfigSchema = z
15141514
appId: z.string().optional(),
15151515
appPassword: SecretInputSchema.optional().register(sensitive),
15161516
tenantId: z.string().optional(),
1517+
graphTenantId: z.string().optional(),
15171518
authType: z.enum(["secret", "federated"]).optional(),
15181519
certificatePath: z.string().optional(),
15191520
certificateThumbprint: z.string().optional(),

0 commit comments

Comments
 (0)