microsoft/teams.net
Publicmirrored fromhttps://github.com/microsoft/teams.netAvailable
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 | |
| 12 | The 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 | |
| 18 | This 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 | |
| 26 | From the live traffic trace (`oauthflowbot-trace-2026-04-22-raw.log`), the token retrieval call is: |
| 27 | |
| 28 | ``` |
| 29 | GET https://token.botframework.com/api/usertoken/GetToken |
| 30 | ?userid=29:1cgsv1oFLAoTflZ-AxCZ_erWK6f4AqDSzZGpJOS7FuyfB8gn-g9bWmVM8usvvrv2e0atWV6wxZOQCn-xntjVrrQ |
| 31 | &connectionName=teamsgraph |
| 32 | &channelId=msteams |
| 33 | Authorization: Bearer <app-only-token for https://api.botframework.com/.default> |
| 34 | ``` |
| 35 | |
| 36 | The authorization token is an **app-only** token (no user context), acquired by the bot using its own credentials: |
| 37 | ``` |
| 38 | aud: https://api.botframework.com |
| 39 | appid: e3cb1c84-14e3-419c-b39c-1c06097b55fd |
| 40 | idtyp: app |
| 41 | ``` |
| 42 | |
| 43 | ### The attack (step by step) |
| 44 | |
| 45 | An attacker with access to the bot's client secret can reproduce this outside the bot: |
| 46 | |
| 47 | 1. **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 | |
| 56 | 2. **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 | |
| 65 | 3. **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 | |
| 91 | The `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 | |
| 93 | If 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 | |
| 99 | The `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 | |
| 115 | The 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 | |
| 124 | The 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 | |
| 128 | The messaging endpoint is: |
| 129 | ``` |
| 130 | https://klljrqz0-3978.usw2.devtunnels.ms/api/messages |
| 131 | ``` |
| 132 | |
| 133 | This 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 | |
| 137 | The `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 | |
| 147 | The 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 | |
| 149 | The 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 | |
| 168 | The **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 | |
| 173 | The 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 |
| 178 | az ad app update --id e3cb1c84-14e3-419c-b39c-1c06097b55fd \ |
| 179 | --sign-in-audience AzureADMyOrg |
| 180 | ``` |
| 181 | |
| 182 | This ensures only the home tenant (`3f3d1cea-...`) can acquire tokens for this app. |
| 183 | |
| 184 | ### 3. Apply least-privilege scopes to OAuth connections (P1) |
| 185 | |
| 186 | For `teamsgraph`: change scopes from `ChannelMessage.Read.All TeamMember.Read.All` to `User.Read` (what the sample actually uses). |
| 187 | |
| 188 | For `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 | |
| 192 | Use 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 | |
| 196 | Add 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 | |
| 202 | The 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 | |
| 206 | If 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 | |
| 214 | All 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 | |
| 225 | Every 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 | |