// Microsoft Teams platform plugin. // // Required secrets (wrangler secret put): // MICROSOFT_APP_ID — App ID from Azure Bot registration // MICROSOFT_APP_PASSWORD — Client secret (App password) // // Optional: // MICROSOFT_APP_TYPE — "MultiTenant" (default) | "SingleTenant" // MICROSOFT_APP_TENANT_ID — AAD tenant ID (required when SingleTenant) // TEAMS_ALLOWED_USERS — CSV of AAD user object IDs (empty = all) // // Verification: Teams posts activities with Authorization: Bearer . // We check all of: issuer, audience (= MICROSOFT_APP_ID), exp, signature // against JWKS (per-tenant URL for SingleTenant, Bot Framework URL for // MultiTenant), `serviceurl` claim == activity.serviceUrl, // activity.channelId == "msteams", and — for MultiTenant — that the // signing key is endorsed for the `msteams` channel. See // teams-verify.ts for the pure validator functions. // // Replies: Teams needs an OAuth2 access token obtained from // login.microsoftonline.com with MICROSOFT_APP_ID + MICROSOFT_APP_PASSWORD // against scope https://api.botframework.com/.default. The reply itself // POSTs to /v3/conversations//activities // — the serviceUrl varies per region and we carry it through via the // existing RelayMessage.replyToken channel (opaque to the relay core). import { hasNumberProp, hasStringProp, isRecord } from "@mulmoclaude/common"; import { PLATFORMS, type RelayMessage, type Env } from "../types.js"; import { registerPlatform, CONNECTION_MODES, type PlatformPlugin } from "../platform.js"; import { ONE_HOUR_MS, ONE_HOUR_S, TEN_SECONDS_MS } from "../time.js"; import { validateTokenClaims, validateJwkEndorsement, isAllowedSender, type AppType } from "./teams-verify.js"; import { parseJwt, jwtKid, verifyJwtSignature } from "./jwt.js"; import { postJsonChunks } from "./respond.js"; import { makeRelayMessage } from "./relay-message.js"; import { envSecret, requireEnvSecret } from "../utils/envSecret.js"; const MULTITENANT_ISSUER = "https://api.botframework.com"; const MULTITENANT_JWKS_URL = "https://login.botframework.com/v1/.well-known/keys"; const TOKEN_URL = "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token"; const TOKEN_SCOPE = "https://api.botframework.com/.default"; const MAX_TEAMS_TEXT = 28_000; // Teams soft limit is 40k; leave headroom const JWKS_CACHE_TTL_MS = ONE_HOUR_MS; const TOKEN_REFRESH_SKEW_SEC = 300; // refresh 5 min before expiry // ── Type guards ───────────────────────────────────────────────── // ── Config helpers ────────────────────────────────────────────── function getAppType(env: Env): AppType { const raw = typeof env.MICROSOFT_APP_TYPE === "string" ? env.MICROSOFT_APP_TYPE.trim() : ""; return raw === "SingleTenant" ? "SingleTenant" : "MultiTenant"; } function getTenantId(env: Env): string { return typeof env.MICROSOFT_APP_TENANT_ID === "string" ? env.MICROSOFT_APP_TENANT_ID.trim() : ""; } function getJwksUrl(env: Env): string { if (getAppType(env) === "SingleTenant") { const tenantId = getTenantId(env); return `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`; } return MULTITENANT_JWKS_URL; } function getExpectedIssuer(env: Env): string { if (getAppType(env) === "SingleTenant") { const tenantId = getTenantId(env); return `https://sts.windows.net/${tenantId}/`; } return MULTITENANT_ISSUER; } function getAllowedUsers(env: Env): Set { const raw = typeof env.TEAMS_ALLOWED_USERS === "string" ? env.TEAMS_ALLOWED_USERS : ""; return new Set( raw .split(",") .map((entry) => entry.trim()) .filter(Boolean), ); } // ── JWKS cache ────────────────────────────────────────────────── interface JwkKey { kid: string; kty: string; n: string; e: string; alg?: string; // Bot Framework JWKS publishes per-key channel endorsements; we // require `msteams` to be present for MultiTenant auth. endorsements?: string[]; } interface JwksCacheEntry { keys: JwkKey[]; expiresAt: number; } // Cache is keyed by JWKS URL so MultiTenant and SingleTenant modes can // coexist (unusual, but cheap to support). const jwksCache = new Map(); async function fetchJwks(url: string): Promise { const cached = jwksCache.get(url); if (cached && Date.now() < cached.expiresAt) return cached.keys; const res = await fetch(url, { signal: AbortSignal.timeout(TEN_SECONDS_MS) }); if (!res.ok) return cached?.keys ?? []; const data: { keys?: unknown[] } = await res.json(); if (!Array.isArray(data.keys)) return cached?.keys ?? []; const keys = data.keys .filter((key): key is Record => isRecord(key) && typeof key.kid === "string" && typeof key.n === "string") .map((key): JwkKey => { const endorsements = Array.isArray(key.endorsements) ? key.endorsements.filter((entry): entry is string => typeof entry === "string") : undefined; // Absent members are omitted rather than set to `undefined` so the key // stays assignable to the platform's `JsonWebKey` on the importKey path. return { kid: String(key.kid), kty: typeof key.kty === "string" ? key.kty : "RSA", n: String(key.n), e: typeof key.e === "string" ? key.e : "AQAB", ...(typeof key.alg === "string" ? { alg: key.alg } : {}), ...(endorsements !== undefined ? { endorsements } : {}), }; }); jwksCache.set(url, { keys, expiresAt: Date.now() + JWKS_CACHE_TTL_MS }); return keys; } // ── JWT verification ──────────────────────────────────────────── // Verifies the JWT against all of: expected issuer/audience/exp, the // activity body (serviceUrl + channelId cross-checks), the JWKS key's // channel endorsements, and the RSA signature. All four must pass — // signing key alone is not enough; see teams-verify.ts for rationale. async function verifyTeamsJwt(authHeader: string | undefined, env: Env, activity: TeamsMessage): Promise { if (!authHeader?.startsWith("Bearer ")) return false; const token = authHeader.slice(7).trim(); const jwt = parseJwt(token); if (!jwt) return false; const claimsOk = validateTokenClaims({ payload: jwt.payload, appId: envSecret(env, "MICROSOFT_APP_ID") ?? "", expectedIssuer: getExpectedIssuer(env), nowSeconds: Math.floor(Date.now() / 1000), activity: { serviceUrl: activity.serviceUrl, channelId: activity.channelId }, }); if (!claimsOk) return false; const keys = await fetchJwks(getJwksUrl(env)); const jwk = keys.find((key) => key.kid === jwtKid(jwt)); if (!jwk) return false; if (!validateJwkEndorsement(jwk, getAppType(env))) return false; return verifyJwtSignature(jwt, jwk); } // ── Activity parsing ──────────────────────────────────────────── interface TeamsMessage { conversationId: string; senderId: string; senderAadObjectId: string; text: string; serviceUrl: string; channelId: string; } // Wrapper around parseActivity that also tolerates non-JSON bodies. // Returns null for any reason the webhook should still ack 200 OK // (malformed JSON, non-message activity, missing required fields). // Exported for regression tests — the handler inlines the same two // steps (JSON.parse → parseActivity). export function parseWebhookBody(body: string): TeamsMessage | null { let parsed: unknown; try { parsed = JSON.parse(body); } catch { return null; } return parseActivity(parsed); } function parseActivity(body: unknown): TeamsMessage | null { if (!isRecord(body)) return null; // Non-message activities (conversationUpdate, invoke, typing, …) are // legit but we don't forward them to MulmoClaude. if (body.type !== "message") return null; const text = typeof body.text === "string" ? body.text.trim() : ""; if (!text) return null; const { conversation } = body; const { from } = body; const serviceUrl = typeof body.serviceUrl === "string" ? body.serviceUrl.trim() : ""; const channelId = typeof body.channelId === "string" ? body.channelId.trim() : ""; if (!isRecord(conversation) || typeof conversation.id !== "string") return null; if (!isRecord(from) || typeof from.id !== "string") return null; if (!serviceUrl) return null; return { conversationId: conversation.id, senderId: from.id, senderAadObjectId: typeof from.aadObjectId === "string" ? from.aadObjectId : "", text, serviceUrl, channelId, }; } // ── OAuth2 token exchange ─────────────────────────────────────── interface TokenCache { token: string; expiresAt: number; // epoch seconds } let tokenCache: TokenCache | null = null; async function getAccessToken(env: Env): Promise { const now = Math.floor(Date.now() / 1000); if (tokenCache && tokenCache.expiresAt - TOKEN_REFRESH_SKEW_SEC > now) { return tokenCache.token; } const body = new URLSearchParams({ grant_type: "client_credentials", client_id: requireEnvSecret(env, "MICROSOFT_APP_ID"), client_secret: requireEnvSecret(env, "MICROSOFT_APP_PASSWORD"), scope: TOKEN_SCOPE, }); const res = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), signal: AbortSignal.timeout(TEN_SECONDS_MS), }); if (!res.ok) { throw new Error(`Teams token exchange failed: ${res.status}`); } const data: unknown = await res.json(); if (!hasStringProp(data, "access_token")) { throw new Error("Teams token response missing access_token"); } const ttlSec = hasNumberProp(data, "expires_in") ? data.expires_in : ONE_HOUR_S; tokenCache = { token: data.access_token, expiresAt: now + ttlSec }; return data.access_token; } // ── Plugin ────────────────────────────────────────────────────── const teamsPlugin: PlatformPlugin = { name: PLATFORMS.teams, mode: CONNECTION_MODES.webhook, webhookPath: "/webhook/teams", isConfigured(env: Env): boolean { if (!env.MICROSOFT_APP_ID || !env.MICROSOFT_APP_PASSWORD) return false; if (getAppType(env) === "SingleTenant" && !getTenantId(env)) return false; return true; }, async handleWebhook(request: Request, body: string, env: Env): Promise { // Parse the activity first so the JWT verifier can cross-check the // serviceUrl / channelId claims against the body. Non-JSON bodies // and non-message activities (typing, invoke, …) both return null // here — we ack 200 OK with no message, matching Bot Framework's // expectation and avoiding a spurious 500 on malformed payloads. const activity = parseWebhookBody(body); if (!activity) return []; const authHeader = request.headers.get("authorization") ?? undefined; const valid = await verifyTeamsJwt(authHeader, env, activity); if (!valid) throw new Error("Teams JWT verification failed"); const allowed = getAllowedUsers(env); if (!isAllowedSender({ allowed, senderAadObjectId: activity.senderAadObjectId })) { // Drop messages from users not on the allowlist — still 200 OK // so the Bot Framework doesn't mark the endpoint as flaky. return []; } return [ makeRelayMessage({ platform: PLATFORMS.teams, senderId: activity.senderAadObjectId || activity.senderId, chatId: activity.conversationId, text: activity.text, // Carry the activity's serviceUrl through the outbound path — // the relay's response routing treats replyToken as opaque. replyToken: activity.serviceUrl, }), ]; }, async sendResponse(chatId: string, text: string, env: Env, replyToken?: string): Promise { const serviceUrl = typeof replyToken === "string" ? replyToken.trim() : ""; if (!serviceUrl) { throw new Error("Teams sendResponse missing serviceUrl (no prior inbound message to reply to)"); } const base = serviceUrl.replace(/\/$/, ""); const endpoint = `${base}/v3/conversations/${encodeURIComponent(chatId)}/activities`; await postJsonChunks({ text, maxTextLength: MAX_TEAMS_TEXT, label: "Teams", endpoint, accessToken: await getAccessToken(env), buildBody: (chunk) => ({ type: "message", text: chunk }), }); }, }; registerPlatform(teamsPlugin);