Skip to content

Commit fc79604

Browse files
committed
feat(msteams): add defaultAzureCredential auth type
New authType "defaultAzureCredential" defers credential selection to @azure/identity's DefaultAzureCredential, which tries environment variables, workload identity, managed identity, az cli, and other sources automatically. Enables passwordless Teams auth in AKS via workload identity without explicit certificate or secret management. Added DefaultAzureCredentialTokenProvider in sdk.ts as a drop-in replacement for MsalTokenProvider. Added createTokenProvider() factory that returns the appropriate provider based on authType. Updated all 4 call sites (probe, monitor, send-context, graph) to use the factory. Added @azure/identity as a dependency of the msteams extension.
1 parent 597e428 commit fc79604

File tree

12 files changed

+124
-12
lines changed

12 files changed

+124
-12
lines changed

extensions/msteams/package.json

Lines changed: 2 additions & 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/agents-hosting": "^1.3.1",
89
"express": "^5.2.1"
910
},
@@ -31,6 +32,7 @@
3132
},
3233
"releaseChecks": {
3334
"rootDependencyMirrorAllowlist": [
35+
"@azure/identity",
3436
"@microsoft/agents-hosting"
3537
]
3638
}

extensions/msteams/src/graph.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { MSTeamsConfig } from "openclaw/plugin-sdk/msteams";
22
import { GRAPH_ROOT } from "./attachments/shared.js";
3-
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
3+
import { createTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
44
import { readAccessToken } from "./token-response.js";
55
import { resolveMSTeamsCredentials } from "./token.js";
66

@@ -57,7 +57,7 @@ export async function resolveGraphToken(cfg: unknown): Promise<string> {
5757
throw new Error("MS Teams credentials missing");
5858
}
5959
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
60-
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
60+
const tokenProvider = createTokenProvider(creds, authConfig, sdk);
6161
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com");
6262
const accessToken = readAccessToken(token);
6363
if (!accessToken) {

extensions/msteams/src/monitor.lifecycle.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ vi.mock("./resolve-allowlist.js", () => ({
112112

113113
vi.mock("./sdk.js", () => ({
114114
createMSTeamsAdapter: () => createMSTeamsAdapter(),
115+
createTokenProvider: () => ({ getAccessToken: vi.fn(async () => "mock-token") }),
115116
loadMSTeamsSdkWithAuth: () => loadMSTeamsSdkWithAuth(),
116117
}));
117118

extensions/msteams/src/monitor.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
resolveMSTeamsUserAllowlist,
2020
} from "./resolve-allowlist.js";
2121
import { getMSTeamsRuntime } from "./runtime.js";
22-
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
22+
import { createMSTeamsAdapter, createTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
2323
import { resolveMSTeamsCredentials } from "./token.js";
2424

2525
export type MonitorMSTeamsOpts = {
@@ -248,10 +248,10 @@ export async function monitorMSTeamsProvider(
248248
const express = await import("express");
249249

250250
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
251-
const { ActivityHandler, MsalTokenProvider, authorizeJWT } = sdk;
251+
const { ActivityHandler, authorizeJWT } = sdk;
252252

253253
// Auth configuration - create early so adapter is available for deliverReplies
254-
const tokenProvider = new MsalTokenProvider(authConfig);
254+
const tokenProvider = createTokenProvider(creds, authConfig, sdk);
255255
const adapter = createMSTeamsAdapter(authConfig, sdk);
256256

257257
const handler = registerMSTeamsHandlers(new ActivityHandler() as MSTeamsActivityHandler, {

extensions/msteams/src/probe.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
type MSTeamsConfig,
55
} from "openclaw/plugin-sdk/msteams";
66
import { formatUnknownError } from "./errors.js";
7-
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
7+
import { createTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
88
import { readAccessToken } from "./token-response.js";
99
import { resolveMSTeamsCredentials } from "./token.js";
1010

@@ -66,7 +66,7 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsRes
6666

6767
try {
6868
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
69-
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
69+
const tokenProvider = createTokenProvider(creds, authConfig, sdk);
7070
await tokenProvider.getAccessToken("https://api.botframework.com");
7171
let graph:
7272
| {

extensions/msteams/src/sdk.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,52 @@ import type { MSTeamsCredentials } from "./token.js";
44
export type MSTeamsSdk = typeof import("@microsoft/agents-hosting");
55
export type MSTeamsAuthConfig = ReturnType<MSTeamsSdk["getAuthConfigWithDefaults"]>;
66

7+
/**
8+
* Token provider that wraps @azure/identity's DefaultAzureCredential.
9+
* Implements the same getAccessToken(scope) interface as MsalTokenProvider
10+
* so it can be used as a drop-in replacement throughout the plugin.
11+
*
12+
* Accepts appId (clientId) and tenantId so the credential targets the
13+
* correct bot identity — required for workload identity and user-assigned
14+
* managed identity where multiple identities may be available.
15+
*/
16+
export class DefaultAzureCredentialTokenProvider {
17+
private credential: import("@azure/identity").DefaultAzureCredential | undefined;
18+
private readonly clientId: string;
19+
private readonly tenantId: string;
20+
21+
constructor(clientId: string, tenantId: string) {
22+
this.clientId = clientId;
23+
this.tenantId = tenantId;
24+
}
25+
26+
/**
27+
* Acquire a token for the given resource. Callers pass resource URIs
28+
* (e.g. "https://graph.microsoft.com"), which are normalized to AAD
29+
* scopes ("https://graph.microsoft.com/.default") as required by
30+
* @azure/identity's getToken().
31+
*/
32+
async getAccessToken(resource: string) {
33+
if (!this.credential) {
34+
const { DefaultAzureCredential } = await import("@azure/identity");
35+
this.credential = new DefaultAzureCredential({
36+
managedIdentityClientId: this.clientId,
37+
workloadIdentityClientId: this.clientId,
38+
tenantId: this.tenantId,
39+
});
40+
}
41+
// Normalize resource URI to AAD scope — MsalTokenProvider accepts bare
42+
// resource URIs but DefaultAzureCredential.getToken() requires scopes.
43+
const scope = resource.endsWith("/.default")
44+
? resource
45+
: `${resource.replace(/\/+$/, "")}/.default`;
46+
const token = await this.credential.getToken(scope);
47+
if (!token)
48+
throw new Error(`DefaultAzureCredential: failed to acquire token for scope ${scope}`);
49+
return token.token;
50+
}
51+
}
52+
753
export async function loadMSTeamsSdk(): Promise<MSTeamsSdk> {
854
return await import("@microsoft/agents-hosting");
955
}
@@ -27,6 +73,10 @@ export function buildMSTeamsAuthConfig(
2773
if (creds.ficClientId) base.FICClientId = creds.ficClientId;
2874
if (creds.widAssertionFile) base.WIDAssertionFile = creds.widAssertionFile;
2975
break;
76+
case "defaultAzureCredential":
77+
// No additional fields needed — DAC handles credential discovery.
78+
// The SDK still needs clientId/tenantId for audience validation.
79+
break;
3080
case "clientSecret":
3181
default:
3282
if (creds.appPassword) base.clientSecret = creds.appPassword;
@@ -36,6 +86,21 @@ export function buildMSTeamsAuthConfig(
3686
return sdk.getAuthConfigWithDefaults(base);
3787
}
3888

89+
/**
90+
* Create the appropriate token provider based on auth type.
91+
* For defaultAzureCredential, returns our DAC wrapper instead of MsalTokenProvider.
92+
*/
93+
export function createTokenProvider(
94+
creds: MSTeamsCredentials,
95+
authConfig: MSTeamsAuthConfig,
96+
sdk: MSTeamsSdk,
97+
) {
98+
if (creds.authType === "defaultAzureCredential") {
99+
return new DefaultAzureCredentialTokenProvider(creds.appId, creds.tenantId);
100+
}
101+
return new sdk.MsalTokenProvider(authConfig);
102+
}
103+
39104
export function createMSTeamsAdapter(
40105
authConfig: MSTeamsAuthConfig,
41106
sdk: MSTeamsSdk,
@@ -46,5 +111,5 @@ export function createMSTeamsAdapter(
46111
export async function loadMSTeamsSdkWithAuth(creds: MSTeamsCredentials) {
47112
const sdk = await loadMSTeamsSdk();
48113
const authConfig = buildMSTeamsAuthConfig(creds, sdk);
49-
return { sdk, authConfig };
114+
return { sdk, authConfig, creds };
50115
}

extensions/msteams/src/send-context.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
} from "./conversation-store.js";
1212
import type { MSTeamsAdapter } from "./messenger.js";
1313
import { getMSTeamsRuntime } from "./runtime.js";
14-
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
14+
import { createMSTeamsAdapter, createTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
1515
import { resolveMSTeamsCredentials } from "./token.js";
1616

1717
export type MSTeamsConversationType = "personal" | "groupChat" | "channel";
@@ -127,7 +127,7 @@ export async function resolveMSTeamsSendContext(params: {
127127
const adapter = createMSTeamsAdapter(authConfig, sdk);
128128

129129
// Create token provider for Graph API / OneDrive operations
130-
const tokenProvider = new sdk.MsalTokenProvider(authConfig) as MSTeamsAccessTokenProvider;
130+
const tokenProvider = createTokenProvider(creds, authConfig, sdk) as MSTeamsAccessTokenProvider;
131131

132132
// Determine conversation type from stored reference
133133
const storedConversationType = ref.conversation?.conversationType?.toLowerCase() ?? "";

extensions/msteams/src/setup-surface.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,14 @@ export const msteamsSetupWizard: ChannelSetupWizard = {
374374
appId,
375375
appPassword,
376376
tenantId,
377+
// Reset auth type and related fields when re-entering credentials
378+
// as client secret — otherwise the old authType takes precedence.
379+
authType: "clientSecret",
380+
certPemFile: undefined,
381+
certKeyFile: undefined,
382+
sendX5C: undefined,
383+
ficClientId: undefined,
384+
widAssertionFile: undefined,
377385
},
378386
},
379387
};

extensions/msteams/src/token.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,19 @@ describe("resolveMSTeamsCredentials", () => {
164164
).toBeUndefined();
165165
});
166166

167+
it("resolves defaultAzureCredential with only appId and tenantId", () => {
168+
const resolved = resolveMSTeamsCredentials({
169+
appId: "app-id",
170+
tenantId: "tenant-id",
171+
authType: "defaultAzureCredential",
172+
});
173+
174+
expect(resolved?.authType).toBe("defaultAzureCredential");
175+
expect(resolved?.appId).toBe("app-id");
176+
expect(resolved?.tenantId).toBe("tenant-id");
177+
expect(resolved?.appPassword).toBeUndefined();
178+
});
179+
167180
it("throws when appPassword remains an unresolved SecretRef object", () => {
168181
expect(() =>
169182
resolveMSTeamsCredentials({
@@ -268,4 +281,14 @@ describe("hasConfiguredMSTeamsCredentials", () => {
268281
}),
269282
).toBe(false);
270283
});
284+
285+
it("detects defaultAzureCredential as configured with only appId and tenantId", () => {
286+
expect(
287+
hasConfiguredMSTeamsCredentials({
288+
appId: "app-id",
289+
tenantId: "tenant-id",
290+
authType: "defaultAzureCredential",
291+
}),
292+
).toBe(true);
293+
});
271294
});

extensions/msteams/src/token.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ export function hasConfiguredMSTeamsCredentials(cfg?: MSTeamsConfig): boolean {
7777
return hasCertificateAuthMaterial(cfg);
7878
case "federatedCredential":
7979
return hasFederatedAuthMaterial(cfg);
80+
case "defaultAzureCredential":
81+
// DAC handles its own credential discovery — appId + tenantId is sufficient.
82+
return true;
8083
case "clientSecret":
8184
default:
8285
return Boolean(hasConfiguredSecretInput(cfg?.appPassword));
@@ -119,6 +122,11 @@ export function resolveMSTeamsCredentials(cfg?: MSTeamsConfig): MSTeamsCredentia
119122
widAssertionFile: trimPresence(cfg?.widAssertionFile),
120123
};
121124
}
125+
case "defaultAzureCredential": {
126+
// DAC defers credential selection to @azure/identity at runtime.
127+
// Only appId + tenantId are required from config.
128+
return { appId, tenantId, authType };
129+
}
122130
case "clientSecret":
123131
default: {
124132
const appPassword =

0 commit comments

Comments
 (0)