I’m building a B2C website with Cosmos DB as the back-end store and starting with common elements like Authentication. In my prior post, we connected the Cookie Middleware with custom membership logic and a standard username/password login method. In this one, we’ll be extending the system to also allow users to register and login via a third party provider (Twitter).

3 Authentication Scenarios: User/Pass, Twitter, API Keys

3 Authentication Scenarios: User/Pass, Twitter, API Keys

In this post I’ll also start exploring User Authentications as a separate document collection, rather than as additional fields on my User document. I’ve noticed in several past systems I’ve built API keys and authentication mechanisms as properties on Users, but recently started considering that, like my house keys, mixing properties of the user with authentication methods on a single “User” record has been making me uncomfortable.

Defining People (Users) Separate from House Keys (User Authentication)

Defining People (Users) Separate from House Keys (User Authentication)

Let’s find out if this works.

Breaking it down

In the prior post, I outlined the authentication needs of this system and started breaking it into separate steps forward. Interactive logins would be able to take advantage of a standard username/password system or, alternatively, using a third-party system like Twitter. Once a user has an account, they can create and manage API Key/Secret pairs for API access (which is the next post).

So where are we so far? We have built an ASP.net Core 2 website and added Cookie Middleware that is associated with LoginSession classes stored in Cosmos DB. Users can register with a username, password, and email address and are stored as LoginUser documents in Cosmos DB. Business logic for this is bundled up in a concrete CosmosDBMembership class while the Persistence logic to work with Cosmos DB is encapsulated in a UserPersistence class, both of which are registered with the built-in ASP.Net Core 2 Dependency Injeciton services collection.

Let’s extend the Membership logic to support Twitter:

File Changes for Twitter Addition

File Changes for Twitter Addition

Here’s what we’ll be adding:

  • A Register via Twitter button, callback URL + form, and registration endpoint
  • A Login via Twitter button and callback URL
  • Some supporting methods in CosmosDBMembership
  • Some supporting methods in Persistence.Users
  • A new DocumentCollection in Cosmos DB for alternative User Authentications

This is a much smaller task then the initial work to put in custom authentication, but like that post I’ll present the changes file-by-file rather than in the order I developed them in.

The source code through this set of changes is available here: github: Sample_ASPNetCore2AndCosmosDB, Post #3 Branch

Using the Twitter Middleware

The Twitter middleware uses a standard OAuth sign-in process, but does so in a way that takes care of all the details for us.

When a Challenge is issued through the twitter middleware, it takes precautions like generating a one time roundtrip token and stores that token and additional parameters for us in a shortlived cookie. It then sends the user over to twitter with a callback URL and that roundtrip token which Twitter uses once the User has logged in and granted us access. Twitter calls back to the callback, which is handled 100% by the middleware, including verifying that roundtrip token came back around successfully. Information from Twitter is added to the Claims on the HttpContext, just as cookie informaiton is when we use the Cookie Middleware, so we can then use that information to perform our internal authorization logic.

Twitter OAuth and Middleware Flow

Twitter OAuth and Middleware Flow

Once they’re logged in (or registered), we can generate a session and forward them to the URL they were originally headed to.

Set Up Twitter

The first step is to set up credentials with Twitter for the application. The documented MSDN setup documentation has good steps for this, so we can follow through on the twitter side to get an app and access key setup.

Twitter App Setup

Twitter App Setup

We’ll be storing the API Key and access token in our settings file for now, later we’ll take advantage of secrets management to store it in a way that doesn’t expose it to git.

Add Middleware

The next part is to configure the middleware to use those App settings from Twitter. Once we receive the information back from Twitter, we will use that twitter Id to either continue user registration or attempt to log the user in, depending on where they started. Rather than build conditional logic into the Twitter options for the middleware, we can instead provide a final URL to redirect to for each endpoint:

  • /account/login/twitter should finally redirect to /account/login/twitter/continue
  • /account/register/twitter should finally redirect to /account/login/register/continue

When the redirect comes back from Twitter, it passed back account information that is parsed into claims on the HttpContext.

During the Twitter authentication challenge, there is an internal cookie to carry state between the call to twitter and callback. Once the account information has been parsed, however, that information exists purely in the HttpContext for that request.

