Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/teams-token-passthrough.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 24 additions & 1 deletion apps/docs/content/adapters/official/teams.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>",
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"',
Expand All @@ -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

Expand Down Expand Up @@ -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);
},
});
```

<Callout type="warn">
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.
</Callout>

## Advanced

### User lookup
Expand Down
56 changes: 56 additions & 0 deletions packages/adapter-teams/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
10 changes: 6 additions & 4 deletions packages/adapter-teams/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -42,6 +43,7 @@ export function toAppOptions(
...(clientSecret ? { clientSecret } : {}),
...(tenantId ? { tenantId } : {}),
...(managedIdentityClientId ? { managedIdentityClientId } : {}),
...(config.token ? { token: config.token } : {}),
...(serviceUrl ? { serviceUrl } : {}),
};
}
10 changes: 10 additions & 0 deletions packages/adapter-teams/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

// ==========================================================================
Expand Down
14 changes: 14 additions & 0 deletions packages/adapter-teams/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
/** Override bot username (optional) */
userName?: string;
}
Expand Down