Multi-tenant ASP.NET Core app - configuring authentication

Part 2 of 3

In this article, we will set up multi-tenant authentication. How is it supposed to work? When a user is logged in within tenant1 then they will not be a valid user from the tenant2 point of view. We will use cookie-based authentication for that.

Parts of the series

Multi-tenant ASP.NET Core app - configuring authentication

We are going to extend the code based on the solution from the previous article. Obviously, you can follow along by downloading the whole project from my GitHub. This solution in based on ASP.NET Core 8.

Preparing basic sign-in mechanism

First of all, let’s create a very basic login form (Login.cshtml). It sends POST requests to Login action on AccountController. The view’s model is of the type LoginViewModel and contains UserName, Password, and ReturnUrl properties.

@model MultitenantWebApp.LoginViewModel
<div>Please login</div>
<form asp-controller="Account" asp-action="Login" method="post">    <input type="hidden" asp-for="ReturnUrl"/>    <div>        Login: <input type="text" asp-for="UserName"/>    </div>    <div>        Password: <input type="password" asp-for="Password"/>    </div>    <input type="submit" value="Login"/></form>
HTML
public class LoginViewModel{    public string UserName { get; set; }    public string Password { get; set; }    public string ReturnUrl { get; set; }}
C#

Now we need to implement AccountController. It has Login action that can be invoked with GET or POST requests. GET Login returns Login view, POST Login creates ClaimsPrincipal object based on ViewModel. For simplicity, there is no user’s credentials validation mechanism. In other words, all usernames and passwords are valid (very secure 😄!). This is the place where you can add your own validation logic.

After successfully signing in, the application redirects the user to the tenant base URL. Unfortunately Redirect("/") won’t cooperate with PathBase which is set in TenantResolutionMiddleware. That’s one of the drawbacks of this resolution strategy. There’s also Logout a method that deletes a user’s cookie.

public class AccountController(ITenantContext tenantContext) : Controller{    [HttpGet]    public ActionResult Login([FromQuery] string returnUrl)    {        return View(new LoginViewModel        {            ReturnUrl = returnUrl        });    }
    [HttpPost]    [AutoValidateAntiforgeryToken]    public async Task<ActionResult> Login([FromForm] LoginViewModel viewModel)    {        var claims = new List<Claim>        {            new("UserName", viewModel.UserName)        };
        var identity = new ClaimsIdentity(            claims, CookieAuthenticationDefaults.AuthenticationScheme);
        var claimsPrincipal = new ClaimsPrincipal(identity);
        await HttpContext.SignInAsync(            CookieAuthenticationDefaults.AuthenticationScheme, claimsPrincipal);
        return Redirect(viewModel.ReturnUrl ?? $"/{tenantContext.CurrentTenant.Name}");    }
    [HttpGet]    public async Task<ActionResult> Logout()    {        await HttpContext.SignOutAsync();        return RedirectToAction("Login");    }}
C#

In HomeController we also need to modify Index method so it returns logged user’s information. Note that this controller is also decorated with AuthorizeAttribute. As a result, only users that have signed in are able to invoke actions here.

[Authorize]public class HomeController(ITenantContext tenantContext) : Controller{    [HttpGet]    public ActionResult Index()    {        var loggedUserName = User.Claims.Single(x => x.Type == "UserName").Value;        var tenantName = tenantContext.CurrentTenant.Name;
        return View(new HomeViewModel        {            Message = $"User '{loggedUserName}' is logged on tenant '{tenantName}'"        });    }}
public class HomeViewModel{    public required string Message { get; init; }}
C#

In order to make it work correctly and securely, we need to resolve tenant-specific cookie options per request. In this scenario, it will be a cookie name and data protection, provider. The last one is needed to hold the user back from decrypting another tenant's cookie.

Our flow works like this:

  • Authentication handler requests CookieAuthenticationOptions object. For that purpose it uses IOptionMonitor.
  • IOptionMonitor invokes Create method on IOptionsFactory.
  • IOptionsFactory creates options object and after that invokes Configure method on IConfigureNamedOptions.
  • IConfigureNamedOptions sets tenant specific options.

In this solution, we are going to register our own implementation of IOptionsMonitor and IConfigureNamedOptions.

Multi-tenant app - favicon

Let’s start from the end – IConfigureNamedOptions. It takes IHttpContextAccessor and enigmatic Action in a constructor. This action is a delegate that will be set on the dependency container level. In Configure method, apart from validating some stuff, we just call injected Action. There is also an overload for Configure method which is required by IConfigureNamedOptions interface. Options factory won’t use it so for simplicity it just delegates to the appropriate Configure method with Options.DefaultName which is an empty string. You can see the source code of OptionsFactory here.

public class CookieConfigureNamedOptions(    IHttpContextAccessor httpContextAccessor,    Action<CookieAuthenticationOptions, HttpContext> configureAction)    : IConfigureNamedOptions<CookieAuthenticationOptions>{    public void Configure(string name, CookieAuthenticationOptions options)    {        if (!string.Equals(name,                CookieAuthenticationDefaults.AuthenticationScheme,                StringComparison.Ordinal))        {            return;        }
        var httpContext = httpContextAccessor?.HttpContext;
        if (httpContext == null)        {            throw new InvalidOperationException("HttpContext is not available.");        }
        configureAction(options, httpContext);    }
    public void Configure(CookieAuthenticationOptions options)        => Configure(Options.DefaultName, options);}
