microsoft/teams.net

Public

mirrored fromhttps://github.com/microsoft/teams.netAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
3ddf9fa76ec1801a0e3ca312c6d9855879571ac1

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

core/docs/sso/security-audit-oauthflow-2026-04-22.md

225lines · modecode

1# Security Audit: OAuthFlow Token Retrieval Attack Surface
2
3**Date:** 2026-04-22
4**Scope:** OAuthFlow design, implementation, live traffic trace, Azure Bot Service + Entra ID configuration
5**Bot App ID:** `e3cb1c84-14e3-419c-b39c-1c06097b55fd` ("my-bot-sso")
6**Tenant:** `3f3d1cea-7a18-41af-872b-cfbbd5140984`
7
8---
9
10## Executive Summary
11
12The Bot Framework Token Service (`token.botframework.com`) acts as a **centralized token vault** for all user tokens acquired through OAuth connections. **Any caller that can authenticate as the bot** (i.e., possesses the bot's `AppId` + client secret) can retrieve any user's cached token by calling a single unauthenticated-beyond-app-identity API. The only inputs needed are:
13
14- The bot's credentials (AppId + secret)
15- A user's Teams MRI (semi-public, visible to anyone in the same org/conversation)
16- The connection name (a short string like `"teamsgraph"`)
17
18This is **by design** in the Bot Framework Token Service protocol. The mitigation is entirely dependent on protecting the bot's client secret.
19
20---
21
22## Detailed Attack Reconstruction
23
24### What the trace shows
25
26From the live traffic trace (`oauthflowbot-trace-2026-04-22-raw.log`), the token retrieval call is:
27
28```
29GET https://token.botframework.com/api/usertoken/GetToken
30 ?userid=29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ
31 &connectionName=teamsgraph
32 &channelId=msteams
33Authorization: Bearer <app-only-token for https://api.botframework.com/.default>
34```
35
36The authorization token is an **app-only** token (no user context), acquired by the bot using its own credentials:
37```
38aud: https://api.botframework.com
39appid: e3cb1c84-14e3-419c-b39c-1c06097b55fd
40idtyp: app
41```
42
43### The attack (step by step)
44
45An attacker with access to the bot's client secret can reproduce this outside the bot:
46
471. **Acquire app-only token:**
48 ```bash
49 curl -X POST https://login.microsoftonline.com/3f3d1cea-7a18-41af-872b-cfbbd5140984/oauth2/v2.0/token \
50 -d "client_id=e3cb1c84-14e3-419c-b39c-1c06097b55fd" \
51 -d "client_secret=<stolen-secret>" \
52 -d "scope=https://api.botframework.com/.default" \
53 -d "grant_type=client_credentials"
54 ```
55
562. **Retrieve any user's token:**
57 ```bash
58 curl "https://token.botframework.com/api/usertoken/GetToken?\
59 userid=29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ\
60 &connectionName=teamsgraph\
61 &channelId=msteams" \
62 -H "Authorization: Bearer <token-from-step-1>"
63 ```
64
653. **Use the returned delegated token** to call Microsoft Graph, GitHub, etc. as the victim user.
66
67### What tokens are at risk
68
69| Connection | Provider App | Scopes | Impact |
70|---|---|---|---|
71| `teamsgraph` | `9f43e2fb-2cbd-4303-aaf4-c6d209dc2666` ("RidoGraphExperiment") | `ChannelMessage.Read.All TeamMember.Read.All` | **Read ALL channel messages and team members as the user** |
72| `sso` | `e3cb1c84-14e3-419c-b39c-1c06097b55fd` (bot itself) | `User.Read Calendars.Read` | Read user profile and calendar |
73| `gh` | `Ov23ligyZwD5j1u41P81` (GitHub OAuth App) | `repo pr` | **Full access to user's GitHub repositories** |
74| `sso-bad` | Unknown | Unknown | (likely a test connection) |
75
76### How easy is it to get the inputs?
77
78| Input | Difficulty | How |
79|---|---|---|
80| Bot AppId | **Trivial** | Visible in every activity (`recipient.id`), in the OAuthCard, in the base64 state, in bot manifests |
81| Client secret | **Medium** | Stored in: app settings, Key Vault, CI/CD pipelines, developer machines, `.env` files. Hint: `a-t`, expires 2028-04-20 |
82| User MRI | **Low** | Visible to any user in the same conversation. Format: `29:<base64>`. Enumerable via Graph API with `TeamMember.Read.All` |
83| Connection name | **Low** | Visible in OAuthCard payload (`connectionName: "teamsgraph"`), guessable, or enumerable if you have the bot's Azure subscription access |
84
85---
86
87## Configuration Findings
88
89### Finding 1: CRITICAL — Overprivileged OAuth Connection Scopes
90
91The `teamsgraph` connection requests `ChannelMessage.Read.All` and `TeamMember.Read.All`. These are high-privilege delegated permissions that grant access far beyond what the sample bot uses (it only calls `/me`).
92
93If a user's token is stolen via the attack above, the attacker gets these broad permissions for free.
94
95**Recommendation:** Apply least-privilege. The sample only needs `User.Read`. Remove `ChannelMessage.Read.All` and `TeamMember.Read.All` from the connection scopes.
96
97### Finding 2: HIGH — `teamsgraph` Uses a Separate App Registration
98
99The `teamsgraph` connection's `clientId` is `9f43e2fb-2cbd-4303-aaf4-c6d209dc2666` ("RidoGraphExperiment") — a **different** app registration than the bot itself. This means:
100
101- The Token Service performs OBO (on-behalf-of) using this separate app's credentials
102- This separate app has its own client secrets (hints: `2PX` expiring 2026-10-17, `Fta` expiring 2027-04-17)
103- Two sets of credentials must be protected, doubling the attack surface
104- The `RidoGraphExperiment` app's credentials are stored in the Token Service, not in the bot's code, but if the bot credentials are compromised, the stored tokens (already exchanged) are directly accessible
105
106**Recommendation:** Use the bot's own app ID for the OAuth connection where possible (as the `sso` connection already does). This reduces the number of credential sets to protect.
107
108### Finding 3: HIGH — `signInAudience` vs `msaAppType` Mismatch
109
110| Setting | Value |
111|---|---|
112| Entra App `signInAudience` | `AzureADMultipleOrgs` (any Entra tenant) |
113| Bot Service `msaAppType` | `SingleTenant` |
114
115The Entra app accepts tokens from **any** Azure AD tenant, but the Bot Service is configured as single-tenant. This mismatch means:
116
117- An attacker from a different tenant could acquire an app-only token against `https://api.botframework.com/.default` using a service principal in their own tenant (if the app is registered as multi-org)
118- The Bot Framework Token Service may or may not enforce tenant isolation on the `GetToken` API
119
120**Recommendation:** Align the Entra app `signInAudience` to `AzureADMyOrg` (single tenant) to match the Bot Service configuration. This restricts token acquisition to the bot's home tenant.
121
122### Finding 4: MEDIUM — `appRoleAssignmentRequired: false`
123
124The bot's service principal does not require role assignment. Combined with `AzureADMultipleOrgs`, any user in any tenant can authenticate. While this is typical for bots (they need to accept tokens from the Bot Framework), it should be reviewed.
125
126### Finding 5: MEDIUM — Dev Tunnel Endpoint in Production Bot Registration
127
128The messaging endpoint is:
129```
130https://klljrqz0-3978.usw2.devtunnels.ms/api/messages
131```
132
133This is a dev tunnel URL. If this bot registration is also used for testing with real user tokens, those tokens are cached in the Token Service and retrievable even after the dev tunnel is shut down. The tokens persist until they expire or the user signs out.
134
135### Finding 6: MEDIUM — GitHub Connection Has `repo` Scope
136
137The `gh` connection grants `repo` scope — full read/write access to all repositories. A stolen GitHub token would allow an attacker to read private code, push malicious commits, or exfiltrate proprietary source code.
138
139**Recommendation:** Use fine-grained GitHub permissions or the minimum scope needed.
140
141---
142
143## What the SDK Can and Cannot Do
144
145### Cannot fix (Bot Framework Token Service design)
146
147The core issue — that `GetToken` only requires bot identity + userId — is a **Token Service protocol property**. The Token Service treats the bot as a trusted party for all its users. This is analogous to how a web app's backend can use its OAuth client credentials to access stored refresh tokens.
148
149The SDK cannot add additional authorization to the Token Service API.
150
151### Can mitigate
152
153| Mitigation | Where | Status |
154|---|---|---|
155| **Document the threat model** | Design doc, SDK docs | Not done |
156| **Warn about credential protection** | Sample README, getting-started guide | Not done |
157| **Log token retrieval attempts** | OAuthFlow.cs `GetTokenAsync` | Partially done (debug-level) |
158| **Support Managed Identity** | BotConfig.cs | Supported (eliminates client secret) |
159| **Support Federated Identity** | BotConfig.cs | Supported (eliminates client secret) |
160| **Reduce default log verbosity** | BotAuthenticationHandler.cs | Not done (full claims at Trace) |
161
162---
163
164## Recommendations (Priority Order)
165
166### 1. Eliminate the client secret (P0)
167
168The **single most effective mitigation** is to remove the client secret entirely:
169
170- **Managed Identity**: If the bot runs on Azure (App Service, Container Apps), use system-assigned managed identity. No secret to steal.
171- **Federated Identity Credentials**: For non-Azure hosts or CI/CD, use workload identity federation. No secret stored.
172
173The bot's `BotConfig` already supports both (`Credential.ManagedIdentity`, `Credential.FederatedIdentity`). The sample should demonstrate this.
174
175### 2. Fix the `signInAudience` mismatch (P0)
176
177```bash
178az ad app update --id e3cb1c84-14e3-419c-b39c-1c06097b55fd \
179 --sign-in-audience AzureADMyOrg
180```
181
182This ensures only the home tenant (`3f3d1cea-...`) can acquire tokens for this app.
183
184### 3. Apply least-privilege scopes to OAuth connections (P1)
185
186For `teamsgraph`: change scopes from `ChannelMessage.Read.All TeamMember.Read.All` to `User.Read` (what the sample actually uses).
187
188For `gh`: change from `repo pr` to `read:user` if only profile info is needed.
189
190### 4. Consolidate to a single app registration (P1)
191
192Use the bot's own app ID (`e3cb1c84-...`) for the `teamsgraph` OAuth connection instead of the separate `RidoGraphExperiment` app. This halves the credential surface.
193
194### 5. Document the Token Service threat model (P1)
195
196Add to the OAuthFlow design doc:
197
198> **Security Note:** The Bot Framework Token Service stores user tokens on behalf of the bot. Any entity that can authenticate as the bot (via AppId + credential) can retrieve any user's cached token by calling the Token Service API with the user's ID and connection name. Protect the bot's credentials with the same rigor as a database connection string. Prefer Managed Identity or Federated Identity Credentials over client secrets.
199
200### 6. Rotate the existing client secret (P1)
201
202The current secret (hint: `a-t`, created 2026-04-20, expires 2028-04-20) has a **2-year lifetime** — far too long. Rotate immediately and set a shorter expiry (90 days max) as a bridge while migrating to Managed Identity.
203
204### 7. Clean up the dev tunnel endpoint (P2)
205
206If this bot registration was used with real users during development, their tokens may still be cached. Either:
207- Sign out all users via the Token Service API
208- Delete and recreate the bot registration for production use
209
210---
211
212## Appendix: Token Service API Surface (Attack-Relevant)
213
214All endpoints authenticated with bot's app-only token for `https://api.botframework.com/.default`:
215
216| Endpoint | Method | What it does |
217|---|---|---|
218| `/api/usertoken/GetToken?userid=X&connectionName=Y&channelId=Z` | GET | **Returns the user's cached access token** |
219| `/api/usertoken/GetToken?userid=X&connectionName=Y&channelId=Z&code=C` | GET | Exchanges a verify-state code for a token |
220| `/api/usertoken/SignOut?userid=X&connectionName=Y&channelId=Z` | DELETE | Revokes a user's cached token |
221| `/api/usertoken/GetTokenStatus?userid=X&channelId=Z` | GET | Lists all connections and whether tokens exist |
222| `/api/usertoken/exchange?userid=X&connectionName=Y&channelId=Z` | POST | Exchanges an SSO token for an access token |
223| `/api/botsignin/GetSignInResource?state=X` | GET | Returns sign-in URL + TokenExchangeResource |
224
225Every one of these is callable by anyone with the bot's credentials. The `GetTokenStatus` endpoint even lets an attacker enumerate which connections a user has tokens for without knowing the connection names.
226