diff --git a/libraries/Microsoft.Bot.Builder/SharePoint/SharePointActivityHandler.cs b/libraries/Microsoft.Bot.Builder/SharePoint/SharePointActivityHandler.cs index e3e9b98a21..1741a54789 100644 --- a/libraries/Microsoft.Bot.Builder/SharePoint/SharePointActivityHandler.cs +++ b/libraries/Microsoft.Bot.Builder/SharePoint/SharePointActivityHandler.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Bot.Connector; +using Microsoft.Bot.Connector.Authentication; using Microsoft.Bot.Schema; using Microsoft.Bot.Schema.SharePoint; using Microsoft.Bot.Schema.Teams; @@ -21,6 +22,23 @@ namespace Microsoft.Bot.Builder.SharePoint /// public class SharePointActivityHandler : ActivityHandler { + /// + /// Safely casts an object to an object of type . + /// + /// The object to be casted. + /// Template type. + /// The object casted in the new type. + internal static T SafeCast(object value) + { + var obj = value as JObject; + if (obj == null) + { + throw new InvokeResponseException(HttpStatusCode.BadRequest, $"expected type '{value.GetType().Name}'"); + } + + return obj.ToObject(); + } + /// /// Invoked when an invoke activity is received from the connector. /// Invoke activities can be used to communicate many different things. @@ -62,6 +80,10 @@ protected override async Task OnInvokeActivityAsync(ITurnContext case "cardExtension/handleAction": return CreateInvokeResponse(await OnSharePointTaskHandleActionAsync(turnContext, SafeCast(turnContext.Activity.Value), cancellationToken).ConfigureAwait(false)); + + case "cardExtension/token": + await OnSignInInvokeAsync(turnContext, cancellationToken).ConfigureAwait(false); + return CreateInvokeResponse(); } } } @@ -139,22 +161,6 @@ protected virtual Task OnSharePointTaskHandleActionAsy throw new InvokeResponseException(HttpStatusCode.NotImplemented); } - /// - /// Safely casts an object to an object of type . - /// - /// The object to be casted. - /// The object casted in the new type. - private static T SafeCast(object value) - { - var obj = value as JObject; - if (obj == null) - { - throw new InvokeResponseException(HttpStatusCode.BadRequest, $"expected type '{value.GetType().Name}'"); - } - - return obj.ToObject(); - } - private void ValidateSetPropertyPaneConfigurationResponse(BaseHandleActionResponse response) { if (response is QuickViewHandleActionResponse) diff --git a/libraries/Microsoft.Bot.Builder/SharePoint/SharePointSSOTokenExchangeMiddleware.cs b/libraries/Microsoft.Bot.Builder/SharePoint/SharePointSSOTokenExchangeMiddleware.cs new file mode 100644 index 0000000000..6cae3d12e3 --- /dev/null +++ b/libraries/Microsoft.Bot.Builder/SharePoint/SharePointSSOTokenExchangeMiddleware.cs @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Connector; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.Bot.Schema; +using Microsoft.Bot.Schema.SharePoint; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Bot.Builder.SharePoint +{ + /// + /// If the activity name is cardExtension/token, this middleware will attempt to + /// exchange the token, and deduplicate the incoming call, ensuring only one + /// exchange request is processed. + /// + /// + /// If a user is signed into multiple devices, 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 SharePointSSOTokenExchangeMiddleware + { + 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 SharePointSSOTokenExchangeMiddleware(IStorage storage, string connectionName) + { + if (storage == null) + { + // TODO: ADD THIS BACK IN + // throw new ArgumentNullException(nameof(storage)); + } + + if (string.IsNullOrEmpty(connectionName)) + { + throw new ArgumentNullException(nameof(connectionName)); + } + + _oAuthConnectionName = connectionName; + _storage = storage; + } + + /// + /// Handles a turn. + /// + /// turn context. + /// cancellation token. + /// Task. + public async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default) + { + if (string.Equals(Channels.M365, turnContext.Activity.ChannelId, StringComparison.OrdinalIgnoreCase) + && string.Equals(SignInConstants.SharePointTokenExchange, turnContext.Activity.Name, StringComparison.OrdinalIgnoreCase)) + { + // If the TokenExchange is NOT successful, the response will have already been sent by ExchangedTokenAsync + if (!await this.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 + if (!await DeduplicatedTokenExchangeIdAsync(turnContext, cancellationToken).ConfigureAwait(false)) + { + // If the token is not exchangeable, do not process this activity further. + return; + } + } + + return; + } + + 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")) + { + // 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 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; + + AceRequest aceRequest = SharePointActivityHandler.SafeCast(turnContext.Activity.Value); + + try + { + var userTokenClient = turnContext.TurnState.Get(); + if (userTokenClient != null) + { + tokenExchangeResponse = await userTokenClient.ExchangeTokenAsync( + turnContext.Activity.From.Id, + _oAuthConnectionName, + turnContext.Activity.ChannelId, + new TokenExchangeRequest { Token = aceRequest.Data as string }, + cancellationToken).ConfigureAwait(false); + } + else if (turnContext.Adapter is IExtendedUserTokenProvider adapter) + { + tokenExchangeResponse = await adapter.ExchangeTokenAsync( + turnContext, + _oAuthConnectionName, + turnContext.Activity.From.Id, + new TokenExchangeRequest { Token = aceRequest.Data as string }, + 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 (ignoring, see comment below) + catch +#pragma warning restore CA1031 // Do not catch general exception types + { + // 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 = "FAKE 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")}"; + } + } + } +} diff --git a/libraries/Microsoft.Bot.Connector/Channels.cs b/libraries/Microsoft.Bot.Connector/Channels.cs index 274b5da326..74d31d4e0c 100644 --- a/libraries/Microsoft.Bot.Connector/Channels.cs +++ b/libraries/Microsoft.Bot.Connector/Channels.cs @@ -144,5 +144,10 @@ public class Channels /// Outlook channel. /// public const string Outlook = "outlook"; + + /// + /// M365 channel. + /// + public const string M365 = "m365extensions"; } } diff --git a/libraries/Microsoft.Bot.Schema/SignInConstants.cs b/libraries/Microsoft.Bot.Schema/SignInConstants.cs index d8a9ccefe2..d662ce2cca 100644 --- a/libraries/Microsoft.Bot.Schema/SignInConstants.cs +++ b/libraries/Microsoft.Bot.Schema/SignInConstants.cs @@ -28,5 +28,10 @@ public static class SignInConstants /// The EventActivity name when a token is sent to the bot. /// public const string TokenResponseEventName = "tokens/response"; + + /// + /// The invoke operation used to exchange a sharepoint token for SSO. + /// + public const string SharePointTokenExchange = "cardExtension/token"; } }