Microsoft BOT framework, transparent authentication with the webchat control

Hi,

In this post, I will explain how you can transparently authenticate end users to a BOT whose the backend is hosted in Azure.

I’m only covering the webchat channel and more particularly the webchat control that is available out of the box when enabling the web chat channel in the BOT configuration page.  As this sample leverages various building blocs, I assume that you already know ADAL and the Microsoft BOT framework.

At the time of writing this blog post, the framework is still in preview so things are subject to change in the future.

A good reading regarding BOT authentication is the following article written by Tsuyoshi Matsuzaki. In that article, Tsuyoshi explains how to leverage the built-in login UI plus two other techniques. What he explains is perfectly suitable in some situations and for all the channels but always requires a manual intervention from the end user. In my particular scenario, I want to make use of the webchat control inside of an organization, so the BOT isn’t meant to be consumed worldwide. I can control the population that will access it so I can deal with the scalability aspects.

So, in a nutshell, the idea consists in creating an ASP.NET WebChat controller that is protected by Azure AD. That controller performs the HTTP request against the https://webchat.botframework.com/embed/?s=&userid=&username=” endpoint, passing in the secret, the userid and the username. The latter parameters are extracted from the ClaimsPrincipal.Current object. Both the userid and username parameters will be transmitted to your BOT and be available from the activity object that is transmitted by the BOT framework.

On top of extracting the parameters in the WebChat controller, we can directly grab an AccessToken for the Microsoft Graph for the current user and save it in the BOT state using the userid as a key. This will allow the BOT to interact with the Graph API on behalf of the current user.

The overall result is that if you use the webchat control from a SharePoint Online site or from an Azure WebApp that’s already protected by AAD, you will have a transparent authentication and a ready-to-use AccessToken for the current user. Here is a screenshot of this sample BOT:

bot3

As an end user, I simply say something and the BOT queries the Graph to return my picture and my email address. I didn’t have to use the Login UI from the BOT framework nor to send any kind of magic code. I simply started the conversation. Of course, this BOT has no business value but the purpose is to demonstrate how to authenticate, get an AccessToken and use it from the BOT.

I’m not going through all the steps here as it’d take too long. Note that the entire source code is available on GitHub. However, don’t run away as the some configuration is required to get something up and running. I will describe below what needs to be done.

