March 3, 2016

Multi-tenant middleware pipelines in ASP.NET Core

ASP.NET Core Applications are created using middleware components that are assembled together to form a HTTP pipeline. Each middleware component has the opportunity to modify the HTTP request and response before passing it on to the next component in the pipeline, as the following diagram illustrates:

ASP.NET Core Middleware Sequence Diagram

In a typical ASP.NET Core application you’d likely have a number of middleware components configured, for example:

  • Static Files Middleware
  • Authentication Middleware
  • MVC

If you’re using SaasKit you’ll also be using the multi-tenancy middleware that resolves tenants on each request:

app.UseMultitenancy<AppTenant>();

Authentication Middleware

A few weeks ago I wrote about how to isolate tenant data using Entity Framework. The example application in that article demonstrated how we could have separate membership databases for each tenant. It didn’t however show some of the issues using ASP.NET Authentication Middleware in multi-tenant environments.

An unfortunate design choice in the authentication middleware means that middleware options are created at the point the middleware is registered. For example, here’s how we would register the Google OAuth middleware:

 builder.UseGoogleAuthentication(options =>
 {
     options.AuthenticationScheme = "Google";
     options.SignInScheme = "Cookies";

     options.ClientId = "xxx";
     options.ClientSecret = "xxx";
 });

Looking at the source for this extension method we can see that the options are created immediately before registering the middleware:

public static IApplicationBuilder UseGoogleAuthentication(this IApplicationBuilder app, GoogleOptions options)
{
    if (app == null)
    {
        throw new ArgumentNullException(nameof(app));
    }
    if (options == null)
    {
        throw new ArgumentNullException(nameof(options));
    }

    return app.UseMiddleware<GoogleMiddleware>(Options.Create(options));
}

A simplified illustration of the pipeline would look something like this:

ASP.NET Core Pipeline

The best way to illustrate why this is a problem is using an example. I’ve wired up both the cookie and Google OAuth middleware so if you ever wanted to know how to do this from scratch without ASP.NET Identity, you’re in luck:

app.UseCookieAuthentication(options =>
{
    options.AuthenticationScheme = "Cookies";
    options.LoginPath = new PathString("/account/login");
    options.AccessDeniedPath = new PathString("/account/forbidden");
    options.AutomaticAuthenticate = true;
    options.AutomaticChallenge = true;
});

app.UseGoogleAuthentication(options =>
{
    options.AuthenticationScheme = "Google";
    options.SignInScheme = "Cookies";

    options.ClientId = "xxx";
    options.ClientSecret = "xxx";
});

The AccountController supports logging in with Google:

public IActionResult Google()
{
    var props = new AuthenticationProperties
    {
        RedirectUri  = "/home/about"
    };
    
    return new ChallengeResult("Google", props);
}

Note: Returning a ChallengeResult is effectively the same as calling:

HttpContext.Authentication.ChallengeAsync("Google");

And of course we have a way to log out:

public async Task<IActionResult> LogOut()
{
    await HttpContext.Authentication.SignOutAsync("Cookies");

    return RedirectToAction("index", "home");
}

The about page has been protected by decorating the corresponding action method with [Authorize] and simply lists out the claims of the current authenticated user:

<table class="table">
  @foreach (var claim in User.Claims)
  {
      <tr>
        <td>@claim.Type</td>
        <td>@claim.Value</td>
      </tr>
  }
</table>

So nothing too exciting here.

Running the example sets up two tenants on http://localhost:60000 (Tenant 1) and http://localhost:60001 (Tenant 2). Running the app (dnx web) and browsing to Tenant 1’s site I can successfully log in with Google:

Google Consent Screen

After giving my consent I’m redirected back to the app and can see my claims on the About page:

Google Claims

So that all works fine. Now let’s browse to http://localhost:60001 (Tenant 2). Notice anything odd?

Logged in twice

In case you didn’t spot it, we’re already logged in! This is because we’re using the same localhost domain name for both tenants and cookies don’t honour ports. Whilst this may not be a common scenario in public web apps, you would have the same problem if you wanted to identify your tenants by path e.g. http://localhost/tenantid.

The solution is to name your auth cookie differently per tenant but since the Cookie Auth options are created when the middleware is registered, all tenants get the same options.

To demonstrate the second issue we’ll log out of Tenant 2 and attempt to log in with Google:

Google OAuth error page

This time we get an error concerning our callback URL since we’ve configured the Google Auth middleware to use Tenant 1’s settings which obviously have a different callback URL.

Again this is caused by the fact that the auth middleware options are created once per application.

Let’s fork!

Whilst this issue has been raised, it’s not going to be fixed before ASP.NET Core v1 is released.

One possible solution is to register the middleware per tenant which we can do by forking the root request pipeline:

Forked ASP.NET Core pipelines

In the above diagram you can see we have forked pipelines for two tenants each with their own instances of the authentication middleware.

The latest SaasKit release makes this possible via the UsePerTenant extension on IApplicationBuilder enabling you to create independent request pipelines per tenant.

To fix the example application we can do the following:

app.UsePerTenant<AppTenant>((ctx, builder) =>
{
    builder.UseCookieAuthentication(options =>
    {
        options.AuthenticationScheme = "Cookies";
        options.LoginPath = new PathString("/account/login");
        options.AccessDeniedPath = new PathString("/account/forbidden");
        options.AutomaticAuthenticate = true;
        options.AutomaticChallenge = true;

        options.CookieName = $"{ctx.Tenant.Id}.AspNet.Cookies";
    });

    builder.UseGoogleAuthentication(options =>
    {
        options.AuthenticationScheme = "Google";
        options.SignInScheme = "Cookies";

        options.ClientId = Configuration[$"{ctx.Tenant.Id}:GoogleClientId"];
        options.ClientSecret = Configuration[$"{ctx.Tenant.Id}:GoogleClientSecret"];
    });
});

Notice that I have access to the current tenant when I configure the pipeline so I’m able to obtain the tenant specific Google auth settings. I’m also naming the auth cookie differently per tenant so I can use the same domain but different ports/paths.

If you’d like to run the example yourself you’ll need to add tenantX:GoogleClientId and tenantX:GoogleClientSecret to your appSettings.json. I’m using user secrets rather than checking my precious keys into GitHub.

Taking it further

Being able to create pipelines per tenant opens up many possibilities. Rather than just configuring the same middleware components per tenant you could create custom pipelines depending on a tenant’s configuration:

app.UsePerTenant<AppTenant>((ctx, builder) =>
{
    if (ctx.Tenant.UseGoogleAuth) {
	    builder.UseGoogleAuthentication(options =>
	    {

	    });
    }

    if (ctx.Tenant.UseFacebookAuth) {
	    builder.UseFacebookAuthentication(options =>
	    {
	        
	    });
    }
});

Wrapping Up

The UsePerTenant method in SaasKit enables you to create forked request pipelines for each tenant which among many other possibilities means you can use the ASP.NET Authentication Middleware in multi-tenant applications.

Special thanks to Joe Audette, Sébastien Ros and Kévin Chalet for their valuable feedback and help with this feature.

© 2022 Ben Foster