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 <bradgroux@users.noreply.github.com>
This commit is contained in:
sudie-codes
2026-04-10 00:38:01 -07:00
committed by GitHub
parent 360955a7c8
commit 828ebd43d4
11 changed files with 1061 additions and 4 deletions

View File

@@ -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

View File

@@ -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: <T>(params: {
onFlush: (entries: T[]) => Promise<void>;
}): { enqueue: (entry: T) => Promise<void> } => ({
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: <T extends Record<string, unknown>>(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> = {},
): 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<typeof vi.fn> } {
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<typeof vi.fn>;
};
}
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<string, string>;
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<MSTeamsActivityHandler["run"]>;
};
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<MSTeamsActivityHandler["run"]>;
};
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<MSTeamsActivityHandler["run"]>;
};
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<MSTeamsActivityHandler["run"]>;
};
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");
});
});

View File

@@ -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<T extends MSTeamsActivityHandler>(
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);
};
}

View File

@@ -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;
};

View File

@@ -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: {

View File

@@ -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

View File

@@ -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<MSTeamsSsoStoredToken | null>;
save(token: MSTeamsSsoStoredToken): Promise<void>;
remove(params: { connectionName: string; userId: string }): Promise<boolean>;
};
type SsoStoreData = {
version: 1;
// Keyed by `${connectionName}::${userId}` for a simple flat map on disk.
tokens: Record<string, MSTeamsSsoStoredToken>;
};
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<string, unknown>;
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<SsoStoreData> => {
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<string, MSTeamsSsoStoredToken>();
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));
},
};
}

View File

@@ -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<string, string>;
body?: string;
},
) => Promise<{
ok: boolean;
status: number;
json(): Promise<unknown>;
text(): Promise<string>;
}>;
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<string, unknown>;
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<string, unknown>;
const state = typeof obj.state === "string" ? obj.state : undefined;
return { state };
}
type UserTokenServiceCallParams = {
baseUrl: string;
path: string;
query: Record<string, string>;
method: "GET" | "POST";
body?: unknown;
bearerToken: string;
fetchImpl: MSTeamsSsoFetch;
};
async function callUserTokenService(
params: UserTokenServiceCallParams,
): Promise<BotFrameworkUserTokenResponse | { error: string; status: number }> {
const qs = new URLSearchParams(params.query).toString();
const url = `${params.baseUrl.replace(/\/+$/, "")}${params.path}?${qs}`;
const headers: Record<string, string> = {
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<string, unknown>;
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<MSTeamsSsoResult> {
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<MSTeamsSsoResult> {
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 };
}

View File

@@ -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,

View File

@@ -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" {

View File

@@ -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",
});
}
});