Files
openclaw/extensions/msteams/src/sdk.ts
Brandon eecda912ee fix(msteams): surface network errors blocking bot JWT validation and outbound replies (#77674) (#78081)
* fix(msteams): surface network errors blocking Teams bot JWT validation and outbound replies (#77674)

When login.botframework.com or smba.trafficmanager.net egress is blocked,
errors previously disappeared completely. JWT validator swallowed network
errors and returned false (401 looked identical to a bad credential), and
outbound send failures with transport-level codes had no hint pointing to
the Connector endpoint.

- sdk.ts: rethrow ECONNREFUSED/ENOTFOUND/EHOSTUNREACH/ETIMEDOUT/ECONNRESET
  from the JWKS key fetch so callers can distinguish firewall blocks from bad
  credentials; add isJwksNetworkError() helper
- monitor.ts: catch rethrown network errors in JWT middleware and log at
  runtime.error level with an actionable message pointing to
  login.botframework.com:443; upgrade allowlist resolution failures from
  runtime.log (optional/silent) to runtime.error
- errors.ts: add "network" kind to classifyMSTeamsSendError for transport-level
  errors (ECONNREFUSED, ENOTFOUND, etc.); add formatMSTeamsSendErrorHint for
  "network" kind pointing to smba.trafficmanager.net and egress rules
- monitor-handler.ts, message-handler.ts: remove spurious ?. from runtime.error
  calls (RuntimeEnv.error is a required non-optional field)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(msteams): surface blocked botframework egress

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Brad Groux <3053586+BradGroux@users.noreply.github.com>
2026-05-05 23:11:06 -05:00

917 lines
30 KiB
TypeScript

import * as fs from "node:fs";
// IHttpServerAdapter is re-exported via the public barrel (`export * from './http'`)
// but tsgo cannot resolve the chain. Use the dist subpath directly (type-only import).
import type { IHttpServerAdapter } from "@microsoft/teams.apps/dist/http/index.js";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { formatUnknownError } from "./errors.js";
import type { MSTeamsAdapter } from "./messenger.js";
import type { MSTeamsCredentials, MSTeamsFederatedCredentials } from "./token.js";
import { buildUserAgent } from "./user-agent.js";
/**
* Resolved Teams SDK modules loaded lazily to avoid importing when the
* provider is disabled.
*/
export type MSTeamsTeamsSdk = {
App: typeof import("@microsoft/teams.apps").App;
Client: typeof import("@microsoft/teams.api").Client;
};
/**
* A Teams SDK App instance used for token management and proactive messaging.
*/
type MSTeamsApp = InstanceType<MSTeamsTeamsSdk["App"]>;
/**
* Token provider compatible with the existing codebase, wrapping the Teams
* SDK App's token methods.
*/
type MSTeamsTokenProvider = {
getAccessToken: (scope: string) => Promise<string>;
};
type MSTeamsBotIdentity = {
id?: string;
name?: string;
};
type MSTeamsSendContext = {
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
updateActivity: (activityUpdate: object) => Promise<{ id?: string } | void>;
deleteActivity: (activityId: string) => Promise<void>;
};
type MSTeamsProcessContext = MSTeamsSendContext & {
activity: Record<string, unknown> | undefined;
sendActivities: (
activities: Array<{ type: string } & Record<string, unknown>>,
) => Promise<unknown[]>;
};
type AzureAccessToken = {
token?: string;
} | null;
type AzureTokenCredential = {
getToken: (scope: string | string[]) => Promise<AzureAccessToken>;
};
type AzureIdentityModule = {
ClientCertificateCredential: new (
tenantId: string,
clientId: string,
options: { certificate: string },
) => AzureTokenCredential;
ManagedIdentityCredential: new (clientId?: string) => AzureTokenCredential;
};
const AZURE_IDENTITY_MODULE = "@azure/identity";
let azureIdentityModulePromise: Promise<AzureIdentityModule> | null = null;
async function loadAzureIdentity(): Promise<AzureIdentityModule> {
azureIdentityModulePromise ??= import(AZURE_IDENTITY_MODULE) as Promise<AzureIdentityModule>;
return azureIdentityModulePromise;
}
let msTeamsSdkPromise: Promise<MSTeamsTeamsSdk> | null = null;
async function loadMSTeamsSdk(): Promise<MSTeamsTeamsSdk> {
msTeamsSdkPromise ??= Promise.all([
import("@microsoft/teams.apps"),
import("@microsoft/teams.api"),
]).then(([appsModule, apiModule]) => ({
App: appsModule.App,
Client: apiModule.Client,
}));
return msTeamsSdkPromise;
}
/**
* Create a no-op HTTP server adapter that satisfies the Teams SDK's
* IHttpServerAdapter interface without spinning up an Express server.
*
* OpenClaw manages its own Express server for the Teams webhook endpoint, so
* the SDK's built-in HTTP server is unnecessary. Passing this adapter via the
* `httpServerAdapter` option prevents the SDK from creating the default
* HttpPlugin (which uses the deprecated `plugins` array and registers an
* Express middleware with the pattern `/api*` — invalid in Express 5).
*
* See: https://github.com/openclaw/openclaw/issues/55161
* See: https://github.com/openclaw/openclaw/issues/60732
*/
function createNoOpHttpServerAdapter(): IHttpServerAdapter {
return {
registerRoute() {},
};
}
/**
* Create a Teams SDK App instance from credentials. The App manages token
* acquisition, JWT validation, and the HTTP server lifecycle.
*
* This replaces the previous CloudAdapter + MsalTokenProvider + authorizeJWT
* from @microsoft/agents-hosting.
*/
export async function createMSTeamsApp(
creds: MSTeamsCredentials,
sdk: MSTeamsTeamsSdk,
): Promise<MSTeamsApp> {
if (creds.type === "federated") {
return createFederatedApp(creds, sdk);
}
return new sdk.App({
clientId: creds.appId,
clientSecret: creds.appPassword,
tenantId: creds.tenantId,
httpServerAdapter: createNoOpHttpServerAdapter(),
} as ConstructorParameters<MSTeamsTeamsSdk["App"]>[0]);
}
function createFederatedApp(creds: MSTeamsFederatedCredentials, sdk: MSTeamsTeamsSdk): MSTeamsApp {
if (creds.useManagedIdentity) {
return createManagedIdentityApp(creds, sdk);
}
// Certificate-based auth
if (!creds.certificatePath) {
throw new Error("Federated credentials require either a certificate path or managed identity.");
}
let privateKey: string;
try {
privateKey = fs.readFileSync(creds.certificatePath, "utf-8");
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to read certificate file at '${creds.certificatePath}': ${msg}`, {
cause: err,
});
}
return createCertificateApp(creds, privateKey, sdk);
}
function createCertificateApp(
creds: MSTeamsFederatedCredentials,
privateKey: string,
sdk: MSTeamsTeamsSdk,
): MSTeamsApp {
// Lazily create and cache the credential so the token cache is reused.
let credentialPromise: Promise<AzureTokenCredential> | null = null;
const getCredential = async () => {
if (!credentialPromise) {
credentialPromise = loadAzureIdentity().then(
(az) =>
new az.ClientCertificateCredential(creds.tenantId, creds.appId, {
certificate: privateKey,
}),
);
}
return credentialPromise;
};
const tokenProvider = async (scope: string | string[]): Promise<string> => {
const credential = await getCredential();
const token = await credential.getToken(scope);
if (!token?.token) {
throw new Error("Failed to acquire token via certificate credential.");
}
return token.token;
};
return new sdk.App({
clientId: creds.appId,
tenantId: creds.tenantId,
token: tokenProvider,
httpServerAdapter: createNoOpHttpServerAdapter(),
} as unknown as ConstructorParameters<MSTeamsTeamsSdk["App"]>[0]);
}
function createManagedIdentityApp(
creds: MSTeamsFederatedCredentials,
sdk: MSTeamsTeamsSdk,
): MSTeamsApp {
// Lazily create and cache the credential instance so the token cache is
// reused across calls instead of hitting IMDS/AAD on every message.
let credentialPromise: Promise<AzureTokenCredential> | null = null;
const getCredential = async () => {
if (!credentialPromise) {
credentialPromise = loadAzureIdentity().then((az) =>
creds.managedIdentityClientId
? new az.ManagedIdentityCredential(creds.managedIdentityClientId)
: new az.ManagedIdentityCredential(),
);
}
return credentialPromise;
};
const tokenProvider = async (scope: string | string[]): Promise<string> => {
const credential = await getCredential();
const token = await credential.getToken(scope);
if (!token?.token) {
throw new Error("Failed to acquire token via managed identity.");
}
return token.token;
};
return new sdk.App({
clientId: creds.appId,
tenantId: creds.tenantId,
token: tokenProvider,
httpServerAdapter: createNoOpHttpServerAdapter(),
} as unknown as ConstructorParameters<MSTeamsTeamsSdk["App"]>[0]);
}
/**
* Build a token provider that uses the Teams SDK App for token acquisition.
*/
export function createMSTeamsTokenProvider(app: MSTeamsApp): MSTeamsTokenProvider {
return {
async getAccessToken(scope: string): Promise<string> {
if (scope.includes("graph.microsoft.com")) {
const token = await (
app as unknown as { getAppGraphToken(): Promise<{ toString(): string } | null> }
).getAppGraphToken();
return token ? String(token) : "";
}
const token = await (
app as unknown as { getBotToken(): Promise<{ toString(): string } | null> }
).getBotToken();
return token ? String(token) : "";
},
};
}
function createBotTokenGetter(app: MSTeamsApp): () => Promise<string | undefined> {
return async () => {
const token = await (
app as unknown as { getBotToken(): Promise<{ toString(): string } | null> }
).getBotToken();
return token ? String(token) : undefined;
};
}
function createApiClient(
sdk: MSTeamsTeamsSdk,
serviceUrl: string,
getToken: () => Promise<string | undefined>,
) {
return new sdk.Client(serviceUrl, {
token: async () => (await getToken()) || undefined,
headers: { "User-Agent": buildUserAgent() },
} as Record<string, unknown>);
}
function normalizeOutboundActivity(textOrActivity: string | object): Record<string, unknown> {
return typeof textOrActivity === "string"
? ({ type: "message", text: textOrActivity } as Record<string, unknown>)
: (textOrActivity as Record<string, unknown>);
}
function createSendContext(params: {
sdk: MSTeamsTeamsSdk;
serviceUrl?: string;
conversationId?: string;
conversationType?: string;
bot?: MSTeamsBotIdentity;
replyToActivityId?: string;
getToken: () => Promise<string | undefined>;
treatInvokeResponseAsNoop?: boolean;
/**
* Azure AD tenant ID for the target conversation. Bot Framework requires this
* on outbound proactive activities so the connector can route them to the
* correct tenant. Missing `tenantId` causes HTTP 403 on proactive sends.
*/
tenantId?: string;
/** Target user's Teams user ID (e.g. `29:xxx`); included on the recipient field for routing. */
recipientId?: string;
/** Target user's Azure AD object ID; included as the recipient on personal DMs. */
recipientAadObjectId?: string;
}): MSTeamsSendContext {
const apiClient =
params.serviceUrl && params.conversationId
? createApiClient(params.sdk, params.serviceUrl, params.getToken)
: undefined;
return {
async sendActivity(textOrActivity: string | object): Promise<unknown> {
const msg = normalizeOutboundActivity(textOrActivity);
if (params.treatInvokeResponseAsNoop && msg.type === "invokeResponse") {
return { id: "invokeResponse" };
}
if (!apiClient || !params.conversationId) {
return { id: "unknown" };
}
// Merge caller-provided channelData with the tenant metadata so Bot
// Framework receives `channelData.tenant.id` (the canonical source it
// uses to route proactive sends). Preserve any existing channelData
// fields the caller set (e.g. feedbackLoopEnabled).
const existingChannelData =
msg.channelData && typeof msg.channelData === "object"
? (msg.channelData as Record<string, unknown>)
: undefined;
const channelData = params.tenantId
? {
...existingChannelData,
tenant: { id: params.tenantId },
}
: existingChannelData;
return await apiClient.conversations.activities(params.conversationId).create({
type: "message",
...msg,
...(channelData ? { channelData } : {}),
from: params.bot?.id
? { id: params.bot.id, name: params.bot.name ?? "", role: "bot" }
: undefined,
conversation: {
id: params.conversationId,
conversationType: params.conversationType ?? "personal",
...(params.tenantId ? { tenantId: params.tenantId } : {}),
},
...(params.recipientId || params.recipientAadObjectId
? {
recipient: {
...(params.recipientId ? { id: params.recipientId } : {}),
...(params.recipientAadObjectId
? { aadObjectId: params.recipientAadObjectId }
: {}),
},
}
: {}),
...(params.replyToActivityId && !msg.replyToId
? { replyToId: params.replyToActivityId }
: {}),
} as Parameters<
typeof apiClient.conversations.activities extends (id: string) => {
create: (a: infer _T) => unknown;
}
? never
: never
>[0]);
},
async updateActivity(activityUpdate: object): Promise<{ id?: string } | void> {
const nextActivity = activityUpdate as { id?: string } & Record<string, unknown>;
const activityId = nextActivity.id;
if (!activityId) {
throw new Error("updateActivity requires an activity id");
}
if (!params.serviceUrl || !params.conversationId) {
return { id: "unknown" };
}
return await updateActivityViaRest({
serviceUrl: params.serviceUrl,
conversationId: params.conversationId,
activityId,
activity: nextActivity,
token: await params.getToken(),
});
},
async deleteActivity(activityId: string): Promise<void> {
if (!activityId) {
throw new Error("deleteActivity requires an activity id");
}
if (!params.serviceUrl || !params.conversationId) {
return;
}
await deleteActivityViaRest({
serviceUrl: params.serviceUrl,
conversationId: params.conversationId,
activityId,
token: await params.getToken(),
});
},
};
}
function createProcessContext(params: {
sdk: MSTeamsTeamsSdk;
activity: Record<string, unknown> | undefined;
getToken: () => Promise<string | undefined>;
}): MSTeamsProcessContext {
const serviceUrl = params.activity?.serviceUrl as string | undefined;
const conversationId = (params.activity?.conversation as Record<string, unknown>)?.id as
| string
| undefined;
const conversationType = (params.activity?.conversation as Record<string, unknown>)
?.conversationType as string | undefined;
const replyToActivityId = params.activity?.id as string | undefined;
const bot: MSTeamsBotIdentity | undefined =
params.activity?.recipient && typeof params.activity.recipient === "object"
? {
id: (params.activity.recipient as Record<string, unknown>).id as string | undefined,
name: (params.activity.recipient as Record<string, unknown>).name as string | undefined,
}
: undefined;
const sendContext = createSendContext({
sdk: params.sdk,
serviceUrl,
conversationId,
conversationType,
bot,
replyToActivityId,
getToken: params.getToken,
treatInvokeResponseAsNoop: true,
});
return {
activity: params.activity,
...sendContext,
async sendActivities(activities: Array<{ type: string } & Record<string, unknown>>) {
const results = [];
for (const activity of activities) {
results.push(await sendContext.sendActivity(activity));
}
return results;
},
};
}
/**
* Update an existing activity via the Bot Framework REST API.
* PUT /v3/conversations/{conversationId}/activities/{activityId}
*/
async function updateActivityViaRest(params: {
serviceUrl: string;
conversationId: string;
activityId: string;
activity: Record<string, unknown>;
token?: string;
}): Promise<{ id?: string }> {
const { serviceUrl, conversationId, activityId, activity, token } = params;
const baseUrl = serviceUrl.replace(/\/+$/, "");
const url = `${baseUrl}/v3/conversations/${encodeURIComponent(conversationId)}/activities/${encodeURIComponent(activityId)}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": buildUserAgent(),
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const currentFetch = globalThis.fetch;
const { response, release } = await fetchWithSsrFGuard({
url,
fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit),
init: {
method: "PUT",
headers,
body: JSON.stringify({
type: "message",
...activity,
id: activityId,
}),
},
auditContext: "msteams-update-activity",
});
try {
if (!response.ok) {
const body = await response.text().catch(() => "");
throw Object.assign(new Error(`updateActivity failed: HTTP ${response.status} ${body}`), {
statusCode: response.status,
});
}
return await response.json().catch(() => ({ id: activityId }));
} finally {
await release();
}
}
/**
* Delete an existing activity via the Bot Framework REST API.
* DELETE /v3/conversations/{conversationId}/activities/{activityId}
*/
async function deleteActivityViaRest(params: {
serviceUrl: string;
conversationId: string;
activityId: string;
token?: string;
}): Promise<void> {
const { serviceUrl, conversationId, activityId, token } = params;
const baseUrl = serviceUrl.replace(/\/+$/, "");
const url = `${baseUrl}/v3/conversations/${encodeURIComponent(conversationId)}/activities/${encodeURIComponent(activityId)}`;
const headers: Record<string, string> = {
"User-Agent": buildUserAgent(),
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const currentFetch = globalThis.fetch;
const { response, release } = await fetchWithSsrFGuard({
url,
fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit),
init: {
method: "DELETE",
headers,
},
auditContext: "msteams-delete-activity",
});
try {
if (!response.ok) {
const body = await response.text().catch(() => "");
throw Object.assign(new Error(`deleteActivity failed: HTTP ${response.status} ${body}`), {
statusCode: response.status,
});
}
} finally {
await release();
}
}
/**
* Build a CloudAdapter-compatible adapter using the Teams SDK REST client.
*
* This replaces the previous CloudAdapter from @microsoft/agents-hosting.
* For incoming requests: the App's HTTP server handles JWT validation.
* For proactive sends: uses the Bot Framework REST API via
* @microsoft/teams.api Client.
*/
export function createMSTeamsAdapter(app: MSTeamsApp, sdk: MSTeamsTeamsSdk): MSTeamsAdapter {
return {
async continueConversation(_appId, reference, logic) {
const serviceUrl = reference.serviceUrl;
if (!serviceUrl) {
throw new Error("Missing serviceUrl in conversation reference");
}
const conversationId = reference.conversation?.id;
if (!conversationId) {
throw new Error("Missing conversation.id in conversation reference");
}
// Bot Framework requires `tenantId` on proactive sends so the connector
// can route them to the correct Azure AD tenant. Without it, requests
// fail with HTTP 403. Prefer the top-level `reference.tenantId` (captured
// from `activity.channelData.tenant.id` at inbound time) and fall back
// to `conversation.tenantId` for older stored references.
const tenantId = reference.tenantId ?? reference.conversation?.tenantId;
const recipientAadObjectId = reference.aadObjectId ?? reference.user?.aadObjectId;
const recipientId = reference.user?.id;
const sendContext = createSendContext({
sdk,
serviceUrl,
conversationId,
conversationType: reference.conversation?.conversationType,
bot: reference.agent ?? undefined,
getToken: createBotTokenGetter(app),
tenantId,
recipientId,
recipientAadObjectId,
});
await logic(sendContext);
},
async process(req, res, logic) {
const request = req as { body?: Record<string, unknown> };
const response = res as {
status: (code: number) => { send: (body?: unknown) => void };
};
const activity = request.body;
const isInvoke = (activity as Record<string, unknown>)?.type === "invoke";
try {
const context = createProcessContext({
sdk,
activity,
getToken: createBotTokenGetter(app),
});
// For invoke activities, send HTTP 200 immediately before running
// handler logic so slow operations (file uploads, reflections) don't
// hit Teams invoke timeouts ("unable to reach app").
if (isInvoke) {
response.status(200).send();
}
await logic(context);
if (!isInvoke) {
response.status(200).send();
}
} catch (err) {
if (!isInvoke) {
response.status(500).send({ error: formatUnknownError(err) });
}
}
},
async updateActivity(_context, _activity) {
// No-op: updateActivity is handled via REST in streaming-message.ts
},
async deleteActivity(_context, _reference) {
// No-op: deleteActivity not yet implemented for Teams SDK adapter
},
};
}
export async function loadMSTeamsSdkWithAuth(creds: MSTeamsCredentials) {
const sdk = await loadMSTeamsSdk();
const app = await createMSTeamsApp(creds, sdk);
return { sdk, app };
}
/**
* Bot Framework issuer → JWKS mapping.
* During Microsoft's transition, inbound service tokens can be signed by either
* the legacy Bot Framework issuer or the Entra issuer. Each gets its own JWKS
* endpoint so we verify signatures with the correct key set.
*/
const BOT_FRAMEWORK_ISSUERS: ReadonlyArray<{
issuer: string | ((tenantId: string) => string);
jwksUri: string;
}> = [
{
issuer: "https://api.botframework.com",
jwksUri: "https://login.botframework.com/v1/.well-known/keys",
},
{
issuer: (tenantId: string) => `https://login.microsoftonline.com/${tenantId}/v2.0`,
jwksUri: "https://login.microsoftonline.com/common/discovery/v2.0/keys",
},
{
// SingleTenant bot deployments (Microsoft's default since 2025-07-31) get
// tokens signed by the Azure AD v1 endpoint, whose issuer is scoped to the
// bot's tenant. This must be a function so each deployment accepts its own
// tenant rather than a single hardcoded one (#64270).
issuer: (tenantId: string) => `https://sts.windows.net/${tenantId}/`,
jwksUri: "https://login.microsoftonline.com/common/discovery/v2.0/keys",
},
];
type BotFrameworkJwtDeps = {
jwt: Pick<typeof import("jsonwebtoken"), "decode" | "verify">;
JwksClient: typeof import("jwks-rsa").JwksClient;
};
type JsonwebtokenRuntime = BotFrameworkJwtDeps["jwt"];
type JwksClientCtor = BotFrameworkJwtDeps["JwksClient"];
const BOT_FRAMEWORK_GLOBAL_AUDIENCE = "https://api.botframework.com";
function isJwtPayloadObject(
value: unknown,
): value is { iss?: unknown; aud?: unknown; appid?: unknown; azp?: unknown } {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function getAudienceClaims(payload: unknown): string[] {
if (!isJwtPayloadObject(payload)) {
return [];
}
const audience = payload.aud;
if (typeof audience === "string") {
const trimmed = audience.trim();
return trimmed ? [trimmed] : [];
}
if (Array.isArray(audience)) {
return audience
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter(Boolean);
}
return [];
}
function normalizeBotIdentityClaim(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
return normalized || null;
}
function hasExpectedBotIdentity(payload: unknown, expectedAppId: string): boolean {
if (!isJwtPayloadObject(payload)) {
return false;
}
const expected = normalizeBotIdentityClaim(expectedAppId);
if (!expected) {
return false;
}
return (
normalizeBotIdentityClaim(payload.appid) === expected ||
normalizeBotIdentityClaim(payload.azp) === expected
);
}
let botFrameworkJwtDepsPromise: Promise<BotFrameworkJwtDeps> | null = null;
function hasDefaultExport(value: unknown): value is { default?: unknown } {
return !!value && typeof value === "object" && "default" in value;
}
function isJsonwebtokenRuntime(value: unknown): value is JsonwebtokenRuntime {
return (
!!value &&
typeof value === "object" &&
typeof (value as { decode?: unknown }).decode === "function" &&
typeof (value as { verify?: unknown }).verify === "function"
);
}
function loadJsonwebtokenRuntime(jwtModule: unknown): JsonwebtokenRuntime {
const jwt = hasDefaultExport(jwtModule) ? (jwtModule.default ?? jwtModule) : jwtModule;
if (!isJsonwebtokenRuntime(jwt)) {
throw new Error("jsonwebtoken did not export decode/verify");
}
return jwt;
}
function isJwksClientRuntime(value: unknown): value is JwksClientCtor {
return typeof value === "function";
}
function loadJwksClientRuntime(jwksModule: unknown): JwksClientCtor {
const direct =
jwksModule && typeof jwksModule === "object"
? (jwksModule as { JwksClient?: unknown }).JwksClient
: undefined;
const fallback =
hasDefaultExport(jwksModule) && jwksModule.default && typeof jwksModule.default === "object"
? (jwksModule.default as { JwksClient?: unknown }).JwksClient
: undefined;
const JwksClient = direct ?? fallback;
if (!isJwksClientRuntime(JwksClient)) {
throw new Error("jwks-rsa did not export JwksClient");
}
return JwksClient;
}
async function loadBotFrameworkJwtDeps(): Promise<BotFrameworkJwtDeps> {
botFrameworkJwtDepsPromise ??= Promise.all([import("jsonwebtoken"), import("jwks-rsa")]).then(
([jwtModule, jwksModule]) => {
return {
jwt: loadJsonwebtokenRuntime(jwtModule),
JwksClient: loadJwksClientRuntime(jwksModule),
};
},
);
return botFrameworkJwtDepsPromise;
}
/**
* Create a Bot Framework JWT validator using jsonwebtoken + jwks-rsa directly.
*
* The @microsoft/teams.apps JwtValidator hardcodes audience to [clientId, api://clientId],
* which rejects valid Bot Framework tokens that carry aud: "https://api.botframework.com".
* This implementation uses jsonwebtoken directly with the correct audience list, matching
* the behavior of the legacy @microsoft/agents-hosting authorizeJWT middleware.
*
* Security invariants:
* - signature verification via issuer-specific JWKS endpoints
* - audience validation: appId, api://appId, and https://api.botframework.com
* - issuer validation: strict allowlist (Bot Framework + tenant-scoped Entra)
* - expiration validation with 5-minute clock tolerance
*/
export async function createBotFrameworkJwtValidator(creds: MSTeamsCredentials): Promise<{
validate: (authHeader: string) => Promise<boolean>;
}> {
const { jwt, JwksClient } = await loadBotFrameworkJwtDeps();
const allowedAudiences: [string, ...string[]] = [
creds.appId,
`api://${creds.appId}`,
BOT_FRAMEWORK_GLOBAL_AUDIENCE,
];
const allowedIssuers = BOT_FRAMEWORK_ISSUERS.map((entry) =>
typeof entry.issuer === "function" ? entry.issuer(creds.tenantId) : entry.issuer,
) as [string, ...string[]];
// One JWKS client per distinct endpoint, cached for the validator lifetime.
const jwksClients = new Map<string, InstanceType<typeof JwksClient>>();
function getJwksClient(uri: string): InstanceType<typeof JwksClient> {
let client = jwksClients.get(uri);
if (!client) {
client = new JwksClient({
jwksUri: uri,
cache: true,
cacheMaxAge: 600_000,
rateLimit: true,
});
jwksClients.set(uri, client);
}
return client;
}
/** Decode the token header without verification to determine the kid. */
function decodeHeader(token: string): { kid?: string } | null {
const decoded = jwt.decode(token, { complete: true });
return decoded && typeof decoded === "object" ? (decoded.header as { kid?: string }) : null;
}
/** Resolve the issuer entry for a token's issuer claim (pre-verification). */
function resolveIssuerEntry(issuerClaim: string | undefined) {
if (!issuerClaim) {
return undefined;
}
return BOT_FRAMEWORK_ISSUERS.find((entry) => {
const expected =
typeof entry.issuer === "function" ? entry.issuer(creds.tenantId) : entry.issuer;
return expected === issuerClaim;
});
}
return {
async validate(authHeader: string, _serviceUrl?: string): Promise<boolean> {
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
if (!token) {
return false;
}
// Decode without verification to extract issuer and kid for key lookup.
const header = decodeHeader(token);
const unverifiedPayload = jwt.decode(token);
if (
!header?.kid ||
!isJwtPayloadObject(unverifiedPayload) ||
typeof unverifiedPayload.iss !== "string"
) {
return false;
}
// Resolve which JWKS endpoint to use based on the issuer claim.
const issuerEntry = resolveIssuerEntry(unverifiedPayload.iss);
if (!issuerEntry) {
return false;
}
const client = getJwksClient(issuerEntry.jwksUri);
try {
const signingKey = await client.getSigningKey(header.kid);
const publicKey = signingKey.getPublicKey();
const verifiedPayload = jwt.verify(token, publicKey, {
audience: allowedAudiences,
issuer: allowedIssuers,
algorithms: ["RS256"],
clockTolerance: 300,
});
if (!isJwtPayloadObject(verifiedPayload)) {
return false;
}
const audiences = getAudienceClaims(verifiedPayload);
if (
audiences.includes(BOT_FRAMEWORK_GLOBAL_AUDIENCE) &&
!hasExpectedBotIdentity(verifiedPayload, creds.appId)
) {
return false;
}
return true;
} catch (err) {
// Network-level failures (DNS, firewall, TLS) must be distinguished from
// invalid tokens so callers can log them at an appropriate severity.
// Rethrow so the JWT middleware can emit an actionable warning instead of
// silently returning 401 (which looks identical to a bad credential).
if (isJwksNetworkError(err)) {
throw err;
}
return false;
}
},
};
}
/**
* Return true when the error originated from a network-level failure fetching
* the JWKS endpoint (DNS resolution, connection refused, TLS handshake, etc.)
* rather than from token verification logic.
*/
function isJwksNetworkError(err: unknown): boolean {
if (!(err instanceof Error)) {
return false;
}
const code = (err as NodeJS.ErrnoException).code;
if (
code === "ECONNREFUSED" ||
code === "ENOTFOUND" ||
code === "EHOSTUNREACH" ||
code === "ETIMEDOUT" ||
code === "ECONNRESET"
) {
return true;
}
// jwks-rsa wraps fetch failures with a message containing the URL or "key fetching"
return (
/jwks|key fetch|getSigningKey/i.test(err.message) && /network|fetch|connect/i.test(err.message)
);
}