From 828ebd43d47b6bd8b69c4d3e1b1d123f0e97fc74 Mon Sep 17 00:00:00 2001 From: sudie-codes Date: Fri, 10 Apr 2026 00:38:01 -0700 Subject: [PATCH] feat(msteams): handle signin/tokenExchange and signin/verifyState for SSO (#60956) (#64089) * feat(msteams): handle signin/tokenExchange and signin/verifyState for SSO (#60956) * test(msteams): mock conversationStore.get in thread session fixture --------- Co-authored-by: Brad Groux --- docs/.generated/config-baseline.sha256 | 8 +- .../msteams/src/monitor-handler.sso.test.ts | 458 ++++++++++++++++++ extensions/msteams/src/monitor-handler.ts | 91 ++++ .../msteams/src/monitor-handler.types.ts | 7 + .../message-handler.thread-session.test.ts | 1 + extensions/msteams/src/monitor.ts | 19 + extensions/msteams/src/sso-token-store.ts | 125 +++++ extensions/msteams/src/sso.ts | 300 ++++++++++++ ...ndled-channel-config-metadata.generated.ts | 12 + src/config/types.msteams.ts | 29 ++ src/config/zod-schema.providers-core.ts | 15 + 11 files changed, 1061 insertions(+), 4 deletions(-) create mode 100644 extensions/msteams/src/monitor-handler.sso.test.ts create mode 100644 extensions/msteams/src/sso-token-store.ts create mode 100644 extensions/msteams/src/sso.ts diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 18239a3d1c4..c8e85b02dbb 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -0a75b57f5dbb0bb1488eacb47111ee22ff42dd3747bfe07bb69c9445d5e55c3e config-baseline.json -ff15bb8b4231fc80174249ae89bcb61439d7adda5ee6be95e4d304680253a59f config-baseline.core.json -7f42b22b46c487d64aaac46001ba9d9096cf7bf0b1c263a54d39946303ff5018 config-baseline.channel.json -483d4f3c1d516719870ad6f2aba6779b9950f85471ee77b9994a077a7574a892 config-baseline.plugin.json +a962c1d7ddffa15f2333854f77b03da4f6db07fada16f288377ee1daf50afc08 config-baseline.json +3c8455d44a63d495ad295d2c9d76fed7a190b80344dabaa0e78ba433bf2d253b config-baseline.core.json +df55c673a1cdbebc4fe68baaaf9d0d4289313be5034be92f0d510726a086b1d6 config-baseline.channel.json +3f6fccab66a9abe7e1dd412fb01b13b944ed24edbe09df55ada3323acc7f76fe config-baseline.plugin.json diff --git a/extensions/msteams/src/monitor-handler.sso.test.ts b/extensions/msteams/src/monitor-handler.sso.test.ts new file mode 100644 index 00000000000..599d60d559d --- /dev/null +++ b/extensions/msteams/src/monitor-handler.sso.test.ts @@ -0,0 +1,458 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; +import type { PluginRuntime } from "../runtime-api.js"; +import { + type MSTeamsActivityHandler, + type MSTeamsMessageHandlerDeps, + registerMSTeamsHandlers, +} from "./monitor-handler.js"; +import { + createActivityHandler as baseCreateActivityHandler, + createMSTeamsMessageHandlerDeps, +} from "./monitor-handler.test-helpers.js"; +import { setMSTeamsRuntime } from "./runtime.js"; +import type { MSTeamsTurnContext } from "./sdk-types.js"; +import { createMSTeamsSsoTokenStoreMemory } from "./sso-token-store.js"; +import { + type MSTeamsSsoFetch, + handleSigninTokenExchangeInvoke, + handleSigninVerifyStateInvoke, + parseSigninTokenExchangeValue, + parseSigninVerifyStateValue, +} from "./sso.js"; + +function installTestRuntime(): void { + setMSTeamsRuntime({ + logging: { shouldLogVerbose: () => false }, + system: { enqueueSystemEvent: vi.fn() }, + channel: { + debounce: { + resolveInboundDebounceMs: () => 0, + createInboundDebouncer: (params: { + onFlush: (entries: T[]) => Promise; + }): { enqueue: (entry: T) => Promise } => ({ + enqueue: async (entry: T) => { + await params.onFlush([entry]); + }, + }), + }, + pairing: { + readAllowFromStore: vi.fn(async () => []), + upsertPairingRequest: vi.fn(async () => null), + }, + text: { + hasControlCommand: () => false, + }, + routing: { + resolveAgentRoute: ({ peer }: { peer: { kind: string; id: string } }) => ({ + sessionKey: `msteams:${peer.kind}:${peer.id}`, + agentId: "default", + accountId: "default", + }), + }, + reply: { + formatAgentEnvelope: ({ body }: { body: string }) => body, + finalizeInboundContext: >(ctx: T) => ctx, + }, + session: { + recordInboundSession: vi.fn(async () => undefined), + }, + }, + } as unknown as PluginRuntime); +} + +function createActivityHandler() { + const run = vi.fn(async () => undefined); + const handler = baseCreateActivityHandler(run); + return { handler, run }; +} + +function createDepsWithoutSso( + overrides: Partial = {}, +): MSTeamsMessageHandlerDeps { + const base = createMSTeamsMessageHandlerDeps(); + return { ...base, ...overrides }; +} + +function createSsoDeps(params: { fetchImpl: MSTeamsSsoFetch }) { + const tokenStore = createMSTeamsSsoTokenStoreMemory(); + const tokenProvider = { + getAccessToken: vi.fn(async () => "bf-service-token"), + }; + return { + sso: { + tokenProvider, + tokenStore, + connectionName: "GraphConnection", + fetchImpl: params.fetchImpl, + }, + tokenStore, + tokenProvider, + }; +} + +function createSigninInvokeContext(params: { + name: "signin/tokenExchange" | "signin/verifyState"; + value: unknown; + userAadId?: string; + userBfId?: string; +}): MSTeamsTurnContext & { sendActivity: ReturnType } { + return { + activity: { + id: "invoke-1", + type: "invoke", + name: params.name, + channelId: "msteams", + serviceUrl: "https://service.example.test", + from: { + id: params.userBfId ?? "bf-user", + aadObjectId: params.userAadId ?? "aad-user-guid", + name: "Test User", + }, + recipient: { id: "bot-id", name: "Bot" }, + conversation: { + id: "19:personal-chat", + conversationType: "personal", + }, + channelData: {}, + attachments: [], + value: params.value, + }, + sendActivity: vi.fn(async () => ({ id: "ack-id" })), + sendActivities: vi.fn(async () => []), + updateActivity: vi.fn(async () => ({ id: "update" })), + deleteActivity: vi.fn(async () => {}), + } as unknown as MSTeamsTurnContext & { + sendActivity: ReturnType; + }; +} + +function createFakeFetch(handlers: Array<(url: string, init?: unknown) => unknown>) { + const calls: Array<{ url: string; init?: unknown }> = []; + const fetchImpl: MSTeamsSsoFetch = async (url, init) => { + calls.push({ url, init }); + const handler = handlers.shift(); + if (!handler) { + throw new Error("unexpected fetch call"); + } + const response = handler(url, init) as { + ok: boolean; + status: number; + body: unknown; + }; + return { + ok: response.ok, + status: response.status, + json: async () => response.body, + text: async () => + typeof response.body === "string" ? response.body : JSON.stringify(response.body ?? ""), + }; + }; + return { fetchImpl, calls }; +} + +describe("msteams signin invoke value parsers", () => { + it("parses signin/tokenExchange values", () => { + expect( + parseSigninTokenExchangeValue({ + id: "flow-1", + connectionName: "Graph", + token: "eyJ...", + }), + ).toEqual({ id: "flow-1", connectionName: "Graph", token: "eyJ..." }); + }); + + it("rejects non-object signin/tokenExchange values", () => { + expect(parseSigninTokenExchangeValue(null)).toBeNull(); + expect(parseSigninTokenExchangeValue("nope")).toBeNull(); + }); + + it("parses signin/verifyState values", () => { + expect(parseSigninVerifyStateValue({ state: "123456" })).toEqual({ state: "123456" }); + expect(parseSigninVerifyStateValue({})).toEqual({ state: undefined }); + expect(parseSigninVerifyStateValue(null)).toBeNull(); + }); +}); + +describe("handleSigninTokenExchangeInvoke", () => { + it("exchanges the Teams token and persists the result", async () => { + const { fetchImpl, calls } = createFakeFetch([ + () => ({ + ok: true, + status: 200, + body: { + channelId: "msteams", + connectionName: "GraphConnection", + token: "delegated-graph-token", + expiration: "2030-01-01T00:00:00Z", + }, + }), + ]); + const { sso, tokenStore } = createSsoDeps({ fetchImpl }); + + const result = await handleSigninTokenExchangeInvoke({ + value: { id: "flow-1", connectionName: "GraphConnection", token: "exchangeable-token" }, + user: { userId: "aad-user-guid", channelId: "msteams" }, + deps: sso, + }); + + expect(result).toEqual({ + ok: true, + token: "delegated-graph-token", + expiresAt: "2030-01-01T00:00:00Z", + }); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toContain("/api/usertoken/exchange"); + expect(calls[0]?.url).toContain("userId=aad-user-guid"); + expect(calls[0]?.url).toContain("connectionName=GraphConnection"); + expect(calls[0]?.url).toContain("channelId=msteams"); + + const init = calls[0]?.init as { + method?: string; + headers?: Record; + body?: string; + }; + expect(init?.method).toBe("POST"); + expect(init?.headers?.Authorization).toBe("Bearer bf-service-token"); + expect(JSON.parse(init?.body ?? "{}")).toEqual({ token: "exchangeable-token" }); + + const stored = await tokenStore.get({ + connectionName: "GraphConnection", + userId: "aad-user-guid", + }); + expect(stored?.token).toBe("delegated-graph-token"); + expect(stored?.expiresAt).toBe("2030-01-01T00:00:00Z"); + }); + + it("returns a service error when the User Token service rejects the exchange", async () => { + const { fetchImpl } = createFakeFetch([ + () => ({ ok: false, status: 502, body: "bad gateway" }), + ]); + const { sso, tokenStore } = createSsoDeps({ fetchImpl }); + + const result = await handleSigninTokenExchangeInvoke({ + value: { id: "flow-1", connectionName: "GraphConnection", token: "exchangeable-token" }, + user: { userId: "aad-user-guid", channelId: "msteams" }, + deps: sso, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe("service_error"); + expect(result.status).toBe(502); + expect(result.message).toContain("bad gateway"); + } + const stored = await tokenStore.get({ + connectionName: "GraphConnection", + userId: "aad-user-guid", + }); + expect(stored).toBeNull(); + }); + + it("refuses to exchange without a user id", async () => { + const { fetchImpl, calls } = createFakeFetch([]); + const { sso } = createSsoDeps({ fetchImpl }); + + const result = await handleSigninTokenExchangeInvoke({ + value: { id: "flow-1", connectionName: "GraphConnection", token: "exchangeable-token" }, + user: { userId: "", channelId: "msteams" }, + deps: sso, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe("missing_user"); + } + expect(calls).toHaveLength(0); + }); +}); + +describe("handleSigninVerifyStateInvoke", () => { + it("fetches the user token for the magic code and persists it", async () => { + const { fetchImpl, calls } = createFakeFetch([ + () => ({ + ok: true, + status: 200, + body: { + channelId: "msteams", + connectionName: "GraphConnection", + token: "delegated-token-2", + expiration: "2031-02-03T04:05:06Z", + }, + }), + ]); + const { sso, tokenStore } = createSsoDeps({ fetchImpl }); + + const result = await handleSigninVerifyStateInvoke({ + value: { state: "654321" }, + user: { userId: "aad-user-guid", channelId: "msteams" }, + deps: sso, + }); + + expect(result.ok).toBe(true); + expect(calls[0]?.url).toContain("/api/usertoken/GetToken"); + expect(calls[0]?.url).toContain("code=654321"); + const init = calls[0]?.init as { method?: string }; + expect(init?.method).toBe("GET"); + + const stored = await tokenStore.get({ + connectionName: "GraphConnection", + userId: "aad-user-guid", + }); + expect(stored?.token).toBe("delegated-token-2"); + }); + + it("rejects invocations without a state code", async () => { + const { fetchImpl, calls } = createFakeFetch([]); + const { sso } = createSsoDeps({ fetchImpl }); + const result = await handleSigninVerifyStateInvoke({ + value: { state: " " }, + user: { userId: "aad-user-guid", channelId: "msteams" }, + deps: sso, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe("missing_state"); + } + expect(calls).toHaveLength(0); + }); +}); + +describe("msteams signin invoke handler registration", () => { + beforeAll(() => { + installTestRuntime(); + }); + + it("acks signin invokes even when sso is not configured", async () => { + const deps = createDepsWithoutSso(); + const { handler, run } = createActivityHandler(); + const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & { + run: NonNullable; + }; + + const ctx = createSigninInvokeContext({ + name: "signin/tokenExchange", + value: { id: "x", connectionName: "Graph", token: "exchangeable" }, + }); + + await registered.run(ctx); + + expect(ctx.sendActivity).toHaveBeenCalledWith( + expect.objectContaining({ + type: "invokeResponse", + value: expect.objectContaining({ status: 200 }), + }), + ); + expect(run).not.toHaveBeenCalled(); + expect(deps.log.debug).toHaveBeenCalledWith( + "signin invoke received but msteams.sso is not configured", + expect.objectContaining({ name: "signin/tokenExchange" }), + ); + }); + + it("invokes the token exchange handler when sso is configured", async () => { + const { fetchImpl } = createFakeFetch([ + () => ({ + ok: true, + status: 200, + body: { + channelId: "msteams", + connectionName: "GraphConnection", + token: "delegated-graph-token", + expiration: "2030-01-01T00:00:00Z", + }, + }), + ]); + const { sso, tokenStore } = createSsoDeps({ fetchImpl }); + const deps = createDepsWithoutSso({ sso }); + const { handler } = createActivityHandler(); + const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & { + run: NonNullable; + }; + + const ctx = createSigninInvokeContext({ + name: "signin/tokenExchange", + value: { id: "x", connectionName: "GraphConnection", token: "exchangeable" }, + }); + + await registered.run(ctx); + + expect(ctx.sendActivity).toHaveBeenCalledWith( + expect.objectContaining({ + type: "invokeResponse", + value: expect.objectContaining({ status: 200 }), + }), + ); + expect(deps.log.info).toHaveBeenCalledWith( + "msteams sso token exchanged", + expect.objectContaining({ userId: "aad-user-guid", hasExpiry: true }), + ); + const stored = await tokenStore.get({ + connectionName: "GraphConnection", + userId: "aad-user-guid", + }); + expect(stored?.token).toBe("delegated-graph-token"); + }); + + it("logs an error when the token exchange fails", async () => { + const { fetchImpl } = createFakeFetch([ + () => ({ ok: false, status: 400, body: "bad request" }), + ]); + const { sso } = createSsoDeps({ fetchImpl }); + const deps = createDepsWithoutSso({ sso }); + const { handler } = createActivityHandler(); + const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & { + run: NonNullable; + }; + + const ctx = createSigninInvokeContext({ + name: "signin/tokenExchange", + value: { id: "x", connectionName: "GraphConnection", token: "exchangeable" }, + }); + + await registered.run(ctx); + + expect(ctx.sendActivity).toHaveBeenCalledWith( + expect.objectContaining({ type: "invokeResponse" }), + ); + expect(deps.log.error).toHaveBeenCalledWith( + "msteams sso token exchange failed", + expect.objectContaining({ code: "unexpected_response", status: 400 }), + ); + }); + + it("handles signin/verifyState via the magic-code flow", async () => { + const { fetchImpl } = createFakeFetch([ + () => ({ + ok: true, + status: 200, + body: { + channelId: "msteams", + connectionName: "GraphConnection", + token: "delegated-token-3", + }, + }), + ]); + const { sso, tokenStore } = createSsoDeps({ fetchImpl }); + const deps = createDepsWithoutSso({ sso }); + const { handler } = createActivityHandler(); + const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & { + run: NonNullable; + }; + + const ctx = createSigninInvokeContext({ + name: "signin/verifyState", + value: { state: "112233" }, + }); + + await registered.run(ctx); + + expect(deps.log.info).toHaveBeenCalledWith( + "msteams sso verifyState succeeded", + expect.objectContaining({ userId: "aad-user-guid" }), + ); + const stored = await tokenStore.get({ + connectionName: "GraphConnection", + userId: "aad-user-guid", + }); + expect(stored?.token).toBe("delegated-token-3"); + }); +}); diff --git a/extensions/msteams/src/monitor-handler.ts b/extensions/msteams/src/monitor-handler.ts index a5fcb8b534c..5b7957e0e9c 100644 --- a/extensions/msteams/src/monitor-handler.ts +++ b/extensions/msteams/src/monitor-handler.ts @@ -11,6 +11,13 @@ import { getPendingUpload, removePendingUpload } from "./pending-uploads.js"; import { withRevokedProxyFallback } from "./revoked-context.js"; import { getMSTeamsRuntime } from "./runtime.js"; import type { MSTeamsTurnContext } from "./sdk-types.js"; +import { + handleSigninTokenExchangeInvoke, + handleSigninVerifyStateInvoke, + type MSTeamsSsoDeps, + parseSigninTokenExchangeValue, + parseSigninVerifyStateValue, +} from "./sso.js"; import { buildGroupWelcomeText, buildWelcomeCard } from "./welcome-card.js"; export type { MSTeamsMessageHandlerDeps } from "./monitor-handler.types.js"; import type { MSTeamsMessageHandlerDeps } from "./monitor-handler.types.js"; @@ -424,6 +431,90 @@ export function registerMSTeamsHandlers( deps.log.debug?.("skipping adaptive card action invoke without value payload"); } + // Bot Framework OAuth SSO: Teams sends signin/tokenExchange (with a + // Teams-provided exchangeable token) or signin/verifyState (magic + // code fallback) after an oauthCard is presented. We must ack with + // HTTP 200 and, if configured, exchange the token with the Bot + // Framework User Token service and persist it for downstream tools. + if ( + ctx.activity?.type === "invoke" && + (ctx.activity?.name === "signin/tokenExchange" || + ctx.activity?.name === "signin/verifyState") + ) { + // Always ack immediately — silently dropping the invoke causes + // the Teams card UI to report "Something went wrong". + await ctx.sendActivity({ type: "invokeResponse", value: { status: 200, body: {} } }); + + if (!deps.sso) { + deps.log.debug?.("signin invoke received but msteams.sso is not configured", { + name: ctx.activity.name, + }); + return; + } + + const user = { + userId: ctx.activity.from?.aadObjectId ?? ctx.activity.from?.id ?? "", + channelId: ctx.activity.channelId ?? "msteams", + }; + + try { + if (ctx.activity.name === "signin/tokenExchange") { + const parsed = parseSigninTokenExchangeValue(ctx.activity.value); + if (!parsed) { + deps.log.debug?.("invalid signin/tokenExchange invoke value"); + return; + } + const result = await handleSigninTokenExchangeInvoke({ + value: parsed, + user, + deps: deps.sso, + }); + if (result.ok) { + deps.log.info("msteams sso token exchanged", { + userId: user.userId, + hasExpiry: Boolean(result.expiresAt), + }); + } else { + deps.log.error("msteams sso token exchange failed", { + code: result.code, + status: result.status, + message: result.message, + }); + } + return; + } + + // signin/verifyState + const parsed = parseSigninVerifyStateValue(ctx.activity.value); + if (!parsed) { + deps.log.debug?.("invalid signin/verifyState invoke value"); + return; + } + const result = await handleSigninVerifyStateInvoke({ + value: parsed, + user, + deps: deps.sso, + }); + if (result.ok) { + deps.log.info("msteams sso verifyState succeeded", { + userId: user.userId, + hasExpiry: Boolean(result.expiresAt), + }); + } else { + deps.log.error("msteams sso verifyState failed", { + code: result.code, + status: result.status, + message: result.message, + }); + } + } catch (err) { + deps.log.error("msteams sso invoke handler error", { + error: formatUnknownError(err), + }); + } + return; + } + return originalRun.call(handler, context); }; } diff --git a/extensions/msteams/src/monitor-handler.types.ts b/extensions/msteams/src/monitor-handler.types.ts index 85643fbafd5..0fe9e895bf7 100644 --- a/extensions/msteams/src/monitor-handler.types.ts +++ b/extensions/msteams/src/monitor-handler.types.ts @@ -3,6 +3,7 @@ import type { MSTeamsConversationStore } from "./conversation-store.js"; import type { MSTeamsAdapter } from "./messenger.js"; import type { MSTeamsMonitorLogger } from "./monitor-types.js"; import type { MSTeamsPollStore } from "./polls.js"; +import type { MSTeamsSsoDeps } from "./sso.js"; export type MSTeamsMessageHandlerDeps = { cfg: OpenClawConfig; @@ -17,4 +18,10 @@ export type MSTeamsMessageHandlerDeps = { conversationStore: MSTeamsConversationStore; pollStore: MSTeamsPollStore; log: MSTeamsMonitorLogger; + /** + * Optional Bot Framework OAuth SSO deps. When omitted the plugin + * does not handle `signin/tokenExchange` or `signin/verifyState` + * invokes, matching the pre-SSO behavior. + */ + sso?: MSTeamsSsoDeps; }; diff --git a/extensions/msteams/src/monitor-handler/message-handler.thread-session.test.ts b/extensions/msteams/src/monitor-handler/message-handler.thread-session.test.ts index 6fef9ccd294..e45d1822690 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.thread-session.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.thread-session.test.ts @@ -101,6 +101,7 @@ describe("msteams thread session isolation", () => { textLimit: 4000, mediaMaxBytes: 1024 * 1024, conversationStore: { + get: vi.fn(async () => null), upsert: vi.fn(async () => undefined), } as unknown as MSTeamsMessageHandlerDeps["conversationStore"], pollStore: { diff --git a/extensions/msteams/src/monitor.ts b/extensions/msteams/src/monitor.ts index 40c3602f311..c03a6cebddd 100644 --- a/extensions/msteams/src/monitor.ts +++ b/extensions/msteams/src/monitor.ts @@ -24,6 +24,8 @@ import { createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth, } from "./sdk.js"; +import { createMSTeamsSsoTokenStoreFs } from "./sso-token-store.js"; +import type { MSTeamsSsoDeps } from "./sso.js"; import { resolveMSTeamsCredentials } from "./token.js"; import { applyMSTeamsWebhookTimeouts } from "./webhook-timeouts.js"; @@ -233,6 +235,22 @@ export async function monitorMSTeamsProvider( const adapter = createMSTeamsAdapter(app, sdk); + // Build SSO deps when the operator has opted in and a connection name + // is configured. Leaving `sso` undefined matches the pre-SSO behavior + // (the plugin will still ack signin invokes, but will not attempt a + // Bot Framework token exchange or persist anything). + let ssoDeps: MSTeamsSsoDeps | undefined; + if (msteamsCfg.sso?.enabled && msteamsCfg.sso.connectionName) { + ssoDeps = { + tokenProvider, + tokenStore: createMSTeamsSsoTokenStoreFs(), + connectionName: msteamsCfg.sso.connectionName, + }; + log.debug?.("msteams sso enabled", { + connectionName: msteamsCfg.sso.connectionName, + }); + } + // Build a simple ActivityHandler-compatible object const handler = buildActivityHandler(); registerMSTeamsHandlers(handler, { @@ -246,6 +264,7 @@ export async function monitorMSTeamsProvider( conversationStore, pollStore, log, + sso: ssoDeps, }); // Create Express server diff --git a/extensions/msteams/src/sso-token-store.ts b/extensions/msteams/src/sso-token-store.ts new file mode 100644 index 00000000000..3b58c90e3d2 --- /dev/null +++ b/extensions/msteams/src/sso-token-store.ts @@ -0,0 +1,125 @@ +/** + * File-backed store for Bot Framework OAuth SSO tokens. + * + * Tokens are keyed by (connectionName, userId). `userId` should be the + * stable AAD object ID (`activity.from.aadObjectId`) when available, + * falling back to the Bot Framework `activity.from.id`. + * + * The store is intentionally minimal: it persists the exchanged user + * token plus its expiration so consumers (for example tool handlers + * that call Microsoft Graph with delegated permissions) can fetch a + * valid token without reaching back into Bot Framework every turn. + */ + +import { resolveMSTeamsStorePath } from "./storage.js"; +import { readJsonFile, withFileLock, writeJsonFile } from "./store-fs.js"; + +export type MSTeamsSsoStoredToken = { + /** Connection name from the Bot Framework OAuth connection setting. */ + connectionName: string; + /** Stable user identifier (AAD object ID preferred). */ + userId: string; + /** Exchanged user access token. */ + token: string; + /** Expiration (ISO 8601) when the Bot Framework user token service reports one. */ + expiresAt?: string; + /** ISO 8601 timestamp for the last successful exchange. */ + updatedAt: string; +}; + +export type MSTeamsSsoTokenStore = { + get(params: { connectionName: string; userId: string }): Promise; + save(token: MSTeamsSsoStoredToken): Promise; + remove(params: { connectionName: string; userId: string }): Promise; +}; + +type SsoStoreData = { + version: 1; + // Keyed by `${connectionName}::${userId}` for a simple flat map on disk. + tokens: Record; +}; + +const STORE_FILENAME = "msteams-sso-tokens.json"; + +function makeKey(connectionName: string, userId: string): string { + return `${connectionName}::${userId}`; +} + +function isSsoStoreData(value: unknown): value is SsoStoreData { + if (!value || typeof value !== "object") { + return false; + } + const obj = value as Record; + return obj.version === 1 && typeof obj.tokens === "object" && obj.tokens !== null; +} + +export function createMSTeamsSsoTokenStoreFs(params?: { + env?: NodeJS.ProcessEnv; + homedir?: () => string; + stateDir?: string; + storePath?: string; +}): MSTeamsSsoTokenStore { + const filePath = resolveMSTeamsStorePath({ + filename: STORE_FILENAME, + env: params?.env, + homedir: params?.homedir, + stateDir: params?.stateDir, + storePath: params?.storePath, + }); + + const empty: SsoStoreData = { version: 1, tokens: {} }; + + const readStore = async (): Promise => { + const { value } = await readJsonFile(filePath, empty); + if (!isSsoStoreData(value)) { + return { version: 1, tokens: {} }; + } + return value; + }; + + return { + async get({ connectionName, userId }) { + const store = await readStore(); + return store.tokens[makeKey(connectionName, userId)] ?? null; + }, + + async save(token) { + await withFileLock(filePath, empty, async () => { + const store = await readStore(); + const key = makeKey(token.connectionName, token.userId); + store.tokens[key] = { ...token }; + await writeJsonFile(filePath, store); + }); + }, + + async remove({ connectionName, userId }) { + let removed = false; + await withFileLock(filePath, empty, async () => { + const store = await readStore(); + const key = makeKey(connectionName, userId); + if (store.tokens[key]) { + delete store.tokens[key]; + removed = true; + await writeJsonFile(filePath, store); + } + }); + return removed; + }, + }; +} + +/** In-memory store, primarily useful for tests. */ +export function createMSTeamsSsoTokenStoreMemory(): MSTeamsSsoTokenStore { + const tokens = new Map(); + return { + async get({ connectionName, userId }) { + return tokens.get(makeKey(connectionName, userId)) ?? null; + }, + async save(token) { + tokens.set(makeKey(token.connectionName, token.userId), { ...token }); + }, + async remove({ connectionName, userId }) { + return tokens.delete(makeKey(connectionName, userId)); + }, + }; +} diff --git a/extensions/msteams/src/sso.ts b/extensions/msteams/src/sso.ts new file mode 100644 index 00000000000..f0b3181bb39 --- /dev/null +++ b/extensions/msteams/src/sso.ts @@ -0,0 +1,300 @@ +/** + * Bot Framework OAuth SSO invoke handlers for Microsoft Teams. + * + * Handles two invoke activities Teams sends when the bot has presented + * an `oauthCard` or when the user completes an interactive sign-in: + * + * 1. `signin/tokenExchange` + * The Teams client obtained an exchangeable token from the bot's + * AAD app and forwards it to the bot. The bot exchanges that token + * with the Bot Framework User Token service, which returns the real + * delegated user token (for example, a Microsoft Graph access token + * if the OAuth connection is set up for Graph). + * + * 2. `signin/verifyState` + * Fallback for the magic-code flow: the user finishes sign-in in a + * browser tab, receives a 6-digit code, and pastes it back into the + * chat. The bot then asks the User Token service for the token + * corresponding to that code. + * + * In both cases the bot must reply with an `invokeResponse` (HTTP 200) + * immediately or the Teams UI shows "Something went wrong". Callers of + * {@link handleSigninTokenExchangeInvoke} and + * {@link handleSigninVerifyStateInvoke} are responsible for sending + * that ack; these helpers encapsulate token exchange and persistence. + */ + +import type { MSTeamsAccessTokenProvider } from "./monitor-handler.js"; +import type { MSTeamsSsoTokenStore } from "./sso-token-store.js"; +import { buildUserAgent } from "./user-agent.js"; + +/** Scope used to obtain a Bot Framework service token. */ +export const BOT_FRAMEWORK_TOKEN_SCOPE = "https://api.botframework.com/.default"; + +/** Bot Framework User Token service base URL. */ +export const BOT_FRAMEWORK_USER_TOKEN_BASE_URL = "https://token.botframework.com"; + +/** + * Response shape returned by the Bot Framework User Token service for + * `GetUserToken` and `ExchangeToken`. + * + * @see https://learn.microsoft.com/azure/bot-service/rest-api/bot-framework-rest-connector-user-token-service + */ +export type BotFrameworkUserTokenResponse = { + channelId?: string; + connectionName: string; + token: string; + expiration?: string; +}; + +export type MSTeamsSsoFetch = ( + input: string, + init?: { + method?: string; + headers?: Record; + body?: string; + }, +) => Promise<{ + ok: boolean; + status: number; + json(): Promise; + text(): Promise; +}>; + +export type MSTeamsSsoDeps = { + tokenProvider: MSTeamsAccessTokenProvider; + tokenStore: MSTeamsSsoTokenStore; + connectionName: string; + /** Override `fetch` for testing. */ + fetchImpl?: MSTeamsSsoFetch; + /** Override the User Token service base URL (testing / sovereign clouds). */ + userTokenBaseUrl?: string; +}; + +export type MSTeamsSsoUser = { + /** Stable user identifier — AAD object ID when available. */ + userId: string; + /** Bot Framework channel ID (default: "msteams"). */ + channelId?: string; +}; + +export type MSTeamsSsoResult = + | { + ok: true; + token: string; + expiresAt?: string; + } + | { + ok: false; + code: + | "missing_user" + | "missing_connection" + | "missing_token" + | "missing_state" + | "service_error" + | "unexpected_response"; + message: string; + status?: number; + }; + +export type SigninTokenExchangeValue = { + id?: string; + connectionName?: string; + token?: string; +}; + +export type SigninVerifyStateValue = { + state?: string; +}; + +/** + * Extract and validate the `signin/tokenExchange` activity value. Teams + * delivers `{ id, connectionName, token }`; any field may be missing on + * malformed invocations, so callers should check the parsed result. + */ +export function parseSigninTokenExchangeValue(value: unknown): SigninTokenExchangeValue | null { + if (!value || typeof value !== "object") { + return null; + } + const obj = value as Record; + const id = typeof obj.id === "string" ? obj.id : undefined; + const connectionName = typeof obj.connectionName === "string" ? obj.connectionName : undefined; + const token = typeof obj.token === "string" ? obj.token : undefined; + return { id, connectionName, token }; +} + +/** Extract the `signin/verifyState` activity value `{ state }`. */ +export function parseSigninVerifyStateValue(value: unknown): SigninVerifyStateValue | null { + if (!value || typeof value !== "object") { + return null; + } + const obj = value as Record; + const state = typeof obj.state === "string" ? obj.state : undefined; + return { state }; +} + +type UserTokenServiceCallParams = { + baseUrl: string; + path: string; + query: Record; + method: "GET" | "POST"; + body?: unknown; + bearerToken: string; + fetchImpl: MSTeamsSsoFetch; +}; + +async function callUserTokenService( + params: UserTokenServiceCallParams, +): Promise { + const qs = new URLSearchParams(params.query).toString(); + const url = `${params.baseUrl.replace(/\/+$/, "")}${params.path}?${qs}`; + const headers: Record = { + Accept: "application/json", + Authorization: `Bearer ${params.bearerToken}`, + "User-Agent": buildUserAgent(), + }; + if (params.body !== undefined) { + headers["Content-Type"] = "application/json"; + } + const response = await params.fetchImpl(url, { + method: params.method, + headers, + body: params.body === undefined ? undefined : JSON.stringify(params.body), + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + return { error: text || `HTTP ${response.status}`, status: response.status }; + } + let parsed: unknown; + try { + parsed = await response.json(); + } catch { + return { error: "invalid JSON from User Token service", status: response.status }; + } + if (!parsed || typeof parsed !== "object") { + return { error: "empty response from User Token service", status: response.status }; + } + const obj = parsed as Record; + const token = typeof obj.token === "string" ? obj.token : undefined; + const connectionName = typeof obj.connectionName === "string" ? obj.connectionName : undefined; + const channelId = typeof obj.channelId === "string" ? obj.channelId : undefined; + const expiration = typeof obj.expiration === "string" ? obj.expiration : undefined; + if (!token || !connectionName) { + return { error: "User Token service response missing token/connectionName", status: 502 }; + } + return { channelId, connectionName, token, expiration }; +} + +/** + * Exchange a Teams SSO token for a delegated user token via Bot + * Framework's User Token service, then persist the result. + */ +export async function handleSigninTokenExchangeInvoke(params: { + value: SigninTokenExchangeValue; + user: MSTeamsSsoUser; + deps: MSTeamsSsoDeps; +}): Promise { + const { value, user, deps } = params; + if (!user.userId) { + return { ok: false, code: "missing_user", message: "no user id on invoke activity" }; + } + const connectionName = value.connectionName?.trim() || deps.connectionName; + if (!connectionName) { + return { ok: false, code: "missing_connection", message: "no OAuth connection name" }; + } + if (!value.token) { + return { ok: false, code: "missing_token", message: "no exchangeable token on invoke" }; + } + + const bearer = await deps.tokenProvider.getAccessToken(BOT_FRAMEWORK_TOKEN_SCOPE); + const fetchImpl = deps.fetchImpl ?? (globalThis.fetch as unknown as MSTeamsSsoFetch); + const result = await callUserTokenService({ + baseUrl: deps.userTokenBaseUrl ?? BOT_FRAMEWORK_USER_TOKEN_BASE_URL, + path: "/api/usertoken/exchange", + query: { + userId: user.userId, + connectionName, + channelId: user.channelId ?? "msteams", + }, + method: "POST", + body: { token: value.token }, + bearerToken: bearer, + fetchImpl, + }); + + if ("error" in result) { + return { + ok: false, + code: result.status >= 500 ? "service_error" : "unexpected_response", + message: result.error, + status: result.status, + }; + } + + await deps.tokenStore.save({ + connectionName, + userId: user.userId, + token: result.token, + expiresAt: result.expiration, + updatedAt: new Date().toISOString(), + }); + + return { ok: true, token: result.token, expiresAt: result.expiration }; +} + +/** + * Finish a magic-code sign-in: look up the user token for the state + * code via Bot Framework's User Token service, then persist it. + */ +export async function handleSigninVerifyStateInvoke(params: { + value: SigninVerifyStateValue; + user: MSTeamsSsoUser; + deps: MSTeamsSsoDeps; +}): Promise { + const { value, user, deps } = params; + if (!user.userId) { + return { ok: false, code: "missing_user", message: "no user id on invoke activity" }; + } + if (!deps.connectionName) { + return { ok: false, code: "missing_connection", message: "no OAuth connection name" }; + } + const state = value.state?.trim(); + if (!state) { + return { ok: false, code: "missing_state", message: "no state code on invoke" }; + } + + const bearer = await deps.tokenProvider.getAccessToken(BOT_FRAMEWORK_TOKEN_SCOPE); + const fetchImpl = deps.fetchImpl ?? (globalThis.fetch as unknown as MSTeamsSsoFetch); + const result = await callUserTokenService({ + baseUrl: deps.userTokenBaseUrl ?? BOT_FRAMEWORK_USER_TOKEN_BASE_URL, + path: "/api/usertoken/GetToken", + query: { + userId: user.userId, + connectionName: deps.connectionName, + channelId: user.channelId ?? "msteams", + code: state, + }, + method: "GET", + bearerToken: bearer, + fetchImpl, + }); + + if ("error" in result) { + return { + ok: false, + code: result.status >= 500 ? "service_error" : "unexpected_response", + message: result.error, + status: result.status, + }; + } + + await deps.tokenStore.save({ + connectionName: deps.connectionName, + userId: user.userId, + token: result.token, + expiresAt: result.expiration, + updatedAt: new Date().toISOString(), + }); + + return { ok: true, token: result.token, expiresAt: result.expiration }; +} diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 008eea120a1..4acc41eb625 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -8161,6 +8161,18 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ minimum: 0, maximum: 9007199254740991, }, + sso: { + type: "object", + properties: { + enabled: { + type: "boolean", + }, + connectionName: { + type: "string", + }, + }, + additionalProperties: false, + }, }, required: ["dmPolicy", "groupPolicy"], additionalProperties: false, diff --git a/src/config/types.msteams.ts b/src/config/types.msteams.ts index 5c88f2bbb25..0e8c5989d9d 100644 --- a/src/config/types.msteams.ts +++ b/src/config/types.msteams.ts @@ -20,6 +20,33 @@ export type MSTeamsWebhookConfig = { path?: string; }; +/** + * Bot Framework OAuth SSO configuration for Microsoft Teams. + * + * When enabled, the plugin handles the `signin/tokenExchange` and + * `signin/verifyState` invoke activities that Teams sends after an + * `oauthCard` is presented to the user. The exchanged user token is + * persisted via the Bot Framework User Token service so downstream + * tools can call Microsoft Graph with delegated permissions. + * + * Prerequisites (Azure portal): + * - The bot's Azure AD (Entra) app is configured with an exposed API + * scope (for example `access_as_user`) and lists the Teams client + * IDs in `knownClientApplications`. + * - The Bot Framework channel registration has an OAuth Connection + * Setting whose name matches `connectionName` below, pointing at + * the same Azure AD app. + */ +export type MSTeamsSsoConfig = { + /** If true, handle signin/tokenExchange + signin/verifyState invokes. Default: false. */ + enabled?: boolean; + /** + * Name of the OAuth connection configured on the Bot Framework channel + * registration (Azure Bot resource). Required when `enabled` is true. + */ + connectionName?: string; +}; + /** Reply style for MS Teams messages. */ export type MSTeamsReplyStyle = "thread" | "top-level"; @@ -140,6 +167,8 @@ export type MSTeamsConfig = { feedbackReflection?: boolean; /** Minimum interval (ms) between reflections per session. Default: 300000 (5 min). */ feedbackReflectionCooldownMs?: number; + /** Bot Framework OAuth SSO (signin/tokenExchange + signin/verifyState) settings. */ + sso?: MSTeamsSsoConfig; }; declare module "./types.channels.js" { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index b54ddb42c36..72214b2e51a 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -1577,6 +1577,13 @@ export const MSTeamsConfigSchema = z feedbackEnabled: z.boolean().optional(), feedbackReflection: z.boolean().optional(), feedbackReflectionCooldownMs: z.number().int().min(0).optional(), + sso: z + .object({ + enabled: z.boolean().optional(), + connectionName: z.string().optional(), + }) + .strict() + .optional(), }) .strict() .superRefine((value, ctx) => { @@ -1596,4 +1603,12 @@ export const MSTeamsConfigSchema = z message: 'channels.msteams.dmPolicy="allowlist" requires channels.msteams.allowFrom to contain at least one sender ID', }); + if (value.sso?.enabled === true && !value.sso.connectionName?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["sso", "connectionName"], + message: + "channels.msteams.sso.enabled=true requires channels.msteams.sso.connectionName to identify the Bot Framework OAuth connection", + }); + } });