microsoft / botframework-sdk

Bot Framework provides the most comprehensive experience for building conversation applications.
MIT License
7.49k stars 2.44k forks source link

Authentication Link Malformed In iOS SMS App (SMSChannel) #4897

Closed mkonkolowicz closed 5 years ago

mkonkolowicz commented 6 years ago

Bot Info

Issue Description

When using generic oauth mechanisms for authentication, the link returned by the authentication mechanism is not encapsulated, so it is not easily clicked on, on a mobile device. In order to retrieve the magic string for authentication from bot framework, the user has to copy paste the string into a note, remove some verbiage and then repaste the link into a mobile browser.

Code Example

var tokenResponse = Context.GetUserTokenAsync(string connectionName);

Reproduction Steps

  1. Ensure generic oauth setup on bot framework azure resource
  2. Attempt to get token through "context.GetUserTokenAsync"
  3. Observe link sent back by bot framework for magic code.

Expected Behavior

The full provided link is clickable in SMS (iOS) app. The user should not have to cut and paste the link, but be able to click to launch it in a mobile browser.

Actual Results

The link is cut and the user has to copy paste and "clean" (remove verbiage) from the provided link before pasting into browser. img_1772

JasonSowers commented 6 years ago

Spent some time on this today unfortunately plan A and plan B were not possible. Working on plan C but hit a roadblock. Will continue to look into this and report my findings.

JasonSowers commented 6 years ago

So I have figured out a workaround for this that is fairly involved. I do not think it is too bad as you will be generally able to copy and paste my code. The general idea is that instead of sending the link via the normal means, we call and get the link. We also generate a Guid for the user so we know to whom that link belongs if there are multiple concurrent users. We store these values in a static dictionary as a Key(guid)/Value(url) pair. So now we have that stored and we can use the guid to look up that url. We create another endpoint in out bot for the sole purpose of redirecting to the obscenely long url so the user never sees it.

First we create our controller to handle the redirecting: NOTE: we have to use a 307 redirect, a 302 will not work

using System;
using System.Net;
using System.Net.Http;
using System.Web.Http;

namespace Microsoft.Bot.Sample.AadV2Bot.Controllers
{
    public class RedirectController : ApiController
    {
        [System.Web.Http.AcceptVerbs("GET", "POST")]
        [System.Web.Http.HttpGet]
        public HttpResponseMessage Redirection()
        {
            //get the dictionary key and entry
            var guid = Request.RequestUri.PathAndQuery;
            var url = CustomCode.RedirectDictionary[guid];

            //make our redirect reponse as a 307 redirect 
            //NOTE: 302 redirects will NOT work for twilio
            var response = Request.CreateResponse(HttpStatusCode.TemporaryRedirect);
            response.Headers.Location = new Uri(url);
            return response;
        }
    }
}

Great now we have our endpoint taken care of. In my project, I happen to have a lot of custom code for handling the quirkiness of the other channels. So naturally I put my static dictionary there: NOTE: you may want to use another means of storing data than something just kept in memory, also you could make this Dictionary<Guid,Uri>(): if you really wanted to

public static class CustomCode
{
    public static Dictionary<string, string> RedirectDictionary = new Dictionary<string, string>();
}

So right now we have our endpoint and our data store. We need a way to call and get the token so we can execute the graph calls late on. Right now in the RootDialog, there is a method called GetTokenDialog we basically need to copy that Dialog and change it for our purposes. There was nothing changed in the WaitForToken method except we are deleting the dictionary entry for this user. CustomCode.RedirectDictionary.Remove(_guid.ToString()); NOTE: I could not get ngrok endpoints to work in this code, I have to do my testing with a deployed bot because of it. However, localhost endpoints did work for testing in the emulator before deploying. The emulator and Twilio have different behaviors though. Just because something worked on the emulator does not mean it will work in Twilio.

using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Connector;
using Newtonsoft.Json.Linq;
using System;
using System.Threading.Tasks;

namespace Microsoft.Bot.Sample.AadV2Bot.Dialogs
{
    [Serializable]
    public class SignInDialog : IDialog<GetTokenResponse>
    {
        private string _connectionName;
        private string _buttonLabel;
        private string _signInMessage;
        private int _retries;
        private string _retryMessage;
//store our guid here
        private Guid _guid;

        public SignInDialog(string connectionName, string signInMessage, string buttonLabel, int retries = 0,
            string retryMessage = null)
        {
            _connectionName = connectionName;
            _signInMessage = signInMessage;
            _buttonLabel = buttonLabel;
            _retries = retries;
            _retryMessage = retryMessage;
        }

        public async Task StartAsync(IDialogContext context)
        {
            // First ask Bot Service if it already has a token for this user
            var token = await context.GetUserTokenAsync(_connectionName);
            if (token != null)
            {
                context.Done(new GetTokenResponse() {Token = token.Token});
            }
            else
            {
                // If Bot Service does not have a token, send an OAuth card to sign in
                await SendOAuthLinkAsync(context, (Activity) context.Activity);
            }
        }

