ASP.NET Identity, OAuth 2 Social Login, Web API 2, and MVC 5 SPAs

image Wow, that's a mouthful. I feel like that lady on the right sometimes.

So, I spent a few days studying SPA applications and how to use external social logins like Facebook and Google with Web API 2.

It's not for the faint of heart, especially when Microsoft's Web API template with VS 2013 is broken. Microsoft's template implementation is about 70% right.

Pile on top, all the inconsistent Web API login routes to think about. See this post for what's broken. I mostly agree to some extent. In addition, it seems like most "Web API" tutorials out there are outdated. Most of the tutorials have a user's browser parsing an obscure "auth_token" somewhere along the OAuth login flow. Surprise! In MVC5 + WebAPI2 + Social Login, there is no auth token homie! Now, it's all handled by the middleware. LoL. Eh, well, whatever, I didn't want to expose a scary sensitive auth token to the user's browser anyway. But I digress.

Before we begin, some articles & references that will help you

Distilled down to a single blog post, I present to you, ASP.NET Identity's OAuth2 Social Login flow with Web API 2:

So it begins, a new user arrives

Figure A:

image

Figure A, the user arrives and hits your MVC app. Your MVC app sends down your SPA. The user navigates to a login page and Figure B happens.

Figure B:

image

Your SPA app consumes the JSON from ExternalLogins call and renders some choices. In Figure B, Google and Facebook are configured on the server's OWIN middleware. Cool.

Figure C:

image

The user decides to login with "Facebook". In Figure C, it's a full-page navigation to the URL of /ExternalLogin?provider=Facebook. Not an AJAX call.

AuthController.cs (originally AccountController.cs from the original template) sees this request, and processing goes up to a ChallengeResult:

