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..a73e353de 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, 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 @@ -192,6 +197,24 @@ 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); + }, +}); +``` + + +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 new file mode 100644 index 000000000..db2c273ad --- /dev/null +++ b/packages/adapter-teams/src/config.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } 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("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", + 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..82708c605 100644 --- a/packages/adapter-teams/src/config.ts +++ b/packages/adapter-teams/src/config.ts @@ -13,14 +13,15 @@ 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." ); } 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..df794b967 100644 --- a/packages/adapter-teams/src/types.ts +++ b/packages/adapter-teams/src/types.ts @@ -36,6 +36,20 @@ 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). + * + * 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[], + tenantId?: string + ) => string | Promise; /** Override bot username (optional) */ userName?: string; }