To bridge the gap between receiving the Twitter information and being able to use it on the final redirect, we can register a second Cookie middleware and tell twitter to use that middleware’s AuthenticationScheme for SignIn. This way, once the Twitter middleware has finished parsing the Claims, it will turn around and “SignIn” via the new Cookie Middleware. This writes the claims from Twitter to a new cookie for subsequent requests.

SampleCosmosCore2App/Startup.cs

public void ConfigureServices(IServiceCollection services)
{
   // ... MVC, Membership

    services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
        /* External Auth Providers */
        .AddCookie("ExternalCookie")
        .AddTwitter("Twitter", options =>
        {
            options.SignInScheme = "ExternalCookie";

            options.ConsumerKey = Configuration["Authentication:Twitter:ConsumerKey"];
            options.ConsumerSecret = Configuration["Authentication:Twitter:ConsumerSecret"];
        })
        /* 'Session' Cookie Provider */
        .AddCookie((options) =>
        {
            // ...
        });
}

So the only three configurations we need here are the new Cookie provider with an explicit AuthenticationScheme, configuring Twitter to Sign In via that AuthenticationScheme, and then adding our Twitter Key and Secret via the appsettings.json file.

Login Endpoints

To support the Login flow, we need a set of new endpoints and a button.

We’ll add a new /account/login/twitter endpoint with a final callback of /account/login/twitter/continue redirect URL to perform the actual Sign On, and we’ll add a button to the existing Login view:

Addition of a "Login with Twitter" button

Addition of a "Login with Twitter" button

SampleCosmosCore2App/Views/Account/Login.cshtml

...


<div class="box">
    <h2>Login</h2>

    <a asp-action="LoginWithTwitter" asp-route-returnUrl="@TempData["returnUrl"]" class="btn-white">Login with Twitter</a>

      ...
  
</div>

Then we add the endpoints to the Account controller to start the Twitter authentication process and capture the callback values at the end to start a new login session.

[HttpGet("login/twitter")]
[AllowAnonymous]
public IActionResult LoginWithTwitter(string returnUrl = null)
{
    var props = new AuthenticationProperties()
    {
        RedirectUri = "account/login/twitter/continue?returnUrl=" + HttpUtility.UrlEncode(returnUrl)
    };
    return Challenge(props, "Twitter");
}

[HttpGet("login/twitter/continue")]
[AllowAnonymous]
public async Task<IActionResult> LoginWithTwitterContinueAsync(string returnUrl = null)
{
    // use twitter info to create a session
    var cookie = await HttpContext.AuthenticateAsync("ExternalCookie");
    var twitterId = cookie.Principal.FindFirst("urn:twitter:userid");

    var result = await _membership.LoginExternalAsync("Twitter", twitterId.Value);
    if (result.Failed)
    {
        ModelState.AddModelError("", "Twitter account not recognized, have you registered yet?");
        return View("Login");
    }

    await HttpContext.SignOutAsync("ExternalCookie");

    return LocalRedirect(returnUrl ?? _membership.Options.DefaultPathAfterLogin);
}

The Twitter information comes back in the “ExternalCookie” we registered in the Startup configuration. We’ll add a method to CosmosDBMembership to create a LoginSession just like we do with a username/password, except for a third-party identity instead. The last step is to clean up the “External Cookie” using it’s SignOut method.

Originally I added the SignOut for cleanliness, but it was later pointed out to me that this is extra overhead going across the wire on every Request/Response and, in some cases, can actually result in "Request Too Long" web server errors if you stack up too many cookies.

Registration Endpoints

The registration flow is similar to the login flow, but we need one additional endpoint to serve up the registration form once we have the user’s twitter information for authentication.

"Continue with Twitter" on Register Form

"Continue with Twitter" on Register Form

Continuing Registration after logging in as @sqlishard

Continuing Registration after logging in as @sqlishard

SampleCosmosCore2App/Views/Account/Register.cshtml

...



<div class="box">
  <h2>
    Create Account
  </h2>
  
      
  
  <a asp-action="RegisterWithTwitter" class="btn-white">Continue with Twitter</a>
  
      ...
  
</div>

Like the Login form, we add a link to the Registration form.

SampleCosmosCore2App/Views/Account/RegisterWithTwitterContinue.cshtml

@model SampleCosmosCore2App.Models.Account.RegisterWithTwitterModel