C#

Now it’s time to implement IOptionsMonitor. The only responsibility of this is to call Create method on IOptionsFactory. We are going to register it as a singleton, so you can potentially add some kind of cache here (with keys per tenant). Because options instance is lightweight, we are not going to do this here, but feel free to adjust it to your needs.

The reason why we register our own implementation of IOptionsMonitor is that we need to bypass the cache provided by the default implementation (see source code). It holds CookieAuthenticationOptions under the Cookies key, so every tenant would get the same configuration.

public class CookieOptionsMonitor(    IOptionsFactory<CookieAuthenticationOptions> optionsFactory)    : IOptionsMonitor<CookieAuthenticationOptions>{    public CookieAuthenticationOptions CurrentValue => Get(Options.DefaultName);
    public CookieAuthenticationOptions Get(string name)    {        return optionsFactory.Create(name);    }
    public IDisposable OnChange(Action<CookieAuthenticationOptions, string> listener) => null;}
C#

In ServiceCollectionExtensions we can implement AddMultitenantAuthentication method which registers cookie authentication handler. This method also invokes AddPerRequestCookieOptions that contains registration of IOptionsMonitor and IConfigureOptions. As an argument, we provide a delegate that configures tenant-specific cookie options. It sets the cookie name and data protection provider.

As a result, tenant1 won’t be able to decrypt tenant2 cookie and create ClaimsPrincipal based on that. It’s also worth mentioning that purpose, which is an argument of CreateProtector, should be unique throughout your application. Note that if your app is hosted as multiple instances, it needs to synchronize encryption keys! In case you host it on Azure App Service it works out of the box. You can read more about it in these articles:

public static class ServiceCollectionExtensions{    // ...
    public static IServiceCollection AddMultitenantAuthentication(this IServiceCollection services)    {        services.AddAuthentication(options =>        {            options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;        }).AddCookie();
        services.AddPerRequestCookieOptions((options, httpContext) =>        {            var tenant = httpContext                .RequestServices.GetRequiredService<ITenantContext>()                .CurrentTenant;
            options.DataProtectionProvider = httpContext                .RequestServices.GetRequiredService<IDataProtectionProvider>()                .CreateProtector($"App.Tenants.{tenant.Name}");
            options.Cookie.Name = $"{tenant.Name}-Cookie";        });
        return services;    }
    private static void AddPerRequestCookieOptions(        this IServiceCollection services,        Action<CookieAuthenticationOptions, HttpContext> configAction)    {        services.AddSingleton<IOptionsMonitor<CookieAuthenticationOptions>, CookieOptionsMonitor>();        services.AddSingleton<IConfigureOptions<CookieAuthenticationOptions>>(provider =>        {            var httpContextAccessor = provider.GetRequiredService<IHttpContextAccessor>();            return new CookieConfigureNamedOptions(httpContextAccessor, configAction);        });    }}
C#

Finally, to make it work we extend HTTP pipeline (in Program.cs) with authentication and authorization middlewares. It needs to be before MapControllerRoute method.

var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
services.AddHttpContextAccessor();services.AddControllersWithViews();services.AddMultitenancy();services.AddMultitenantAuthentication();
var app = builder.Build();
app.UseDeveloperExceptionPage();app.UseStaticFiles();app.UseMiddleware<TenantResolutionMiddleware>();app.UseRouting();app.UseAuthentication();app.UseAuthorization();
app.MapControllerRoute(    name: "default",    pattern: "{controller=Home}/{action=Index}");
app.Run();
C#

Time for testing

Ok, let’s run the application. Open a browser and sign in to tenant1. After that, we are redirected to the home page with the appropriate message.

Multi-tenant authentication - tenant 1 signed in

When we switch to tenant2 we are no longer authenticated.

Multi-tenant authentication - tenant 2 login form

Let’s log in as tenant2User. We’ve successfully signed in.

Multi-tenant authentication - tenant 2 signed in

When we switch to tenant1 we are still authenticated but as a different user.

Multi-tenant authentication - result summary

Ok, let’s try to hack it 🐱‍💻! I’ve signed out from tenant2 and switched to tenant1. In developer tools, I changed the name of identity cookie to tenant2-Cookie and path to /tenant2. Then I opened the tenant2 route and as you can see I’m not authenticated. Application is not able to decrypt this cookie and create ClaimsPrincipal due to the data protection mechanism.

Multi-tenant authentication - hacking result

You can download the source code from my GitHub.

Conclusion

To sum up, we’ve created a mechanism that allows registering tenant-specific cookie options. Thanks to that users can use our application being signed in on different tenants. There are of course many solutions to solve this problem. The main purpose of this one is to be simple and effective. You can potentially extend it and adjust it to your needs.

© brokul.dev 2024