Skip to content

Commit

Permalink
Support Sso for SharePoint bot ACEs
Browse files Browse the repository at this point in the history
  • Loading branch information
GrahamMcMynn committed Mar 1, 2024
1 parent 07ed900 commit 3c103e5
Show file tree
Hide file tree
Showing 4 changed files with 249 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,6 +22,23 @@ namespace Microsoft.Bot.Builder.SharePoint
/// </summary>
public class SharePointActivityHandler : ActivityHandler
{
/// <summary>
/// Safely casts an object to an object of type <typeparamref name="T"/> .
/// </summary>
/// <param name="value">The object to be casted.</param>
/// <typeparam name="T">Template type.</typeparam>
/// <returns>The object casted in the new type.</returns>
internal static T SafeCast<T>(object value)
{
var obj = value as JObject;
if (obj == null)
{
throw new InvokeResponseException(HttpStatusCode.BadRequest, $"expected type '{value.GetType().Name}'");
}

return obj.ToObject<T>();
}

/// <summary>
/// Invoked when an invoke activity is received from the connector.
/// Invoke activities can be used to communicate many different things.
Expand Down Expand Up @@ -62,6 +80,10 @@ protected override async Task<InvokeResponse> OnInvokeActivityAsync(ITurnContext

case "cardExtension/handleAction":
return CreateInvokeResponse(await OnSharePointTaskHandleActionAsync(turnContext, SafeCast<AceRequest>(turnContext.Activity.Value), cancellationToken).ConfigureAwait(false));

case "cardExtension/token":
await OnSignInInvokeAsync(turnContext, cancellationToken).ConfigureAwait(false);
return CreateInvokeResponse();
}
}
}
Expand Down Expand Up @@ -139,22 +161,6 @@ protected virtual Task<BaseHandleActionResponse> OnSharePointTaskHandleActionAsy
throw new InvokeResponseException(HttpStatusCode.NotImplemented);
}

/// <summary>
/// Safely casts an object to an object of type <typeparamref name="T"/> .
/// </summary>
/// <param name="value">The object to be casted.</param>
/// <returns>The object casted in the new type.</returns>
private static T SafeCast<T>(object value)
{
var obj = value as JObject;
if (obj == null)
{
throw new InvokeResponseException(HttpStatusCode.BadRequest, $"expected type '{value.GetType().Name}'");
}

return obj.ToObject<T>();
}

private void ValidateSetPropertyPaneConfigurationResponse(BaseHandleActionResponse response)
{
if (response is QuickViewHandleActionResponse)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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 <see cref="System.Net.HttpStatusCode.PreconditionFailed"/>.
/// 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.
/// </remarks>
public class SharePointSSOTokenExchangeMiddleware
{
private readonly IStorage _storage;
private readonly string _oAuthConnectionName;

/// <summary>
/// Initializes a new instance of the <see cref="SharePointSSOTokenExchangeMiddleware"/> class.
/// </summary>
/// <param name="storage">The <see cref="IStorage"/> to use for deduplication.</param>
/// <param name="connectionName">The connection name to use for the single
/// sign on token exchange.</param>
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;
}

/// <summary>
/// Handles a turn.
/// </summary>
/// <param name="turnContext">turn context.</param>
/// <param name="cancellationToken">cancellation token.</param>
/// <returns>Task.</returns>
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<bool> 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<string>("id")
};

var storeItems = new Dictionary<string, object> { { 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<bool> ExchangedTokenAsync(ITurnContext turnContext, CancellationToken cancellationToken)
{
TokenResponse tokenExchangeResponse = null;

AceRequest aceRequest = SharePointActivityHandler.SafeCast<AceRequest>(turnContext.Activity.Value);

try
{
var userTokenClient = turnContext.TurnState.Get<UserTokenClient>();
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<string>("id")}";
}
}
}
}
5 changes: 5 additions & 0 deletions libraries/Microsoft.Bot.Connector/Channels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,5 +144,10 @@ public class Channels
/// Outlook channel.
/// </summary>
public const string Outlook = "outlook";

/// <summary>
/// M365 channel.
/// </summary>
public const string M365 = "m365extensions";
}
}
5 changes: 5 additions & 0 deletions libraries/Microsoft.Bot.Schema/SignInConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,10 @@ public static class SignInConstants
/// The EventActivity name when a token is sent to the bot.
/// </summary>
public const string TokenResponseEventName = "tokens/response";

/// <summary>
/// The invoke operation used to exchange a sharepoint token for SSO.
/// </summary>
public const string SharePointTokenExchange = "cardExtension/token";
}
}

0 comments on commit 3c103e5

Please sign in to comment.