@{
    ViewData["Title"] = "Register";
    Layout = "~/Views/Shared/Layout.cshtml";
}



<div class="box">
  <h2>
    Create Account
  </h2>
  
      
  
</div>

And a view that collects a username (for display purposes) once they’ve authenticated with Twitter.

Then we can add the 3 endpoints to start the twitter Challenge, extract the details and feed them into the Registration form, then complete the Registration and log the user in for the first time:

[HttpGet("register/twitter")]
[AllowAnonymous]
public IActionResult RegisterWithTwitter()
{
    var props = new AuthenticationProperties()
    {
        RedirectUri = "account/register/twitter/continue"
    };
    return Challenge(props, "Twitter");
}

[HttpGet("register/twitter/continue")]
[AllowAnonymous]
public async Task<IActionResult> RegisterWithTwitterContinueAsync()
{
    // use twitter info to set some sensible defaults
    var cookie = await HttpContext.AuthenticateAsync("ExternalCookie");
    var twitterId = cookie.Principal.FindFirst("urn:twitter:userid");
    var twitterUsername = cookie.Principal.FindFirst("urn:twitter:screenname");

    // verify the id is not already registered, short circuit to login screen
    if (await _membership.IsAlreadyRegisteredAsync("Twitter", twitterId.Value))
    {
        ModelState.AddModelError("", $"Welcome back! Your twitter account @{twitterUsername.Value} is already registered. Maybe login instead?");
        return View("Login");
    }

    var suggestedUsername = await FindUniqueSuggestionAsync(twitterUsername.Value);

    var model = new RegisterWithTwitterModel() {
        TwitterId = twitterId.Value,
        TwitterUsername = twitterUsername.Value,
        UserName = suggestedUsername
    };

    return View("RegisterWithTwitterContinue", model);
}

[HttpPost("register/twitter/continue")]
public async Task<IActionResult> RegisterWithTwitterContinueAsync(RegisterWithTwitterModel model)
{
    if (!ModelState.IsValid)
    {
        return View("RegisterWithTwitterContinue", model);
    }

    var result = await _membership.RegisterExternalAsync(model.UserName, model.Email, "Twitter", model.TwitterId);
    if (result.Failed)
    {
        ModelState.AddModelError("", result.ErrorMessage);
        return View("RegisterWithTwitterContinue", model);
    }

    await HttpContext.SignOutAsync("ExternalCookie");

    return LocalRedirect(_membership.Options.DefaultPathAfterLogin);
}

Once again, the last step in the process is to SignOut of the “ExternalCookie”, cleaning up after the stored Twitter information.

Membership and Persistence

The changes for the membership object are fairly light. We need to be able to:

  • Register(username, email, "Twitter", twitterId): Register a new account w/ Twitter authentication
  • Login("Twitter", twitterId): Login with “Twitter” authentication

These will expose the new Persistence methods we need against Cosmos DB.

public class CosmosDBMembership : ICustomMembership
{
    // ...

    public async Task<LoginResult> LoginExternalAsync(string scheme, string identity)
    {
        var authScheme = StringToScheme(scheme);
        var user = await _persistence.Users.GetUserByAuthenticationAsync(authScheme, identity);
        if (user == null)
        {
            return LoginResult.GetFailed();
        }

        await SignInAsync(user);

        return LoginResult.GetSuccess();
    }

    public async Task<RegisterResult> RegisterExternalAsync(string username, string email, string scheme, string identity)
    {
        var user = new LoginUser()
        {
            Username = username,
            Email = email
        };
        var userAuth = new LoginUserAuthentication()
        {
            Scheme = StringToScheme(scheme),
            Identity = identity
        };

        try
        {
            user = await _persistence.Users.CreateUserAsync(user);
        }
        catch (Exception)
        {
            //TODO reduce breadth of exception statement
            return RegisterResult.GetFailed("Username is already in use");
        }

        try
        {
            userAuth.UserId = user.Id;
            userAuth = await _persistence.Users.CreateUserAuthenticationAsync(userAuth);
        }
        catch (Exception)
        {
            // cleanup
            await _persistence.Users.DeleteUserAsync(user);
            throw;
        }

        await SignInAsync(user);

        return RegisterResult.GetSuccess();
    }

