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>
public class LoginViewModel{ public string UserName { get; set; } public string Password { get; set; } public string ReturnUrl { get; set; }}
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"); }}
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; }}
Resolving cookie options
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 usesIOptionMonitor
. IOptionMonitor
invokesCreate
method onIOptionsFactory
.IOptionsFactory
creates options object and after that invokesConfigure
method onIConfigureNamedOptions
.IConfigureNamedOptions
sets tenant specific options.
In this solution, we are going to register our own implementation of IOptionsMonitor
and IConfigureNamedOptions
.
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);}
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;}
Registering cookie authentication handler
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:
- Share authentication cookies among ASP.NET apps
- Data Protection key management and lifetime in ASP.NET Core
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); }); }}
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();
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.
When we switch to tenant2
we are no longer authenticated.
Let’s log in as tenant2User
. We’ve successfully signed in.
When we switch to tenant1
we are still authenticated but as a different user.
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.
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.