# Setting up Microsoft Teams

Source: https://immiscible.fly.dev/docs/guides/teams

# Setting up Microsoft Teams

Approval requests arrive in a Teams channel as an Adaptive Card that says who wants what and why it was held, with Approve, Deny and Open in console. Approve opens a short confirmation (an `Action.ShowCard`) whose **Confirm approval** button is the one that submits; Deny submits at once. At or above the chat line, Approve is a link to the console instead. Escalations, freezes, incidents and kill-switch drill results arrive as cards in the same channel.

A decision from Teams is the console's decision: the same rules as the console and as Slack (see [setup-slack.md](https://immiscible.fly.dev/docs/guides/slack.md)): a mapped member who can decide for that agent, named approvers, separation of duties, holds, a fresh check against the mandate, and the chat line above which approval needs the console. It is recorded with channel `teams` and the person's Microsoft Entra object id (or email) on the approval and in the evidence ledger.

There are two ways to connect Teams. Use the **Teams app** when the server has one: nothing to paste, no relay, cards change in place, and approvers can be messaged one to one. The **Workflows webhook** below is the fallback for a server without the app.

## The Teams app

### What the operator registers, once

One bot, single tenant, in the operator's own Microsoft Entra tenant. New multi-tenant bots were deprecated after 31 July 2025 (https://learn.microsoft.com/en-us/azure/bot-service/bot-builder-authentication); a single-tenant bot reaches customers' tenants by being installed there from its app package.

1. In the Azure portal, **Create a resource**, **Azure Bot**. **Type of App:** **Single Tenant**. **Creation type:** **Create new Microsoft App ID**. Any resource group and the free pricing tier will do.
2. On the bot's **Configuration** page: **Messaging endpoint** `https://<host>/teams/messages`. Note the **Microsoft App ID** and the **App Tenant ID**.
3. **Manage Password** (it opens the Entra app registration): **Certificates and secrets**, **New client secret**. Copy the value. Or upload a certificate instead and keep its private key and the SHA-1 thumbprint Entra shows.
4. On the bot's **Channels** page, add **Microsoft Teams** and accept the terms.
5. Set the secrets:

```shell
fly secrets set MICROSOFT_BOT_APP_ID=<app id> MICROSOFT_BOT_TENANT_ID=<tenant id> MICROSOFT_BOT_APP_PASSWORD=<client secret>
# or, with a certificate instead of a password:
fly secrets set MICROSOFT_BOT_CERTIFICATE_KEY="$(cat bot-key.pem)" MICROSOFT_BOT_CERTIFICATE_THUMBPRINT=<sha-1 thumbprint>
```

To list the app in the Teams store instead of customers uploading it, submit the same package through Partner Center; that is optional and not needed to start.

### What the customer does

1. On **Connections**, choose **Connect** on Microsoft Teams and **Download the Teams app** (a zip with `manifest.json` and two icons drawn from the mark).
2. A Teams admin uploads it once: Teams admin centre, **Teams apps**, **Manage apps**, **Upload new app**. Where the organisation lets people upload custom apps, anyone can use **Apps**, **Manage your apps**, **Upload an app** in Teams.
3. Add the app to the team. The bot posts **Connect to Immiscible** in the channel; an owner or admin of the Immiscible workspace opens it, sees the team and channel, and confirms. The link works once, for seven days.

People are linked to their Immiscible accounts the first time they press a button: the bot asks Teams for their email from the team roster and links them if it is a member's. A decision is recorded with their Entra object id. Removing the app from the team disconnects the workspace.

### How it works

From Microsoft's documentation, built without the Bot Framework SDK:

- **Every inbound activity** carries a JWT, verified against the key set named by `https://login.botframework.com/v1/.well-known/openidconfiguration` (read at least daily, and again on an unknown key id): RS256, issuer `https://api.botframework.com`, audience the bot's app id, five minutes of clock skew, the `serviceUrl` claim equal to the activity's, and the key endorsed for `msteams`, else 403 (https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication). The service URL must also be a Microsoft Bot Connector host before a token is sent to it.
- **Outbound**, a client credentials token from `https://login.microsoftonline.com/<MICROSOFT_BOT_TENANT_ID>/oauth2/v2.0/token`, scope `https://api.botframework.com/.default`, with the password or a certificate client assertion.
- **Approvals** go to the channel and, one to one, to each decider already linked in Teams (https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages). The cards are the same as the webhook's.
- **Buttons:** `Action.Submit` arrives as a message whose value is the button's data; `Action.Execute` as an `adaptiveCard/action` invoke, answered with the new card. Each button carries a token for its decision, so a press cannot be turned into another decision. Every copy of the card is updated in place after a decision, wherever it was made.
- **The rules are the console's:** a linked member who can decide for that agent, named approvers, separation of duties, holds, and the chat line above which approving needs the console (Teams answers with the console link).

## The Workflows webhook

The **Workflows (Power Automate) webhook** style, with a signed callback:

1. Immiscible posts each card to a Workflows webhook URL you create.
2. Your flow posts the card to the channel and, for approvals, waits for someone to press Approve or Deny.
3. Your flow, or a small relay it calls, sends the submission and the responder to Immiscible's callback, signed with an HMAC-SHA256 secret that only your relay and Immiscible hold.

