October 9, 2015

How to test OWIN OAuth Middleware

We recently integrated Fabrik with Vimeo so that our customers can browse and add their Vimeo videos directly within our dashboard.

The Vimeo API uses OAuth 2 for authentication so I wrote an OWIN OAuth authentication provider (which I contributed here).

If you’re familiar with the OWIN authentication middleware you’ll know you need to provide some kind of callback URL to handle the response from the third-party provider i.e. Vimeo. A simplified version of our handler looks like so:

public async Task<ActionResult> ExternalLoginCallback(string returnUrl)
{
    var loginInfo = await authenticationManager.GetExternalLoginInfoAsync(XsrfKey, User.UserId.ToString());
    
    // Handle cases where the access token is not being returned
    if (loginInfo == null)
    {
        return RedirectToAction("Login");
    }

    // Store the provider login details and claims
    var user = await userManager.FindUserByUsernameAsync(User.Email);
    var login = user.SetLogin(loginInfo.Login.LoginProvider, loginInfo.Login.ProviderKey);

    foreach (var claim in loginInfo.ExternalIdentity.Claims)
    {
        login.AddClaim(claim.Type, claim.Value);
    }

    user.AuditLogin(loginInfo.Login.LoginProvider, Request.UserHostAddress);
    await userManager.UpdateUserAsync(user);

    // Drop the external auth cookie since we've stored the provider details
    authenticationManager.SignOut(DefaultAuthenticationTypes.ExternalCookie);

    return HandleRedirect(returnUrl);
}

Hopefully the code is pretty self explanatory. We’re using our own hand-rolled identity system but it’s similar to ASP.NET Identity in that each User can have logins for one or more providers.

When we get a valid authenticated response from Vimeo we create a new Login for the user and store all the claims returned from the authentication middleware. This includes information like the Vimeo user name, avatar and bearer token (so we can authenticate requests to the Vimeo API).

When it came to testing this, I created a mock IAuthenticationManager (we’re using NSubstitute) and proceeded to mock the call to GetExternalLoginInfoAsync.

Unfortunately this does not work since it’s an extension method. Instead you need to mock IAuthenticationManager.AuthenticateAsync which is what is what is being called internally. The minimum required is to create a ClaimsIdentity that includes the NameIdentifier claim. Make sure you set the issuer if you rely on the name of the provider.

Since we’re using the overload of GetExternalLoginInfoAsync that performs an XRSF check it’s also necessary to set the properties of the AuthenticateResult providing our XSRF key and the expected value:

context["given the external login is valid"] = () =>
{
    User user = null;
    
    before = () =>
    {
        user = new User("a@b.com", "Test", "user", FabrikLogin.FabrikLoginProviderId, "a@b.com");
        userManager.FindUserByUsernameAsync("a@b.com").Returns(Task.FromResult(user));
        
        var externalUser = new ClaimsIdentity(new[] {
            new Claim(ClaimTypes.NameIdentifier, "123456", null, "vimeo"),
            new Claim(CustomClaimTypes.AppAccessToken, "xxx")
        });

        var properties = new Dictionary<string, string>
        {
            { "XsrfId", "1" } // user id
        };

        var authenticationResult = new AuthenticateResult(externalUser, new AuthenticationProperties(properties), new AuthenticationDescription());
        authenticationManager.AuthenticateAsync(Arg.Any<string>()).Returns(Task.FromResult(authenticationResult));
    };

    it["should create a login for the external provider"] = () 
        => user.FindLogin("vimeo").should_not_be_null();

    it["should add any claims returned by the external provider"] = () 
        => user.FindLogin("vimeo").GetClaimValue(CustomClaimTypes.AppAccessToken).should_be("xxx");

    it["should audit the login"] = () 
        => user.LastLoginDate.Date.should_be(DateTime.UtcNow.Date);

    it["should update the user"] = () 
        => userManager.Received().UpdateUserAsync(user);

    it["should remove the external login cookie"] = () 
        => authenticationManager.Received().SignOut(DefaultAuthenticationTypes.ExternalCookie);

    it["should redirect the user to the return url"] = ()
        => result.should_cast_to<RedirectResult>().Url.should_be("/#/projects");
};

Happy testing!

© 2022 Ben Foster