mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
* 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:
@@ -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
|
||||
|
||||
458
extensions/msteams/src/monitor-handler.sso.test.ts
Normal file
458
extensions/msteams/src/monitor-handler.sso.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
125
extensions/msteams/src/sso-token-store.ts
Normal file
125
extensions/msteams/src/sso-token-store.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
}
|
||||
300
extensions/msteams/src/sso.ts
Normal file
300
extensions/msteams/src/sso.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user