microsoft/teams.net
Publicmirrored fromhttps://github.com/microsoft/teams.netAvailable
core/src/Microsoft.Teams.Bot.Apps/Auth/OAuthFlow.cs
467lines · modecode
| 1 | // Copyright (c) Microsoft Corporation. |
| 2 | // Licensed under the MIT License. |
| 3 | |
| 4 | using System.Collections.Concurrent; |
| 5 | using System.Text.Json; |
| 6 | using Microsoft.Extensions.Logging; |
| 7 | using Microsoft.Teams.Bot.Apps.Handlers; |
| 8 | using Microsoft.Teams.Bot.Apps.Schema; |
| 9 | using Microsoft.Teams.Bot.Core; |
| 10 | |
| 11 | namespace Microsoft.Teams.Bot.Apps.Auth; |
| 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> |
| 19 | public 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> |
| 29 | public 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> |
| 35 | public 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) { Value = 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 | |