@dbx-tools/teams 0.3.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +259 -0
- package/index.ts +22 -0
- package/package.json +56 -0
- package/src/auth.ts +294 -0
- package/src/builder.ts +102 -0
- package/src/config.ts +268 -0
- package/src/connector.ts +103 -0
- package/src/conversation.ts +640 -0
- package/src/defaults.ts +89 -0
- package/src/messaging.ts +155 -0
- package/src/plugin.ts +499 -0
- package/src/runtime.ts +190 -0
- package/src/tool.ts +84 -0
package/README.md
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# @dbx-tools/teams
|
|
2
|
+
|
|
3
|
+
Server-side Microsoft Teams Adaptive Card runtime, agent tool, and AppKit
|
|
4
|
+
plugin.
|
|
5
|
+
|
|
6
|
+
Import this package when an AppKit or Mastra backend needs to answer like a
|
|
7
|
+
Teams bot - a conversation endpoint whose replies are Adaptive Cards - or to turn
|
|
8
|
+
a model's short, structured description of a status/result into a valid Teams
|
|
9
|
+
Adaptive Card to render in a preview UI or post to a channel. AppKit ships no
|
|
10
|
+
first-party Teams / Adaptive Card surface, so this is additive rather than an
|
|
11
|
+
alternative to a native plugin. The browser-safe card schemas live in
|
|
12
|
+
[`@dbx-tools/shared-teams`](../../shared/teams), and the React renderer (the
|
|
13
|
+
`adaptivecards` JavaScript renderer) lives in
|
|
14
|
+
[`@dbx-tools/ui-teams`](../../ui/teams).
|
|
15
|
+
|
|
16
|
+
**Key features:**
|
|
17
|
+
|
|
18
|
+
- AppKit plugin registration that resolves config, logs the effective card
|
|
19
|
+
version and webhook state at boot, and mounts card-build / card-post routes.
|
|
20
|
+
- Two agent surfaces over one runtime: a Mastra `create_teams_card` tool and an
|
|
21
|
+
AppKit `teams.createCard` tool, both taking the small `CardSpec` vocabulary.
|
|
22
|
+
- A deterministic builder that compiles a `CardSpec` (title, subtitle, text,
|
|
23
|
+
key/value facts, link actions) into a valid Adaptive Card 1.5 document, so a
|
|
24
|
+
card is well-formed by construction rather than by hoping the model produced
|
|
25
|
+
correct schema.
|
|
26
|
+
- Optional posting to a Teams incoming webhook, wrapped in the attachment
|
|
27
|
+
envelope Teams expects, with a retrying execution policy.
|
|
28
|
+
- `POST /api/teams/messages` - the REAL Microsoft Teams messaging endpoint: the
|
|
29
|
+
URL you paste into an Azure Bot registration so a Teams channel can chat with
|
|
30
|
+
your agents. It validates the Bot Service JWT (signature, issuer, and an
|
|
31
|
+
audience equal to your bot's app id), acknowledges immediately, and delivers
|
|
32
|
+
the agent's card back over the Bot Framework Connector API. This is the
|
|
33
|
+
Teams-shaped analogue of how the Mastra plugin exposes MCP at a path - the
|
|
34
|
+
protocol is the interface.
|
|
35
|
+
- `POST /api/teams/activity` - the same turn, run SYNCHRONOUSLY with the reply
|
|
36
|
+
activities in the response body. No bot registration required, so this is what
|
|
37
|
+
a local client, a test, or any non-Teams caller uses. The conversation id maps
|
|
38
|
+
onto the agent's memory thread, so repeat turns continue one conversation.
|
|
39
|
+
- `POST /api/teams/card` route a browser dev page posts to for a live card
|
|
40
|
+
preview, and `POST /api/teams/post` to push a card to the webhook.
|
|
41
|
+
|
|
42
|
+
## Register The AppKit Plugin
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { createApp, server } from "@databricks/appkit";
|
|
46
|
+
import { plugin as teamsPlugin, tool as teamsTool } from "@dbx-tools/teams";
|
|
47
|
+
import { agents, plugin as mastraPlugin } from "@dbx-tools/appkit-mastra";
|
|
48
|
+
|
|
49
|
+
const support = agents.createAgent({
|
|
50
|
+
instructions: "Summarize results as Teams cards when asked.",
|
|
51
|
+
tools: () => ({ create_teams_card: teamsTool.teamsCardTool() }),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await createApp({
|
|
55
|
+
plugins: [
|
|
56
|
+
server(),
|
|
57
|
+
teamsPlugin.teams({ webhookUrl: process.env.TEAMS_WEBHOOK_URL }),
|
|
58
|
+
mastraPlugin.mastra({ agents: support, storage: true }),
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`plugin.teams()` resolves config, primes the shared runtime, logs the effective
|
|
64
|
+
card version and whether a webhook is wired up, and mounts the build / post
|
|
65
|
+
routes under `/api/teams`. `tool.teamsCardTool()` creates the Mastra
|
|
66
|
+
`create_teams_card` tool; the AppKit `teams.createCard` tool is the same
|
|
67
|
+
capability for an AppKit agent and is auto-inheritable because building a card
|
|
68
|
+
has no side effects.
|
|
69
|
+
|
|
70
|
+
## Converse In Cards
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
curl -X POST http://localhost:8000/api/teams/activity \
|
|
74
|
+
-H 'content-type: application/json' \
|
|
75
|
+
-d '{
|
|
76
|
+
"activity": {
|
|
77
|
+
"type": "message",
|
|
78
|
+
"text": "did the deploy land?",
|
|
79
|
+
"from": { "id": "user-1", "name": "Reggie" },
|
|
80
|
+
"conversation": { "id": "conv-1" }
|
|
81
|
+
}
|
|
82
|
+
}'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The reply is `{ "activities": [...] }`, each activity carrying
|
|
86
|
+
`attachments[].contentType === "application/vnd.microsoft.card.adaptive"` with a
|
|
87
|
+
compiled Adaptive Card under `content` - what a Teams client renders.
|
|
88
|
+
|
|
89
|
+
A turn runs in two passes, and the order is the whole point:
|
|
90
|
+
|
|
91
|
+
1. **Answer.** The agent answers the question normally - its tools available, no
|
|
92
|
+
`structuredOutput`, and nothing about cards in the prompt.
|
|
93
|
+
2. **Format.** A second `structuredOutput` pass reformats that answer into a
|
|
94
|
+
`CardSpec`, which the builder compiles.
|
|
95
|
+
|
|
96
|
+
Asking for the answer AND the card shape in one request makes the model treat
|
|
97
|
+
formatting as the task: it emits a card immediately, never calls its tools, and a
|
|
98
|
+
question that should have queried Genie comes back as "I don't have a real system
|
|
99
|
+
connected - here is a template card". Answering first means this endpoint's
|
|
100
|
+
CONTENT matches what the streaming chat endpoint would say; only the
|
|
101
|
+
presentation differs. Agents keep `create_teams_card` for the other direction -
|
|
102
|
+
answering in prose on a chat endpoint and choosing to attach a card - and when
|
|
103
|
+
the agent calls it during pass 1, that spec wins and pass 2 is skipped.
|
|
104
|
+
|
|
105
|
+
Because the schema is prompt-injected rather than provider-enforced (Databricks
|
|
106
|
+
Model Serving rejects `response_format` alongside `tools`), the format pass is
|
|
107
|
+
best-effort, so the answer is recovered in layers: the parsed spec, a full
|
|
108
|
+
Adaptive Card DOCUMENT read back into the spec vocabulary (what a capable model
|
|
109
|
+
often returns when asked for "an Adaptive Card"), the payload Mastra rejected off
|
|
110
|
+
the thrown error, JSON embedded in prose, then the prose itself. A formatting
|
|
111
|
+
miss costs structure, never the answer.
|
|
112
|
+
|
|
113
|
+
Host embed markers (`[data:<id>]`, `[chart:<id>]`) are stripped from the answer:
|
|
114
|
+
a chat UI swaps those for a rendered table or chart, but a card has no such slot,
|
|
115
|
+
so a marker would render as literal `[data:01f1...]` where a number belongs. The
|
|
116
|
+
answering pass asks the agent for the values themselves instead.
|
|
117
|
+
|
|
118
|
+
The agent is resolved from the sibling Mastra plugin by registered name
|
|
119
|
+
(`agentPlugin`, default `mastra`), so this package stays a leaf add-on and takes
|
|
120
|
+
no dependency on `@dbx-tools/appkit-mastra`. Pass `agentId` in the body to pick a
|
|
121
|
+
specific agent; omit it for the default one. An unmounted agent plugin answers
|
|
122
|
+
503, an unknown `agentId` answers 404.
|
|
123
|
+
|
|
124
|
+
The same lookup fetches the plugin's `createRequestContext()`, which builds the
|
|
125
|
+
per-turn Mastra `RequestContext` the turn passes to both passes. This is required
|
|
126
|
+
for parity with chat: user-scoped tools read the AppKit user off that context, so
|
|
127
|
+
without it `ask_genie` answers "the data source is unreachable" while the chat
|
|
128
|
+
routes answer with real numbers. A provider that exposes no factory still serves
|
|
129
|
+
turns, just without user-scoped tools.
|
|
130
|
+
|
|
131
|
+
Activities that carry no prompt - a `typing` indicator, a `conversationUpdate`
|
|
132
|
+
when someone joins, an empty message - answer `{ "activities": [] }` rather than
|
|
133
|
+
erroring, which is what a real bot does with them.
|
|
134
|
+
|
|
135
|
+
[`@dbx-tools/ui-teams`](../../ui/teams)'s `TeamsChat` is the matching client.
|
|
136
|
+
|
|
137
|
+
## Build A Card Directly
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { builder } from "@dbx-tools/teams";
|
|
141
|
+
|
|
142
|
+
const { card } = builder.buildCardResult({
|
|
143
|
+
title: "Deployment succeeded",
|
|
144
|
+
subtitle: "prod • 2m ago",
|
|
145
|
+
facts: [{ title: "Version", value: "1.4.2" }],
|
|
146
|
+
actions: [{ title: "View run", url: "https://example.com/runs/42" }],
|
|
147
|
+
});
|
|
148
|
+
// `card` is a full Adaptive Card document; render it with @dbx-tools/ui-teams
|
|
149
|
+
// or post it with the plugin's `postCard` export.
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Why Use This Over Native AppKit
|
|
153
|
+
|
|
154
|
+
AppKit has no Teams or Adaptive Card surface at all, so use this whenever an
|
|
155
|
+
agent should emit a Teams card. The value is the policy layer around a card: the
|
|
156
|
+
model works in a small, safe vocabulary (`CardSpec`) instead of raw Adaptive
|
|
157
|
+
Card JSON, the builder guarantees a schema-valid Adaptive Card 1.5 document, and
|
|
158
|
+
posting is gated behind an explicitly configured incoming webhook. Same add-on
|
|
159
|
+
shape as [`@dbx-tools/email`](../email).
|
|
160
|
+
|
|
161
|
+
## Configuration
|
|
162
|
+
|
|
163
|
+
Precedence per field: explicit plugin config wins, then the matching
|
|
164
|
+
environment variable.
|
|
165
|
+
|
|
166
|
+
- `cardVersion` / `TEAMS_CARD_VERSION` - Adaptive Card schema version the
|
|
167
|
+
builder targets. Defaults to `1.5` (what Teams supports).
|
|
168
|
+
- `webhookUrl` / `TEAMS_WEBHOOK_URL` - optional Teams incoming-webhook URL. When
|
|
169
|
+
unset, posting is disabled and the plugin only builds cards for a UI to
|
|
170
|
+
render. A value that is not an absolute URL fails config resolution.
|
|
171
|
+
- `agentPlugin` / `TEAMS_AGENT_PLUGIN` - registered name of the sibling plugin
|
|
172
|
+
whose agents answer a conversation turn. Defaults to `mastra`; set it when the
|
|
173
|
+
Mastra plugin is mounted under a `config.name` override.
|
|
174
|
+
- `appId` / `TEAMS_APP_ID` (alias `MICROSOFT_APP_ID`) - the Entra app (client)
|
|
175
|
+
id from your Azure Bot registration. Required before `POST /messages` accepts
|
|
176
|
+
anything: it is the audience an inbound token must carry.
|
|
177
|
+
- `appPassword` / `TEAMS_APP_PASSWORD` (alias `MICROSOFT_APP_PASSWORD`) - client
|
|
178
|
+
secret for that app id, used to fetch the outbound Connector token.
|
|
179
|
+
- `appTenantId` / `TEAMS_APP_TENANT_ID` (alias `MICROSOFT_APP_TENANT_ID`) - set
|
|
180
|
+
only for a SINGLE-tenant bot; leave unset for multi-tenant.
|
|
181
|
+
- `allowUnauthenticated` / `TEAMS_ALLOW_UNAUTHENTICATED` - serve `/messages`
|
|
182
|
+
with NO token validation, replying in the HTTP response instead of through the
|
|
183
|
+
Connector API. For local development only, and ignored unless `NODE_ENV` is
|
|
184
|
+
`development`, so a production build cannot be talked into it by an
|
|
185
|
+
environment variable. See "Connect A Real Teams Channel".
|
|
186
|
+
|
|
187
|
+
The `MICROSOFT_APP_*` aliases are the names the Bot Framework SDK and the Azure
|
|
188
|
+
portal already use, so an existing bot's environment drops in unchanged.
|
|
189
|
+
|
|
190
|
+
## Connect A Real Teams Channel
|
|
191
|
+
|
|
192
|
+
1. Create an **Azure Bot** resource and note its **Microsoft App ID**; create a
|
|
193
|
+
client secret for it.
|
|
194
|
+
2. Set its **Messaging endpoint** to `https://<your-app-host>/api/teams/messages`.
|
|
195
|
+
3. Enable the **Microsoft Teams** channel on the bot.
|
|
196
|
+
4. Give the app `TEAMS_APP_ID` and `TEAMS_APP_PASSWORD` (plus
|
|
197
|
+
`TEAMS_APP_TENANT_ID` for a single-tenant registration).
|
|
198
|
+
5. Install the bot in Teams (via a Teams app manifest referencing the same app
|
|
199
|
+
id) and message it. The agent answers in Adaptive Cards.
|
|
200
|
+
|
|
201
|
+
What the endpoint does on each inbound request:
|
|
202
|
+
|
|
203
|
+
- **Validates the JWT** against the Bot Framework JWKS, requiring a trusted
|
|
204
|
+
issuer and an audience equal to `appId`. The audience check is the one that
|
|
205
|
+
matters: a token the Bot Service issued for someone ELSE's bot is signed by the
|
|
206
|
+
same keys, so without it anyone with their own bot could drive your agent.
|
|
207
|
+
- **Pins the reply destination to the token.** `serviceUrl` arrives in the
|
|
208
|
+
request body, and replies carry your bot's credentials, so it is honored only
|
|
209
|
+
when it matches the verified token's own `serviceurl` claim.
|
|
210
|
+
- **Answers `200` before running the agent.** Bot Service times out an
|
|
211
|
+
unacknowledged activity in seconds and retries it, while a card takes longer
|
|
212
|
+
than that - so a synchronous reply would produce duplicate cards. The typing
|
|
213
|
+
indicator shows while the agent works, and the card is delivered through the
|
|
214
|
+
Connector API when it is ready.
|
|
215
|
+
|
|
216
|
+
For local development without a registration, set `allowUnauthenticated: true`
|
|
217
|
+
(with `NODE_ENV=development`) and `/messages` will answer in the HTTP response,
|
|
218
|
+
which is how the in-repo demo renders live cards.
|
|
219
|
+
|
|
220
|
+
## Routes
|
|
221
|
+
|
|
222
|
+
Mounted under the plugin base path `/api/teams`:
|
|
223
|
+
|
|
224
|
+
- `POST /messages` - the Teams messaging endpoint (see above). Body is a bare Bot
|
|
225
|
+
Framework activity. `401` on a token that fails validation, `503` when no
|
|
226
|
+
`appId`/`appPassword` is configured, `400` on an invalid activity or a
|
|
227
|
+
`serviceUrl` that does not match the token. Answers `200` with an empty body;
|
|
228
|
+
the reply arrives over the Connector API.
|
|
229
|
+
- `POST /activity` - run one conversation turn. Body is
|
|
230
|
+
`{ activity, agentId?, model? }`; the response is `{ activities }`, each
|
|
231
|
+
carrying Adaptive Card attachments. `400` on an invalid activity, `404` on an
|
|
232
|
+
unknown `agentId`, `503` when no agent plugin is registered.
|
|
233
|
+
- `POST /card` - compile a `CardSpec` request body into an Adaptive Card
|
|
234
|
+
document. The dev display page posts here to preview cards live.
|
|
235
|
+
- `POST /post` - compile then push a `CardSpec` to the configured Teams
|
|
236
|
+
incoming webhook; a `400` when the body is invalid, an error when no webhook
|
|
237
|
+
is configured.
|
|
238
|
+
|
|
239
|
+
## Modules
|
|
240
|
+
|
|
241
|
+
- `plugin` - `teams` (the plugin factory) and `TeamsPlugin`.
|
|
242
|
+
- `tool` - `teamsCardTool` and `CREATE_CARD_DESCRIPTION`.
|
|
243
|
+
- `builder` - `buildAdaptiveCard` / `buildCardResult` (pure `CardSpec` -> card
|
|
244
|
+
compilation).
|
|
245
|
+
- `conversation` - `runCardTurn` (one activity in, card activities out),
|
|
246
|
+
`resolveCardAgent`, `promptOf`, `toReplyActivity`, `CARD_TURN_INSTRUCTIONS`,
|
|
247
|
+
and the `CardAgentLike` / `AgentProviderLike` shapes.
|
|
248
|
+
- `auth` - `verifyBotToken` (inbound Bot Service JWT validation),
|
|
249
|
+
`connectorToken` (outbound client-credentials token), `isAllowedServiceUrl`,
|
|
250
|
+
and `resetTeamsAuth`.
|
|
251
|
+
- `connector` - `sendActivity` / `sendTyping`, the Bot Framework Connector API
|
|
252
|
+
calls a reply is delivered through.
|
|
253
|
+
- `messaging` - `deliverTurn` (acknowledge-then-deliver turn) and
|
|
254
|
+
`resolveServiceUrl`.
|
|
255
|
+
- `runtime` - `getTeamsRuntime`, `setTeamsExecutor`, `buildCard`, `postCard`,
|
|
256
|
+
and the `TeamsExecutor` / `TeamsRuntime` types.
|
|
257
|
+
- `config` - `resolveTeamsConfig`, `TEAMS_CONFIG_SCHEMA`, the env-name
|
|
258
|
+
constants, and the `TeamsPluginConfig` / `ResolvedTeamsConfig` types.
|
|
259
|
+
- `defaults` - the interceptor execution settings and named caps.
|
package/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// GENERATED by projen watch - DO NOT EDIT.
|
|
2
|
+
// Regenerated from the exporting modules in ./src.
|
|
3
|
+
// Hand edits are overwritten on the next watch; this file is read-only.
|
|
4
|
+
|
|
5
|
+
export * as auth from "./src/auth";
|
|
6
|
+
export * as builder from "./src/builder";
|
|
7
|
+
export * as config from "./src/config";
|
|
8
|
+
export * as connector from "./src/connector";
|
|
9
|
+
export * as conversation from "./src/conversation";
|
|
10
|
+
export * as defaults from "./src/defaults";
|
|
11
|
+
export * as messaging from "./src/messaging";
|
|
12
|
+
export * as plugin from "./src/plugin";
|
|
13
|
+
export * as runtime from "./src/runtime";
|
|
14
|
+
export * as tool from "./src/tool";
|
|
15
|
+
export type { VerifiedBotToken, VerifyOptions } from "./src/auth";
|
|
16
|
+
export type { TeamsPluginConfig, ResolvedTeamsConfig } from "./src/config";
|
|
17
|
+
export type { ConnectorTarget } from "./src/connector";
|
|
18
|
+
export type { CardAgentLike, AgentResult, AgentProviderLike, CardTurnOptions, CardContextFactory } from "./src/conversation";
|
|
19
|
+
export type { TeamsExecuteConfig, TeamsExecutionSettings } from "./src/defaults";
|
|
20
|
+
export type { BotCredentials, DeliverTurnOptions } from "./src/messaging";
|
|
21
|
+
export type { TeamsExecutor, TeamsRuntime } from "./src/runtime";
|
|
22
|
+
export type { TeamsCardToolOptions } from "./src/tool";
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dbx-tools/teams",
|
|
3
|
+
"repository": {
|
|
4
|
+
"type": "git",
|
|
5
|
+
"url": "git+https://github.com/reggie-db/dbx-tools.git",
|
|
6
|
+
"directory": "workspaces/node/teams"
|
|
7
|
+
},
|
|
8
|
+
"devDependencies": {
|
|
9
|
+
"@types/express": "^5.0.5",
|
|
10
|
+
"@types/json-schema": "^7",
|
|
11
|
+
"@types/node": "^24.6.0",
|
|
12
|
+
"tsx": "^4.23.0",
|
|
13
|
+
"typescript": "^5.9.3"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@databricks/appkit": "^0.43.0",
|
|
17
|
+
"@mastra/core": "^1.47.0",
|
|
18
|
+
"jose": "^6.2.3",
|
|
19
|
+
"zod": "4.3.6",
|
|
20
|
+
"@dbx-tools/shared-core": "0.3.39",
|
|
21
|
+
"@dbx-tools/shared-teams": "0.3.39"
|
|
22
|
+
},
|
|
23
|
+
"main": "index.ts",
|
|
24
|
+
"license": "UNLICENSED",
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"version": "0.3.39",
|
|
29
|
+
"types": "index.ts",
|
|
30
|
+
"type": "module",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./index.ts",
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"index.ts",
|
|
37
|
+
"src"
|
|
38
|
+
],
|
|
39
|
+
"dbxToolsConfig": {
|
|
40
|
+
"tags": [
|
|
41
|
+
"node"
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
"//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"pnpm exec projen\".",
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "projen build",
|
|
47
|
+
"compile": "projen compile",
|
|
48
|
+
"default": "projen default",
|
|
49
|
+
"package": "projen package",
|
|
50
|
+
"post-compile": "projen post-compile",
|
|
51
|
+
"pre-compile": "projen pre-compile",
|
|
52
|
+
"test": "projen test",
|
|
53
|
+
"watch": "projen watch",
|
|
54
|
+
"projen": "projen"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound and outbound authentication for the Teams messaging endpoint.
|
|
3
|
+
*
|
|
4
|
+
* `POST /api/teams/messages` is a PUBLIC URL - Azure Bot Service calls it from
|
|
5
|
+
* the internet, so it cannot sit behind AppKit's OBO headers or a workspace
|
|
6
|
+
* login. Its only trust boundary is the JWT the Bot Service signs each request
|
|
7
|
+
* with, which is what this module verifies:
|
|
8
|
+
*
|
|
9
|
+
* 1. fetch the Bot Framework OpenID metadata to discover the signing JWKS;
|
|
10
|
+
* 2. verify the token's signature against that key set;
|
|
11
|
+
* 3. check `issuer` is a known Bot Service issuer and `audience` is exactly
|
|
12
|
+
* this bot's app id.
|
|
13
|
+
*
|
|
14
|
+
* All three matter. Skipping (3) is the classic bot vulnerability: a token the
|
|
15
|
+
* Bot Service legitimately issued for a DIFFERENT bot still verifies against the
|
|
16
|
+
* same JWKS, so without an audience check anyone with their own bot could drive
|
|
17
|
+
* this agent.
|
|
18
|
+
*
|
|
19
|
+
* The outbound half is the reverse: replies go to the Connector API, which needs
|
|
20
|
+
* a client-credentials token for the bot's own app registration. Both key sets
|
|
21
|
+
* and tokens are cached, since a busy channel would otherwise re-fetch metadata
|
|
22
|
+
* on every turn.
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { error, log } from "@dbx-tools/shared-core";
|
|
28
|
+
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* OpenID metadata document for tokens the Bot Service sends a bot in the public
|
|
32
|
+
* cloud. The JWKS URI is read from this document rather than hard-coded so a key
|
|
33
|
+
* rotation on Microsoft's side needs no release here.
|
|
34
|
+
*
|
|
35
|
+
* Overridable per call ({@link VerifyOptions.metadataUrl}) because a sovereign
|
|
36
|
+
* cloud (GCC High / DoD) publishes its own metadata endpoint - and because it
|
|
37
|
+
* makes the verifier testable against a local key set.
|
|
38
|
+
*/
|
|
39
|
+
export const BOT_OPENID_METADATA =
|
|
40
|
+
"https://login.botframework.com/v1/.well-known/openidconfiguration";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Accepted `iss` values on an inbound token.
|
|
44
|
+
*
|
|
45
|
+
* The Bot Service has issued tokens under several issuers across channel and
|
|
46
|
+
* tenant configurations, and a single-tenant bot receives the v2 Entra issuer
|
|
47
|
+
* with its own tenant id spliced in (handled by {@link isTrustedIssuer}). The
|
|
48
|
+
* fixed set covers the channel-issued cases.
|
|
49
|
+
*/
|
|
50
|
+
const TRUSTED_ISSUERS = [
|
|
51
|
+
"https://api.botframework.com",
|
|
52
|
+
"https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/",
|
|
53
|
+
"https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0",
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
/** Token endpoint issuing the Connector credentials an outbound reply uses. */
|
|
57
|
+
const LOGIN_TOKEN_URL = (tenant: string) =>
|
|
58
|
+
`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Scope requested for a Connector token. `.default` asks for the app's
|
|
62
|
+
* statically configured permissions, which is what a bot registration grants.
|
|
63
|
+
*/
|
|
64
|
+
const CONNECTOR_SCOPE = "https://api.botframework.com/.default";
|
|
65
|
+
|
|
66
|
+
/** Tenant used for a multi-tenant bot, which has no tenant of its own. */
|
|
67
|
+
const MULTI_TENANT = "botframework.com";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Refresh an access token this many milliseconds BEFORE it actually expires, so
|
|
71
|
+
* a token never expires mid-flight between the check and the Connector call.
|
|
72
|
+
*/
|
|
73
|
+
const TOKEN_SKEW_MS = 60_000;
|
|
74
|
+
|
|
75
|
+
const logger = log.logger("teams:auth");
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Lazily-created, cached remote key set. `createRemoteJWKSet` handles its own
|
|
79
|
+
* key caching and rotation (re-fetching only on an unknown `kid`), so this is
|
|
80
|
+
* created once per process rather than per request.
|
|
81
|
+
*/
|
|
82
|
+
let keySet: ReturnType<typeof createRemoteJWKSet> | undefined;
|
|
83
|
+
|
|
84
|
+
/** Cached JWKS URI discovered from the OpenID metadata document, keyed by metadata URL. */
|
|
85
|
+
let jwksUri: string | undefined;
|
|
86
|
+
|
|
87
|
+
/** Metadata URL the cached key set was built from, so an override busts the cache. */
|
|
88
|
+
let keySetSource: string | undefined;
|
|
89
|
+
|
|
90
|
+
/** Reset cached auth state. Test seam; not part of the public contract. */
|
|
91
|
+
export const resetTeamsAuth = (): void => {
|
|
92
|
+
keySet = undefined;
|
|
93
|
+
jwksUri = undefined;
|
|
94
|
+
keySetSource = undefined;
|
|
95
|
+
tokenCache = undefined;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Discover the signing JWKS URI from the Bot Framework OpenID metadata.
|
|
100
|
+
*
|
|
101
|
+
* Cached for the life of the process: it is a stable pointer, and the key
|
|
102
|
+
* rotation that actually matters happens inside the key set it names.
|
|
103
|
+
*/
|
|
104
|
+
const discoverJwksUri = async (metadataUrl: string, signal?: AbortSignal): Promise<string> => {
|
|
105
|
+
if (jwksUri && keySetSource === metadataUrl) return jwksUri;
|
|
106
|
+
const response = await fetch(metadataUrl, { ...(signal ? { signal } : {}) });
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
throw new Error(`teams: could not fetch Bot Framework OpenID metadata (${response.status})`);
|
|
109
|
+
}
|
|
110
|
+
const metadata = (await response.json()) as { jwks_uri?: unknown };
|
|
111
|
+
const uri = typeof metadata.jwks_uri === "string" ? metadata.jwks_uri : null;
|
|
112
|
+
if (!uri) throw new Error("teams: Bot Framework OpenID metadata carried no jwks_uri");
|
|
113
|
+
jwksUri = uri;
|
|
114
|
+
keySetSource = metadataUrl;
|
|
115
|
+
return uri;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** The key set for the discovered JWKS URI, created once and reused. */
|
|
119
|
+
const signingKeys = async (
|
|
120
|
+
metadataUrl: string,
|
|
121
|
+
signal?: AbortSignal,
|
|
122
|
+
): Promise<ReturnType<typeof createRemoteJWKSet>> => {
|
|
123
|
+
if (keySet && keySetSource === metadataUrl) return keySet;
|
|
124
|
+
const uri = await discoverJwksUri(metadataUrl, signal);
|
|
125
|
+
keySet = createRemoteJWKSet(new URL(uri));
|
|
126
|
+
return keySet;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Whether `issuer` is one this bot accepts.
|
|
131
|
+
*
|
|
132
|
+
* A single-tenant bot receives tokens issued by its OWN tenant, so the
|
|
133
|
+
* configured tenant's v2 issuer is accepted in addition to the fixed channel
|
|
134
|
+
* issuers.
|
|
135
|
+
*/
|
|
136
|
+
const isTrustedIssuer = (issuer: string | undefined, tenantId?: string): boolean => {
|
|
137
|
+
if (!issuer) return false;
|
|
138
|
+
if ((TRUSTED_ISSUERS as readonly string[]).includes(issuer)) return true;
|
|
139
|
+
if (!tenantId) return false;
|
|
140
|
+
return (
|
|
141
|
+
issuer === `https://login.microsoftonline.com/${tenantId}/v2.0` ||
|
|
142
|
+
issuer === `https://sts.windows.net/${tenantId}/`
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** A verified inbound Bot Framework token. */
|
|
147
|
+
export interface VerifiedBotToken {
|
|
148
|
+
/** The token's claims, after signature / issuer / audience validation. */
|
|
149
|
+
claims: JWTPayload;
|
|
150
|
+
/**
|
|
151
|
+
* The `serviceUrl` the token was issued for, when it carries one. Bot Service
|
|
152
|
+
* tokens include this claim; comparing it to the activity's `serviceUrl` is
|
|
153
|
+
* what stops a valid token being replayed to redirect replies elsewhere.
|
|
154
|
+
*/
|
|
155
|
+
serviceUrl?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Options for {@link verifyBotToken}. */
|
|
159
|
+
export interface VerifyOptions {
|
|
160
|
+
/** The bot's app id; the ONLY audience an inbound token may carry. */
|
|
161
|
+
appId: string;
|
|
162
|
+
/** Tenant of a single-tenant bot, whose own issuer is then also accepted. */
|
|
163
|
+
appTenantId?: string;
|
|
164
|
+
/**
|
|
165
|
+
* OpenID metadata document naming the signing key set. Defaults to
|
|
166
|
+
* {@link BOT_OPENID_METADATA}; override for a sovereign cloud.
|
|
167
|
+
*/
|
|
168
|
+
metadataUrl?: string;
|
|
169
|
+
/** Cancels the metadata / JWKS fetch with the request. */
|
|
170
|
+
signal?: AbortSignal;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Verify the `Authorization` header on an inbound Bot Service request.
|
|
175
|
+
*
|
|
176
|
+
* Rejects (by throwing) a missing / malformed header, a bad signature, an
|
|
177
|
+
* untrusted issuer, or an audience that is not this bot's `appId`. The caller
|
|
178
|
+
* turns a throw into a 401 - never into a processed activity.
|
|
179
|
+
*/
|
|
180
|
+
export const verifyBotToken = async (
|
|
181
|
+
authorization: string | undefined,
|
|
182
|
+
options: VerifyOptions,
|
|
183
|
+
): Promise<VerifiedBotToken> => {
|
|
184
|
+
const token = bearerToken(authorization);
|
|
185
|
+
if (!token) throw new Error("teams: request carried no bearer token");
|
|
186
|
+
|
|
187
|
+
const keys = await signingKeys(options.metadataUrl ?? BOT_OPENID_METADATA, options.signal);
|
|
188
|
+
// `audience` is enforced by the verifier itself: a token the Bot Service
|
|
189
|
+
// issued for someone else's bot verifies against this same JWKS, so the
|
|
190
|
+
// audience check - not the signature - is what binds a request to THIS bot.
|
|
191
|
+
const { payload } = await jwtVerify(token, keys, { audience: options.appId });
|
|
192
|
+
|
|
193
|
+
if (!isTrustedIssuer(payload.iss, options.appTenantId)) {
|
|
194
|
+
throw new Error(`teams: untrusted token issuer '${payload.iss ?? "none"}'`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const serviceUrl = typeof payload.serviceurl === "string" ? payload.serviceurl : undefined;
|
|
198
|
+
return { claims: payload, ...(serviceUrl ? { serviceUrl } : {}) };
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/** Read the bearer value out of an `Authorization` header. */
|
|
202
|
+
const bearerToken = (authorization: string | undefined): string | null => {
|
|
203
|
+
if (!authorization) return null;
|
|
204
|
+
const [scheme, value] = authorization.split(/\s+/, 2);
|
|
205
|
+
if (!value || scheme?.toLowerCase() !== "bearer") return null;
|
|
206
|
+
return value.trim() || null;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** A cached Connector access token and the moment it stops being usable. */
|
|
210
|
+
interface CachedToken {
|
|
211
|
+
token: string;
|
|
212
|
+
expiresAt: number;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let tokenCache: CachedToken | undefined;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Fetch (or reuse) a Connector API access token for the bot's own app
|
|
219
|
+
* registration.
|
|
220
|
+
*
|
|
221
|
+
* Cached until shortly before expiry: a Connector token is valid for ~1h, and
|
|
222
|
+
* re-fetching per reply would add a round trip to every turn.
|
|
223
|
+
*/
|
|
224
|
+
export const connectorToken = async (options: {
|
|
225
|
+
appId: string;
|
|
226
|
+
appPassword: string;
|
|
227
|
+
appTenantId?: string;
|
|
228
|
+
signal?: AbortSignal;
|
|
229
|
+
}): Promise<string> => {
|
|
230
|
+
const now = Date.now();
|
|
231
|
+
if (tokenCache && tokenCache.expiresAt > now) return tokenCache.token;
|
|
232
|
+
|
|
233
|
+
const body = new URLSearchParams({
|
|
234
|
+
grant_type: "client_credentials",
|
|
235
|
+
client_id: options.appId,
|
|
236
|
+
client_secret: options.appPassword,
|
|
237
|
+
scope: CONNECTOR_SCOPE,
|
|
238
|
+
});
|
|
239
|
+
const response = await fetch(LOGIN_TOKEN_URL(options.appTenantId ?? MULTI_TENANT), {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
242
|
+
body,
|
|
243
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
244
|
+
});
|
|
245
|
+
if (!response.ok) {
|
|
246
|
+
const detail = await response.text().catch(() => "");
|
|
247
|
+
throw new Error(
|
|
248
|
+
`teams: could not obtain a Connector token (${response.status}) ${detail}`.trim(),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const payload = (await response.json()) as { access_token?: unknown; expires_in?: unknown };
|
|
252
|
+
const token = typeof payload.access_token === "string" ? payload.access_token : null;
|
|
253
|
+
if (!token) throw new Error("teams: token response carried no access_token");
|
|
254
|
+
const expiresIn = typeof payload.expires_in === "number" ? payload.expires_in : 3600;
|
|
255
|
+
tokenCache = { token, expiresAt: now + expiresIn * 1000 - TOKEN_SKEW_MS };
|
|
256
|
+
logger.debug("connector token refreshed", { expiresIn });
|
|
257
|
+
return token;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Host suffixes replies may be sent to when the token pins no `serviceUrl`.
|
|
262
|
+
*
|
|
263
|
+
* `smba.trafficmanager.net` is the one that matters in practice: that is where
|
|
264
|
+
* Teams itself serves the Connector API from (per-region, e.g.
|
|
265
|
+
* `https://smba.trafficmanager.net/amer/`), so omitting it would reject every
|
|
266
|
+
* real Teams reply. Compared as dot-prefixed suffixes (or exact matches) so a
|
|
267
|
+
* lookalike host like `botframework.com.attacker.example` cannot pass.
|
|
268
|
+
*/
|
|
269
|
+
const ALLOWED_SERVICE_HOSTS = ["botframework.com", "trafficmanager.net", "microsoft.com"] as const;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Whether `serviceUrl` is one replies may be sent to.
|
|
273
|
+
*
|
|
274
|
+
* Only ever the host the verified token was issued for. A Bot Service token is a
|
|
275
|
+
* bearer credential, so honoring the `serviceUrl` from the request BODY would
|
|
276
|
+
* let a replayed token point the bot's authenticated replies (and its token) at
|
|
277
|
+
* an attacker-controlled host. When the token carries no `serviceurl` claim the
|
|
278
|
+
* body value is accepted but restricted to Microsoft's own domains.
|
|
279
|
+
*/
|
|
280
|
+
export const isAllowedServiceUrl = (serviceUrl: string, tokenServiceUrl?: string): boolean => {
|
|
281
|
+
const normalize = (value: string) => value.replace(/\/+$/, "").toLowerCase();
|
|
282
|
+
if (tokenServiceUrl) return normalize(serviceUrl) === normalize(tokenServiceUrl);
|
|
283
|
+
try {
|
|
284
|
+
const { protocol, hostname } = new URL(serviceUrl);
|
|
285
|
+
if (protocol !== "https:") return false;
|
|
286
|
+
const host = hostname.toLowerCase();
|
|
287
|
+
return ALLOWED_SERVICE_HOSTS.some(
|
|
288
|
+
(allowed) => host === allowed || host.endsWith(`.${allowed}`),
|
|
289
|
+
);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
logger.debug("rejecting unparseable serviceUrl", { error: error.errorMessage(err) });
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
};
|