microsoft/teams.net
Publicmirrored fromhttps://github.com/microsoft/teams.netAvailable
core/docs/sso/OAuthFlow-Design.md
700lines · modecode
| 1 | # OAuthFlow Design Document |
| 2 | |
| 3 | ## Overview |
| 4 | |
| 5 | `OAuthFlow` provides a high-level abstraction for Teams Bot SSO (Single Sign-On) authentication. It encapsulates the full OAuth lifecycle -- silent token acquisition, SSO token exchange, fallback sign-in, and sign-out -- so developers can add user authentication with minimal plumbing. |
| 6 | |
| 7 | The design builds on top of the existing `UserTokenClient` (core) and `UserTokenApiClient` / `BotSignInClient` (Apps layer), and follows the handler-based routing pattern established by `AdaptiveCardExtensions`, `TaskExtensions`, etc. |
| 8 | |
| 9 | ## Motivation |
| 10 | |
| 11 | Teams SSO requires coordinating multiple moving parts: |
| 12 | |
| 13 | 1. Checking the Bot Framework Token Store for an existing token |
| 14 | 2. Sending an OAuthCard with a `TokenExchangeResource` to trigger silent SSO |
| 15 | 3. Handling `signin/tokenExchange` invoke activities (with deduplication) |
| 16 | 4. Handling `signin/verifyState` invoke activities (fallback sign-in flow) |
| 17 | 5. Handling `signin/failure` invoke activities (client-side SSO failures) |
| 18 | 6. Calling `UserTokenClient.ExchangeTokenAsync` to complete the on-behalf-of exchange |
| 19 | |
| 20 | Without an abstraction, every bot developer must wire this up manually. `OAuthFlow` reduces it to a few method calls. |
| 21 | |
| 22 | ## Architecture |
| 23 | |
| 24 | ``` |
| 25 | TeamsBotApplication |
| 26 | ├── AppId ← from BotConfig.ClientId |
| 27 | ├── OAuthRegistry ← holds all OAuthFlow instances |
| 28 | ├── Router |
| 29 | │ ├── ... existing routes ... |
| 30 | │ ├── invoke/signin/tokenExchange ← registered by OAuthFlow |
| 31 | │ ├── invoke/signin/verifyState ← registered by OAuthFlow |
| 32 | │ └── invoke/signin/failure ← registered by OAuthFlow (client-side SSO failures) |
| 33 | └── OAuthFlow (one per connection) |
| 34 | ├── SignInAsync() → silent token check + OAuthCard |
| 35 | ├── SignOutAsync() → revoke token |
| 36 | ├── IsSignedInAsync() → check token store |
| 37 | ├── GetTokenAsync() → silent-only token retrieval |
| 38 | ├── OnSignInComplete() → callback after successful exchange |
| 39 | └── OnSignInFailure() → callback on exchange failure |
| 40 | ``` |
| 41 | |
| 42 | ### Two API Layers |
| 43 | |
| 44 | Developers can use **either** the context-level API (simple, matches Teams SDK v2 pattern) or the OAuthFlow-instance API (advanced, explicit per-connection control): |
| 45 | |
| 46 | | Scenario | Context API (simple) | OAuthFlow API (advanced) | |
| 47 | |---|---|---| |
| 48 | | Sign in | `context.SignIn(new OAuthOptions { ConnectionName = "gh" })` | `githubAuth.SignInAsync(context)` (uses options from `AddOAuthFlow`) | |
| 49 | | Sign out | `context.SignOut("gh")` | `githubAuth.SignOutAsync(context)` | |
| 50 | | Check status | `context.IsSignedInAsync("gh")` | `githubAuth.IsSignedInAsync(context)` | |
| 51 | | All connections | `context.GetConnectionStatusAsync()` | `graphAuth.GetConnectionStatusAsync(context)` | |
| 52 | | Single connection | `context.SignIn()` / `context.IsSignedIn` | `auth.SignInAsync(context)` | |
| 53 | |
| 54 | ### Relationship to existing clients |
| 55 | |
| 56 | ``` |
| 57 | OAuthFlow (Apps layer - developer-facing) |
| 58 | │ |
| 59 | ├── UserTokenClient.GetTokenAsync() → silent token check |
| 60 | ├── UserTokenClient.ExchangeTokenAsync() → SSO token exchange |
| 61 | ├── UserTokenClient.GetTokenStatusAsync() → connection discovery & status |
| 62 | ├── UserTokenClient.SignOutUserAsync() → sign-out |
| 63 | └── UserTokenClient.GetSignInResourceAsync() → sign-in resource (OAuthCard data) |
| 64 | ``` |
| 65 | |
| 66 | `OAuthFlow` does **not** replace these clients. It orchestrates them into a cohesive flow and auto-registers the invoke handlers that the SSO protocol requires. |
| 67 | |
| 68 | ## Breaking Changes from Teams SDK v2 (Spark) |
| 69 | |
| 70 | This section documents every API and behavioral difference between the old `Context<TActivity>` (in `Microsoft.Teams.Apps`) and the new `Context<TActivity>` (in `Microsoft.Teams.Apps`) related to SSO/Auth. |
| 71 | |
| 72 | ### 1. `context.ConnectionName` removed |
| 73 | |
| 74 | **Old (v2)**: `Context<TActivity>` has a `required string ConnectionName` property that holds the app's default connection name (set during context construction, defaults to `"graph"`). `SignIn()` and `SignOut()` fall back to this when no explicit connection name is given. |
| 75 | |
| 76 | **New**: No `ConnectionName` property on context. The default connection is resolved from the `OAuthFlowRegistry` -- if a single `OAuthFlow` is registered, it is used as the default. If multiple are registered, the developer must specify the connection name per-call. |
| 77 | |
| 78 | ```csharp |
| 79 | // Old (v2) -- default connection baked into context |
| 80 | await context.SignIn(); // uses context.ConnectionName ("graph") |
| 81 | |
| 82 | // New -- resolved from OAuthFlowRegistry |
| 83 | bot.AddOAuthFlow("graph"); // single flow → becomes the default |
| 84 | await context.SignIn(); // works (single flow auto-resolves) |
| 85 | |
| 86 | // New -- multiple flows with options configured at registration |
| 87 | var ghAuth = bot.AddOAuthFlow(new OAuthOptions |
| 88 | { |
| 89 | ConnectionName = "gh", |
| 90 | OAuthCardText = "Sign in to GitHub", |
| 91 | SignInButtonText = "Sign In" |
| 92 | }); |
| 93 | await ghAuth.SignInAsync(context); // uses options from registration |
| 94 | ``` |
| 95 | |
| 96 | **Migration**: Replace reads of `context.ConnectionName` with the explicit connection name in `OAuthOptions` or `SignOut(connectionName)`. |
| 97 | |
| 98 | ### 2. `context.IsSignedIn` semantics changed |
| 99 | |
| 100 | **Old (v2)**: `IsSignedIn` is a read/write `bool` property (`{ get; set; }`). It is set to `true` by the framework when a `signin/tokenExchange` invoke completes successfully during the current turn. It is a **per-turn flag**, not a token-store query. It reflects whether the sign-in **just happened** in this turn, not whether a token exists in the store. |
| 101 | |
| 102 | **New**: `IsSignedIn` is a read-only `bool` property that **synchronously queries the token store** (`GetAwaiter().GetResult()`). It checks whether the user has a cached token right now, regardless of what happened during this turn. It cannot be set by the developer. |
| 103 | |
| 104 | | | Old (v2) | New | |
| 105 | |---|---|---| |
| 106 | | Type | `bool { get; set; }` | `bool { get; }` | |
| 107 | | Source of truth | Framework sets it during the turn | Queries token store on each access | |
| 108 | | Async | No (already computed) | No (sync-over-async) | |
| 109 | | Multi-connection | N/A (one default connection) | Checks first registered flow, logs warning if multiple | |
| 110 | | Writable | Yes | No | |
| 111 | |
| 112 | **Recommended migration**: Use `IsSignedInAsync(connectionName?)` for async, connection-aware checks: |
| 113 | |
| 114 | ```csharp |
| 115 | // Old (v2) |
| 116 | if (!context.IsSignedIn) { await context.SignIn(); return; } |
| 117 | |
| 118 | // New (preferred) |
| 119 | if (!await context.IsSignedInAsync("graph", ct)) { await context.SignIn(new OAuthOptions { ConnectionName = "graph" }, ct); return; } |
| 120 | |
| 121 | // New (backwards-compat, single connection only) |
| 122 | if (!context.IsSignedIn) { await context.SignIn(ct); return; } |
| 123 | ``` |
| 124 | |
| 125 | ### 3. `context.UserGraphToken` removed |
| 126 | |
| 127 | **Old (v2)**: `Context<TActivity>` has a `JsonWebToken? UserGraphToken` property set by the framework's `OnTokenExchangeActivity` handler after a successful token exchange. It provides parsed JWT access to the Graph token (claims, expiry, etc.). |
| 128 | |
| 129 | **New**: No `UserGraphToken` property. The token is returned as a raw `string` from `SignIn()` / `GetTokenAsync()` / `OnSignInComplete`. If JWT parsing is needed, the developer must parse it themselves. |
| 130 | |
| 131 | ```csharp |
| 132 | // Old (v2) |
| 133 | var graphClient = new SimpleGraphClient(context.UserGraphToken?.ToString()!); |
| 134 | |
| 135 | // New |
| 136 | string? token = await context.SignIn(new OAuthOptions { ConnectionName = "graph" }, ct); |
| 137 | var graphClient = new SimpleGraphClient(token!); |
| 138 | ``` |
| 139 | |
| 140 | ### 4. `context.SignIn(SSOOptions)` overload removed |
| 141 | |
| 142 | **Old (v2)**: Two `SignIn` overloads exist: |
| 143 | - `SignIn(OAuthOptions?)` -- OAuth flow via Bot Framework Token Service |
| 144 | - `SignIn(SSOOptions)` -- Direct SSO flow with custom scopes and sign-in link (bypasses Token Service, constructs its own `TokenExchangeResource`) |
| 145 | |
| 146 | **New**: Only `SignIn(OAuthOptions?)` is available. The SSO flow is handled transparently when the OAuth connection is configured as Azure AD v2 -- the `TokenExchangeResource` is returned by the Token Service when `MsAppId` is included in the state. |
| 147 | |
| 148 | **Migration**: Remove `SSOOptions` usage. Configure the OAuth connection in Azure Bot settings with the appropriate scopes. The `OAuthFlow` handles SSO automatically for Azure AD connections. |
| 149 | |
| 150 | ### 5. `context.SignIn()` return type is the same but semantics differ |
| 151 | |
| 152 | **Old (v2)**: `SignIn(OAuthOptions?)` returns `Task<string?>`. Returns the cached token if found, otherwise sends OAuthCard and returns `null`. The `SignIn(SSOOptions)` overload returns `Task` (void). |
| 153 | |
| 154 | **New**: `SignIn(OAuthOptions?)` returns `Task<string?>` with the same semantics -- token if cached, `null` if OAuthCard sent. No void overload. |
| 155 | |
| 156 | This is **API-compatible** for the `OAuthOptions` overload. Breaking only for `SSOOptions` users. |
| 157 | |
| 158 | ### 6. `OnSignInComplete` callback signature |
| 159 | |
| 160 | **Old (v2)**: Sign-in success is delivered via an app-level event: |
| 161 | ```csharp |
| 162 | // Old (v2) |
| 163 | teams.OnSignIn(async (plugin, @event, cancellationToken) => { |
| 164 | var token = @event.Token; // Token.Response object |
| 165 | var context = @event.Context; // IContext<SignInActivity> |
| 166 | }); |
| 167 | ``` |
| 168 | |
| 169 | **New**: Sign-in success is delivered via a per-connection callback: |
| 170 | ```csharp |
| 171 | // New |
| 172 | graphAuth.OnSignInComplete(async (context, tokenResponse, ct) => { |
| 173 | string token = tokenResponse.Token!; // GetTokenResult |
| 174 | // context is Context<TeamsActivity> (base type) |
| 175 | }); |
| 176 | ``` |
| 177 | |
| 178 | Key differences: |
| 179 | - **Scope**: Old is app-level (one handler for all connections). New is per-connection. |
| 180 | - **Context type**: Old provides `IContext<SignInActivity>`. New provides `Context<TeamsActivity>` because the sign-in can complete from invoke (tokenExchange, verifyState) activities. |
| 181 | - **Token type**: Old provides `Token.Response` (with `ConnectionName`, `Token`, `Expiration`, `Properties`). New provides `GetTokenResult` (with `ConnectionName`, `Token`). |
| 182 | - **Plugin parameter**: Old receives the plugin instance. New does not -- the context has access to `TeamsBotApplication`. |
| 183 | |
| 184 | ### 7. `OnSignInFailure` callback signature and scope |
| 185 | |
| 186 | **Old (v2)**: App-level handler receiving the failure activity: |
| 187 | ```csharp |
| 188 | // Old (v2) |
| 189 | teams.OnSignInFailure(async (context, cancellationToken) => { |
| 190 | var failure = context.Activity.Value; // SignIn.Failure { Code, Message } |
| 191 | await context.Send("Sign-in failed.", cancellationToken); |
| 192 | }); |
| 193 | ``` |
| 194 | |
| 195 | **New**: Per-connection handler on the `OAuthFlow` instance: |
| 196 | ```csharp |
| 197 | // New |
| 198 | graphAuth.OnSignInFailure(async (context, failure, ct) => { |
| 199 | // context is Context<TeamsActivity> |
| 200 | // failure is non-null for signin/failure invokes (client-side SSO errors) |
| 201 | if (failure is not null) |
| 202 | await context.SendActivityAsync($"Sign-in failed: {failure.Code} — {failure.Message}", ct); |
| 203 | else |
| 204 | await context.SendActivityAsync("Sign-in failed.", ct); |
| 205 | }); |
| 206 | ``` |
| 207 | |
| 208 | Key differences: |
| 209 | - **Scope**: Per-connection instead of app-level. |
| 210 | - **Failure details**: Old provides `SignIn.Failure` with `Code` and `Message` via the activity value. New provides `SignInFailureValue?` — non-null with structured `Code`/`Message` for `signin/failure` invokes (client-side SSO errors), null for server-side token exchange or verify-state failures. |
| 211 | - **`context.Send` → `context.SendActivityAsync`**: Method name change (see below). |
| 212 | |
| 213 | ### 8. `context.Send()` → `context.SendActivityAsync()` |
| 214 | |
| 215 | **Old (v2)**: `context.Send(string)` and `context.Send<T>(T activity)`. |
| 216 | |
| 217 | **New**: `context.SendActivityAsync(string)` and `context.SendActivityAsync(TeamsActivity)`. |
| 218 | |
| 219 | This affects all code inside `OnSignInComplete` and `OnSignInFailure` callbacks. |
| 220 | |
| 221 | ### 9. Group chat handling removed from `SignIn` |
| 222 | |
| 223 | **Old (v2)**: `Context.SignIn()` detects group chats (`Activity.Conversation.IsGroup == true`) and automatically creates a 1:1 conversation with the user before sending the OAuthCard, because group chats don't support SSO. |
| 224 | |
| 225 | **New**: `OAuthFlow.SignInAsync()` does not handle the group-chat-to-1:1 conversion. The OAuthCard is sent to the current conversation. For group chats, the sign-in card will show the button (no SSO), but the popup flow still works. |
| 226 | |
| 227 | **Migration**: If group chat SSO is required, the developer must create the 1:1 conversation manually before calling `context.SignIn()`. |
| 228 | |
| 229 | ### 10. `OAuthOptions` namespace and defaults |
| 230 | |
| 231 | | | Old (v2) | New | |
| 232 | |---|---|---| |
| 233 | | Namespace | `Microsoft.Teams.Apps` | `Microsoft.Teams.Apps.Auth` | |
| 234 | | Base class | `SignInOptions` (abstract) | None (standalone class) | |
| 235 | | `OAuthCardText` default | `"Please Sign In..."` | `"Please Sign In"` | |
| 236 | | `SignInButtonText` default | `"Sign In"` | `"Sign In"` | |
| 237 | | `ConnectionName` | Falls back to `context.ConnectionName` | Falls back to single registered `OAuthFlow` | |
| 238 | |
| 239 | ### 11. `SSOOptions` class removed |
| 240 | |
| 241 | **Old (v2)**: `SSOOptions : SignInOptions` with `required string[] Scopes` and `required string SignInLink`. |
| 242 | |
| 243 | **New**: Not available. SSO is handled automatically for Azure AD connections via the `TokenExchangeResource` mechanism. |
| 244 | |
| 245 | ### 12. No `context.Next()` equivalent in auth handlers |
| 246 | |
| 247 | **Old (v2)**: `context.Next()` continues the middleware/route chain. The `OnSignIn` event handler can call `context.Next()` to continue processing. |
| 248 | |
| 249 | **New**: `OnSignInComplete` and `OnSignInFailure` are terminal callbacks, not middleware. They do not participate in the route chain. |
| 250 | |
| 251 | ### 13. Automatic user token retrieval on every activity removed |
| 252 | |
| 253 | **Old (v2)**: `App.Process()` (App.cs:299-311) silently calls `api.Users.Token.GetAsync()` for **every** inbound activity, using `OAuth.DefaultConnectionName` (defaults to `"graph"`). If a token exists, it sets `context.IsSignedIn = true` and populates `context.UserGraphToken`. If the call fails, the exception is silently swallowed. This means `IsSignedIn` is always pre-populated by the time the developer's handler runs, even if no OAuth flow was configured. |
| 254 | |
| 255 | **New**: No automatic token retrieval. `IsSignedIn` and `GetTokenAsync` are only called when the developer explicitly invokes them. There is no implicit per-turn token check. |
| 256 | |
| 257 | **Impact**: Old code that relied on `context.IsSignedIn` being `true` on the first message (without calling `SignIn()`) must now explicitly call `await context.IsSignedInAsync()` or `await context.SignIn()` to check for a cached token. |
| 258 | |
| 259 | ### 14. Bot token retrieval on startup removed |
| 260 | |
| 261 | **Old (v2)**: `App.Start()` (App.cs:130-141) eagerly calls `Api.Bots.Token.GetAsync(Credentials, TokenClient)` to obtain the bot's own access token at startup. If the call fails, it logs `"Failed to get bot token on app startup."` and continues (non-fatal). A lazy `TokenFactory` (App.cs:64-90) also refreshes the bot token on demand when it expires. |
| 262 | |
| 263 | **New**: Bot-to-service authentication is handled at the Core level (`BotApplication` / `BotConfig.ClientId`) and does not surface in the OAuthFlow layer. There is no explicit bot token fetch on startup in the Apps layer. |
| 264 | |
| 265 | **Impact**: No developer action required -- this is an internal framework change. |
| 266 | |
| 267 | ### 15. No deduplication in old SDK |
| 268 | |
| 269 | **Old (v2)**: The `OnTokenExchangeActivity` handler (AppRouting.cs:69-127) has **no deduplication logic**. Every `signin/tokenExchange` invoke triggers a token exchange call to the Token Service. Duplicate exchanges from multiple Teams endpoints (mobile, desktop, web) all hit the Token Service independently. The `OnSignIn` event fires for each. |
| 270 | |
| 271 | **New**: `OAuthFlow` deduplicates `signin/tokenExchange` by exchange ID using an in-process `ConcurrentDictionary<string, DateTimeOffset>` with a 5-minute TTL. Duplicates receive a `200` no-op response without calling the Token Service or firing callbacks. |
| 272 | |
| 273 | **Impact**: Old code that observed multiple `OnSignIn` events per sign-in (one per endpoint) will now only see `OnSignInComplete` fire once (per instance). Handlers that were designed to be idempotent to tolerate duplicates will still work. |
| 274 | |
| 275 | ### 16. `signin/failure` invoke handler — now registered (parity achieved) |
| 276 | |
| 277 | **Old (v2)**: `OnSignInFailureActivity` (AppRouting.cs:182-225) handles the `signin/failure` invoke sent by the Teams client when SSO fails. It parses 9 documented failure codes: |
| 278 | - `installappfailed`, `authrequestfailed`, `installedappnotfound`, `invokeerror`, `resourcematchfailed`, `oauthcardnotvalid`, `tokenmissing`, `userconsentrequired`, `interactionrequired` |
| 279 | |
| 280 | Each failure is logged with the user ID, conversation ID, failure code, and message. The handler returns `200` to acknowledge. The `OnSignInFailure` app-level event fires with the structured failure details. |
| 281 | |
| 282 | **New**: A `signin/failure` invoke handler is registered automatically by `AddOAuthFlow`. It logs the failure code and message (with extra guidance for `resourcematchfailed`), then fires the `OnSignInFailure` callback on **all** registered flows (since the invoke carries no connection name). The `SignInFailureHandler` delegate receives a `SignInFailureValue?` parameter containing the structured `Code` and `Message` from the Teams client. |
| 283 | |
| 284 | **Differences from v2**: |
| 285 | - **Scope**: Per-connection `OnSignInFailure` callback (fired on all flows) instead of a single app-level event. |
| 286 | - **Delegate signature**: `SignInFailureHandler(Context<TeamsActivity>, SignInFailureValue?, CancellationToken)`. The `SignInFailureValue` parameter is non-null for `signin/failure` invokes and null for server-side token exchange / verify-state failures. |
| 287 | |
| 288 | ### 17. Token exchange error response mapping — now matches v2 (parity achieved) |
| 289 | |
| 290 | **Old (v2)**: The `OnTokenExchangeActivity` handler (AppRouting.cs:102-127) catches `HttpException` and maps error codes selectively: |
| 291 | - `NotFound`, `BadRequest`, `PreconditionFailed` → responds with `PreconditionFailed` (412) and `TokenExchange.InvokeResponse` body containing `Id`, `ConnectionName`, `FailureDetail` |
| 292 | - All other status codes (e.g., `Unauthorized`, `Forbidden`) → responds with the **original** HTTP status code |
| 293 | |
| 294 | **New**: `OAuthFlow.HandleTokenExchangeAsync` now uses the same selective mapping: |
| 295 | - `NotFound`, `BadRequest`, `PreconditionFailed` (or null status code) → responds with `InvokeResponse(412)` and a `TokenExchangeInvokeResponse` body containing `Id`, `ConnectionName`, `FailureDetail` |
| 296 | - All other status codes → responds with the **original** HTTP status code |
| 297 | |
| 298 | **Differences from v2**: |
| 299 | - `FailureDetail` contains `ex.Message` (concise) instead of `ex.ToString()` (full stack trace). This avoids leaking internal implementation details in the invoke response while still providing diagnostic information. |
| 300 | |
| 301 | ### 18. `signin/verifyState` error response — now matches v2 (parity achieved) |
| 302 | |
| 303 | **Old (v2)**: The `OnVerifyStateActivity` handler (AppRouting.cs:129-180): |
| 304 | - Missing `State` parameter → returns `NotFound` (404) with a log warning |
| 305 | - Token exchange failure (`NotFound`, `BadRequest`, `PreconditionFailed`) → returns `PreconditionFailed` (412) |
| 306 | - Other HTTP errors → returns the original status code |
| 307 | |
| 308 | **New**: `OAuthFlow.HandleVerifyStateAsync` now uses the same error mapping: |
| 309 | - Null invoke payload → returns `404` (at route level) |
| 310 | - Null `State` parameter → returns `404` with a log warning |
| 311 | - No token returned → returns `412` |
| 312 | - HTTP failure (`NotFound`, `BadRequest`, `PreconditionFailed`) → returns `412` |
| 313 | - Other HTTP errors → returns the original status code |
| 314 | - No registered flow matched → returns `404` |
| 315 | |
| 316 | ### Summary Table |
| 317 | |
| 318 | | Feature | Old (v2) `Microsoft.Teams.Apps` | New `Microsoft.Teams.Apps` | Breaking? | |
| 319 | |---|---|---|---| |
| 320 | | `context.ConnectionName` | `required string` property | Removed (resolved from registry) | Yes | |
| 321 | | `context.IsSignedIn` | `bool { get; set; }` (per-turn flag) | `bool { get; }` (queries token store) | Yes (semantic) | |
| 322 | | `context.UserGraphToken` | `JsonWebToken?` property | Removed | Yes | |
| 323 | | `context.SignIn(OAuthOptions?)` | Returns `Task<string?>` | Returns `Task<string?>` | No | |
| 324 | | `context.SignIn(SSOOptions)` | Returns `Task` | Removed | Yes | |
| 325 | | `context.SignOut(string?)` | Returns `Task` | Returns `Task` | No | |
| 326 | | `OnSignIn` event | App-level, `SignInEvent` | Per-connection `OnSignInComplete` | Yes | |
| 327 | | `OnSignInFailure` event | App-level, `SignIn.Failure` | Per-connection `OnSignInFailure` | Yes | |
| 328 | | `OAuthOptions` namespace | `Microsoft.Teams.Apps` | `Microsoft.Teams.Apps.Auth` | Yes | |
| 329 | | `SSOOptions` | Available | Removed | Yes | |
| 330 | | Group chat 1:1 fallback | Automatic | Manual | Yes (behavioral) | |
| 331 | | `context.Send()` | Available | `context.SendActivityAsync()` | Yes (rename) | |
| 332 | | `context.Next()` in auth | Available | Not applicable | Yes | |
| 333 | | `IsSignedInAsync()` | Not available | New method | N/A (addition) | |
| 334 | | `GetConnectionStatusAsync()` | Not available | New method | N/A (addition) | |
| 335 | | User token pre-fetch per activity | Automatic (silent, every turn) | On-demand only | Yes (behavioral) | |
| 336 | | Bot token fetch on startup | `App.Start()` fetches eagerly | Handled at Core level | No (internal) | |
| 337 | | Token exchange deduplication | None (every invoke hits Token Service) | `ConcurrentDictionary` by exchange ID, 5-min TTL | Yes (behavioral) | |
| 338 | | `signin/failure` invoke | App-level handler, 9 failure codes | Per-connection `OnSignInFailure` with `SignInFailureValue` | No (parity) | |
| 339 | | Token exchange error response | 412 + body for expected, original for others | 412 + `TokenExchangeInvokeResponse` for expected, original for others | No (parity) | |
| 340 | | `signin/verifyState` error response | 404 (missing state), 412 (exchange failure) | 404 (missing state), 412 (exchange failure) | No (parity) | |
| 341 | |
| 342 | ## API Surface |
| 343 | |
| 344 | ### Registration (DI pattern — recommended) |
| 345 | |
| 346 | ```csharp |
| 347 | // Configure OAuth flows during service registration |
| 348 | services.AddTeamsBotApplication(options => |
| 349 | { |
| 350 | options.AddOAuthFlow("GraphConnection", o => |
| 351 | { |
| 352 | o.OAuthCardText = "Sign in to your Microsoft account"; |
| 353 | o.SignInButtonText = "Sign In to Graph"; |
| 354 | }); |
| 355 | options.AddOAuthFlow("GitHubConnection"); // uses defaults |
| 356 | }); |
| 357 | |
| 358 | // Flows are auto-registered when the bot is constructed. |
| 359 | // Access them for callbacks: |
| 360 | TeamsBotApplication bot = app.UseTeamsBotApplication(); |
| 361 | bot.GetOAuthFlow("GraphConnection").OnSignInComplete(async (ctx, token, ct) => { ... }); |
| 362 | ``` |
| 363 | |
| 364 | ### Registration (imperative — on the bot instance) |
| 365 | |
| 366 | ```csharp |
| 367 | public static class OAuthFlowExtensions |
| 368 | { |
| 369 | /// Register an OAuthFlow with an explicit connection name (uses default OAuthCard text). |
| 370 | public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app, string connectionName); |
| 371 | |
| 372 | /// Register an OAuthFlow with OAuthOptions that configure the connection name |
| 373 | /// and default OAuthCard text. Per-call options override these defaults. |
| 374 | public static OAuthFlow AddOAuthFlow(this TeamsBotApplication app, OAuthOptions options); |
| 375 | } |
| 376 | ``` |
| 377 | |
| 378 | Both approaches register three routes on the app's `Router`: |
| 379 | |
| 380 | | Route name | Activity type | Purpose | |
| 381 | |---|---|---| |
| 382 | | `invoke/signin/tokenExchange` | Invoke | SSO silent token exchange | |
| 383 | | `invoke/signin/verifyState` | Invoke | Fallback sign-in verification | |
| 384 | | `invoke/signin/failure` | Invoke | Teams client-side SSO failure notification | |
| 385 | |
| 386 | ### Context Methods |
| 387 | |
| 388 | ```csharp |
| 389 | public class Context<TActivity> where TActivity : TeamsActivity |
| 390 | { |
| 391 | /// Trigger sign-in flow. Returns cached token or null if OAuthCard sent. |
| 392 | public Task<string?> SignIn(OAuthOptions? options = null, CancellationToken ct = default); |
| 393 | |
| 394 | /// Sign the user out from a connection. |
| 395 | public Task SignOut(string? connectionName = null, CancellationToken ct = default); |
| 396 | |
| 397 | /// Check if user has a cached token (async, connection-aware). |
| 398 | public Task<bool> IsSignedInAsync(string? connectionName = null, CancellationToken ct = default); |
| 399 | |
| 400 | /// Check if user has a cached token (sync, backwards-compat, default connection). |
| 401 | public bool IsSignedIn { get; } |
| 402 | |
| 403 | /// Get token status for all configured connections. |
| 404 | public Task<IList<GetTokenStatusResult>> GetConnectionStatusAsync(CancellationToken ct = default); |
| 405 | } |
| 406 | ``` |
| 407 | |
| 408 | ### OAuthFlow Class |
| 409 | |
| 410 | ```csharp |
| 411 | public class OAuthFlow |
| 412 | { |
| 413 | public string ConnectionName { get; } |
| 414 | |
| 415 | public Task<string?> GetTokenAsync<TActivity>(Context<TActivity> context, CancellationToken ct = default); |
| 416 | public Task<string?> SignInAsync<TActivity>(Context<TActivity> context, CancellationToken ct = default); |
| 417 | public Task<string?> SignInAsync<TActivity>(Context<TActivity> context, OAuthOptions? options, CancellationToken ct = default); |
| 418 | public Task SignOutAsync<TActivity>(Context<TActivity> context, CancellationToken ct = default); |
| 419 | public Task<bool> IsSignedInAsync<TActivity>(Context<TActivity> context, CancellationToken ct = default); |
| 420 | public Task<IList<GetTokenStatusResult>> GetConnectionStatusAsync<TActivity>(Context<TActivity> context, CancellationToken ct = default); |
| 421 | |
| 422 | public OAuthFlow OnSignInComplete(SignInCompleteHandler handler); |
| 423 | public OAuthFlow OnSignInFailure(SignInFailureHandler handler); |
| 424 | } |
| 425 | ``` |
| 426 | |
| 427 | ### OAuthOptions |
| 428 | |
| 429 | ```csharp |
| 430 | public class OAuthOptions |
| 431 | { |
| 432 | public string? ConnectionName { get; set; } |
| 433 | public string OAuthCardText { get; set; } = "Please Sign In"; |
| 434 | public string SignInButtonText { get; set; } = "Sign In"; |
| 435 | } |
| 436 | ``` |
| 437 | |
| 438 | ### Delegates |
| 439 | |
| 440 | ```csharp |
| 441 | public delegate Task SignInCompleteHandler( |
| 442 | Context<TeamsActivity> context, |
| 443 | GetTokenResult tokenResponse, |
| 444 | CancellationToken cancellationToken); |
| 445 | |
| 446 | public delegate Task SignInFailureHandler( |
| 447 | Context<TeamsActivity> context, |
| 448 | SignInFailureValue? failure, |
| 449 | CancellationToken cancellationToken); |
| 450 | ``` |
| 451 | |
| 452 | ## Internal Flow |
| 453 | |
| 454 | ### SignInAsync Sequence |
| 455 | |
| 456 | ``` |
| 457 | Developer calls context.SignIn(options) or oauth.SignInAsync(context) |
| 458 | │ |
| 459 | ├─ 1. Call UserTokenClient.GetTokenAsync(userId, connectionName, channelId) |
| 460 | │ ├─ Token exists → return token string |
| 461 | │ └─ No token ↓ |
| 462 | │ |
| 463 | ├─ 2. Build token exchange state with MsAppId (from BotApplication.AppId) |
| 464 | │ Call UserTokenClient.GetSignInResourceAsync(state) |
| 465 | │ Returns: SignInLink, TokenExchangeResource, TokenPostResource |
| 466 | │ |
| 467 | ├─ 3. Build OAuthCard attachment (serialized as JsonElement for AOT compat): |
| 468 | │ { |
| 469 | │ contentType: "application/vnd.microsoft.card.oauth", |
| 470 | │ content: { |
| 471 | │ text: options.OAuthCardText, |
| 472 | │ buttons: [{ type: "signin", title: options.SignInButtonText, value: signInLink }], |
| 473 | │ connectionName: connectionName, |
| 474 | │ tokenExchangeResource: { id, uri, providerId }, |
| 475 | │ tokenPostResource: { sasUrl } |
| 476 | │ } |
| 477 | │ } |
| 478 | │ |
| 479 | ├─ 4. Send activity with OAuthCard attachment |
| 480 | │ |
| 481 | └─ 5. Return null (sign-in pending) |
| 482 | ``` |
| 483 | |
| 484 | **Critical**: The state must include `MsAppId` (from `BotApplication.AppId`, sourced from `BotConfig.ClientId`). Without it, the Token Service returns `tokenExchangeResource: null` and Teams cannot perform SSO or automatic verify-state after popup sign-in. |
| 485 | |
| 486 | ### signin/tokenExchange Invoke Handler |
| 487 | |
| 488 | ``` |
| 489 | Teams client sends invoke: signin/tokenExchange |
| 490 | │ |
| 491 | ├─ 1. Deserialize value → SignInTokenExchangeValue { Id, ConnectionName, Token } |
| 492 | │ |
| 493 | ├─ 2. Deduplication check (by value.Id) |
| 494 | │ ├─ Already processed → respond 200 (no-op) |
| 495 | │ └─ New ↓ |
| 496 | │ |
| 497 | ├─ 3. Resolve OAuthFlow by ConnectionName |
| 498 | │ |
| 499 | ├─ 4. Call UserTokenClient.ExchangeTokenAsync(userId, connectionName, channelId, token) |
| 500 | │ ├─ Success → fire OnSignInComplete, respond InvokeResponse(200) |
| 501 | │ └─ Failure → fire OnSignInFailure(context, null, ct): |
| 502 | │ ├─ NotFound/BadRequest/PreconditionFailed → respond 412 + TokenExchangeInvokeResponse body |
| 503 | │ └─ Other status codes (401, 403, etc.) → respond with original status code |
| 504 | │ |
| 505 | └─ 5. Record exchange Id as processed (dedup) |
| 506 | ``` |
| 507 | |
| 508 | ### signin/verifyState Invoke Handler |
| 509 | |
| 510 | ``` |
| 511 | Teams client sends invoke: signin/verifyState |
| 512 | │ |
| 513 | ├─ 1. Deserialize value → SignInVerifyStateValue { State } |
| 514 | │ ├─ Null payload → respond 404 |
| 515 | │ └─ Parsed ↓ |
| 516 | │ |
| 517 | ├─ 2. Try each registered OAuthFlow (verifyState has no connectionName): |
| 518 | │ For each flow: |
| 519 | │ ├─ Null State → respond 404 |
| 520 | │ └─ Call UserTokenClient.GetTokenAsync(userId, connectionName, channelId, code: state) |
| 521 | │ ├─ Token returned → fire OnSignInComplete, respond InvokeResponse(200), stop |
| 522 | │ ├─ HttpException (expected) → fire OnSignInFailure, respond 412 |
| 523 | │ ├─ HttpException (other) → fire OnSignInFailure, respond original status code |
| 524 | │ └─ No token → fire OnSignInFailure, respond 412 |
| 525 | │ |
| 526 | ├─ 3. If no flow succeeded → respond 404 |
| 527 | │ |
| 528 | └─ Done |
| 529 | ``` |
| 530 | |
| 531 | ### signin/failure Invoke Handler |
| 532 | |
| 533 | ``` |
| 534 | Teams client sends invoke: signin/failure |
| 535 | │ |
| 536 | ├─ 1. Deserialize value → SignInFailureValue { Code, Message } |
| 537 | │ (e.g., Code="resourcematchfailed", Message="...") |
| 538 | │ |
| 539 | ├─ 2. Log warning with user ID, conversation ID, failure code, and message. |
| 540 | │ Extra guidance logged for "resourcematchfailed" (check Entra app Expose an API). |
| 541 | │ |
| 542 | ├─ 3. Fire OnSignInFailure(context, failureValue, ct) on ALL registered flows |
| 543 | │ (no connection name in payload → notify all) |
| 544 | │ |
| 545 | └─ 4. Respond InvokeResponse(200) to acknowledge |
| 546 | ``` |
| 547 | |
| 548 | ### Deduplication |
| 549 | |
| 550 | Teams may send duplicate `signin/tokenExchange` invokes because the user can have multiple active endpoints (mobile, desktop, web) and Teams sends the exchange request from each one. The `OAuthFlow` deduplicates by tracking processed exchange IDs. |
| 551 | |
| 552 | **Default implementation**: In-process `ConcurrentDictionary<string, DateTimeOffset>` with a 5-minute TTL. This works for single-instance deployments and development. |
| 553 | |
| 554 | **Production consideration**: When the bot is deployed behind a load balancer with multiple instances (e.g., Azure App Service scaled to N nodes), duplicate `signin/tokenExchange` invokes may arrive at **different instances**. The in-process cache cannot deduplicate across instances, so the token exchange may be attempted multiple times. While the Token Service is idempotent (duplicate exchanges succeed harmlessly), the `OnSignInComplete` callback may fire more than once. |
| 555 | |
| 556 | For production multi-instance deployments, the deduplication store should be replaced with a distributed cache (e.g., Redis, Azure Cache). This is a future extensibility point -- the `OAuthFlow` should accept an `IDistributedCache` or similar abstraction to allow external storage: |
| 557 | |
| 558 | ```csharp |
| 559 | // Future API (not yet implemented) |
| 560 | bot.AddOAuthFlow("GraphConnection", options => |
| 561 | { |
| 562 | options.DeduplicationStore = new RedisDeduplicationStore(redisConnection); |
| 563 | }); |
| 564 | ``` |
| 565 | |
| 566 | Until this is implemented, multi-instance deployments should be aware that `OnSignInComplete` may fire on more than one instance for the same sign-in. Handlers should be idempotent. |
| 567 | |
| 568 | ## Multi-Connection Sample |
| 569 | |
| 570 | A bot that uses **two** OAuth connections: one for Microsoft Graph and one for GitHub. |
| 571 | |
| 572 | ### Configuration |
| 573 | |
| 574 | Azure Bot resource has two OAuth connection settings: |
| 575 | |
| 576 | | Connection name | Provider | Scopes | |
| 577 | |---|---|---| |
| 578 | | `GraphConnection` | Azure AD v2 | `User.Read Calendars.Read` | |
| 579 | | `GitHubConnection` | GitHub | `repo read:user` | |
| 580 | |
| 581 | ### Registration (DI pattern — recommended) |
| 582 | |
| 583 | ```csharp |
| 584 | var builder = WebApplication.CreateSlimBuilder(args); |
| 585 | |
| 586 | // Configure OAuth flows at the DI level |
| 587 | builder.Services.AddTeamsBotApplication(options => |
| 588 | { |
| 589 | options.AddOAuthFlow("GraphConnection", o => |
| 590 | { |
| 591 | o.OAuthCardText = "Sign in to your Microsoft account"; |
| 592 | o.SignInButtonText = "Sign In to Graph"; |
| 593 | }); |
| 594 | options.AddOAuthFlow("GitHubConnection", o => |
| 595 | { |
| 596 | o.OAuthCardText = "Sign in to your GitHub account"; |
| 597 | o.SignInButtonText = "Sign In to GitHub"; |
| 598 | }); |
| 599 | }); |
| 600 | |
| 601 | var app = builder.Build(); |
| 602 | TeamsBotApplication bot = app.UseTeamsBotApplication(); |
| 603 | |
| 604 | // Get pre-registered flows and attach callbacks |
| 605 | OAuthFlow graphAuth = bot.GetOAuthFlow("GraphConnection"); |
| 606 | OAuthFlow githubAuth = bot.GetOAuthFlow("GitHubConnection"); |
| 607 | |
| 608 | graphAuth.OnSignInComplete(async (context, tokenResponse, ct) => |
| 609 | { |
| 610 | await context.SendActivityAsync($"Connected to Graph ({tokenResponse.ConnectionName})!", ct); |
| 611 | }); |
| 612 | |
| 613 | githubAuth.OnSignInComplete(async (context, tokenResponse, ct) => |
| 614 | { |
| 615 | await context.SendActivityAsync($"Connected to GitHub ({tokenResponse.ConnectionName})!", ct); |
| 616 | }); |
| 617 | |
| 618 | // SignInAsync uses the OAuthCardText/SignInButtonText configured at registration |
| 619 | bot.OnMessage(@"(?i)^login graph$", async (context, ct) => |
| 620 | { |
| 621 | string? token = await graphAuth.SignInAsync(context, ct); |
| 622 | |
| 623 | if (token is not null) |
| 624 | await context.SendActivityAsync("Already signed in to Graph.", ct); |
| 625 | }); |
| 626 | |
| 627 | bot.OnMessage(@"(?i)^login github$", async (context, ct) => |
| 628 | { |
| 629 | string? token = await githubAuth.SignInAsync(context, ct); |
| 630 | |
| 631 | if (token is not null) |
| 632 | await context.SendActivityAsync("Already signed in to GitHub.", ct); |
| 633 | }); |
| 634 | |
| 635 | bot.OnMessage(@"(?i)^status$", async (context, ct) => |
| 636 | { |
| 637 | var statuses = await context.GetConnectionStatusAsync(ct); |
| 638 | var lines = statuses.Select(s => |
| 639 | $"- **{s.ConnectionName}** ({s.ServiceProviderDisplayName}): " + |
| 640 | $"{(s.HasToken == true ? "connected" : "not connected")}"); |
| 641 | |
| 642 | await context.SendActivityAsync("OAuth connections:\n" + string.Join("\n", lines), ct); |
| 643 | }); |
| 644 | |
| 645 | bot.OnMessage(@"(?i)^logout$", async (context, ct) => |
| 646 | { |
| 647 | await context.SignOut("GraphConnection", ct); |
| 648 | await context.SignOut("GitHubConnection", ct); |
| 649 | await context.SendActivityAsync("Signed out from all services.", ct); |
| 650 | }); |
| 651 | |
| 652 | app.Run(); |
| 653 | ``` |
| 654 | |
| 655 | ### How Multi-Connection Invoke Routing Works |
| 656 | |
| 657 | When multiple `OAuthFlow` instances are registered, invoke routes are registered **once** (shared). The dispatch logic differs by invoke type: |
| 658 | |
| 659 | - **`signin/tokenExchange`**: dispatches by `connectionName` from the invoke value (exact match). |
| 660 | - **`signin/verifyState`**: tries each registered flow sequentially (no connection name in the payload). |
| 661 | - **`signin/failure`**: fires `OnSignInFailure` on all registered flows (no connection name in the payload). |
| 662 | |
| 663 | ## File Placement |
| 664 | |
| 665 | | File | Location | |
| 666 | |---|---| |
| 667 | | `TeamsBotApplicationOptions.cs` | `Microsoft.Teams.Apps/TeamsBotApplicationOptions.cs` | |
| 668 | | `OAuthFlow.cs` | `Microsoft.Teams.Apps/Auth/OAuthFlow.cs` | |
| 669 | | `OAuthFlowExtensions.cs` | `Microsoft.Teams.Apps/Auth/OAuthFlowExtensions.cs` | |
| 670 | | `OAuthOptions.cs` | `Microsoft.Teams.Apps/Auth/OAuthOptions.cs` | |
| 671 | | `SignInTokenExchangeValue.cs` | `Microsoft.Teams.Apps/Auth/SignInTokenExchangeValue.cs` | |
| 672 | | `SignInVerifyStateValue.cs` | `Microsoft.Teams.Apps/Auth/SignInVerifyStateValue.cs` | |
| 673 | | `SignInFailureValue.cs` | `Microsoft.Teams.Apps/Auth/SignInFailureValue.cs` | |
| 674 | | `TokenExchangeInvokeResponse.cs` | `Microsoft.Teams.Apps/Auth/TokenExchangeInvokeResponse.cs` | |
| 675 | | `OAuthCard.cs` | `Microsoft.Teams.Apps/Schema/OAuthCard.cs` | |
| 676 | |
| 677 | ## Changes to Core |
| 678 | |
| 679 | | File | Change | |
| 680 | |---|---| |
| 681 | | `BotApplication.cs` | Added `AppId` public property (from `BotApplicationOptions.AppId`) | |
| 682 | | `MessageHandler.cs` | Selectors now match against `TextWithoutMentions` instead of `Text` | |
| 683 | | `MessageActivity.cs` | Added `TextWithoutMentions` computed property (strips bot @mention) | |
| 684 | | `TeamsAttachment.cs` | Added `AttachmentContentType.OAuthCard` constant | |
| 685 | |
| 686 | ## Edge Cases & Constraints |
| 687 | |
| 688 | | Scenario | Behavior | |
| 689 | |---|---| |
| 690 | | SSO not supported (channel scope) | SSO only works in personal and group chat. In channels, the OAuthCard shows the sign-in button directly (no token exchange). | |
| 691 | | User denies consent | Teams sends `signin/tokenExchange` but exchange fails. OAuthFlow responds 412 with `TokenExchangeInvokeResponse` body, Teams shows sign-in button fallback. `OnSignInFailure` fires with `failure: null`. | |
| 692 | | Teams SSO client failure | Teams sends `signin/failure` invoke with structured `Code`/`Message`. OAuthFlow logs the failure, fires `OnSignInFailure` on all flows with `failure: SignInFailureValue`, responds 200. | |
| 693 | | Duplicate `signin/tokenExchange` | Deduplicated by exchange ID. First wins, duplicates get 200 no-op. | |
| 694 | | Token expired | `GetTokenAsync` returns null (token store returns 404). `SignInAsync` re-initiates the flow. | |
| 695 | | Missing connection name with multiple flows | Throws `InvalidOperationException` listing registered connections. | |
| 696 | | `signin/verifyState` with multiple connections | Tries each registered flow until one succeeds (200). Returns 404 if none match. | |
| 697 | | `IsSignedIn` with multiple connections | Checks the first registered connection, logs `Trace.TraceWarning`. Prefer `IsSignedInAsync(connectionName)`. | |
| 698 | | Missing `MsAppId` in sign-in state | Token Service returns `tokenExchangeResource: null`. SSO and automatic verify-state fail. OAuthFlow includes `MsAppId` from `BotApplication.AppId` to prevent this. | |
| 699 | | Non-AAD providers (GitHub, etc.) | No `tokenExchangeResource` returned regardless of `MsAppId`. Sign-in completes via popup + `signin/verifyState`. | |
| 700 | | OAuthCard JSON serialization | `OAuthCard` is serialized to `JsonElement` before attaching, to avoid `NotSupportedException` from the source-generated `TeamsActivityJsonContext`. | |
| 701 | |