        private async Task SendOAuthLinkAsync(IDialogContext context, Activity activity)
        {
            var reply = activity.CreateReply();
            _guid = Guid.NewGuid();

            var client = activity.GetOAuthClient();
            var link = await client.OAuthApi.GetSignInLinkAsync(activity, _connectionName);

            CustomCode.RedirectDictionary.Add("/api/redirect?" +_guid, link);
            var url = $"https://jasonaadv2test.azurewebsites.net/api/redirect?{_guid}";

            if (activity.ChannelId == ChannelIds.Sms)
            {
                reply.Text ="Use this link to get your magic code\r\n  " +url;
            }
            else
            {
                reply.Attachments= new List<Attachment>()
                {
                    new HeroCard(
                        title: "Sign in",
                        text: "Click this button to get your magic code.\r\n Enter the magic code to continue",
                        buttons: new List<CardAction>()
                        {
                            new CardAction(text:"Get Code",type: ActionTypes.OpenUrl, title:"Get Code", value: url, displayText:"Get Code")
                        }
                        ).ToAttachment()
                };
            }
            await context.PostAsync(reply);

            context.Wait(WaitForToken);
        }

        private async Task WaitForToken(IDialogContext context, IAwaitable<object> result)
        {
            var activity = await result as Activity;
            CustomCode.RedirectDictionary.Remove(_guid.ToString());
            var tokenResponse = activity.ReadTokenResponseContent();
            string verificationCode = null;
            if (tokenResponse != null)
            {
                context.Done(new GetTokenResponse() {Token = tokenResponse.Token});
                return;
            }
            else if (activity.IsTeamsVerificationInvoke())
            {
                JObject value = activity.Value as JObject;
                if (value != null)
                {
                    verificationCode = (string) (value["state"]);
                }
            }
            else if (!string.IsNullOrEmpty(activity.Text))
            {
                verificationCode = activity.Text;
            }

            tokenResponse = await context.GetUserTokenAsync(_connectionName, verificationCode);
            if (tokenResponse != null)
            {
                context.Done(new GetTokenResponse() {Token = tokenResponse.Token});

                return;
            }

            // decide whether to retry or not
            if (_retries > 0)
            {
                _retries--;
                await context.PostAsync(_retryMessage);
                await SendOAuthLinkAsync(context, activity);
            }
            else
            {
                context.Done(new GetTokenResponse() {NonTokenResponse = activity.Text});
                return;
            }
        }
    }
}

Now we are in the home stretch, the final thing we need to do is call that dialog. So, for example, we call the ListMe method like this in the RootDialog context.Call(GetTokenDialog(), ListMe); which right now calls the GetTokenDialog and not the SignInDialog we just made. So lets make a method to do that by basically copying the GetTokenDialog Method and making it call our new Dialog.

        private SignInDialog CreateSignInDialog()
        {
            return new SignInDialog(
                ConnectionName, 
                $"Please sign in to {ConnectionName} to proceed.",
                "Sign In",
                0,
                "Hmm. Something went wrong, let's try again.");                
        }

And wherever we are currently calling the GetTokenDialog we need to add an if statement like this example where we are using the ListMe method

            else if (message.ToLowerInvariant().Equals("me"))
            {
                if (activity.ChannelId == ChannelIds.Sms)
                {
                    context.Call(CreateSignInDialog(), ListMe);
                }
                else
                {
                    context.Call(CreateGetTokenDialog(), ListMe);
                }
            }

Now the link sent from the bot should show up as a link preview in iOS, and a much shorter link in android. All functionality should be retained. Please let me know if you have any issues

JasonSowers commented 6 years ago

A colleague just told me I should have used a ConcurrentDictionary over a Dictionary, so If you would like to go that route you would have to make those changes.

JasonSowers commented 6 years ago

@mkonkolowicz please let me know your results with this when you have the chance to implement it.

mkonkolowicz commented 6 years ago

@JasonSowers the workaround def. worked. Thanks so much for your help! When can we expect this baked into the bot framework?

JasonSowers commented 6 years ago

Right now I do not have a timeline.

stevengum commented 5 years ago

Thank you for opening an issue against the Bot Framework SDK v3. As part of the Bot Framework v4 release, we’ve moved all v3 work to a new repo located at https://github.com/microsoft/botbuilder-v3. We will continue to support and offer maintenance updates to v3 via this new repo.

From now on, https://github.com/microsoft/botbuilder repo will be used as hub, with pointers to all the different SDK languages, tools and samples repos.

As part of this restructuring, we are closing all tickets in this repo.

For defects or feature requests, please create a new issue in the new Bot Framework v3 repo found here: https://github.com/microsoft/botbuilder-v3/issues

For Azure Bot Service Channel specific defects or feature requests (e.g. Facebook, Twilio, Teams, Slack, etc.), please create a new issue in the new Bot Framework Channel repo found here: https://github.com/microsoft/botframework-services/issues

For product behavior, how-to, or general understanding questions, please use Stackoverflow. https://stackoverflow.com/search?q=bot+framework

Thank you.

The Bot Framework Team