From 7e97eda81de30e563c0a4e8429255a31b50017c6 Mon Sep 17 00:00:00 2001 From: "Ricardo Minguez Pablos (RIDO)" Date: Mon, 20 Apr 2026 23:44:48 -0700 Subject: [PATCH] Add CompatTeamsSSOTokenExchangeMiddleware for SSO token exchange The BF SDK's TeamsSSOTokenExchangeMiddleware triggers code paths through TeamsActivityHandler.OnTeamsMembersAddedDispatchAsync that call TeamsInfo.GetMemberAsync, which casts IConversations to the concrete Conversations class. This fails with InvalidCastException because CompatConversations only implements IConversations: System.InvalidCastException: Unable to cast object of type 'Microsoft.Teams.Bot.Compat.CompatConversations' to type 'Microsoft.Bot.Connector.Conversations'. This adds CompatTeamsSSOTokenExchangeMiddleware as a drop-in replacement that works with CompatUserTokenClient. Calling code should use this middleware and CompatTeamsInfo instead of the BF SDK equivalents. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CompatTeamsSSOTokenExchangeMiddleware.cs | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 core/src/Microsoft.Teams.Bot.Compat/CompatTeamsSSOTokenExchangeMiddleware.cs diff --git a/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsSSOTokenExchangeMiddleware.cs b/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsSSOTokenExchangeMiddleware.cs new file mode 100644 index 00000000..23a9cb6e --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Compat/CompatTeamsSSOTokenExchangeMiddleware.cs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Connector; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.Bot.Schema; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Teams.Bot.Compat; + +/// +/// If the activity name is signin/tokenExchange, this middleware will attempt to +/// exchange the token, and deduplicate the incoming call, ensuring only one +/// exchange request is processed. +/// +/// +/// This is a compatibility reimplementation of +/// Microsoft.Bot.Builder.Teams.TeamsSSOTokenExchangeMiddleware that works with +/// the Teams Bot Core SDK via . +/// +/// If a user is signed into multiple Teams clients, the Bot could receive a +/// "signin/tokenExchange" from each client. Each token exchange request for a +/// specific user login will have an identical Activity.Value.Id. +/// +/// Only one of these token exchange requests should be processed by the bot. +/// The others return . +/// For a distributed bot in production, this requires a distributed storage +/// ensuring only one token exchange is processed. This middleware supports +/// CosmosDb storage found in Microsoft.Bot.Builder.Azure, or MemoryStorage for +/// local development. IStorage's ETag implementation for token exchange activity +/// deduplication. +/// +public class CompatTeamsSSOTokenExchangeMiddleware : IMiddleware +{ + private readonly IStorage _storage; + private readonly string _oAuthConnectionName; + + /// + /// Initializes a new instance of the class. + /// + /// The to use for deduplication. + /// The connection name to use for the single sign on token exchange. + public CompatTeamsSSOTokenExchangeMiddleware(IStorage storage, string connectionName) + { + ArgumentNullException.ThrowIfNull(storage); + ArgumentException.ThrowIfNullOrEmpty(connectionName); + + _oAuthConnectionName = connectionName; + _storage = storage; + } + + /// + public async Task OnTurnAsync(ITurnContext turnContext, NextDelegate next, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(turnContext); + ArgumentNullException.ThrowIfNull(next); + + if (string.Equals(Channels.Msteams, turnContext.Activity.ChannelId, StringComparison.OrdinalIgnoreCase) + && string.Equals(SignInConstants.TokenExchangeOperationName, turnContext.Activity.Name, StringComparison.OrdinalIgnoreCase)) + { + // If the TokenExchange is NOT successful, the response will have already been sent by ExchangedTokenAsync + if (!await ExchangedTokenAsync(turnContext, cancellationToken).ConfigureAwait(false)) + { + return; + } + + // Only one token exchange should proceed from here. Deduplication is performed second because in the case + // of failure due to consent required, every caller needs to receive the response. + if (!await DeduplicatedTokenExchangeIdAsync(turnContext, cancellationToken).ConfigureAwait(false)) + { + // If the token is not exchangeable, do not process this activity further. + return; + } + } + + await next(cancellationToken).ConfigureAwait(false); + } + + private async Task DeduplicatedTokenExchangeIdAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + // Create a StoreItem with Etag of the unique 'signin/tokenExchange' request + var storeItem = new TokenStoreItem + { + ETag = (turnContext.Activity.Value as JObject)!.Value("id") + }; + + var storeItems = new Dictionary { { TokenStoreItem.GetStorageKey(turnContext), storeItem } }; + try + { + // Writing the IStoreItem with ETag of unique id will succeed only once + await _storage.WriteAsync(storeItems, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + + // Memory storage throws a generic exception with a Message of 'Etag conflict. [other error info]' + // CosmosDbPartitionedStorage throws: ex.Message.Contains("pre-condition is not met") + when (ex.Message.StartsWith("Etag conflict", StringComparison.OrdinalIgnoreCase) || ex.Message.Contains("pre-condition is not met", StringComparison.OrdinalIgnoreCase)) + { + // Do NOT proceed processing this message, some other thread or machine already has processed it. + + // Send 200 invoke response. + await SendInvokeResponseAsync(turnContext, cancellationToken: cancellationToken).ConfigureAwait(false); + return false; + } + + return true; + } + + private static async Task SendInvokeResponseAsync(ITurnContext turnContext, object? body = null, HttpStatusCode httpStatusCode = HttpStatusCode.OK, CancellationToken cancellationToken = default) + { + await turnContext.SendActivityAsync( + new Activity + { + Type = ActivityTypesEx.InvokeResponse, + Value = new InvokeResponse + { + Status = (int)httpStatusCode, + Body = body, + }, + }, cancellationToken).ConfigureAwait(false); + } + + private async Task ExchangedTokenAsync(ITurnContext turnContext, CancellationToken cancellationToken) + { + TokenResponse? tokenExchangeResponse = null; + var tokenExchangeRequest = ((JObject)turnContext.Activity.Value)?.ToObject(); + + try + { + var userTokenClient = turnContext.TurnState.Get(); + if (userTokenClient != null) + { + tokenExchangeResponse = await userTokenClient.ExchangeTokenAsync( + turnContext.Activity.From.Id, + _oAuthConnectionName, + turnContext.Activity.ChannelId, + new TokenExchangeRequest { Token = tokenExchangeRequest!.Token }, + cancellationToken).ConfigureAwait(false); + } + else + { + throw new NotSupportedException("Token Exchange is not supported by the current adapter."); + } + } +#pragma warning disable CA1031 // Do not catch general exception types (matching BF SDK behavior) + catch +#pragma warning restore CA1031 + { + // Ignore Exceptions + // If token exchange failed for any reason, tokenExchangeResponse above stays null, + // and hence we send back a failure invoke response to the caller. + } + + if (string.IsNullOrEmpty(tokenExchangeResponse?.Token)) + { + // The token could not be exchanged (which could be due to a consent requirement) + // Notify the sender that PreconditionFailed so they can respond accordingly. + + var invokeResponse = new TokenExchangeInvokeResponse + { + Id = tokenExchangeRequest!.Id, + ConnectionName = _oAuthConnectionName, + FailureDetail = "The bot is unable to exchange token. Proceed with regular login.", + }; + + await SendInvokeResponseAsync(turnContext, invokeResponse, HttpStatusCode.PreconditionFailed, cancellationToken).ConfigureAwait(false); + + return false; + } + + return true; + } + + private class TokenStoreItem : IStoreItem + { + public string? ETag { get; set; } + + public static string GetStorageKey(ITurnContext turnContext) + { + var activity = turnContext.Activity; + var channelId = activity.ChannelId ?? throw new InvalidOperationException("invalid activity-missing channelId"); + var conversationId = activity.Conversation?.Id ?? throw new InvalidOperationException("invalid activity-missing Conversation.Id"); + + var value = activity.Value as JObject; + if (value == null || !value.ContainsKey("id")) + { + throw new InvalidOperationException("Invalid signin/tokenExchange. Missing activity.Value.Id."); + } + + return $"{channelId}/{conversationId}/{value.Value("id")}"; + } + } +}