microsoft/teams.net

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v2.0.8

Branches

Tags

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

Clone

HTTPS

Download ZIP

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
7The 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
11Teams SSO requires coordinating multiple moving parts:
12
131. Checking the Bot Framework Token Store for an existing token
142. Sending an OAuthCard with a `TokenExchangeResource` to trigger silent SSO
153. Handling `signin/tokenExchange` invoke activities (with deduplication)
164. Handling `signin/verifyState` invoke activities (fallback sign-in flow)
175. Handling `signin/failure` invoke activities (client-side SSO failures)
186. Calling `UserTokenClient.ExchangeTokenAsync` to complete the on-behalf-of exchange
19
20Without an abstraction, every bot developer must wire this up manually. `OAuthFlow` reduces it to a few method calls.
21
22## Architecture
23
24```
25TeamsBotApplication
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
44Developers 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```
57OAuthFlow (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
70This 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
80await context.SignIn(); // uses context.ConnectionName ("graph")
81
82// New -- resolved from OAuthFlowRegistry
83bot.AddOAuthFlow("graph"); // single flow → becomes the default
84await context.SignIn(); // works (single flow auto-resolves)
85
86// New -- multiple flows with options configured at registration
87var ghAuth = bot.AddOAuthFlow(new OAuthOptions
88{
89 ConnectionName = "gh",
90 OAuthCardText = "Sign in to GitHub",
91 SignInButtonText = "Sign In"
92});
93await 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)
116if (!context.IsSignedIn) { await context.SignIn(); return; }
117
118// New (preferred)
119if (!await context.IsSignedInAsync("graph", ct)) { await context.SignIn(new OAuthOptions { ConnectionName = "graph" }, ct); return; }
120
121// New (backwards-compat, single connection only)
122if (!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)
133var graphClient = new SimpleGraphClient(context.UserGraphToken?.ToString()!);
134
135// New
136string? token = await context.SignIn(new OAuthOptions { ConnectionName = "graph" }, ct);
137var 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
156This 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)
163teams.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
172graphAuth.OnSignInComplete(async (context, tokenResponse, ct) => {
173 string token = tokenResponse.Token!; // GetTokenResult
174 // context is Context<TeamsActivity> (base type)
175});
176```
177
178Key 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)
189teams.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
198graphAuth.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
208Key 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
219This 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
280Each 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
348services.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:
360TeamsBotApplication bot = app.UseTeamsBotApplication();
361bot.GetOAuthFlow("GraphConnection").OnSignInComplete(async (ctx, token, ct) => { ... });
362```
363
364### Registration (imperative — on the bot instance)
365
366```csharp
367public 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
378Both 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
389public 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
411public 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
430public 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
441public delegate Task SignInCompleteHandler(
442 Context<TeamsActivity> context,
443 GetTokenResult tokenResponse,
444 CancellationToken cancellationToken);
445
446public delegate Task SignInFailureHandler(
447 Context<TeamsActivity> context,
448 SignInFailureValue? failure,
449 CancellationToken cancellationToken);
450```
451
452## Internal Flow
453
454### SignInAsync Sequence
455
456```
457Developer 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```
489Teams 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```
511Teams 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```
534Teams 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
550Teams 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
556For 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)
560bot.AddOAuthFlow("GraphConnection", options =>
561{
562 options.DeduplicationStore = new RedisDeduplicationStore(redisConnection);
563});
564```
565
566Until 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
570A bot that uses **two** OAuth connections: one for Microsoft Graph and one for GitHub.
571
572### Configuration
573
574Azure 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
584var builder = WebApplication.CreateSlimBuilder(args);
585
586// Configure OAuth flows at the DI level
587builder.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
601var app = builder.Build();
602TeamsBotApplication bot = app.UseTeamsBotApplication();
603
604// Get pre-registered flows and attach callbacks
605OAuthFlow graphAuth = bot.GetOAuthFlow("GraphConnection");
606OAuthFlow githubAuth = bot.GetOAuthFlow("GitHubConnection");
607
608graphAuth.OnSignInComplete(async (context, tokenResponse, ct) =>
609{
610 await context.SendActivityAsync($"Connected to Graph ({tokenResponse.ConnectionName})!", ct);
611});
612
613githubAuth.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
619bot.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
627bot.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
635bot.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
645bot.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
652app.Run();
653```
654
655### How Multi-Connection Invoke Routing Works
656
657When 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