// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System.Collections.Concurrent; using System.Diagnostics; using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Teams.Apps.Diagnostics; using Microsoft.Teams.Apps.Handlers; using Microsoft.Teams.Apps.Schema; using Microsoft.Teams.Core; using Microsoft.Teams.Core.Diagnostics; namespace Microsoft.Teams.Apps.OAuth; /// /// Delegate invoked after a successful OAuth token exchange or sign-in verification. /// /// The activity context (invoke context from SSO or verifyState). /// The token result containing the access token and connection name. /// A cancellation token. public delegate Task SignInCompleteHandler(Context context, GetTokenResult tokenResponse, CancellationToken cancellationToken); /// /// Delegate invoked when an OAuth token exchange or sign-in verification fails. /// /// The activity context. /// Optional failure details. Non-null when the failure originates from a Teams client-side /// signin/failure invoke (contains the structured failure code and message). /// Null when the failure is a server-side token exchange or verify-state failure. /// A cancellation token. public delegate Task SignInFailureHandler(Context context, SignInFailureValue? failure, CancellationToken cancellationToken); /// /// Provides a high-level abstraction for Teams Bot SSO authentication. /// Encapsulates silent token acquisition, SSO token exchange, fallback sign-in, and sign-out. /// public class OAuthFlow { private readonly TeamsBotApplication _app; private readonly ILogger _logger; private readonly string _connectionName; private readonly OAuthOptions _defaultOptions; private SignInCompleteHandler? _onSignInComplete; private SignInFailureHandler? _onSignInFailure; // In-memory fallback for deduplication when turn state is not configured. // When UseState() is configured, conversation state is used instead (works across instances). private readonly ConcurrentDictionary _processedExchanges = new(); // In-memory fallback for pending sign-in tracking when turn state is not configured. // When UseState() is configured, user state is used instead (works across instances). private readonly ConcurrentDictionary _pendingSignIns = new(); internal OAuthFlow(TeamsBotApplication app, string connectionName, OAuthOptions options, ILogger logger) { _app = app; _connectionName = connectionName; _defaultOptions = options; _logger = logger; } /// /// The OAuth connection name. /// public string ConnectionName => _connectionName; /// /// Register a callback invoked after a successful token exchange (SSO or fallback sign-in). /// /// The handler to invoke on successful sign-in. /// This instance for chaining. public OAuthFlow OnSignInComplete(SignInCompleteHandler handler) { _onSignInComplete = handler; return this; } /// /// Register a callback invoked when token exchange fails. /// /// The handler to invoke on sign-in failure. /// This instance for chaining. public OAuthFlow OnSignInFailure(SignInFailureHandler handler) { _onSignInFailure = handler; return this; } /// /// Attempt silent token acquisition from the Bot Framework Token Store. /// /// The activity type. /// The current turn context. /// A cancellation token. /// The access token string, or null if no token is cached. public async Task GetTokenAsync(Context context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity { ArgumentNullException.ThrowIfNull(context); string userId = GetUserId(context); string channelId = GetChannelId(context); using Activity? span = AppsTelemetry.Source.StartActivity(AppsTelemetry.Spans.OAuthGetToken, ActivityKind.Internal); span?.SetTag(AppsTelemetry.Tags.OAuthConnection, _connectionName); span?.SetTag(AppsTelemetry.Tags.OAuthOperation, AppsTelemetry.OAuthOperations.GetToken); long startTs = Stopwatch.GetTimestamp(); string result = AppsTelemetry.OAuthResults.Miss; try { GetTokenResult? tokenResult = await _app.UserTokenClient.GetTokenAsync(userId, _connectionName, channelId, cancellationToken: cancellationToken).ConfigureAwait(false); result = tokenResult?.Token is not null ? AppsTelemetry.OAuthResults.Hit : AppsTelemetry.OAuthResults.Miss; span?.SetTag(AppsTelemetry.Tags.OAuthResult, result); return tokenResult?.Token; } catch (Exception ex) { result = AppsTelemetry.OAuthResults.Failure; string errorType = ex is HttpRequestException ? AppsTelemetry.OAuthErrorTypes.HttpError : AppsTelemetry.OAuthErrorTypes.InvalidOperation; RecordOAuthError(span, AppsTelemetry.OAuthOperations.GetToken, errorType, ex); throw; } finally { RecordOperation(AppsTelemetry.OAuthOperations.GetToken, result, startTs); } } /// /// Attempt silent token acquisition; if no token is available, send an OAuthCard to initiate the SSO flow. /// /// The activity type. /// The current turn context. /// A cancellation token. /// The token if already cached, or null if SSO was initiated (the result will arrive via ). public Task SignInAsync(Context context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity => SignInAsync(context, options: null, cancellationToken); /// /// Attempt silent token acquisition; if no token is available, send an OAuthCard to initiate the SSO flow. /// /// The activity type. /// The current turn context. /// OAuth options for customizing the sign-in card text. /// A cancellation token. /// The token if already cached, or null if SSO was initiated (the result will arrive via ). public async Task SignInAsync(Context context, OAuthOptions? options, CancellationToken cancellationToken = default) where TActivity : TeamsActivity { ArgumentNullException.ThrowIfNull(context); options ??= _defaultOptions; string userId = GetUserId(context); string channelId = GetChannelId(context); using Activity? span = AppsTelemetry.Source.StartActivity(AppsTelemetry.Spans.OAuthSignIn, ActivityKind.Internal); span?.SetTag(AppsTelemetry.Tags.OAuthConnection, _connectionName); span?.SetTag(AppsTelemetry.Tags.OAuthOperation, AppsTelemetry.OAuthOperations.SignIn); long startTs = Stopwatch.GetTimestamp(); string result = AppsTelemetry.OAuthResults.Failure; try { // 1. Try silent token acquisition GetTokenResult? existingToken = await _app.UserTokenClient.GetTokenAsync(userId, _connectionName, channelId, cancellationToken: cancellationToken).ConfigureAwait(false); if (existingToken?.Token is not null) { _logger.LogDebug("Token found in store for connection '{ConnectionName}', user '{UserId}'.", _connectionName, userId); result = AppsTelemetry.OAuthResults.Cached; span?.SetTag(AppsTelemetry.Tags.OAuthResult, result); return existingToken.Token; } // 2. No token - get sign-in resource and send OAuthCard _logger.LogDebug("No cached token for connection '{ConnectionName}'. Initiating sign-in flow.", _connectionName); // Build state with MsAppId so the Token Service returns TokenExchangeResource for SSO var tokenExchangeState = new { ConnectionName = _connectionName, Conversation = new { ActivityId = context.Activity.Id, Bot = new { Id = context.Activity.Recipient?.Id }, ChannelId = channelId, Conversation = new { Id = context.Activity.Conversation?.Id }, ServiceUrl = context.Activity.ServiceUrl?.ToString(), User = new { Id = userId } }, MsAppId = _app.AppId }; string state = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(tokenExchangeState)); GetSignInResourceResult signInResource = await _app.UserTokenClient .GetSignInResourceAsync(state, cancellationToken: cancellationToken) .ConfigureAwait(false); OAuthCard oauthCard = new() { Text = options.OAuthCardText, ConnectionName = _connectionName, Buttons = [ new SuggestedAction(ActionTypes.SignIn, options.SignInButtonText, signInResource.SignInLink) ], TokenExchangeResource = signInResource.TokenExchangeResource, TokenPostResource = signInResource.TokenPostResource }; // Serialize to JsonElement so the source-generated JSON context can handle it JsonElement oauthCardJson = JsonSerializer.SerializeToElement(oauthCard); TeamsAttachment attachment = TeamsAttachment.CreateBuilder() .WithContentType(AttachmentContentTypes.OAuthCard) .WithContent(oauthCardJson) .Build(); TeamsActivity oauthActivity = TeamsActivity.CreateBuilder() .WithConversationReference(context.Activity) .WithRecipient(context.Activity.From, false) .WithAttachment(attachment) .Build(); await context.SendActivityAsync(oauthActivity, cancellationToken).ConfigureAwait(false); // Track that this user has a pending sign-in for this flow. // Use user state when available (distributed); fall back to in-memory otherwise. SetPendingSignIn(context, userId); span?.AddEvent(new ActivityEvent(AppsTelemetry.OAuthEvents.CardSent)); result = AppsTelemetry.OAuthResults.CardSent; span?.SetTag(AppsTelemetry.Tags.OAuthResult, result); return null; } catch (Exception ex) { result = AppsTelemetry.OAuthResults.Failure; string errorType = ex is HttpRequestException ? AppsTelemetry.OAuthErrorTypes.HttpError : AppsTelemetry.OAuthErrorTypes.InvalidOperation; RecordOAuthError(span, AppsTelemetry.OAuthOperations.SignIn, errorType, ex); throw; } finally { RecordOperation(AppsTelemetry.OAuthOperations.SignIn, result, startTs); } } /// /// Sign the user out, revoking their token from the Bot Framework Token Store. /// /// The activity type. /// The current turn context. /// A cancellation token. public async Task SignOutAsync(Context context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity { ArgumentNullException.ThrowIfNull(context); string userId = GetUserId(context); string channelId = GetChannelId(context); using Activity? span = AppsTelemetry.Source.StartActivity(AppsTelemetry.Spans.OAuthSignOut, ActivityKind.Internal); span?.SetTag(AppsTelemetry.Tags.OAuthConnection, _connectionName); span?.SetTag(AppsTelemetry.Tags.OAuthOperation, AppsTelemetry.OAuthOperations.SignOut); long startTs = Stopwatch.GetTimestamp(); string result = AppsTelemetry.OAuthResults.Failure; try { _logger.LogDebug("Signing out user '{UserId}' from connection '{ConnectionName}'.", userId, _connectionName); await _app.UserTokenClient.SignOutUserAsync(userId, _connectionName, channelId, cancellationToken).ConfigureAwait(false); result = AppsTelemetry.OAuthResults.Success; span?.SetTag(AppsTelemetry.Tags.OAuthResult, result); } catch (Exception ex) { result = AppsTelemetry.OAuthResults.Failure; string errorType = ex is HttpRequestException ? AppsTelemetry.OAuthErrorTypes.HttpError : AppsTelemetry.OAuthErrorTypes.InvalidOperation; RecordOAuthError(span, AppsTelemetry.OAuthOperations.SignOut, errorType, ex); throw; } finally { RecordOperation(AppsTelemetry.OAuthOperations.SignOut, result, startTs); } } /// /// Check whether the user has a valid cached token for this flow's connection. /// /// The activity type. /// The current turn context. /// A cancellation token. /// True if the user has a valid token; false otherwise. public async Task IsSignedInAsync(Context context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity { string? token = await GetTokenAsync(context, cancellationToken).ConfigureAwait(false); return token is not null; } /// /// Get the token status for all configured OAuth connections. /// This calls GetTokenStatus which returns every connection registered on the bot, /// so the developer never needs to enumerate connection names manually. /// /// The activity type. /// The current turn context. /// A cancellation token. /// A list of token status results for all configured connections. public async Task> GetConnectionStatusAsync(Context context, CancellationToken cancellationToken = default) where TActivity : TeamsActivity { ArgumentNullException.ThrowIfNull(context); string userId = GetUserId(context); string channelId = GetChannelId(context); using Activity? span = AppsTelemetry.Source.StartActivity(AppsTelemetry.Spans.OAuthConnectionStatus, ActivityKind.Internal); span?.SetTag(AppsTelemetry.Tags.OAuthConnection, AppsTelemetry.OAuthAllConnections); span?.SetTag(AppsTelemetry.Tags.OAuthOperation, AppsTelemetry.OAuthOperations.ConnectionStatus); long startTs = Stopwatch.GetTimestamp(); string result = AppsTelemetry.OAuthResults.Failure; try { IList statuses = await _app.UserTokenClient.GetTokenStatusAsync(userId, channelId, cancellationToken: cancellationToken).ConfigureAwait(false); result = AppsTelemetry.OAuthResults.Success; span?.SetTag(AppsTelemetry.Tags.OAuthResult, result); return statuses; } catch (Exception ex) { result = AppsTelemetry.OAuthResults.Failure; string errorType = ex is HttpRequestException ? AppsTelemetry.OAuthErrorTypes.HttpError : AppsTelemetry.OAuthErrorTypes.InvalidOperation; RecordOAuthError(span, AppsTelemetry.OAuthOperations.ConnectionStatus, errorType, ex, AppsTelemetry.OAuthAllConnections); throw; } finally { RecordOperation(AppsTelemetry.OAuthOperations.ConnectionStatus, result, startTs, AppsTelemetry.OAuthAllConnections); } } /// /// Handles the signin/tokenExchange invoke activity. /// internal async Task HandleTokenExchangeAsync(Context context, SignInTokenExchangeValue exchangeValue, CancellationToken cancellationToken) { string exchangeId = string.IsNullOrEmpty(exchangeValue.Id) ? Guid.NewGuid().ToString("N") : exchangeValue.Id; string connectionName = exchangeValue.ConnectionName ?? _connectionName; using Activity? span = AppsTelemetry.Source.StartActivity(AppsTelemetry.Spans.OAuthTokenExchange, ActivityKind.Internal); span?.SetTag(AppsTelemetry.Tags.OAuthConnection, connectionName); span?.SetTag(AppsTelemetry.Tags.OAuthOperation, AppsTelemetry.OAuthOperations.TokenExchange); long startTs = Stopwatch.GetTimestamp(); string result = AppsTelemetry.OAuthResults.Failure; InvokeResponse response; try { // Deduplication: Teams sends duplicate exchanges from multiple endpoints. // Use conversation state when available (distributed, works across instances); // fall back to in-memory ConcurrentDictionary otherwise. if (IsDuplicateExchange(context, exchangeId)) { _logger.LogDebug("Duplicate signin/tokenExchange with Id '{ExchangeId}' - returning 200 no-op.", exchangeId); result = AppsTelemetry.OAuthResults.Duplicate; span?.SetTag(AppsTelemetry.Tags.OAuthResult, result); response = new InvokeResponse(200); span?.SetTag(AppsTelemetry.Tags.InvokeResponseStatus, response.Status); return response; } string userId = GetUserId(context); string channelId = GetChannelId(context); try { GetTokenResult tokenResult = await _app.UserTokenClient .ExchangeTokenAsync(userId, connectionName, channelId, exchangeValue.Token, cancellationToken) .ConfigureAwait(false); if (tokenResult?.Token is not null) { ClearPendingSignIn(context, userId); _logger.LogDebug("Token exchange succeeded for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); bool callbackInvoked = false; if (_onSignInComplete is not null) { Context ctx = context.CreateDerivedContext((TeamsActivity)context.Activity); await _onSignInComplete(ctx, tokenResult, cancellationToken).ConfigureAwait(false); callbackInvoked = true; } span?.SetTag(AppsTelemetry.Tags.OAuthCallbackInvoked, callbackInvoked); result = AppsTelemetry.OAuthResults.Success; span?.SetTag(AppsTelemetry.Tags.OAuthResult, result); response = new InvokeResponse(200); span?.SetTag(AppsTelemetry.Tags.InvokeResponseStatus, response.Status); return response; } } catch (HttpRequestException ex) { ClearPendingSignIn(context, userId); _logger.LogWarning(ex, "Token exchange failed for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); response = await HandleTokenExchangeFailureAsync(context, exchangeValue, ex.StatusCode, ex.Message, cancellationToken).ConfigureAwait(false); result = AppsTelemetry.OAuthResults.Failure; span?.SetTag(AppsTelemetry.Tags.OAuthResult, result); span?.SetTag(AppsTelemetry.Tags.InvokeResponseStatus, response.Status); if (IsUnexpectedHttpStatus(ex.StatusCode)) { RecordOAuthError(span, AppsTelemetry.OAuthOperations.TokenExchange, AppsTelemetry.OAuthErrorTypes.HttpError, ex, connectionName); } return response; } catch (InvalidOperationException ex) { ClearPendingSignIn(context, userId); _logger.LogWarning(ex, "Token exchange failed for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); response = await HandleTokenExchangeFailureAsync(context, exchangeValue, null, ex.Message, cancellationToken).ConfigureAwait(false); result = AppsTelemetry.OAuthResults.Failure; span?.SetTag(AppsTelemetry.Tags.OAuthResult, result); span?.SetTag(AppsTelemetry.Tags.InvokeResponseStatus, response.Status); RecordOAuthError(span, AppsTelemetry.OAuthOperations.TokenExchange, AppsTelemetry.OAuthErrorTypes.InvalidOperation, ex, connectionName); return response; } // Token was null without exception — treat as expected failure ClearPendingSignIn(context, userId); response = await HandleTokenExchangeFailureAsync(context, exchangeValue, null, "Token exchange returned null token.", cancellationToken).ConfigureAwait(false); result = AppsTelemetry.OAuthResults.Failure; span?.SetTag(AppsTelemetry.Tags.OAuthResult, result); span?.SetTag(AppsTelemetry.Tags.OAuthErrorType, AppsTelemetry.OAuthErrorTypes.EmptyToken); span?.SetTag(AppsTelemetry.Tags.InvokeResponseStatus, response.Status); return response; } finally { // Do NOT clear the dedup key here. A late duplicate from a second Teams // endpoint may arrive after this exchange completes; if the key is already // gone the duplicate will be treated as new. Let entries expire naturally // via the 5-minute TTL in CleanupExpiredEntries. RecordOperation(AppsTelemetry.OAuthOperations.TokenExchange, result, startTs, connectionName); } } private async Task HandleTokenExchangeFailureAsync( Context context, SignInTokenExchangeValue exchangeValue, System.Net.HttpStatusCode? statusCode, string? failureDetail, CancellationToken cancellationToken) { if (_onSignInFailure is not null) { Context baseContext = context.CreateDerivedContext((TeamsActivity)context.Activity); await _onSignInFailure(baseContext, null, cancellationToken).ConfigureAwait(false); } // For unexpected status codes (e.g., 401 Unauthorized, 403 Forbidden), // return the original status code so the caller can distinguish the failure. if (statusCode.HasValue && statusCode.Value != System.Net.HttpStatusCode.NotFound && statusCode.Value != System.Net.HttpStatusCode.BadRequest && statusCode.Value != System.Net.HttpStatusCode.PreconditionFailed) { return new InvokeResponse((int)statusCode.Value); } // 412 tells Teams to show the sign-in card as fallback. // Include a response body with the exchange ID and failure detail for diagnostics. return new InvokeResponse(412, new TokenExchangeInvokeResponse { Id = exchangeValue.Id, ConnectionName = exchangeValue.ConnectionName, FailureDetail = failureDetail }); } /// /// Handles the signin/verifyState invoke activity. /// internal async Task HandleVerifyStateAsync(Context context, SignInVerifyStateValue verifyValue, CancellationToken cancellationToken) { using Activity? span = AppsTelemetry.Source.StartActivity(AppsTelemetry.Spans.OAuthVerifyState, ActivityKind.Internal); span?.SetTag(AppsTelemetry.Tags.OAuthConnection, _connectionName); span?.SetTag(AppsTelemetry.Tags.OAuthOperation, AppsTelemetry.OAuthOperations.VerifyState); long startTs = Stopwatch.GetTimestamp(); string result = AppsTelemetry.OAuthResults.Failure; InvokeResponse response; try { if (verifyValue.State is null) { _logger.LogWarning( "Verify state: state parameter is null for conversation '{ConversationId}', user '{UserId}'.", context.Activity.Conversation?.Id, context.Activity.From?.Id); result = AppsTelemetry.OAuthResults.Failure; span?.SetTag(AppsTelemetry.Tags.OAuthResult, result); response = new InvokeResponse(404); span?.SetTag(AppsTelemetry.Tags.InvokeResponseStatus, response.Status); return response; } string userId = GetUserId(context); string channelId = GetChannelId(context); string connectionName = _connectionName; try { GetTokenResult? tokenResult = await _app.UserTokenClient .GetTokenAsync(userId, connectionName, channelId, code: verifyValue.State, cancellationToken: cancellationToken) .ConfigureAwait(false); if (tokenResult?.Token is not null) { ClearPendingSignIn(context, userId); _logger.LogDebug("Verify state succeeded for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); bool callbackInvoked = false; if (_onSignInComplete is not null) { Context baseContext = context.CreateDerivedContext((TeamsActivity)context.Activity); await _onSignInComplete(baseContext, tokenResult, cancellationToken).ConfigureAwait(false); callbackInvoked = true; } span?.SetTag(AppsTelemetry.Tags.OAuthCallbackInvoked, callbackInvoked); result = AppsTelemetry.OAuthResults.Success; span?.SetTag(AppsTelemetry.Tags.OAuthResult, result); response = new InvokeResponse(200); span?.SetTag(AppsTelemetry.Tags.InvokeResponseStatus, response.Status); return response; } } catch (HttpRequestException ex) { ClearPendingSignIn(context, userId); _logger.LogWarning(ex, "Verify state failed for connection '{ConnectionName}', user '{UserId}'.", connectionName, userId); bool callbackInvoked = false; if (_onSignInFailure is not null) { Context baseContext = context.CreateDerivedContext((TeamsActivity)context.Activity); await _onSignInFailure(baseContext, null, cancellationToken).ConfigureAwait(false); callbackInvoked = true; } span?.SetTag(AppsTelemetry.Tags.OAuthCallbackInvoked, callbackInvoked); result = AppsTelemetry.OAuthResults.Failure; span?.SetTag(AppsTelemetry.Tags.OAuthResult, result); // For unexpected status codes, return the original code if (IsUnexpectedHttpStatus(ex.StatusCode)) { response = new InvokeResponse((int)ex.StatusCode!.Value); span?.SetTag(AppsTelemetry.Tags.InvokeResponseStatus, response.Status); RecordOAuthError(span, AppsTelemetry.OAuthOperations.VerifyState, AppsTelemetry.OAuthErrorTypes.HttpError, ex); return response; } // 412 tells Teams to fall back to the sign-in card response = new InvokeResponse(412); span?.SetTag(AppsTelemetry.Tags.InvokeResponseStatus, response.Status); return response; } // No token returned — the code likely belongs to a different connection. // Do NOT fire OnSignInFailure or clear pending state; the verifyState loop // in OAuthFlowExtensions will try the next registered flow. _logger.LogDebug("Verify state: no token for connection '{ConnectionName}', user '{UserId}'. Code may belong to another connection.", connectionName, userId); result = AppsTelemetry.OAuthResults.NoToken; span?.SetTag(AppsTelemetry.Tags.OAuthResult, result); response = new InvokeResponse(412); span?.SetTag(AppsTelemetry.Tags.InvokeResponseStatus, response.Status); return response; } finally { RecordOperation(AppsTelemetry.OAuthOperations.VerifyState, result, startTs); } } /// /// Whether this flow has a pending sign-in for the user in the given context. /// Used to scope signin/failure notifications to flows that initiated a sign-in. /// /// /// When turn state is configured, checks user state (works across instances). /// Falls back to in-memory tracking when state is not configured. /// Callers should fall back to notifying all flows when no flow reports a pending sign-in /// (e.g., multi-instance deployment without distributed state). /// internal bool HasPendingSignIn(Context context) { return GetPendingSignInTimestamp(context) is not null; } /// /// Returns the timestamp when this flow's pending sign-in was initiated, or null if none. /// Used to select the most recently initiated flow when multiple flows have pending sign-ins. /// internal DateTimeOffset? GetPendingSignInTimestamp(Context context) { string pendingKey = $"__oauth:pending:{_connectionName}"; if (context.HasState && context.State.UserState is not null) { DateTimeOffset ts = context.State.UserState.Get(pendingKey); return ts != default ? ts : null; } string userId = context.Activity.From?.Id ?? string.Empty; return _pendingSignIns.TryGetValue(userId, out DateTimeOffset timestamp) ? timestamp : null; } /// /// Handles the signin/failure invoke activity sent by the Teams client when SSO fails client-side. /// internal async Task HandleSignInFailureAsync(Context context, SignInFailureValue failureValue, CancellationToken cancellationToken) { using Activity? span = AppsTelemetry.Source.StartActivity(AppsTelemetry.Spans.OAuthSignInFailure, ActivityKind.Internal); span?.SetTag(AppsTelemetry.Tags.OAuthConnection, _connectionName); span?.SetTag(AppsTelemetry.Tags.OAuthOperation, AppsTelemetry.OAuthOperations.SignInFailure); if (!string.IsNullOrEmpty(failureValue.Code)) { span?.SetTag(AppsTelemetry.Tags.OAuthFailureCode, failureValue.Code); } long startTs = Stopwatch.GetTimestamp(); string result = AppsTelemetry.OAuthResults.Notified; try { string? userId = context.Activity.From?.Id; if (userId is not null) { ClearPendingSignIn(context, userId); } _logger.LogWarning( "Sign-in failed for user '{UserId}' in conversation '{ConversationId}': {FailureCode} — {FailureMessage}.{Guidance}", userId, context.Activity.Conversation?.Id, failureValue.Code, failureValue.Message, string.Equals(failureValue.Code, "resourcematchfailed", StringComparison.OrdinalIgnoreCase) ? " 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." : string.Empty); bool callbackInvoked = false; if (_onSignInFailure is not null) { Context baseContext = context.CreateDerivedContext((TeamsActivity)context.Activity); await _onSignInFailure(baseContext, failureValue, cancellationToken).ConfigureAwait(false); callbackInvoked = true; } span?.SetTag(AppsTelemetry.Tags.OAuthCallbackInvoked, callbackInvoked); span?.SetTag(AppsTelemetry.Tags.OAuthResult, result); InvokeResponse response = new(200); span?.SetTag(AppsTelemetry.Tags.InvokeResponseStatus, response.Status); return response; } finally { RecordOperation(AppsTelemetry.OAuthOperations.SignInFailure, result, startTs); } } /// /// Check whether the given exchange ID has already been processed, and mark it as processed if not. /// Always uses in-memory ConcurrentDictionary for atomic same-instance dedup (concurrent requests /// load separate state snapshots, so state alone cannot catch same-instance races). /// Additionally persists to conversation state when available for cross-instance dedup. /// private bool IsDuplicateExchange(Context context, string exchangeId) { // Atomic same-instance check — catches concurrent duplicate requests on this node if (!_processedExchanges.TryAdd(exchangeId, DateTimeOffset.UtcNow)) { return true; } // Cross-instance check via state — catches duplicates routed to different nodes string dedupKey = $"__oauth:exchange:{exchangeId}"; if (context.HasState) { if (context.State.ConversationState.ContainsKey(dedupKey)) { return true; } context.State.ConversationState.Set(dedupKey, DateTimeOffset.UtcNow); } CleanupExpiredEntries(); return false; } /// /// Record that this user has a pending sign-in for this flow. /// Uses user state when available (distributed); falls back to in-memory. /// private void SetPendingSignIn(Context context, string userId) where TActivity : TeamsActivity { string pendingKey = $"__oauth:pending:{_connectionName}"; if (context.HasState && context.State.UserState is not null) { context.State.UserState.Set(pendingKey, DateTimeOffset.UtcNow); } else { _pendingSignIns[userId] = DateTimeOffset.UtcNow; } } /// /// Clear the pending sign-in tracking for this user/flow. /// Clears from both state and in-memory to handle transitions. /// private void ClearPendingSignIn(Context context, string userId) where TActivity : TeamsActivity { string pendingKey = $"__oauth:pending:{_connectionName}"; if (context.HasState && context.State.UserState is not null) { context.State.UserState.Remove(pendingKey); } _pendingSignIns.TryRemove(userId, out _); } private void CleanupExpiredEntries() { DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddMinutes(-5); foreach (KeyValuePair kvp in _processedExchanges) { if (kvp.Value < cutoff) { _processedExchanges.TryRemove(kvp.Key, out _); } } foreach (KeyValuePair kvp in _pendingSignIns) { if (kvp.Value < cutoff) { _pendingSignIns.TryRemove(kvp.Key, out _); } } } private static string GetUserId(Context context) where TActivity : TeamsActivity => context.Activity.From?.Id ?? throw new InvalidOperationException("Activity.From.Id is required for OAuth operations."); private static string GetChannelId(Context context) where TActivity : TeamsActivity => context.Activity.ChannelId ?? throw new InvalidOperationException("Activity.ChannelId is required for OAuth operations."); // ── Telemetry helpers ──────────────────────────────────────────────── private void RecordOperation(string operation, string result, long startTimestamp, string? connectionName = null) { double elapsedMs = Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds; TagList tags = new() { { AppsTelemetry.Tags.OAuthConnection, connectionName ?? _connectionName }, { AppsTelemetry.Tags.OAuthOperation, operation }, { AppsTelemetry.Tags.OAuthResult, result }, }; AppsTelemetry.OAuthOperationCount.Add(1, tags); AppsTelemetry.OAuthOperationDuration.Record(elapsedMs, tags); } private void RecordOAuthError(Activity? span, string operation, string errorType, Exception ex, string? connectionName = null) { span?.SetTag(AppsTelemetry.Tags.OAuthErrorType, errorType); span?.RecordException(ex); AppsTelemetry.OAuthErrors.Add(1, BuildErrorTags(operation, errorType, connectionName)); } private TagList BuildErrorTags(string operation, string errorType, string? connectionName = null) => new() { { AppsTelemetry.Tags.OAuthConnection, connectionName ?? _connectionName }, { AppsTelemetry.Tags.OAuthOperation, operation }, { AppsTelemetry.Tags.OAuthErrorType, errorType }, }; private static bool IsUnexpectedHttpStatus(System.Net.HttpStatusCode? statusCode) => statusCode.HasValue && statusCode.Value != System.Net.HttpStatusCode.NotFound && statusCode.Value != System.Net.HttpStatusCode.BadRequest && statusCode.Value != System.Net.HttpStatusCode.PreconditionFailed; }