From ee11a8630323b7d53fc2375a6139ffb11d5becaa Mon Sep 17 00:00:00 2001 From: CamdenA21 Date: Tue, 21 Jul 2026 19:55:31 -0400 Subject: [PATCH 1/2] feat(teams): forward a custom token factory to the Teams SDK TeamsAdapterConfig never forwarded a token field to the underlying @microsoft/teams.apps AppOptions.token, even though the Teams SDK already supports it as a "bring your own credentials" escape hatch (TokenCredentials['token']). The only non-secret auth path exposed was `federated`, which maps to managedIdentityClientId and only resolves via Azure-native managed-identity sources (IMDS, AppService, etc.) - unreachable from platforms like Vercel that can't reach Azure IMDS but still need to mint access tokens through an external mechanism (e.g. a workload-identity federation bridge). Add `token` to TeamsAdapterConfig and forward it in toAppOptions, matching the real TokenCredentials['token'] signature. Also omit clientSecret when a token factory is configured - TokenManager checks clientId+clientSecret before clientId+token, so a stray client-secret env var would otherwise silently override the token factory. Signed-off-by: CamdenA21 --- .changeset/teams-token-passthrough.md | 5 +++ apps/docs/content/adapters/official/teams.mdx | 21 +++++++++- packages/adapter-teams/src/config.test.ts | 39 +++++++++++++++++++ packages/adapter-teams/src/config.ts | 8 ++-- packages/adapter-teams/src/index.test.ts | 10 +++++ packages/adapter-teams/src/types.ts | 10 +++++ 6 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 .changeset/teams-token-passthrough.md create mode 100644 packages/adapter-teams/src/config.test.ts diff --git a/.changeset/teams-token-passthrough.md b/.changeset/teams-token-passthrough.md new file mode 100644 index 000000000..6fcac7d6e --- /dev/null +++ b/.changeset/teams-token-passthrough.md @@ -0,0 +1,5 @@ +--- +'@chat-adapter/teams': minor +--- + +Add a `token` config option to `TeamsAdapterConfig` for supplying a custom token factory, forwarded to the Teams SDK's `AppOptions.token`. This lets bots authenticate on runtimes that can't reach Azure IMDS (so `federated` managed identity isn't reachable) but can still mint access tokens through an external mechanism, without needing a static client secret. diff --git a/apps/docs/content/adapters/official/teams.mdx b/apps/docs/content/adapters/official/teams.mdx index 38bfae7f2..0aef50af7 100644 --- a/apps/docs/content/adapters/official/teams.mdx +++ b/apps/docs/content/adapters/official/teams.mdx @@ -101,6 +101,11 @@ bot.onNewMention(async (thread, message) => { type: "FederatedConfig", description: "Federated (workload identity) authentication config.", }, + token: { + type: "(scope: string | string[], tenantId?: string) => string | Promise", + description: + "Custom token factory for outbound Bot Framework/Graph calls, for runtimes that can't reach Azure IMDS (so `federated` isn't reachable) but can still mint access tokens through an external mechanism.", + }, appType: { type: '"MultiTenant" | "SingleTenant"', default: '"MultiTenant"', @@ -124,7 +129,7 @@ bot.onNewMention(async (thread, message) => { }} /> -`appId` is required. Exactly one authentication method (`appPassword` or `federated`) must be provided. +`appId` is required. Exactly one authentication method (`appPassword`, `federated`, or `token`) must be provided. ## Authentication @@ -192,6 +197,20 @@ createTeamsAdapter({ }); ``` +**Custom token factory** — for runtimes without access to Azure IMDS (e.g. serverless platforms), provide your own token-minting logic. Maps to `AppOptions.token` in the Teams SDK: + +```typescript +createTeamsAdapter({ + appId: "your_app_id_here", + appTenantId: "your_tenant_id_here", + token: async (scope, tenantId) => { + // fetch or mint an access token for the given scope/tenant however your + // runtime supports it (e.g. a workload-identity federation bridge) + return await getAccessToken(scope, tenantId); + }, +}); +``` + ## Advanced ### User lookup diff --git a/packages/adapter-teams/src/config.test.ts b/packages/adapter-teams/src/config.test.ts new file mode 100644 index 000000000..81718703f --- /dev/null +++ b/packages/adapter-teams/src/config.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { toAppOptions } from "./config"; + +describe("toAppOptions", () => { + it("forwards a custom token factory to the Teams SDK", () => { + const token = async (_scope: string | string[], _tenantId?: string) => + "custom-access-token"; + + const options = toAppOptions({ + appId: "test-client-id", + appTenantId: "test-tenant-id", + token, + }); + + expect(options.token).toBe(token); + expect(options.clientId).toBe("test-client-id"); + expect(options.tenantId).toBe("test-tenant-id"); + }); + + it("omits clientSecret when a token factory is provided", () => { + const options = toAppOptions({ + appId: "test-client-id", + appPassword: "should-be-ignored", + token: async () => "custom-access-token", + }); + + expect(options.clientSecret).toBeUndefined(); + }); + + it("omits token when not provided", () => { + const options = toAppOptions({ + appId: "test-client-id", + appPassword: "test-secret", + }); + + expect(options.token).toBeUndefined(); + expect(options.clientSecret).toBe("test-secret"); + }); +}); diff --git a/packages/adapter-teams/src/config.ts b/packages/adapter-teams/src/config.ts index 72dd017a6..5d1dc8868 100644 --- a/packages/adapter-teams/src/config.ts +++ b/packages/adapter-teams/src/config.ts @@ -18,9 +18,10 @@ export function toAppOptions( } const clientId = config.appId ?? process.env.TEAMS_APP_ID; - const clientSecret = config.federated - ? undefined - : (config.appPassword ?? process.env.TEAMS_APP_PASSWORD); + const clientSecret = + config.federated || config.token + ? undefined + : (config.appPassword ?? process.env.TEAMS_APP_PASSWORD); // For SingleTenant, tenantId is required. For MultiTenant, omit it. const tenantId = @@ -42,6 +43,7 @@ export function toAppOptions( ...(clientSecret ? { clientSecret } : {}), ...(tenantId ? { tenantId } : {}), ...(managedIdentityClientId ? { managedIdentityClientId } : {}), + ...(config.token ? { token: config.token } : {}), ...(serviceUrl ? { serviceUrl } : {}), }; } diff --git a/packages/adapter-teams/src/index.test.ts b/packages/adapter-teams/src/index.test.ts index 8376a6935..baab76b15 100644 --- a/packages/adapter-teams/src/index.test.ts +++ b/packages/adapter-teams/src/index.test.ts @@ -436,6 +436,16 @@ describe("TeamsAdapter", () => { }); expect(adapter).toBeInstanceOf(TeamsAdapter); }); + + it("should create adapter with a custom token factory", () => { + const adapter = createTeamsAdapter({ + appId: "test", + appTenantId: "test-tenant", + token: async () => "custom-access-token", + logger, + }); + expect(adapter).toBeInstanceOf(TeamsAdapter); + }); }); // ========================================================================== diff --git a/packages/adapter-teams/src/types.ts b/packages/adapter-teams/src/types.ts index a72780874..ff967a346 100644 --- a/packages/adapter-teams/src/types.ts +++ b/packages/adapter-teams/src/types.ts @@ -36,6 +36,16 @@ export interface TeamsAdapterConfig { federated?: TeamsAuthFederated; /** Logger instance for error reporting. Defaults to ConsoleLogger. */ logger?: Logger; + /** + * Custom token factory for outbound Bot Framework/Graph calls. Maps to the underlying + * Teams SDK's AppOptions.token. Use this on runtimes that can't reach Azure IMDS (so + * `federated` managed identity isn't reachable) but still need to mint access tokens + * through an external mechanism (e.g. a workload-identity federation bridge). + */ + token?: ( + scope: string | string[], + tenantId?: string + ) => string | Promise; /** Override bot username (optional) */ userName?: string; } From be60f7c0f077ac67ed4f4a580abd6489e336b77e Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Wed, 22 Jul 2026 13:12:37 +1000 Subject: [PATCH 2/2] docs(teams): document token auth precedence and CLIENT_SECRET caveat, cover env-var path in tests - Note in the token JSDoc and docs that the Teams SDK prefers a generic CLIENT_SECRET env var over the token factory - Replace the unenforced "exactly one auth method" doc claim with the actual precedence (token > federated > appPassword) - Mention token in the certificate-auth error message - Add a config test covering TEAMS_APP_PASSWORD being ignored when a token factory is provided --- apps/docs/content/adapters/official/teams.mdx | 6 +++++- packages/adapter-teams/src/config.test.ts | 19 ++++++++++++++++++- packages/adapter-teams/src/config.ts | 2 +- packages/adapter-teams/src/types.ts | 4 ++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/docs/content/adapters/official/teams.mdx b/apps/docs/content/adapters/official/teams.mdx index 0aef50af7..a73e353de 100644 --- a/apps/docs/content/adapters/official/teams.mdx +++ b/apps/docs/content/adapters/official/teams.mdx @@ -129,7 +129,7 @@ bot.onNewMention(async (thread, message) => { }} /> -`appId` is required. Exactly one authentication method (`appPassword`, `federated`, or `token`) must be provided. +`appId` is required, along with one authentication method (`appPassword`, `federated`, or `token`). If more than one is configured, `token` takes precedence over `federated`, which takes precedence over `appPassword`. ## Authentication @@ -211,6 +211,10 @@ createTeamsAdapter({ }); ``` + +The Teams SDK reads a generic `CLIENT_SECRET` environment variable and prefers it over the token factory. Make sure `CLIENT_SECRET` is not set in your deployment environment, or the bot will silently fall back to client-secret auth. + + ## Advanced ### User lookup diff --git a/packages/adapter-teams/src/config.test.ts b/packages/adapter-teams/src/config.test.ts index 81718703f..db2c273ad 100644 --- a/packages/adapter-teams/src/config.test.ts +++ b/packages/adapter-teams/src/config.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { toAppOptions } from "./config"; describe("toAppOptions", () => { @@ -27,6 +27,23 @@ describe("toAppOptions", () => { expect(options.clientSecret).toBeUndefined(); }); + it("ignores TEAMS_APP_PASSWORD env var when a token factory is provided", () => { + vi.stubEnv("TEAMS_APP_PASSWORD", "env-secret"); + try { + const token = async () => "custom-access-token"; + + const options = toAppOptions({ + appId: "test-client-id", + token, + }); + + expect(options.clientSecret).toBeUndefined(); + expect(options.token).toBe(token); + } finally { + vi.unstubAllEnvs(); + } + }); + it("omits token when not provided", () => { const options = toAppOptions({ appId: "test-client-id", diff --git a/packages/adapter-teams/src/config.ts b/packages/adapter-teams/src/config.ts index 5d1dc8868..82708c605 100644 --- a/packages/adapter-teams/src/config.ts +++ b/packages/adapter-teams/src/config.ts @@ -13,7 +13,7 @@ export function toAppOptions( if (config.certificate) { throw new Error( "Certificate-based authentication is not yet supported by the Teams SDK adapter. " + - "Use appPassword (client secret) or federated (workload identity) authentication instead." + "Use appPassword (client secret), federated (workload identity), or token (custom token factory) authentication instead." ); } diff --git a/packages/adapter-teams/src/types.ts b/packages/adapter-teams/src/types.ts index ff967a346..df794b967 100644 --- a/packages/adapter-teams/src/types.ts +++ b/packages/adapter-teams/src/types.ts @@ -41,6 +41,10 @@ export interface TeamsAdapterConfig { * Teams SDK's AppOptions.token. Use this on runtimes that can't reach Azure IMDS (so * `federated` managed identity isn't reachable) but still need to mint access tokens * through an external mechanism (e.g. a workload-identity federation bridge). + * + * Note: the underlying Teams SDK also reads a generic `CLIENT_SECRET` env var and + * prefers client-secret auth over the token factory when both are present. Make sure + * `CLIENT_SECRET` is not set in the deployment environment when using this option. */ token?: ( scope: string | string[],