Skip to content

Commit 26f633b

Browse files
HDYAChudi HuangBradGroux
authored
feat(msteams): add federated credential support (certificate + managed identity) (#53615)
* feat(msteams): add federated authentication support (certificate + managed identity + workload identity) * msteams: fix vitest 4.1.2 compat, type errors, and regenerate config baseline * msteams: fix lint errors, update fetch allowlist, regenerate protocol Swift * fix(msteams): gate secret-only delegated auth flows * fix(ci): unblock gateway watch and install smoke * fix(ci): restore mergeability for pr 53615 * fix(ci): restore channel registry helper typing * fix(ci): refresh raw fetch guard allowlist --------- Co-authored-by: Chudi Huang <[email protected]> Co-authored-by: Brad Groux <[email protected]>
1 parent acd3697 commit 26f633b

37 files changed

Lines changed: 2132 additions & 764 deletions

docs/channels/msteams.md

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ title: "Microsoft Teams"
99

1010
> "Abandon all hope, ye who enter here."
1111
12-
Updated: 2026-01-21
12+
Updated: 2026-03-25
1313

1414
Status: text + DM attachments are supported; channel/group file sending requires `sharePointSiteId` + Graph permissions (see [Sending files in group chats](#sending-files-in-group-chats)). Polls are sent via Adaptive Cards. Message actions expose explicit `upload-file` for file-first sends.
1515

@@ -43,7 +43,7 @@ Details: [Plugins](/tools/plugin)
4343
4. Expose `/api/messages` (port 3978 by default) via a public URL or tunnel.
4444
5. Install the Teams app package and start the gateway.
4545

46-
Minimal config:
46+
Minimal config (client secret):
4747

4848
```json5
4949
{
@@ -59,6 +59,8 @@ Minimal config:
5959
}
6060
```
6161

62+
For production deployments, consider using [federated authentication](#federated-authentication-certificate--managed-identity) (certificate or managed identity) instead of client secrets.
63+
6264
Note: group chats are blocked by default (`channels.msteams.groupPolicy: "allowlist"`). To allow group replies, set `channels.msteams.groupAllowFrom` (or use `groupPolicy: "open"` to allow any member, mention-gated).
6365

6466
## Goals
@@ -190,6 +192,148 @@ Before configuring OpenClaw, you need to create an Azure Bot resource.
190192
2. Click **Microsoft Teams** → Configure → Save
191193
3. Accept the Terms of Service
192194

195+
## Federated Authentication (Certificate + Managed Identity)
196+
197+
> Added in 2026.3.24
198+
199+
For production deployments, OpenClaw supports **federated authentication** as a more secure alternative to client secrets. Two methods are available:
200+
201+
### Option A: Certificate-based authentication
202+
203+
Use a PEM certificate registered with your Entra ID app registration.
204+
205+
**Setup:**
206+
207+
1. Generate or obtain a certificate (PEM format with private key).
208+
2. In Entra ID → App Registration → **Certificates & secrets****Certificates** → Upload the public certificate.
209+
210+
**Config:**
211+
212+
```json5
213+
{
214+
channels: {
215+
msteams: {
216+
enabled: true,
217+
appId: "<APP_ID>",
218+
tenantId: "<TENANT_ID>",
219+
authType: "federated",
220+
certificatePath: "/path/to/cert.pem",
221+
webhook: { port: 3978, path: "/api/messages" },
222+
},
223+
},
224+
}
225+
```
226+
227+
**Env vars:**
228+
229+
- `MSTEAMS_AUTH_TYPE=federated`
230+
- `MSTEAMS_CERTIFICATE_PATH=/path/to/cert.pem`
231+
232+
### Option B: Azure Managed Identity
233+
234+
Use Azure Managed Identity for passwordless authentication. This is ideal for deployments on Azure infrastructure (AKS, App Service, Azure VMs) where a managed identity is available.
235+
236+
**How it works:**
237+
238+
1. The bot pod/VM has a managed identity (system-assigned or user-assigned).
239+
2. A **federated identity credential** links the managed identity to the Entra ID app registration.
240+
3. At runtime, OpenClaw uses `@azure/identity` to acquire tokens from the Azure IMDS endpoint (`169.254.169.254`).
241+
4. The token is passed to the Teams SDK for bot authentication.
242+
243+
**Prerequisites:**
244+
245+
- Azure infrastructure with managed identity enabled (AKS workload identity, App Service, VM)
246+
- Federated identity credential created on the Entra ID app registration
247+
- Network access to IMDS (`169.254.169.254:80`) from the pod/VM
248+
249+
**Config (system-assigned managed identity):**
250+
251+
```json5
252+
{
253+
channels: {
254+
msteams: {
255+
enabled: true,
256+
appId: "<APP_ID>",
257+
tenantId: "<TENANT_ID>",
258+
authType: "federated",
259+
useManagedIdentity: true,
260+
webhook: { port: 3978, path: "/api/messages" },
261+
},
262+
},
263+
}
264+
```
265+
266+
**Config (user-assigned managed identity):**
267+
268+
```json5
269+
{
270+
channels: {
271+
msteams: {
272+
enabled: true,
273+
appId: "<APP_ID>",
274+
tenantId: "<TENANT_ID>",
275+
authType: "federated",
276+
useManagedIdentity: true,
277+
managedIdentityClientId: "<MI_CLIENT_ID>",
278+
webhook: { port: 3978, path: "/api/messages" },
279+
},
280+
},
281+
}
282+
```
283+
284+
**Env vars:**
285+
286+
- `MSTEAMS_AUTH_TYPE=federated`
287+
- `MSTEAMS_USE_MANAGED_IDENTITY=true`
288+
- `MSTEAMS_MANAGED_IDENTITY_CLIENT_ID=<client-id>` (only for user-assigned)
289+
290+
### AKS Workload Identity Setup
291+
292+
For AKS deployments using workload identity:
293+
294+
1. **Enable workload identity** on your AKS cluster.
295+
2. **Create a federated identity credential** on the Entra ID app registration:
296+
297+
```bash
298+
az ad app federated-credential create --id <APP_OBJECT_ID> --parameters '{
299+
"name": "my-bot-workload-identity",
300+
"issuer": "<AKS_OIDC_ISSUER_URL>",
301+
"subject": "system:serviceaccount:<NAMESPACE>:<SERVICE_ACCOUNT>",
302+
"audiences": ["api://AzureADTokenExchange"]
303+
}'
304+
```
305+
306+
3. **Annotate the Kubernetes service account** with the app client ID:
307+
308+
```yaml
309+
apiVersion: v1
310+
kind: ServiceAccount
311+
metadata:
312+
name: my-bot-sa
313+
annotations:
314+
azure.workload.identity/client-id: "<APP_CLIENT_ID>"
315+
```
316+
317+
4. **Label the pod** for workload identity injection:
318+
319+
```yaml
320+
metadata:
321+
labels:
322+
azure.workload.identity/use: "true"
323+
```
324+
325+
5. **Ensure network access** to IMDS (`169.254.169.254`) — if using NetworkPolicy, add an egress rule allowing traffic to `169.254.169.254/32` on port 80.
326+
327+
### Auth type comparison
328+
329+
| Method | Config | Pros | Cons |
330+
| -------------------- | ---------------------------------------------- | ---------------------------------- | ------------------------------------- |
331+
| **Client secret** | `appPassword` | Simple setup | Secret rotation required, less secure |
332+
| **Certificate** | `authType: "federated"` + `certificatePath` | No shared secret over network | Certificate management overhead |
333+
| **Managed Identity** | `authType: "federated"` + `useManagedIdentity` | Passwordless, no secrets to manage | Azure infrastructure required |
334+
335+
**Default behavior:** When `authType` is not set, OpenClaw defaults to client secret authentication. Existing configurations continue to work without changes.
336+
193337
## Local Development (Tunneling)
194338

195339
Teams can't reach `localhost`. Use a tunnel for local development:
@@ -279,6 +423,11 @@ This is often easier than hand-editing JSON manifests.
279423
- `MSTEAMS_APP_ID`
280424
- `MSTEAMS_APP_PASSWORD`
281425
- `MSTEAMS_TENANT_ID`
426+
- `MSTEAMS_AUTH_TYPE` (optional: `"secret"` or `"federated"`)
427+
- `MSTEAMS_CERTIFICATE_PATH` (federated + certificate)
428+
- `MSTEAMS_CERTIFICATE_THUMBPRINT` (optional, not required for auth)
429+
- `MSTEAMS_USE_MANAGED_IDENTITY` (federated + managed identity)
430+
- `MSTEAMS_MANAGED_IDENTITY_CLIENT_ID` (user-assigned MI only)
282431

283432
5. **Bot endpoint**
284433
- Set the Azure Bot Messaging Endpoint to:
@@ -492,6 +641,11 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
492641
- `toolsBySender` keys should use explicit prefixes:
493642
`id:`, `e164:`, `username:`, `name:` (legacy unprefixed keys still map to `id:` only).
494643
- `channels.msteams.actions.memberInfo`: enable or disable the Graph-backed member info action (default: enabled when Graph credentials are available).
644+
- `channels.msteams.authType`: authentication type — `"secret"` (default) or `"federated"`.
645+
- `channels.msteams.certificatePath`: path to PEM certificate file (federated + certificate auth).
646+
- `channels.msteams.certificateThumbprint`: certificate thumbprint (optional, not required for auth).
647+
- `channels.msteams.useManagedIdentity`: enable managed identity auth (federated mode).
648+
- `channels.msteams.managedIdentityClientId`: client ID for user-assigned managed identity.
495649
- `channels.msteams.sharePointSiteId`: SharePoint site ID for file uploads in group chats/channels (see [Sending files in group chats](#sending-files-in-group-chats)).
496650

497651
## Routing & Sessions

extensions/discord/src/monitor/gateway-plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import * as carbonGateway from "@buape/carbon/gateway";
21
import { randomUUID } from "node:crypto";
2+
import * as carbonGateway from "@buape/carbon/gateway";
33
import type { APIGatewayBotInfo } from "discord-api-types/v10";
44
import * as httpsProxyAgent from "https-proxy-agent";
55
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";

extensions/discord/src/monitor/provider.proxy.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -281,9 +281,7 @@ describe("createDiscordGatewayPlugin", () => {
281281
createWebSocket("wss://gateway.discord.gg/?attempt=1");
282282
createWebSocket("wss://gateway.discord.gg/?attempt=2");
283283

284-
const openCalls = captureWsEventSpy.mock.calls.filter(
285-
([event]) => event?.kind === "ws-open",
286-
);
284+
const openCalls = captureWsEventSpy.mock.calls.filter(([event]) => event?.kind === "ws-open");
287285
expect(openCalls).toHaveLength(2);
288286
expect(openCalls[0]?.[0]?.flowId).not.toBe(openCalls[1]?.[0]?.flowId);
289287
});

extensions/discord/src/monitor/rest-fetch.ts

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import { randomUUID } from "node:crypto";
12
import { wrapFetchWithAbortSignal } from "openclaw/plugin-sdk/fetch-runtime";
2-
import { captureHttpExchange, resolveEffectiveDebugProxyUrl } from "openclaw/plugin-sdk/proxy-capture";
3+
import {
4+
captureHttpExchange,
5+
resolveEffectiveDebugProxyUrl,
6+
} from "openclaw/plugin-sdk/proxy-capture";
7+
import { resolveRequestUrl } from "openclaw/plugin-sdk/request-url";
38
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
4-
import { randomUUID } from "node:crypto";
59
import { ProxyAgent, fetch as undiciFetch } from "undici";
610
import { withValidatedDiscordProxy } from "../proxy-fetch.js";
711

@@ -12,24 +16,24 @@ export function resolveDiscordRestFetch(
1216
const effectiveProxyUrl = resolveEffectiveDebugProxyUrl(proxyUrl);
1317
const fetcher = withValidatedDiscordProxy(effectiveProxyUrl, runtime, (proxy) => {
1418
const agent = new ProxyAgent(proxy);
15-
return wrapFetchWithAbortSignal(
16-
((input: RequestInfo | URL, init?: RequestInit) =>
17-
(undiciFetch(input as string | URL, {
19+
return wrapFetchWithAbortSignal(((input: RequestInfo | URL, init?: RequestInit) =>
20+
(
21+
undiciFetch(input as string | URL, {
1822
...(init as Record<string, unknown>),
1923
dispatcher: agent,
20-
}) as unknown as Promise<Response>).then((response) => {
21-
captureHttpExchange({
22-
url: input instanceof URL ? input.toString() : String(input),
23-
method: init?.method ?? "GET",
24-
requestHeaders: init?.headers as Headers | Record<string, string> | undefined,
25-
requestBody: (init as RequestInit & { body?: BodyInit | null })?.body ?? null,
26-
response,
27-
flowId: randomUUID(),
28-
meta: { subsystem: "discord-rest" },
29-
});
30-
return response;
31-
})) as typeof fetch,
32-
);
24+
}) as unknown as Promise<Response>
25+
).then((response) => {
26+
captureHttpExchange({
27+
url: resolveRequestUrl(input),
28+
method: init?.method ?? "GET",
29+
requestHeaders: init?.headers as Headers | Record<string, string> | undefined,
30+
requestBody: (init as RequestInit & { body?: BodyInit | null })?.body ?? null,
31+
response,
32+
flowId: randomUUID(),
33+
meta: { subsystem: "discord-rest" },
34+
});
35+
return response;
36+
})) as typeof fetch);
3337
});
3438
if (!fetcher) {
3539
return fetch;

extensions/mattermost/src/mattermost/monitor-websocket.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
import { randomUUID } from "node:crypto";
12
import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
23
import {
34
captureWsEvent,
45
createDebugProxyWebSocketAgent,
56
resolveDebugProxySettings,
67
} from "openclaw/plugin-sdk/proxy-capture";
78
import { z } from "openclaw/plugin-sdk/zod";
8-
import { randomUUID } from "node:crypto";
99
import WebSocket from "ws";
1010
import { MattermostPostSchema, type MattermostPost } from "./client.js";
1111
import { rawDataToString } from "./monitor-helpers.js";
@@ -280,7 +280,7 @@ export function createMattermostConnectOnce(
280280
direction: "inbound",
281281
kind: "ws-frame",
282282
flowId,
283-
payload: Buffer.isBuffer(data) ? data : Buffer.from(String(data)),
283+
payload: Buffer.from(rawDataToString(data)),
284284
meta: { subsystem: "mattermost-websocket" },
285285
});
286286
const raw = rawDataToString(data);

extensions/microsoft/speech-provider.test.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
import { mkdtempSync, writeFileSync } from "node:fs";
12
import os from "node:os";
23
import path from "node:path";
3-
import { mkdtempSync, writeFileSync } from "node:fs";
44
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
55
import { afterEach, describe, expect, it, vi } from "vitest";
66
import {
@@ -90,9 +90,11 @@ describe("listMicrosoftVoices", () => {
9090
process.env.OPENCLAW_DEBUG_PROXY_BLOB_DIR = path.join(tempDir, "blobs");
9191
process.env.OPENCLAW_DEBUG_PROXY_SESSION_ID = "ms-voices-session";
9292

93-
globalThis.fetch = vi.fn().mockResolvedValue(
94-
new Response(JSON.stringify([{ ShortName: "en-US-AvaNeural" }]), { status: 200 }),
95-
) as unknown as typeof globalThis.fetch;
93+
globalThis.fetch = vi
94+
.fn()
95+
.mockResolvedValue(
96+
new Response(JSON.stringify([{ ShortName: "en-US-AvaNeural" }]), { status: 200 }),
97+
) as unknown as typeof globalThis.fetch;
9698

9799
const { getDebugProxyCaptureStore } = await import("../../src/proxy-capture/store.sqlite.js");
98100
const store = getDebugProxyCaptureStore(
@@ -117,7 +119,9 @@ describe("listMicrosoftVoices", () => {
117119
events.some((event) => event.kind === "request" && event.host === "speech.platform.bing.com"),
118120
).toBe(true);
119121
expect(
120-
events.some((event) => event.kind === "response" && event.host === "speech.platform.bing.com"),
122+
events.some(
123+
(event) => event.kind === "response" && event.host === "speech.platform.bing.com",
124+
),
121125
).toBe(true);
122126
});
123127

@@ -131,12 +135,13 @@ describe("listMicrosoftVoices", () => {
131135
process.env.OPENCLAW_DEBUG_PROXY_BLOB_DIR = path.join(tempDir, "blobs");
132136
process.env.OPENCLAW_DEBUG_PROXY_SESSION_ID = "ms-voices-global-session";
133137

134-
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify([{ ShortName: "en-US-AvaNeural" }]), { status: 200 })) as unknown as typeof globalThis.fetch;
138+
globalThis.fetch = vi.fn(
139+
async () => new Response(JSON.stringify([{ ShortName: "en-US-AvaNeural" }]), { status: 200 }),
140+
) as unknown as typeof globalThis.fetch;
135141

136142
const { getDebugProxyCaptureStore } = await import("../../src/proxy-capture/store.sqlite.js");
137-
const { finalizeDebugProxyCapture, initializeDebugProxyCapture } = await import(
138-
"../../src/proxy-capture/runtime.js"
139-
);
143+
const { finalizeDebugProxyCapture, initializeDebugProxyCapture } =
144+
await import("../../src/proxy-capture/runtime.js");
140145
const store = getDebugProxyCaptureStore(
141146
process.env.OPENCLAW_DEBUG_PROXY_DB_PATH,
142147
process.env.OPENCLAW_DEBUG_PROXY_BLOB_DIR,
@@ -160,7 +165,8 @@ describe("listMicrosoftVoices", () => {
160165
.getSessionEvents("ms-voices-global-session", 10)
161166
.filter((event) => event.host === "speech.platform.bing.com");
162167
expect(events).toHaveLength(2);
163-
expect(events.map((event) => event.kind).sort()).toEqual(["request", "response"]);
168+
const kinds = events.map((event) => String(event.kind)).toSorted();
169+
expect(kinds).toEqual(["request", "response"]);
164170
} finally {
165171
globalThis.fetch = originalFetch;
166172
finalizeDebugProxyCapture();

extensions/microsoft/speech-provider.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@ import {
66
generateSecMsGecToken,
77
} from "node-edge-tts/dist/drm.js";
88
import { isVoiceCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
9+
import {
10+
captureHttpExchange,
11+
isDebugProxyGlobalFetchPatchInstalled,
12+
} from "openclaw/plugin-sdk/proxy-capture";
913
import type {
1014
SpeechProviderConfig,
1115
SpeechProviderPlugin,
1216
SpeechVoiceOption,
1317
} from "openclaw/plugin-sdk/speech";
14-
import {
15-
captureHttpExchange,
16-
isDebugProxyGlobalFetchPatchInstalled,
17-
} from "openclaw/plugin-sdk/proxy-capture";
1818
import { asBoolean, asFiniteNumber, asObject, trimToUndefined } from "openclaw/plugin-sdk/speech";
1919
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
2020
import { edgeTTS, inferEdgeExtension } from "./tts.js";

extensions/msteams/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"description": "OpenClaw Microsoft Teams channel plugin",
55
"type": "module",
66
"dependencies": {
7+
"@azure/identity": "^4.9.1",
78
"@microsoft/teams.api": "2.0.7",
89
"@microsoft/teams.apps": "2.0.7",
910
"@sinclair/typebox": "0.34.49",

extensions/msteams/src/graph.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ export async function resolveGraphToken(
198198
}
199199

200200
// Try delegated token if requested and configured
201-
if (options?.preferDelegated && msteamsCfg?.delegatedAuth?.enabled) {
201+
if (options?.preferDelegated && msteamsCfg?.delegatedAuth?.enabled && creds.type === "secret") {
202202
// Dynamic import to avoid circular dependency (token.ts imports from graph.ts indirectly)
203203
const { resolveDelegatedAccessToken } = await import("./token.js");
204204
const delegated = await resolveDelegatedAccessToken({

0 commit comments

Comments
 (0)