    public async Task<bool> IsAlreadyRegisteredAsync(string scheme, string identity)
    {
        return await _persistence.Users.IsIdentityRegisteredAsync(StringToScheme(scheme), identity);
    }

    // ...
}

The Login method is straightforward: Find a user that has a UserAuthentication of Twitter with the given twitter id. If we can’t find a user, we don’t know who they are.

Registration is a little trickier. We have to create and store both a LoginUser and LoginUserAuthentication object to successfully register the user and we need the generated id from the LoginUser that Cosmos DB generates from that save to populate the UserId property on the “LoginUserAuthentication before saving. So we create both classes, populate the UserId in between saves, and if the second save fails for any reason we delete the initial LoginUser document.

I’m not sure that I like the “Twitter” string being passed around, so you can see I’ve switched to an enumerated value for Persistence and will likely refactor that back up the stack later.

Persistence needs some additional setup to create the new DocumentCollection, a method to retrieve a LoginUser document for a given Twitter identity, ability to Delete a user document, and methods to create and check given Twitter identities in the system.

public class UserPersistence
{
    // ...

    public async Task EnsureSetupAsync()
    {
        var databaseUri = UriFactory.CreateDatabaseUri(_databaseId);

        // Collections
        // ...
        await _client.CreateDocumentCollectionIfNotExistsAsync(databaseUri, new DocumentCollection() { Id = AUTHS_DOCUMENT_COLLECTION_ID });
        // ...
    }

    #region Users

    // ...
        
    public async Task<LoginUser> GetUserByAuthenticationAsync(AuthenticationScheme authenticationScheme, string identity)
    {
        var query = _client.CreateDocumentQuery<LoginUserAuthentication>(GetAuthenticationsCollectionUri(), new SqlQuerySpec()
        {
            QueryText = "SELECT * FROM UserAuthentications UA WHERE UA.Scheme = @scheme AND UA.Identity = @identity",
            Parameters = new SqlParameterCollection()
            {
                new SqlParameter("@scheme", authenticationScheme),
                new SqlParameter("@identity", identity)
            }
        });
        var results = await query.AsDocumentQuery()
                                    .ExecuteNextAsync<LoginUserAuthentication>();
        if (results.Count == 0)
        {
            return null;
        }
        else
        {
            return await GetUserAsync(results.First().UserId);
        }
    }

    // ...

    public async Task DeleteUserAsync(LoginUser user)
    {
        await _client.DeleteDocumentAsync(UriFactory.CreateDocumentUri(_databaseId, USERS_DOCUMENT_COLLECTION_ID, user.Id));
    }

    #endregion

    #region Additional Authentication Methods

    public async Task<LoginUserAuthentication> CreateUserAuthenticationAsync(LoginUserAuthentication userAuth)
    {
        var result = await _client.CreateDocumentAsync(GetAuthenticationsCollectionUri(), userAuth, new RequestOptions() { });
        return JsonConvert.DeserializeObject<LoginUserAuthentication>(result.Resource.ToString());
    }


    public async Task<bool> IsIdentityRegisteredAsync(AuthenticationScheme authenticationScheme, string identity)
    {
        var query = _client.CreateDocumentQuery<int>(GetAuthenticationsCollectionUri(), new SqlQuerySpec()
        {
            QueryText = "SELECT VALUE COUNT(1) FROM UserAuthentications UA WHERE UA.Scheme = @scheme AND UA.Identity = @identity",
            Parameters = new SqlParameterCollection()
            {
                new SqlParameter("@scheme", authenticationScheme),
                new SqlParameter("@identity", identity)
            }
        });
        var result = await query.AsDocumentQuery()
                                .ExecuteNextAsync<int>();
        return result.Single() == 1;
    }

    #endregion

    // ...
}

These all follow naturally from the methods created for prior posts.

Wrapping Up, Next Steps

Adding Twitter as an alternative interactive login method was pretty easy, once I figured out how the Middleware worked behind the scenes. Adding a second or third method would also be relatively easy, though I would likely want to use the Middleware options to remap specific claims like “urn:twitter:userid” and the matching value for LinkedIn or others to a common set of named claims to funnel redirects into a common set of callback URls.

Next up is the final Authentication post, adding in a per-request API key/secret method that relies on revocable API keys the user will manage as additional LoginUserAuthentication identities.