// GET api/auth/ExternalLogin
[OverrideAuthentication]
[HostAuthentication(DefaultAuthenticationTypes.ExternalCookie)]
[AllowAnonymous]
[Route("ExternalLogin", Name = "ExternalLogin")]
public async Task<IHttpActionResult> GetExternalLogin(string provider, string error = null)
{
    if( error != null )
    {
        return Redirect(Url.Content("~/") + "#error=" + Uri.EscapeDataString(error));
    }

    //ANONYMOUS will always get trapped here.
    if( !User.Identity.IsAuthenticated )
    {
        return new ChallengeResult(provider, this);
    }

A ChallengeResult is returned to the anonymous user. A ChallengeResult looks like:

public class ChallengeResult : IHttpActionResult
{
    public ChallengeResult(string loginProvider, ApiController controller)
    {
        LoginProvider = loginProvider;
        Request = controller.Request;
    }
    public string LoginProvider { get; set; }
    public HttpRequestMessage Request { get; set; }

    public Task<HttpResponseMessage> ExecuteAsync(CancellationToken cancellationToken)
    {
        Request.GetOwinContext().Authentication.Challenge(LoginProvider);
        var response = new HttpResponseMessage(HttpStatusCode.Unauthorized)
        {
            RequestMessage = Request
        };
        return Task.FromResult(response);
    }
}

ChallengeResult has an HTTP Status Code of Unauthorized. When MVC is executing the ChallengeResult, MVC notifies the OWIN middleware of an Authentication.Challenge("Facebook"). The unauthorized status code triggers the OWIN middleware to wake up and process the pending Authorization.Challenge, and transform the unauthorized status into a 302 redirect to an off-site login provider (Facebook in this case) for authorization as shown in Step 7 in Figure C above. Take note, in particular, the 302 redirect to Facebook has "redirect_uri=http://webapp.com/signin-facebook". This is a callback into the middleware that the user's browser will hit after they are finished authenticating and authorizing your web app on Facebook. In addition, take 2nd note, the middleware has instructed the browser to set a Correlation.Facebook cookie in Step 7 in Figure C above.

The user's browser follows the redirect as shown in Figure D below:

Figure D:

image 

In Figure D Step 8 above, the user authenticates with Facebook and authorizes WebApp.

Then, Figure D Step 9, Facebook issues a 302 redirect to the user's browser back on the OWIN middleware endpoint /signin-facebook. The browser follows the redirect shown in Figure E Step 10 below:

Figure E:

image

Side Note: When the middleware receives the /signin-facebook callback, the middleware needs to check if the current callback originated from the WebApp to prevent a CSRF attack. The Facebook middleware inspects Correlation.Facebook cookie and matches the cookie with some encrypted state inside the callback that was part of the query string on the way out to Facebook and on the way back in from Facebook. Hewf. Stay with me now...

Next, the OWIN middleware uses the code query string to setup a backchannel with Facebook. The code=AQABC... from Figure E, Step 10, is a code to get a real access_token. The access token is then used to query Facebook's Open Graph to get some claims about the user, like their Name, picture, email, etc ... Step 11 and Step 12 in Figure E.

Finally, the OWIN middleware has enough information to create a ClaimsIdentity of an external login, and does so by granting an AuthenticationTicket associated with an ExternalCookie authentication type. The Facebook middleware packs all the claims from the Facebook backchannel into this cookie called .AspNet.ExternalCookie as shown in Step 13 in Figure E.

Lastly, the OWIN middleware redirects the user's browser *back* into the MVC layer (remember our old URL that originally kicked off the process? /api/auth/ExternalLogin). Yep. I know. But, this time, we're going to get past the original ChallengeResult because were coming back as an externally authenticated user with the help of [OverrideAuthentication]+ [HostAuthentication(ExternalCookie)], which sets up the User.Identity.IsAuthenticated to return true skipping the challenge as shown below:

// GET api/auth/ExternalLogin
[OverrideAuthentication]
[HostAuthentication(DefaultAuthenticationTypes.ExternalCookie)]
[AllowAnonymous]
[Route("ExternalLogin", Name = "ExternalLogin")]
public async Task<IHttpActionResult> GetExternalLogin(string provider, string error = null)
{
    if( error != null )
    {
        return Redirect(Url.Content("~/") + "#error=" + Uri.EscapeDataString(error));
    }

    //ANONYMOUS will always get trapped here.
    if( !User.Identity.IsAuthenticated )
    {
        return new ChallengeResult(provider, this);
    }

    var externalLogin = ExternalLoginData.FromIdentity(User.Identity as ClaimsIdentity);

    if( externalLogin == null )
    {
        return InternalServerError();
    }

    if( externalLogin.LoginProvider != provider )
    {
        Authentication.SignOut(DefaultAuthenticationTypes.ExternalCookie);
        return new ChallengeResult(provider, this);
    }

    var user = await this.UserManager.FindAsync(new UserLoginInfo(externalLogin.LoginProvider, externalLogin.ProviderKey));

    var hasRegistered = user != null;

    if( hasRegistered )
    {
        Authentication.SignOut(DefaultAuthenticationTypes.ExternalCookie);

        var oAuthIdentity = await this.UserManager.GenerateUserIdentityAsync(user, OAuthDefaults.AuthenticationType);
        var cookieIdentity = await this.UserManager.GenerateUserIdentityAsync(user, CookieAuthenticationDefaults.AuthenticationType);

        var properties = this.UserManager.CreateProperties(user);

        Authentication.SignIn(properties, oAuthIdentity, cookieIdentity);
        return Redirect(Url.Content("~/app#home"));
    }

    //so even if we get here, ANONYMOUS will never get this far.
    //The browser has been authorized by an external provider.
    //The next step is for the browser to call /RegisterExternal
    //to register an account. We'll throw up an authorized splash to confirm.

    return Redirect(Url.Content("~/app#/authorized"));
}

This time, we hit the bottom of the /api/ExternalLogin call.  This is the part where you can customize the login process. The OWIN middleware has already done the hard work for you. In the case where the user has never registered on WebApp, you have a small window of time to use the ExternalCookie login information for purposes of registration. We redirect the user's browser to ~/app#/authorized, where we throw up a screen and ask for a username. The user clicks "Register" which then calls our Web API endpoint /api/auth/RegisterExternal.

// POST api/auth/RegisterExternal
[OverrideAuthentication]
[HostAuthentication(DefaultAuthenticationTypes.ExternalCookie)]
[Route("RegisterExternal")]
public async Task<IHttpActionResult> RegisterExternal(RegisterExternalBindingModel model)
{
    if( !ModelState.IsValid )
    {
        return BadRequest(ModelState);
    }

    var info = await Authentication.GetExternalLoginInfoAsync();
    if( info == null )
    {
        return InternalServerError();
    }

    var user = await this.UserManager.FindAsync(info.Login);
    
    var hasRegistered = user != null;
    
    if( hasRegistered )
    {
        return BadRequest("External user already registered.");
    }

    user = new User() { UserName = model.Email, Email = model.Email };

    var result = await this.UserManager.CreateAsync(user);
    if( !result.Succeeded )
    {
        return GetErrorResult(result);
    }

    result = await this.UserManager.AddLoginAsync(user.Id, info.Login);
    if( !result.Succeeded )
    {
        return GetErrorResult(result);
    }
    return Ok();
}

And our user now has a local account.

Next blog post I'll will show how to transform the ExternalCookie AuthenticationType into a local usable access_token for Token Barer Authentication for Web API during the life time of the SPA application.

HTH,
Brian Chavez

9 Comments Filed Under [ ASP.NET C# ]