microsoft/teams.net

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
docs/update-release-process

Branches

Tags

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

Clone

HTTPS

Download ZIP

core/src/Microsoft.Teams.Apps/OAuth/OAuthFlow.cs

467lines · modecode

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4using System.Collections.Concurrent;
5using System.Text.Json;
6using Microsoft.Extensions.Logging;
7using Microsoft.Teams.Apps.Handlers;
8using Microsoft.Teams.Apps.Schema;
9using Microsoft.Teams.Core;
10
11namespace Microsoft.Teams.Apps.OAuth;
12
13/// <summary>
14/// Delegate invoked after a successful OAuth token exchange or sign-in verification.
15/// </summary>
16/// <param name="context">The activity context (invoke context from SSO or verifyState).</param>
17/// <param name="tokenResponse">The token result containing the access token and connection name.</param>
18/// <param name="cancellationToken">A cancellation token.</param>
19public delegate Task SignInCompleteHandler(Context<TeamsActivity> context, GetTokenResult tokenResponse, CancellationToken cancellationToken);
20
21/// <summary>
22/// Delegate invoked when an OAuth token exchange or sign-in verification fails.
23/// </summary>
24/// <param name="context">The activity context.</param>
25/// <param name="failure">Optional failure details. Non-null when the failure originates from a Teams client-side
26/// <c>signin/failure</c> invoke (contains the structured failure code and message).
27/// Null when the failure is a server-side token exchange or verify-state failure.</param>
28/// <param name="cancellationToken">A cancellation token.</param>
29public delegate Task SignInFailureHandler(Context<TeamsActivity> context, SignInFailureValue? failure, CancellationToken cancellationToken);
30
31/// <summary>
32/// Provides a high-level abstraction for Teams Bot SSO authentication.
33/// Encapsulates silent token acquisition, SSO token exchange, fallback sign-in, and sign-out.
34/// </summary>
35public class OAuthFlow
36{
37 private readonly TeamsBotApplication _app;
38 private readonly ILogger _logger;
39 private readonly string _connectionName;
40 private readonly OAuthOptions _defaultOptions;
41 private SignInCompleteHandler? _onSignInComplete;
42 private SignInFailureHandler? _onSignInFailure;
43
44 // Deduplication cache for signin/tokenExchange invoke activities.
45 // Teams may send duplicates from multiple endpoints (mobile, desktop, web).
46 private readonly ConcurrentDictionary<string, DateTimeOffset> _processedExchanges = new();
47
48 // Tracks users with a pending sign-in (OAuthCard sent, waiting for tokenExchange/verifyState/failure).
49 // Used to scope signin/failure notifications to flows that actually initiated a sign-in.
50 private readonly ConcurrentDictionary<string, DateTimeOffset> _pendingSignIns = new();
51
52 internal OAuthFlow(TeamsBotApplication app, string connectionName, OAuthOptions options, ILogger logger)
53 {
54 _app = app;
55 _connectionName = connectionName;
56 _defaultOptions = options;
57 _logger = logger;
58 }
59
60 /// <summary>
61 /// The OAuth connection name.
62 /// </summary>
63 public string ConnectionName => _connectionName;
64
65 /// <summary>
66 /// Register a callback invoked after a successful token exchange (SSO or fallback sign-in).
67 /// </summary>
68 /// <param name="handler">The handler to invoke on successful sign-in.</param>
69 /// <returns>This <see cref="OAuthFlow"/> instance for chaining.</returns>
70 public OAuthFlow OnSignInComplete(SignInCompleteHandler handler)
71 {
72 _onSignInComplete = handler;
73 return this;
74 }
75
76 /// <summary>
77 /// Register a callback invoked when token exchange fails.
78 /// </summary>
79 /// <param name="handler">The handler to invoke on sign-in failure.</param>
80 /// <returns>This <see cref="OAuthFlow"/> instance for chaining.</returns>
81 public OAuthFlow OnSignInFailure(SignInFailureHandler handler)
82 {
83 _onSignInFailure = handler;
84 return this;
85 }
86
87 /// <summary>
88 /// Attempt silent token acquisition from the Bot Framework Token Store.
89 /// </summary>
90 /// <typeparam name="TActivity">The activity type.</typeparam>
91 /// <param name="context">The current turn context.</param>
92 /// <param name="cancellationToken">A cancellation token.</param>
93 /// <returns>The access token string, or null if no token is cached.</returns>
94 public async Task<string?> GetTokenAsync<TActivity>(Context<TActivity> context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity
95 {
96 ArgumentNullException.ThrowIfNull(context);
97 string userId = GetUserId(context);
98 string channelId = GetChannelId(context);
99
100 GetTokenResult? result = await _app.UserTokenClient.GetTokenAsync(userId, _connectionName, channelId, cancellationToken: cancellationToken).ConfigureAwait(false);
101 return result?.Token;
102 }
103
104 /// <summary>
105 /// Attempt silent token acquisition; if no token is available, send an OAuthCard to initiate the SSO flow.
106 /// </summary>
107 /// <typeparam name="TActivity">The activity type.</typeparam>
108 /// <param name="context">The current turn context.</param>
109 /// <param name="cancellationToken">A cancellation token.</param>
110 /// <returns>The token if already cached, or null if SSO was initiated (the result will arrive via <see cref="OnSignInComplete"/>).</returns>
111 public Task<string?> SignInAsync<TActivity>(Context<TActivity> context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity
112 => SignInAsync(context, options: null, cancellationToken);
113
114 /// <summary>
115 /// Attempt silent token acquisition; if no token is available, send an OAuthCard to initiate the SSO flow.
116 /// </summary>
117 /// <typeparam name="TActivity">The activity type.</typeparam>
118 /// <param name="context">The current turn context.</param>
119 /// <param name="options">OAuth options for customizing the sign-in card text.</param>
120 /// <param name="cancellationToken">A cancellation token.</param>
121 /// <returns>The token if already cached, or null if SSO was initiated (the result will arrive via <see cref="OnSignInComplete"/>).</returns>
122 public async Task<string?> SignInAsync<TActivity>(Context<TActivity> context, OAuthOptions? options, CancellationToken cancellationToken = default) where TActivity : TeamsActivity
123 {
124 ArgumentNullException.ThrowIfNull(context);
125 options ??= _defaultOptions;
126 string userId = GetUserId(context);
127 string channelId = GetChannelId(context);
128
129 // 1. Try silent token acquisition
130 GetTokenResult? existingToken = await _app.UserTokenClient.GetTokenAsync(userId, _connectionName, channelId, cancellationToken: cancellationToken).ConfigureAwait(false);
131 if (existingToken?.Token is not null)
132 {
133 _logger.LogDebug("Token found in store for connection '{ConnectionName}', user '{UserId}'.", _connectionName, userId);
134 return existingToken.Token;
135 }
136
137 // 2. No token - get sign-in resource and send OAuthCard
138 _logger.LogDebug("No cached token for connection '{ConnectionName}'. Initiating sign-in flow.", _connectionName);
139
140 // Build state with MsAppId so the Token Service returns TokenExchangeResource for SSO
141 var tokenExchangeState = new
142 {
143 ConnectionName = _connectionName,
144 Conversation = new
145 {
146 ActivityId = context.Activity.Id,
147 Bot = new { Id = context.Activity.Recipient?.Id },
148 ChannelId = channelId,
149 Conversation = new { Id = context.Activity.Conversation?.Id },
150 ServiceUrl = context.Activity.ServiceUrl?.ToString(),
151 User = new { Id = userId }
152 },
153 MsAppId = _app.AppId
154 };
155 string state = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(tokenExchangeState));
156
157 GetSignInResourceResult signInResource = await _app.UserTokenClient
158 .GetSignInResourceAsync(state, cancellationToken: cancellationToken)
159 .ConfigureAwait(false);
160
161 OAuthCard oauthCard = new()
162 {
163 Text = options.OAuthCardText,
164 ConnectionName = _connectionName,
165 Buttons =
166 [
167 new SuggestedAction(ActionType.SignIn, options.SignInButtonText, signInResource.SignInLink)
168 ],
169 TokenExchangeResource = signInResource.TokenExchangeResource,
170 TokenPostResource = signInResource.TokenPostResource
171 };
172
173 // Serialize to JsonElement so the source-generated JSON context can handle it
174 JsonElement oauthCardJson = JsonSerializer.SerializeToElement(oauthCard);
175
176 TeamsAttachment attachment = TeamsAttachment.CreateBuilder()
177 .WithContentType(AttachmentContentType.OAuthCard)
178 .WithContent(oauthCardJson)
179 .Build();
180
181 TeamsActivity oauthActivity = TeamsActivity.CreateBuilder()
182 .WithConversationReference(context.Activity)
183 .WithRecipient(context.Activity.From, false)
184 .WithAttachment(attachment)
185 .Build();
186
187 await context.SendActivityAsync(oauthActivity, cancellationToken).ConfigureAwait(false);
188
189 // Track that this user has a pending sign-in for this flow
190 _pendingSignIns[userId] = DateTimeOffset.UtcNow;
191
192 return null;
193 }
194
195 /// <summary>
196 /// Sign the user out, revoking their token from the Bot Framework Token Store.
197 /// </summary>
198 /// <typeparam name="TActivity">The activity type.</typeparam>
199 /// <param name="context">The current turn context.</param>
200 /// <param name="cancellationToken">A cancellation token.</param>
201 public async Task SignOutAsync<TActivity>(Context<TActivity> context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity
202 {
203 ArgumentNullException.ThrowIfNull(context);
204 string userId = GetUserId(context);
205 string channelId = GetChannelId(context);
206
207 _logger.LogDebug("Signing out user '{UserId}' from connection '{ConnectionName}'.", userId, _connectionName);
208 await _app.UserTokenClient.SignOutUserAsync(userId, _connectionName, channelId, cancellationToken).ConfigureAwait(false);
209 }
210
211 /// <summary>
212 /// Check whether the user has a valid cached token for this flow's connection.
213 /// </summary>
214 /// <typeparam name="TActivity">The activity type.</typeparam>
215 /// <param name="context">The current turn context.</param>
216 /// <param name="cancellationToken">A cancellation token.</param>
217 /// <returns>True if the user has a valid token; false otherwise.</returns>
218 public async Task<bool> IsSignedInAsync<TActivity>(Context<TActivity> context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity
219 {
220 string? token = await GetTokenAsync(context, cancellationToken).ConfigureAwait(false);
221 return token is not null;
222 }
223
224 /// <summary>
225 /// Get the token status for all configured OAuth connections.
226 /// This calls GetTokenStatus which returns every connection registered on the bot,
227 /// so the developer never needs to enumerate connection names manually.
228 /// </summary>
229 /// <typeparam name="TActivity">The activity type.</typeparam>
230 /// <param name="context">The current turn context.</param>
231 /// <param name="cancellationToken">A cancellation token.</param>
232 /// <returns>A list of token status results for all configured connections.</returns>
233 public async Task<IList<GetTokenStatusResult>> GetConnectionStatusAsync<TActivity>(Context<TActivity> context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity
234 {
235 ArgumentNullException.ThrowIfNull(context);
236 string userId = GetUserId(context);
237 string channelId = GetChannelId(context);
238
239 return await _app.UserTokenClient.GetTokenStatusAsync(userId, channelId, cancellationToken: cancellationToken).ConfigureAwait(false);
240 }
241
242 /// <summary>
243 /// Handles the signin/tokenExchange invoke activity.
244 /// </summary>
245 internal async Task<InvokeResponse> HandleTokenExchangeAsync(Context<InvokeActivity> context, SignInTokenExchangeValue exchangeValue, CancellationToken cancellationToken)
246 {
247 string exchangeId = exchangeValue.Id ?? string.Empty;
248
249 // Deduplication: Teams sends duplicate exchanges from multiple endpoints
250 if (!_processedExchanges.TryAdd(exchangeId, DateTimeOffset.UtcNow))
251 {
252 _logger.LogDebug("Duplicate signin/tokenExchange with Id '{ExchangeId}' - returning 200 no-op.", exchangeId);
253 return new InvokeResponse(200);
254 }
255
256 CleanupExpiredEntries();
257
258 string userId = GetUserId(context);
259 string channelId = GetChannelId(context);
260 string connectionName = exchangeValue.ConnectionName ?? _connectionName;
261
262 try
263 {
264 GetTokenResult tokenResult = await _app.UserTokenClient
265 .ExchangeTokenAsync(userId, connectionName, channelId, exchangeValue.Token, cancellationToken)
266 .ConfigureAwait(false);
267
268 if (tokenResult?.Token is not null)
269 {
270 _pendingSignIns.TryRemove(userId, out _);
271 _logger.LogDebug("Token exchange succeeded for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId);
272 if (_onSignInComplete is not null)
273 {
274 Context<TeamsActivity> baseContext = new(context.TeamsBotApplication, context.Activity);
275 await _onSignInComplete(baseContext, tokenResult, cancellationToken).ConfigureAwait(false);
276 }
277 return new InvokeResponse(200);
278 }
279 }
280 catch (HttpRequestException ex)
281 {
282 _pendingSignIns.TryRemove(userId, out _);
283 _logger.LogWarning(ex, "Token exchange failed for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId);
284 return await HandleTokenExchangeFailureAsync(context, exchangeValue, ex.StatusCode, ex.Message, cancellationToken).ConfigureAwait(false);
285 }
286 catch (InvalidOperationException ex)
287 {
288 _pendingSignIns.TryRemove(userId, out _);
289 _logger.LogWarning(ex, "Token exchange failed for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId);
290 return await HandleTokenExchangeFailureAsync(context, exchangeValue, null, ex.Message, cancellationToken).ConfigureAwait(false);
291 }
292
293 // Token was null without exception — treat as expected failure
294 _pendingSignIns.TryRemove(userId, out _);
295 return await HandleTokenExchangeFailureAsync(context, exchangeValue, null, "Token exchange returned null token.", cancellationToken).ConfigureAwait(false);
296 }
297
298 private async Task<InvokeResponse> HandleTokenExchangeFailureAsync(
299 Context<InvokeActivity> context,
300 SignInTokenExchangeValue exchangeValue,
301 System.Net.HttpStatusCode? statusCode,
302 string? failureDetail,
303 CancellationToken cancellationToken)
304 {
305 if (_onSignInFailure is not null)
306 {
307 Context<TeamsActivity> baseContext = new(context.TeamsBotApplication, context.Activity);
308 await _onSignInFailure(baseContext, null, cancellationToken).ConfigureAwait(false);
309 }
310
311 // For unexpected status codes (e.g., 401 Unauthorized, 403 Forbidden),
312 // return the original status code so the caller can distinguish the failure.
313 if (statusCode.HasValue
314 && statusCode.Value != System.Net.HttpStatusCode.NotFound
315 && statusCode.Value != System.Net.HttpStatusCode.BadRequest
316 && statusCode.Value != System.Net.HttpStatusCode.PreconditionFailed)
317 {
318 return new InvokeResponse((int)statusCode.Value);
319 }
320
321 // 412 tells Teams to show the sign-in card as fallback.
322 // Include a response body with the exchange ID and failure detail for diagnostics.
323 return new InvokeResponse(412, new TokenExchangeInvokeResponse
324 {
325 Id = exchangeValue.Id,
326 ConnectionName = exchangeValue.ConnectionName,
327 FailureDetail = failureDetail
328 });
329 }
330
331 /// <summary>
332 /// Handles the signin/verifyState invoke activity.
333 /// </summary>
334 internal async Task<InvokeResponse> HandleVerifyStateAsync(Context<InvokeActivity> context, SignInVerifyStateValue verifyValue, CancellationToken cancellationToken)
335 {
336 if (verifyValue.State is null)
337 {
338 _logger.LogWarning(
339 "Verify state: state parameter is null for conversation '{ConversationId}', user '{UserId}'.",
340 context.Activity.Conversation?.Id,
341 context.Activity.From?.Id);
342 return new InvokeResponse(404);
343 }
344
345 string userId = GetUserId(context);
346 string channelId = GetChannelId(context);
347 string connectionName = _connectionName;
348
349 try
350 {
351 GetTokenResult? tokenResult = await _app.UserTokenClient
352 .GetTokenAsync(userId, connectionName, channelId, code: verifyValue.State, cancellationToken: cancellationToken)
353 .ConfigureAwait(false);
354
355 if (tokenResult?.Token is not null)
356 {
357 _pendingSignIns.TryRemove(userId, out _);
358 _logger.LogDebug("Verify state succeeded for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId);
359 if (_onSignInComplete is not null)
360 {
361 Context<TeamsActivity> baseContext = new(context.TeamsBotApplication, context.Activity);
362 await _onSignInComplete(baseContext, tokenResult, cancellationToken).ConfigureAwait(false);
363 }
364 return new InvokeResponse(200);
365 }
366 }
367 catch (HttpRequestException ex)
368 {
369 _pendingSignIns.TryRemove(userId, out _);
370 _logger.LogWarning(ex, "Verify state failed for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId);
371
372 if (_onSignInFailure is not null)
373 {
374 Context<TeamsActivity> baseContext = new(context.TeamsBotApplication, context.Activity);
375 await _onSignInFailure(baseContext, null, cancellationToken).ConfigureAwait(false);
376 }
377
378 // For unexpected status codes, return the original code
379 if (ex.StatusCode.HasValue
380 && ex.StatusCode.Value != System.Net.HttpStatusCode.NotFound
381 && ex.StatusCode.Value != System.Net.HttpStatusCode.BadRequest
382 && ex.StatusCode.Value != System.Net.HttpStatusCode.PreconditionFailed)
383 {
384 return new InvokeResponse((int)ex.StatusCode.Value);
385 }
386
387 // 412 tells Teams to fall back to the sign-in card
388 return new InvokeResponse(412);
389 }
390
391 // No token returned — the code likely belongs to a different connection.
392 // Do NOT fire OnSignInFailure or clear pending state; the verifyState loop
393 // in OAuthFlowExtensions will try the next registered flow.
394 _logger.LogDebug("Verify state: no token for connection '{ConnectionName}', user '{UserId}'. Code may belong to another connection.", connectionName, userId);
395 return new InvokeResponse(412);
396 }
397
398 /// <summary>
399 /// Whether this flow has a pending sign-in for the given user.
400 /// Used to scope <c>signin/failure</c> notifications to flows that initiated a sign-in.
401 /// </summary>
402 /// <remarks>
403 /// Best-effort: in multi-instance deployments the OAuthCard may have been sent by a different instance,
404 /// so this check may return false even when a sign-in is active. Callers should fall back
405 /// to notifying all flows when no flow reports a pending sign-in.
406 /// </remarks>
407 internal bool HasPendingSignIn(string userId)
408 {
409 return _pendingSignIns.ContainsKey(userId);
410 }
411
412 /// <summary>
413 /// Handles the signin/failure invoke activity sent by the Teams client when SSO fails client-side.
414 /// </summary>
415 internal async Task<InvokeResponse> HandleSignInFailureAsync(Context<InvokeActivity> context, SignInFailureValue failureValue, CancellationToken cancellationToken)
416 {
417 string? userId = context.Activity.From?.Id;
418 if (userId is not null)
419 {
420 _pendingSignIns.TryRemove(userId, out _);
421 }
422
423 _logger.LogWarning(
424 "Sign-in failed for user '{UserId}' in conversation '{ConversationId}': {FailureCode} — {FailureMessage}.{Guidance}",
425 userId,
426 context.Activity.Conversation?.Id,
427 failureValue.Code,
428 failureValue.Message,
429 string.Equals(failureValue.Code, "resourcematchfailed", StringComparison.OrdinalIgnoreCase)
430 ? " Verify that your Entra app registration has 'Expose an API' configured with the correct Application ID URI matching your OAuth connection's Token Exchange URL."
431 : string.Empty);
432
433 if (_onSignInFailure is not null)
434 {
435 Context<TeamsActivity> baseContext = new(context.TeamsBotApplication, context.Activity);
436 await _onSignInFailure(baseContext, failureValue, cancellationToken).ConfigureAwait(false);
437 }
438
439 return new InvokeResponse(200);
440 }
441
442 private void CleanupExpiredEntries()
443 {
444 DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddMinutes(-5);
445 foreach (KeyValuePair<string, DateTimeOffset> kvp in _processedExchanges)
446 {
447 if (kvp.Value < cutoff)
448 {
449 _processedExchanges.TryRemove(kvp.Key, out _);
450 }
451 }
452 foreach (KeyValuePair<string, DateTimeOffset> kvp in _pendingSignIns)
453 {
454 if (kvp.Value < cutoff)
455 {
456 _pendingSignIns.TryRemove(kvp.Key, out _);
457 }
458 }
459 }
460
461 private static string GetUserId<TActivity>(Context<TActivity> context) where TActivity : TeamsActivity
462 => context.Activity.From?.Id ?? throw new InvalidOperationException("Activity.From.Id is required for OAuth operations.");
463
464 private static string GetChannelId<TActivity>(Context<TActivity> context) where TActivity : TeamsActivity
465 => context.Activity.ChannelId ?? throw new InvalidOperationException("Activity.ChannelId is required for OAuth operations.");
466
467}
468