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