Home > Mobile >  AspNetCore: How to mock external authentication / Microsoft account for integration tests?
AspNetCore: How to mock external authentication / Microsoft account for integration tests?

Time:03-01

I have an OpenID Connect / OAuth2 server (IdP) in my application stack. IdP allows both local and external authentication.

I have integration tests covering most scenarios, but struggle to create a end-to-end test for an external authentication scenario. There are multiple external providers, but from my application perspective they are all using the same workflow over OpenID Connect, only have slight difference (parameters, ie. redirect uri, scheme name, etc.). So it is enough to test one of them. One of them is Microsoft Account (aka. Azure AD)

Integration test is based on WebApplicationFactory (in-memory server with corresponding HttpClient). Local authentication is quite easy, because the whole part runs in my application domain, have access to full source code, etc. I simply create a request to the authorization endpoint and post back user credentials when prompted (I still need to parse the login page to retrieve the antiforgery token, but that's doable)

But when it comes to external, for example Microsoft Account, login involves multiple steps via AJAX and the final post with over 10 parameters, which I unable to reverse engenineer. Other provider has also same level of difficulty.

Since external providers are just blackboxes, from my IdP's perspective, it's just issuing a challenge (redirect to external authorization) and pick up after redirect. Is there a good way to mock the "in between" part?

CodePudding user response:

My solution was to create a middleware, which will mock the external authentication. And then re-configure options for the external authentication scheme to direct to the path middleware is handling. You may also want to overwrite the signingkey (or turn of signature validation). So this code goes to WebApplicationFactory's ConfigureServices/ConfigureTestServices (etc., depending on your setup), to override original setup:

services.AddTransient<IStartupFilter, FakeExternalAuthenticationStartupFilter>();
services.Configure(AuthenticationSchemes.ExternalMicrosoft, (OpenIdConnectOptions options) =>
{
    options.Configuration = new OpenIdConnectConfiguration
    {
        AuthorizationEndpoint = FakeExternalAuthenticationStartupFilter.AuthorizeEndpoint,
    };

    options.TokenValidationParameters.IssuerSigningKey = FakeExternalAuthenticationStartupFilter.SecurityKey;
});

Remark: WebApplicationFactory does not provide a way to override IApplicationBuilder (middleware) stack, so need to add IStartupFilter

The middleware then needs to issue a token with the security key and issue a form post back to the redirect uri. The usual way to achieve this to return simple HTML page with a form which will submit itself once loaded. This works fine in browsers, but HttpClient won't do anything, so the test have to parse the response and create a post request manually.

While this is doable, I wanted to spare this extra step, having to parse respond and re-send it, and make it a single step. Difficulties were:

  • redirect is not possible (starts as GET request, should ended as POST, need also form data)
  • cookies issued by OpenIdConnectHandler before redirecting (correlation and nonce) necessary to restore state, only available at redirect uri path (Set-Cookie with path=)

My solution was creating a middleware handling authorization (GET) requests at the same path as the redirect uri is set up, issue token and rewrite request so that OpenIdConnectHandler would pick up. Here's middleware's Invoke method:

public async Task Invoke(HttpContext httpContext)
{
    if (!HttpMethods.IsGet(httpContext.Request.Method) || !httpContext.Request.Path.StartsWithSegments(AuthorizeEndpoint))
    {
        await _next(httpContext);
        return;
    }

    // get and validate query parameters
    // Note: these are absolute minimal, might need to add more depending on your flow logic
    var clientId = httpContext.Request.Query["client_id"].FirstOrDefault();
    var state = httpContext.Request.Query["state"].FirstOrDefault();
    var nonce = httpContext.Request.Query["nonce"].FirstOrDefault();

    if (clientId is null || state is null || nonce is null)
    {
        httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
        return;
    }

    var token = CreateToken(clientId, state, nonce); // CreateToken implementation omitted, use same signing key as used above

    httpContext.Request.Method = HttpMethods.Post;
    httpContext.Request.QueryString = QueryString.Empty;
    httpContext.Request.ContentType = "application/x-www-form-urlencoded";
    var content = new FormUrlEncodedContent(new Dictionary<string, string>()
    {
        ["id_token"] = token,
        ["token_type"] = "Bearer",
        ["expires_in"] = "3600",
        ["state"] = state,
    });

    using var buffer = new MemoryStream();
    await content.CopyToAsync(buffer, httpContext.RequestAborted);
    buffer.Seek(offset: 0, loc: SeekOrigin.Begin);

    var oldBody = httpContext.Request.Body;
    httpContext.Request.Body = buffer;

    await _next(httpContext);

    httpContext.Request.Body = oldBody;
}
  • Related