How to use Sign-in Card to authenticate your Bot against a third party login service
Jan 2, 2020

Bot Framework (v4) provides OAuth sign-in feature to authenticate against an OAuth login provider. However, there are few samples showing how to use Sign-in Card to authenticate against a third party login service. This article will show you how to use Sign-in Card to authenticate a third party login service.

Sign-in Card demo

This sample is based on an EchoBot template and it will have following sign-in ability. It does not have a real sign-in logic, it accepts any value user inputs for the UserName.

Step 1: Define User class

A CacheUser represents a Bot conversation user, it links the Bot client user id and the ConversationId to an authenticated user. In this sample, an authenticated user only has one UserName field, before login the default value of UserName is an empty string: "".

public class CacheUser
{
    public string UserName { get; set; }
    public string BotClientUserId { get; set; }
    public string BotConversationId { get; set; }
    public ITurnContext TurnContext { get; set; }
    public CancellationToken CancellationToken { get; set; }
    public bool LoginDetected { get; set; }
    public CacheUser(string botClientUserId, string botConversationId, ITurnContext turnContext, System.Threading.CancellationToken cancellationToken)
    {
        BotClientUserId = botClientUserId;
        BotConversationId = botConversationId;
        UserName = "";
        TurnContext = turnContext;
        CancellationToken = cancellationToken;
        LoginDetected = false;
    }
}

Step 2: Show Sign-in Card as welcome message and instantiate the user

In this sample, we will show Sign-in Card as welcome message. To support WebChat welcome message, I implemented customized welcome event for DirectLine and WebChat, see https://github.com/microsoft/BotFramework-WebChat/tree/master/samples/15.d.backchannel-send-welcome-event for details.

The login URL of the Sign-in card needs to contain the Bot client user id and ConversationId by reading them from turnContext.Activity.From.Id and turnContext.Activity.Conversation.Id.

When presenting the Sign-in Card to current user for the first time, it instantiates the user and save it to the in-memory cache. By that time, the username is "".

protected override async Task OnEventActivityAsync(ITurnContext<IEventActivity> turnContext, CancellationToken cancellationToken)
{
    if (turnContext.Activity.Name == "webchat/join")
    {
        await ShowSigninCard(turnContext, cancellationToken);
    }
}

protected override async Task OnMembersAddedAsync(IList<ChannelAccount> membersAdded, ITurnContext<IConversationUpdateActivity> turnContext, CancellationToken cancellationToken)
{
    if (turnContext.Activity.ChannelId != "directline" && turnContext.Activity.ChannelId != "webchat")
    {
        foreach (var member in membersAdded)
        {
            if (member.Id != turnContext.Activity.Recipient.Id)
            {
                await ShowSigninCard(turnContext, cancellationToken);
            }
        }
    }
}

private async Task ShowSigninCard(ITurnContext turnContext, CancellationToken cancellationToken)
{
    string botClientUserId = turnContext.Activity.From.Id;
    string botConversationId = turnContext.Activity.Conversation.Id;
    var loginUrl = "http://localhost:3978/login.html?userid={botClientUserId}&conversationid={botConversationId}";
    var attachments = new List<Attachment>();
    var reply = MessageFactory.Attachment(attachments);
    var signinCard = new SigninCard
    {
        Text = "BotFramework Sign-in Card",
        Buttons = new List<CardAction> { new CardAction(ActionTypes.Signin, "Sign-in", value: loginUrl) },
    };
    reply.Attachments.Add(signinCard.ToAttachment());

    List<CacheUser> users;
    if (!Cache.TryGetValue("users", out users))
    {
        users = new List<CacheUser>();
    }
    if (!users.Any(u => u.BotClientUserId == botClientUserId && u.BotConversationId == botConversationId))
    {
        users.Add(new CacheUser(botClientUserId, botConversationId, turnContext, cancellationToken));
        Cache.Set("users", users, new MemoryCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromDays(7)));
    }
    await turnContext.SendActivityAsync(reply, cancellationToken);
}

Step 3: Pass the BotClientUserId and ConversationId to the login API

The login page is a static page which only contains a simple form to allow the user to input the user name he/she wants to sign in. The form has two hidden fields recording the userid and conversationid, we need to use JavaScript to read these values from query string and set them properly.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title></title>
</head>
<body>
    <form method="post" action="api/login">
        User Name: <input type="text" name="username" placeholder="Input your user name" required />
        <br />
        Password: <input type="password" name="password" value="123456" readonly />
        <br />
        <input type="hidden" name="userid" value="" />
        <input type="hidden" name="conversationid" value="" />
        <input type="submit" value="Login" />
    </form>
    <script>
        var parseRegexp = /^.+userid=(.+)&conversationid=(.+)$/i;
        var execResult = parseRegexp.exec(location.search);
        if (execResult) {
            document.getElementsByName('userid')[0].value = execResult[1];
            document.getElementsByName('conversationid')[0].value = execResult[2];
        }
    </script>
</body>
</html>

Step 4: Implement the Login API

The Login API reads required values from the post body. It then finds the user instance from the cached user list by botClientUserId and botConversationId. After that it sets the UserName of that user instance to complete the sign-in process.

