// 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;
}