Search code examples
botframeworkmicrosoft-teams

How can I continue an OAuth dialog after an invoke activity in a Microsoft Teams bot?


I have a bot that targets Microsoft Teams, primarily with 1:1 proactive chat messages, so it doesn't really manage much in the way of dialogs. I'm trying to refactor some code that uses application permissions to user delegated ones though, so I'm trying to implement the OAuth flow described here.

I've lifted the authentication dialog from the referenced sample pretty much 1:1 to start with (basic waterfall dialog with an OAuthPrompt that derives from ComponentDialog), and I can get the OAuth prompt with the sign in button, but I can't get the authentication to complete. Here's a code snippet:

    public class myBot : TeamsActivityHandler
    {
...
        public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
        {
...
            if (turnContext.Activity.Type == ActivityTypes.Message && turnContext.Activity.Text == "login")
            {
                var dialogState=_accessors.ConversationState.CreateProperty<DialogState>(nameof(DialogState));
                var dialogSet = new DialogSet(dialogState);
                dialogSet.Add(new AuthDialog());
                DialogContext dc = await dialogSet.CreateContextAsync(turnContext, cancellationToken);
                var turnResult=await dc.BeginDialogAsync("AuthDialog");
                await _accessors.ConversationState.SaveChangesAsync(turnContext, false, cancellationToken);
            }
            else if (turnContext.Activity.Type == ActivityTypes.Invoke && turnContext.Activity.Name == "signin/verifyState")
            {
                var dialogState = _accessors.ConversationState.CreateProperty<DialogState>(nameof(DialogState));
                var dialogSet = new DialogSet(dialogState);
                DialogContext dc = await dialogSet.CreateContextAsync(turnContext, cancellationToken);
                var turnResult=await dc.ContinueDialogAsync();
                await _accessors.ConversationState.SaveChangesAsync(turnContext, false, cancellationToken);
            }


If the user sends "login" I get the OAuth prompt as expected. Clicking the sign in button generates the popup, and completing the login sends the invoke activity back to the bot. The problem is in the block that handles the signin/verifyState. I can get the DialogContext, and try to run ContinueDialog to pass control back to the OAuthPrompt, but I get an exception saying "Failed to continue dialog. A dialog with id AuthDialog could not be found". The thing is, if I inspect the dialog context, I can see that dc.ActiveDialog.Id="AuthDialog", and that the dialog is on the stack. Is there something else I need to do at this point to pass control back to the dialog?

If it matters, this bot is also using task modules, so I need to be able to see the invoke responses I get from those, which means I'm basically dispatching everything from OnTurnAsync.


Solution

  • I think I have this figured out, and it's more about adapting an existing bot that's not using dialogs to add one. To start with, I added a slightly modified version of the Authentication dialog from the sample:

        public class AuthDialog : ComponentDialog
        {
            public AuthDialog()
                : base(nameof(AuthDialog))
            {
                AddDialog(new OAuthPrompt(
                    nameof(OAuthPrompt),
                    new OAuthPromptSettings
                    {
                        ConnectionName = "botTeamsAuth",
                        Text = "Please Sign In",
                        Title = "Sign In",
                        Timeout = 300000, // User has 5 minutes to login (1000 * 60 * 5)
                    }));
    
                AddDialog(new ConfirmPrompt(nameof(ConfirmPrompt)));
    
                AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[]
                {
                    PromptStepAsync,
                    LoginStepAsync
                }));
    
                // The initial child Dialog to run.
                InitialDialogId = nameof(WaterfallDialog);
            }
    
            private async Task<DialogTurnResult> PromptStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
            {
                return await stepContext.BeginDialogAsync(nameof(OAuthPrompt), null, cancellationToken);
            }
    
            private async Task<DialogTurnResult> LoginStepAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
            {
                var tokenResponse = (TokenResponse)stepContext.Result;
                if (tokenResponse?.Token != null)
                {
                    // do something with graph here
                    GraphServiceClient client = iceTeamsBotState.GetGraphClient(tokenResponse.Token);
                    var messages = await client.Me.MailFolders["inbox"].Messages.Request(new List<Microsoft.Graph.QueryOption> { new Microsoft.Graph.QueryOption("$search", $"\"subject:test\"") }).GetAsync();
                    await stepContext.Context.SendActivityAsync($"Found {messages.Count} emails");
                    return await stepContext.EndDialogAsync(cancellationToken: cancellationToken);
                }
                await stepContext.Context.SendActivityAsync(MessageFactory.Text("Login was not successful please try again."), cancellationToken);
                return await stepContext.EndDialogAsync(cancellationToken: cancellationToken);
            }
        }
    

    Then, I needed to change my bot class from this:

        public class myBot : IBot
    

    to this

        public class myBot<T> : TeamsActivityHandler where T:Dialog
    

    Also, I needed to make sure that the validDomains in the manifest added "token.botframework.com" to the list (I had missed this step before).

    Finally I made this change in ConfigureServices:

                //Add the auth dialog
                services.AddSingleton<AuthDialog>();
    
                services.AddBot<myBot<AuthDialog>>(options =>
                {
                    options.CredentialProvider = new SimpleCredentialProvider(botConfig.BotID, botConfig.BotSecret);
    
                    options.OnTurnError = async (context, exception) =>
                    {
                        _log.Error("Exception caught-OnTurnError: ", exception);
                        await context.SendActivityAsync("Sorry, it looks like something went wrong.");
                    };
                });
    

    Note registering the dialog as a singleton, and then adding the template parameter (presumably representing the main dialog) to the bot. Then, in my OnTurnAsync code, I was able to handle a couple of test inputs:

            public override async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken))
            {
    ...
                else if (turnContext.Activity.Type == ActivityTypes.Message && turnContext.Activity.Text == "logout")
                {
                    var dialogState = _accessors.ConversationState.CreateProperty<DialogState>(nameof(DialogState));
                    var dialogSet = new DialogSet(dialogState);
                    dialogSet.Add(new AuthDialog());
                    DialogContext dc = await dialogSet.CreateContextAsync(turnContext, cancellationToken);
                    var botAdapter = (BotFrameworkAdapter)dc.Context.Adapter;
                    await botAdapter.SignOutUserAsync(dc.Context, "botTeamsAuth", null, cancellationToken);
                    await turnContext.SendActivityAsync($"logged out of graph");
                }
                else if (turnContext.Activity.Type == ActivityTypes.Message && turnContext.Activity.Text == "login")
                {
                    var dialogState = _accessors.ConversationState.CreateProperty<DialogState>(nameof(DialogState));
                    var dialogSet = new DialogSet(dialogState);
                    dialogSet.Add(new AuthDialog());
                    DialogContext dc = await dialogSet.CreateContextAsync(turnContext, cancellationToken);
                    var turnResult = await dc.BeginDialogAsync("AuthDialog");
                    await _accessors.ConversationState.SaveChangesAsync(turnContext, false, cancellationToken);
                }
                else if (turnContext.Activity.Type == ActivityTypes.Invoke && turnContext.Activity.Name == "signin/verifyState")
                {
                    var dialogState = _accessors.ConversationState.CreateProperty<DialogState>(nameof(DialogState));
                    var dialogSet = new DialogSet(dialogState);
                    dialogSet.Add(new AuthDialog());  //this is counterintuitive, but it gets around the issue where I get the dialog missing exception
                    DialogContext dc = await dialogSet.CreateContextAsync(turnContext, cancellationToken);
                    var turnResult = await dc.ContinueDialogAsync();
                    await _accessors.ConversationState.SaveChangesAsync(turnContext, false, cancellationToken);
                }
    

    Yes, there's some repeated code in there, but I left it as-is for now for clarity. What seems a little counter intuitive to me at lest is newing up the DialogState and DialogSet to retrieve the existing state members, but that appears to be how this works. In this example, I'm just pulling a couple of messages from the user inbox to validate the connection, but it does work as expected. If the user is already signed in, the response comes back immediately, and if the user needs to auth, then the login card gets sent and the token response comes back.

    In any case, I think I'm unblocked now, and hopefully this solution helps someone else in a similar position. The method that @Hilton mentioned in his answer will also work if you want to use the "magic code" authentication flow. In that case though. you'll call something like this to get the signin link:

                    var adapter = turnContext.Adapter as BotFrameworkAdapter;
                    string url = await adapter.GetOauthSignInLinkAsync(turnContext, "botTeamsAuth");
                    await turnContext.SendActivityAsync($"click here to sign in {url}");
    

    and then get back a numeric code as a message from the user that you can turn in using GetUserTokenAsync() as mentioned in his answer. The card login seems to be a little cleaner though, since it doesn't need to pop an external login window, and the OAuthPrompt dialog wraps the token retrieval/login into one step.