Be clear about what that means:

- **The relay is part of your trust boundary.** Immiscible trusts the responder the relay reports, because Microsoft signed that person in and the relay proves itself with the secret. Anyone holding the secret can report any responder. Keep it in a key vault, not in the flow's plain text.
- **Power Automate cannot compute an HMAC by itself.** There is no HMAC expression. Compute the signature in an Azure Function or a Logic Apps Standard inline code step (below), or call one from the flow.
- **The HTTP action in Power Automate is a premium connector.** Calling the relay from a cloud flow needs a licence that includes it.
- **Cards are not updated in place by Immiscible.** A Workflows webhook gives no message id back. The callback's answer includes an updated card saying who decided; use the flow's "Update an adaptive card in a chat or channel" step to show it.
- **Each card button carries a token** (an HMAC of the workspace, the approval and the decision), so a relay cannot turn a Deny into an Approve, or answer an approval it was not shown, without the secret.

## 1. Create the flow

In Teams, open **Workflows**, and start from **Post to a channel when a webhook request is received**, or build it in Power Automate:

1. Trigger: **When a Teams webhook request is received**. Copy the HTTP POST URL it shows.
2. Immiscible's body is `{ "type": "message", "immiscible": { "kind": "approval" | "notice" }, "attachments": [ { "contentType": "application/vnd.microsoft.card.adaptive", "content": <card> } ] }`. Add a condition on `immiscible.kind`.
3. For `notice`: **Post card in a chat or channel** with the card.
4. For `approval`: **Post adaptive card in a chat or channel and wait for a response**, with the card. Its outputs include the submitted `data` (the `immiscible` object from the button, whether Deny or Confirm approval inside the Approve card: `workspaceId`, `approvalId`, `decision`, `token`) and the responder (their email and Entra object id).
5. Then an **HTTP** step that POSTs to your relay (or straight to Immiscible if your relay is inline code) with:

```
{
  "approvalId": "<data.immiscible.approvalId>",
  "decision": "<data.immiscible.decision>",
  "token": "<data.immiscible.token>",
  "workspaceId": "<data.immiscible.workspaceId>",
  "responder": { "aadObjectId": "<responder object id>", "email": "<responder email>" }
}
```

6. Optionally, **Update an adaptive card in a chat or channel** with the `card` from the answer.

## 2. Connect it in Immiscible

An owner or admin, in **Settings**, **Teams**, pastes the webhook URL, or:

```
PUT /api/w/:wid/chat/teams   { "webhookUrl": "https://....logic.azure.com/workflows/..." }
```

The URL must be https and must not be a private, loopback or link-local address; it is sealed at rest. The answer includes `secret` (it starts `tmsec_`), **shown once**. Put it in your relay's key vault. To replace it: `{ "rotateSecret": true }`. Then **Send a test message**.

## 3. The callback

```
POST /teams/callback/:wid
content-type: application/json
x-immiscible-signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the secret>
```

The timestamp must be within five minutes of Immiscible's clock, the signature is compared in constant time, and each signature is accepted once: a replay is refused. Answers:

| Status | Body |
|---|---|
| 200 | `{ ok: true, status: "approved" \| "denied", decidedBy, card }` |
| 401 | bad or missing signature, a replay, or a token not from a card Immiscible sent |
| 403 | `not_linked` (responder not mapped), `forbidden`, `not_an_approver`, `separation_of_duties`, or `step_up_required` with `url` to the console |
| 409 | already decided, the agent is frozen, or the mandate no longer allows it |

A relay as an Azure Function (Node 18 or later), holding the secret in `IMMISCIBLE_TEAMS_SECRET`:

```js
import { createHmac } from 'node:crypto';

export default async function (context, req) {
  const body = JSON.stringify(req.body);
  const t = Math.floor(Date.now() / 1000);
  const v1 = createHmac('sha256', process.env.IMMISCIBLE_TEAMS_SECRET).update(`${t}.${body}`).digest('hex');
  const res = await fetch(`https://YOUR-IMMISCIBLE-HOST/teams/callback/${process.env.IMMISCIBLE_WORKSPACE_ID}`, {
    method: 'POST',
    headers: { 'content-type': 'application/json', 'x-immiscible-signature': `t=${t},v1=${v1}` },
    body,
  });
  context.res = { status: res.status, headers: { 'content-type': 'application/json' }, body: await res.text() };
}
```

Protect the function itself (a function key, or Entra authentication) so only your flow can call it.

## 4. Map members

The responder is matched to a member of the workspace by Entra object id when one is mapped, otherwise by email.

```
POST /api/w/:wid/chat/teams/members/sync    map every member by their Immiscible email
POST /api/w/:wid/chat/teams/members         { email, aadObjectId }  map one member by object id
GET  /api/w/:wid/chat/teams/members
DELETE /api/w/:wid/chat/teams/members/:externalId
```

Mapping by object id is stronger: an email can be reassigned, an object id is not.

## Disconnecting

Settings, Teams, **Disconnect** (`DELETE /api/w/:wid/chat/teams`). The URL, the secret and the member mappings are deleted, and the callback answers 404 for the workspace. Turn the flow off in Power Automate too.