So, here are the steps:

  • Create an Azure Web App, in this example I’ll work with transparent-auth-bot.azurewebsites.net and download the publishing profile. This Web App will host the project download from GitHub.
  • Create an AAD App of type Web and grant it some delegate permissions to the Graph API. In the Sign-in URL, specify the URL (host header only) from the Azure Web App created earlier. Create a Key (app password) and save it somewhere as we’ll use it later on. Note that you should make an admin consent of this app so that users won’t be prompted to consent since that isn’t feasible from an iframe (used to render the webchat control).
  • Register your BOT (https://dev.botframework.com/), follow the wizard and make sure you specify a valid messaging endpoint. In my example, the messaging endpoint is https://transparent-auth-bot.azurewebsites.net/messages/api. Grab the Bot ID & Password as we’ll need it later on. Here is an example of my BOT once created:
    bot1
  • Configure the WebChat channel to grab the secret you’ll need as a query string parameter later.
  • Download the GitHub project and make sure to register the outcome of the previous configuration steps into the web.config.

In a nutshell, what you’ll see in the GitHub project is the authentication plumbing required to protect the webchat controller.

Let’s now focus on the controllers themselves, the first one is the message controller that cannot be protected by Azure AD as the Microsoft layer will use the BOT authentication mechanism, hence why this controller must be decorated with the [BotAuthentication] attribute. The second controller is our WebChat controller that will return the content to the calling iframe.

Note that these two controllers could be part of different APIs, I’m regrouping them here to deliver a single solution with a single project for sake of simplicity but that’s clearly not a requirement.

WebChat controller

[Authorize]
    public class WebChatController : ApiController
    {
        private string botSecret = ConfigurationManager.AppSettings["BotSecret"];
        private string clientId = ConfigurationManager.AppSettings["ida:ClientId"];
        private string appKey = ConfigurationManager.AppSettings["ida:ClientSecret"];
        private string aadInstance = ConfigurationManager.AppSettings["ida:AADInstance"];
        private string tenantId = ConfigurationManager.AppSettings["ida:TenantId"];
        private string postLogoutRedirectUri = ConfigurationManager.AppSettings["ida:PostLogoutRedirectUri"];
        private string UserAccessToken = null;
        public HttpResponseMessage Get()
        {
            var userId = ClaimsPrincipal.Current.FindFirst(ClaimTypes.NameIdentifier).Value;
            GetTokenViaBootStrap().Wait();
            var botCred = new MicrosoftAppCredentials(
                ConfigurationManager.AppSettings["MicrosoftAppId"],
                ConfigurationManager.AppSettings["MicrosoftAppPassword"]);
            var stateClient = new StateClient(botCred);
            BotState botState = new BotState(stateClient);
            BotData botData = new BotData(eTag: "*");
            botData.SetProperty<string>("GraphAccessToken", UserAccessToken);
            stateClient.BotState.SetUserDataAsync("webchat", userId, botData).Wait();
            string WebChatString =
                new WebClient().DownloadString("https://webchat.botframework.com/embed/transparentauth?s="+botSecret+"&userid=" +
                HttpUtility.UrlEncode(userId) + "&username=" + HttpUtility.UrlEncode(ClaimsPrincipal.Current.Identity.Name));
            WebChatString = WebChatString.Replace("/css/botchat.css", "https://webchat.botframework.com/css/botchat.css");
            WebChatString = WebChatString.Replace("/scripts/botchat.js", "https://webchat.botframework.com/scripts/botchat.js");
            var response = new HttpResponseMessage();
            response.Content = new StringContent(WebChatString);
            response.Content.Headers.ContentType = new MediaTypeHeaderValue("text/html");
            return response;
        }
        private Task GetTokenViaBootStrap()
        {
            return Task.Run(async () =>
            {
                var bc = ClaimsPrincipal.Current.Identities.First().BootstrapContext
                    as System.IdentityModel.Tokens.BootstrapContext;

                string userName = ClaimsPrincipal.Current.FindFirst(ClaimTypes.Upn) != null ? ClaimsPrincipal.Current.FindFirst(ClaimTypes.Upn).Value : ClaimsPrincipal.Current.FindFirst(ClaimTypes.Email).Value;
                string userAccessToken = bc.Token;
                UserAssertion userAssertion = new UserAssertion(bc.Token, "urn:ietf:params:oauth:grant-type:jwt-bearer", userName);
                string userObjectID = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier").Value;
                string signedInUserID = ClaimsPrincipal.Current.FindFirst(ClaimTypes.NameIdentifier).Value;
                AuthenticationContext authContext = new AuthenticationContext(aadInstance + tenantId, new ADALTokenCache(signedInUserID));
                ClientCredential cred = new ClientCredential(clientId,appKey);
                AuthenticationResult res = await authContext.AcquireTokenAsync("https://graph.microsoft.com", cred,
                    userAssertion);
                UserAccessToken = res.AccessToken;
            });

        }
    }

The controller is decorated with the [Authorize] attriibute which ensures that any of its method is protected (see the plumbing from the GitHub project). We start by extracting the userid from the current principal, then we get a fresh AccessToken for the Graph API using AcquireTokenSilentAsync. Once we have the token, we save it into a BotState Data object that we bind to the userid. The Bot State service uses the combination of the BOT credentials, the channel and the UserID to store the data.

The second part of this controller is to perform the request to the out of the box webchat control, passing in our secret, userid and username parameters. We get an HTTP response back. We have to rewrite the references to the .js and .css files as the webchat control makes use of relative paths. This results in having references such as https://yourazurewebapp…/css&#8230;.which is of course wrong since these files live in their own domain. Note that if you don’t want any dependency with the online chat control, you can ghost your own version by rebuilding your own webchat control, see this link for more info.

So, here we’ve saved the AccessToken into the BotState data object so that the BOT can reuse it when necessary.

Message controller

The message controller is the one that is called whenever a new conversation starts. What you must understand is that this controller is not called directly by one of your components. The communication flow is the following: the webchat controll talks to directline.botframework.com and this guy talks to your message controller using the messaging endpoint you configured while registering the BOT.

That said, here is the code:

public class UserInfo
    {
        public string DisplayName { get; set; }
        public string Email { get; set; }
        public string PhoneNumber { get; set; }
        public Stream PhotoStream { get; set; }
    }
    [BotAuthentication]
    public class MessagesController : ApiController
    {

        public async Task<UserInfo> GetUserInfo(GraphServiceClient graphClient)
        {
            User me = await graphClient.Me.Request().GetAsync();

            return new UserInfo
            {
                Email = me.Mail ?? me.UserPrincipalName,
                DisplayName = me.DisplayName,
                PhotoStream= await graphClient.Users[me.Id].Photo.Content.Request().GetAsync()
            };
        }
        GraphServiceClient GetAuthenticatedClient(string token)
        {
            GraphServiceClient graphClient = new GraphServiceClient(
                new DelegateAuthenticationProvider(
                    async (requestMessage) =>
                    {
                        string accessToken = token;
                        // Append the access token to the request.
                        requestMessage.Headers.Authorization = new AuthenticationHeaderValue("bearer", accessToken);
                    }));
            return graphClient;
        }
        public async Task<HttpResponseMessage> Post([FromBody]Activity activity)
        {
            switch (activity.GetActivityType())
            {
                case ActivityTypes.Message:
                    var reply = activity.CreateReply();
                    reply.Attachments = new List<Microsoft.Bot.Connector.Attachment>();
                    var cli = new ConnectorClient(new Uri(activity.ServiceUrl));
                    try
                    {

                        StateClient stateClient = activity.GetStateClient();
                        BotState botState = new BotState(stateClient);
                        BotData botData = await botState.GetUserDataAsync(activity.ChannelId, activity.From.Id);
                        string token = botData.GetProperty<string>("GraphAccessToken");

                        if (!string.IsNullOrEmpty(token))
                        {
                            GraphServiceClient gc = GetAuthenticatedClient(token);
                            UserInfo ui = await GetUserInfo(gc);
                            var memoryStream = new MemoryStream();
                            ui.PhotoStream.CopyTo(memoryStream);
                            reply.Attachments.Add(new Microsoft.Bot.Connector.Attachment
                            {
                                Content = memoryStream.ToArray(),
                                ContentType = "image/png"
                            });
                            reply.Attachments.Add(new HeroCard()
                            {
                                Title = ui.DisplayName,
                                Text = ui.Email,
                                Images = null,
                                Buttons = null
                            }.ToAttachment());

                            //reply.AttachmentLayout = AttachmentLayoutTypes.Carousel;
                        }
                        else
                        {
                            reply.Text = "No token!";
                        }
                    }
                    catch(Exception ex)
                    {
                        reply.Text = ex.Message;
                    }
                    await cli.Conversations.SendToConversationAsync(reply);
                    break;
                case ActivityTypes.ConversationUpdate:
                    var client = new ConnectorClient(new Uri(activity.ServiceUrl));
                    IConversationUpdateActivity update = activity;

                    if (update.MembersAdded.Any())
                    {

                        var newMembers = update.MembersAdded?.Where(t => t.Id != activity.Recipient.Id);
                        foreach (var newMember in newMembers)
                        {
                            var r = activity.CreateReply();
                            r.Text = "Welcome";
                            await client.Conversations.ReplyToActivityAsync(r);
                        }
                    }
                    break;
                case ActivityTypes.ContactRelationUpdate:
                case ActivityTypes.Typing:
                case ActivityTypes.DeleteUserData:
                case ActivityTypes.Ping:
                default:
                    HandleSystemMessage(activity);
                    break;
            }
            var response = Request.CreateResponse(HttpStatusCode.OK);
            return response;

        }
        private Activity HandleSystemMessage(Activity message)
        {
            if (message.Type == ActivityTypes.DeleteUserData)
            {
                // Implement user deletion here
                // If we handle user deletion, return a real message
            }
            else if (message.Type == ActivityTypes.ConversationUpdate)
            {
                // Handle conversation state changes, like members being added and removed
                // Use Activity.MembersAdded and Activity.MembersRemoved and Activity.Action for info
                // Not available in all channels
            }
            else if (message.Type == ActivityTypes.ContactRelationUpdate)
            {
                // Handle add/remove from contact lists
                // Activity.From + Activity.Action represent what happened
            }
            else if (message.Type == ActivityTypes.Typing)
            {
                // Handle knowing tha the user is typing
            }
            else if (message.Type == ActivityTypes.Ping)
            {
            }

            return null;
        }

    }

When the BOT receives a message, it extracts the AccessToken from the BotState data object and performs a query against the Graph API using the SDK. The BOT is able to get this data thanks to both activity.Channel.Id and activity.From.Id that respectively contain “webchat” (in case of the webchat control of course) and the userid we passed in in our webchat controller. Should you use the exact same code through another channel, the BOT wouldn’t find any data in the BotState.

Bottom line

Why using a custom controller instead of simply passing the userid directly to the webchat control in the iframe? I see two good reasons: the 1st one is that thanks to our webchat controller, we can easily generate an AccessToken to anything (my example is for the Graph but it could have been Yammer as well) and the 2nd reason is because if you pass the userid directly, this will be visible in the source code of the page and exposed to vulnerabilities (users could start fiddling with that parameter). Using a proxy, you simply specify the path to your controller without any parameter. Your controller (server-side) will then safely perform the web request (server to server) passing in the secret and the userid that cannot be altered by end users. Of course, the answer will return botchat.js that will reveal the secet & userid if you launch the developer tools. The Azure AD user identifier is not so easily predictable but if you really want to be secure, you might consider passing a combination of UserId/GUID in the userid parameter which should definitely be more secure.

There is one drawback to this approach though: the scalability. Since the iframes will point to your controller and no more directly to Microsoft’s, you’ll have to make sure to run multiple instances of Azure WebApps to support the workload. On the other hand, you needed to think of it to host your message controller that is a must-have unless you go for the BaaS approach which uses Azure Functions.

Note: in this example, the interaction with AAD to get an AccessToken isn’t robust enough because if the conversation lasts more than an hour, the saved AccessToken will expire, thus, you should plan for a fallback mechanism and handle that problem.

Happy Coding!

Advertisements

About Stephane Eyskens

Office 365, Azure PaaS and SharePoint platform expert
This entry was posted in Azure, Azure Active Directory and tagged . Bookmark the permalink.

10 Responses to Microsoft BOT framework, transparent authentication with the webchat control

  1. Pingback: Transparent BOT authentication with Microsoft Teams | Welcome to my blog! Stéphane Eyskens, Office 365 and Azure PaaS Architect

  2. Pingback: Building the nextgen SharePoint search through a BOT and LUIS? | Welcome to my blog! Stéphane Eyskens, Office 365 and Azure PaaS Architect

  3. ashay says:

    Hi,
    must say great post..
    I have implemented same, however, generating own token.
    our tokens are lengthy. I can say they are of 620 characters length. total url size becomes 650characters including username , bot secret.
    but when I hit enter, it shows couldnt send. however, web chat is on.
    If I reduce the size of token to 64 charcters, it works.
    activity.id doesnt accept my token if it is more than that length.
    can you suggest something on this.

    Thanks,
    Ashay

    Like

    • Stephane Eyskens says:

      Hi,

      That’s not what I’m doing. I’m only protectecting my controller endpoint with Azure AD. Then I’m passing in the userid & username but not any kind of token. However, when I’ve authenticated the user, I’m generating an access token which I store in the bot state. So, that’s probably what you should do as well instead of passing the token in the URL.

      Like

  4. Pingback: Contextual authentication with the Bot webchat control in SharePoint

  5. sai chundur says:

    hi I tried your sample I get after welcome a message and then No Token . It is not giving my profile info
    Can you please advise
    thanks

    Like

  6. Clément says:

    Hello Stephane,

    Very good article !
    It helped me to clarify some important points :).
    One thing i would like to ask, you show how to use a transparent auth using a “subsite” tab.
    You mention that the direct one to one chat is not really feasible when you write your article, does it changed since then ?

    Thanks a lot,

    Like

    • Stephane Eyskens says:

      Hi Clément,

      Not sure what you exactly means by a subsite but the reason why I’m using an Azure webapp is because when users call my webchat endpoint, I don’t only authenticate them but I also grab an access token directly which I save in the bot state for that particular user. That’s not something that’s feasible if you call directly the webchat control that’s made available by Microsoft. You need the extra hop to generate and record the access token. If this is not required in your scenario, then you can simply transmit the userid & name via the webchat parameters.

      Like

  7. Pingback: Seamless SSO in Chat Bot | ASK Dev Archives

  8. Pingback: Microsoft Bot transparent authentication * ChatBots

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s