[HttpPost("login")]
public async Task<IActionResult> Login()
{
    StringValues botClientUserId;
    StringValues userName;
    StringValues botConversationId;
    if (Request.Form.TryGetValue("userid", out botClientUserId) && Request.Form.TryGetValue("username", out userName) && Request.Form.TryGetValue("conversationid", out botConversationId))
    {
        List<CacheUser> users;
        if (!Cache.TryGetValue("users", out users))
        {
            users = new List<CacheUser>();
        }
        CacheUser user;
        user = users.Find(u => u.BotClientUserId == botClientUserId && u.BotConversationId == botConversationId);
        if (user != null)
        {
            user.UserName = userName;
            user.LoginDetected = false;
            Cache.Set("users", users, new MemoryCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromDays(7)));
            Logger.LogInformation($"Login User: {userName}, BotClientUserId: {botClientUserId}, BotConversationId: {botConversationId}");
            var content = $"Hi {userName}, you have successfully logged in! You may close this window.";
            return new ContentResult()
            {
                Content = content,
                ContentType = "text/html"
            };
        }
        else
        {
            return StatusCode(500);
        }
    }
    else
    {
        return BadRequest();
    }
}

Step 5: Implement a UserLoginDetectService to detect user login event every 2 seconds in the background

For better user experience, we need to notify the user that he/she has successfully signed in after he/she completes login in the webpage. To achieve this, I implemented a background service to detect new user logins every two seconds. It goes through all users and find out newly signed in users who has a valid UserName and the LoginDetected is false. It then sends the successful sign-in message on this user's own TurnContext and set it's LoginDetected to true to prevent the user being notified again in next run.

class UserLoginDetectService : IHostedService, IDisposable
{
    private readonly ILogger Logger;
    private Timer Timer;
    private IMemoryCache Cache;
    public UserLoginDetectService(ILogger<UserLoginDetectService> logger, IMemoryCache cache)
    {
        Logger = logger;
        Cache = cache;
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        Logger.LogInformation("UserLoginDetectService is starting.");
        Timer = new Timer(DoWork, null, TimeSpan.Zero,
            TimeSpan.FromSeconds(2));

        return Task.CompletedTask;
    }

    private void DoWork(object state)
    {
        List<CacheUser> users = new List<CacheUser>();
        if (Cache.TryGetValue("users", out users))
        {
            for (int i = 0; i < users.Count; i++)
            {
                var user = users[i];
                if (user.UserName != "" && !user.LoginDetected)
                {
                    var text = $"Hi {users[i].UserName}, you have successfully logged in!";
                    users[i].LoginDetected = true;
                    users[i].TurnContext.SendActivityAsync(MessageFactory.Text(text, text), users[i].CancellationToken);
                    Cache.Set("users", users);
                }
            }
        }
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        Timer?.Change(Timeout.Infinite, 0);
        return Task.CompletedTask;
    }

    public void Dispose()
    {
        Timer?.Dispose();
    }
}

Step 6: Default message handling

This part is quite simple, it checks whether the user is already signed in, if not it presents the Sign-in Card again.

protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
{
    CacheUser user = GetLogOnUser(turnContext);
    if (user == null)
    {
        var text = "Please login first";
        await turnContext.SendActivityAsync(MessageFactory.Text(text, text), cancellationToken);
        await ShowSigninCard(turnContext, cancellationToken);
    }
    else
    {
        var text = $"Hi {user.UserName}, welcome to use the Bot!";
        await turnContext.SendActivityAsync(MessageFactory.Text(text, text), cancellationToken);
    }
}

private CacheUser GetLogOnUser(ITurnContext turnContext)
{
    CacheUser user = null;
    string userid = turnContext.Activity.From.Id;
    string conversationId = turnContext.Activity.Conversation.Id;
    List<CacheUser> users;
    if (Cache.TryGetValue("users", out users))
    {
        user = users.Find(u => u.BotClientUserId == userid && u.BotConversationId == conversationId && u.UserName != "");
    }
    return user;
}

Step 7: Don't forget to register the ServiceCollection

This sample uses MemoryCache to record Bot users and HostedService to notify newly signed in users. We need to register them in Startup.cs so we can use them.

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

    // Create the Bot Framework Adapter.
    services.AddSingleton<IBotFrameworkHttpAdapter, AdapterWithErrorHandler>();

    // Create the bot as a transient. In this case the ASP Controller is expecting an IBot.
    services.AddTransient<IBot, EchoBot>();
    services.AddMemoryCache();
    services.AddHostedService<UserLoginDetectService>();
}

For more information about MemoryCache, you can read: Cache in-memory in ASP.NET Core

For more information about HostedService, you can read: Background tasks with hosted services in ASP.NET Core

Takeaway

The core mechanisms of this sample are:

  1. Link the unique Bot client user id and Conversation Id with the sign-in user.
  2. Run a detection service to notify newly signed in users in the background repeatedly.

In a real Bot project, the login logic doesn't have to be in the same running context of the Bot application, you can totally use an external login service as long as you pass the BotClientUserId and ConversationId to the login service. And the user data can be saved in anywhere like a database.

Categories

Bot Framework Azure C#