Switch tenants in an ASP.NET Core app using Azure AD with multi tenants

This article shows how to switch between tenants in an ASP.NET Core multi-tenant application using a multi-tenant Azure App registration to implement the identity provider. Azure roles are added to the Azure App registration and this can be used in the separate enterprise applications created from the multi-tenant Azure App registration to assign users and groups.

Code: https://github.com/damienbod/AspNetCoreTenantSelect

Azure AD is used to implement the identity provider for the ASP.NET Core application. In the home tenant, an Azure App registration was created to support multiple tenants. Three roles for users and groups were created and added to the Azure App registration. The first time a user authenticates using the Azure App registration, an administrator can give consent for the tenant. This creates an Azure enterprise application inside the corresponding tenant. Users or groups can be assigned the roles from the Azure App registration. This is specific for the corresponding tenant only.

If a user exists in two separate tenants, the user needs an easy way to switch between the tenants without a logout and a login. The user can be assigned separate roles in each tenant. The email is used to identify the user, as separate OIDs are created for each tenant. The user can be added as an external user in multiple tenants with the same email.

The ASP.NET Core application uses the Azure App registration to authentication.

The ASP.NET Core application uses Microsoft.Identity.Web to implement the OpenID Connect client. This client uses MSAL. The user of the application needs a way to switch between the tenants. To do this, the specific tenant must be used in the authorize request of the OpenID Connect flow. If the common endpoint is used, which is the standard for a multi-tenant Azure App registration, the user cannot switch between the tenants without an account logout first or using a separate incognito browser.

A cache is used to store the preferred tenant of the authenticated user. The user of the application can select the required tenant and the tenant is used for authentication. Before the authorize request is sent to Azure AD, the ProtocolMessage.IssuerAddress is used with the correct tenant GUID identifier. The prompt select_account was added for the authorize request in the OpenID Connect flow so that the user will always be asked to choose an account. Most of us have multiple identities and account nowadays.

The application requires an authenticated user. The default authentication uses the common endpoint and no select account prompt.

There are different ways to implement the switch tenant logic. I have not focused on this. I just add the selected organization to an in-memory cache. You can for example keep a database of your specific allowed organizations and authorize this after a successful authentication using claims returned from the identity provider. You could also provide the organization as a query parameter in the URL. The Azure AD Microsoft.Identity.Web.UI client and ASP.NET Core application requires that the application starts the authentication flow from a HTTP GET, and not a redirect to a GET or a POST request.

services.AddTransient<TenantProvider>();

services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"));

WebApplication? app = null;

services.Configure<MicrosoftIdentityOptions>(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    options.Prompt = "select_account";
    
    var redirectToIdentityProvider = options.Events.OnRedirectToIdentityProvider;
    options.Events.OnRedirectToIdentityProvider = async context =>
    {
        if(app != null)
        {
            var tenantProvider = app.Services.GetRequiredService<TenantProvider>();
            var email = context.HttpContext!.User.Identity!.Name;
            if (email != null)
            {
                var tenant = tenantProvider.GetTenant(email);
                var address = context.ProtocolMessage.IssuerAddress.Replace("common", tenant.Value);
                context.ProtocolMessage.IssuerAddress = address;
            }
        }

        await redirectToIdentityProvider(context);
    };
});

services.AddRazorPages().AddMvcOptions(options =>
{
    var policy = new AuthorizationPolicyBuilder()
                     .RequireAuthenticatedUser()
                     .Build();
    options.Filters.Add(new AuthorizeFilter(policy));
}).AddMicrosoftIdentityUI();

The TenantProvider service implements the tenant select logic so that a user can switch between tenants or accounts without signing out or switching browsers. This can be replaced with a database or logic as your business requires. I hard coded some test tenants for the organization switch. Some type of persistence or database would be better for this. An in-memory cache is used to persist the user and the preferred organization.

public class TenantProvider
{
    private static readonly SelectListItem _org1 = new("Org1", "7ff95b15-dc21-4ba6-bc92-824856578fc1");
    private static SelectListItem _org2 = new("Org2", "a0958f45-195b-4036-9259-de2f7e594db6");
    private static SelectListItem _org3 = new("Org3", "5698af84-5720-4ff0-bdc3-9d9195314244");
    private static SelectListItem _common = new("common", "common");

    private static readonly object _lock = new();
    private IDistributedCache _cache;
    private const int cacheExpirationInDays = 1;

    public TenantProvider(IDistributedCache cache)
    {
        _cache = cache;
    }

    public void SetTenant(string email, string org)
    {
        AddToCache(email, GetTenantForOrg(org));
    }

    public SelectListItem GetTenant(string email)
    {
        var org = GetFromCache(email);

        if (org != null)
            return org;

        return _common;
    }

    public List<SelectListItem> GetAvailableTenants()
    {
        return new List<SelectListItem> { _org1, _org2, _org3, _common };
    }

    private SelectListItem GetTenantForOrg(string org)
    {
        if (org == "Org1")
            return _org1;
        else if (org == "Org2")
            return _org2;
        else if (org == "Org3")
            return _org3;

        return _common;
    }

    private void AddToCache(string key, SelectListItem userActiveOrg)
    {
        var options = new DistributedCacheEntryOptions()
            .SetSlidingExpiration(TimeSpan.FromDays(cacheExpirationInDays));

        lock (_lock)
        {
            _cache.SetString(key, JsonSerializer.Serialize(userActiveOrg), options);
        }
    }

    private SelectListItem? GetFromCache(string key)
    {
        var item = _cache.GetString(key);
        if (item != null)
        {
            return JsonSerializer.Deserialize<SelectListItem>(item);
        }

        return null;
    }
}

An ASP.NET Core Razor Page is used to implement the tenant switch UI logic. This just displays the available tenants and allows the user to choose a new tenant.

public class SwitchTenantModel : PageModel
{
    private readonly TenantProvider _tenantProvider;

    public SwitchTenantModel(TenantProvider tenantProvider)
    {
        _tenantProvider = tenantProvider;
    }

    [BindProperty]
    public string Domain { get; set; } = string.Empty;

    [BindProperty]
    public string TenantId { get; set; } = string.Empty;

    [BindProperty]
    public List<string> RolesInTenant { get; set; } = new List<string>();

    [BindProperty]
    public string AppTenantName { get; set; } = string.Empty;

    [BindProperty]
    public List<SelectListItem> AvailableAppTenants { get; set; } = new List<SelectListItem>();

    public void OnGet()
    {
        var name = User.Identity!.Name;

        if (name != null)
        {
            AvailableAppTenants = _tenantProvider.GetAvailableTenants();
            AppTenantName = _tenantProvider.GetTenant(name).Text;

            List<Claim> roleClaims = HttpContext.User.FindAll(ClaimTypes.Role).ToList();

            foreach (var role in roleClaims)
            {
                RolesInTenant.Add(role.Value);
            }

            TenantId = HttpContext.User.FindFirstValue("http://schemas.microsoft.com/identity/claims/tenantid");
        }
    }

    /// <summary>
    /// Only works from a direct GET, not a post or a redirect
    /// </summary>
    public IActionResult OnGetSignIn([FromQuery]string domain)
    {
        var email = User.Identity!.Name;
        if(email != null)
            _tenantProvider.SetTenant(email, domain);

        return Challenge(new AuthenticationProperties { RedirectUri = "/" },
                OpenIdConnectDefaults.AuthenticationScheme);
    }
}

The Index Razor page in the ASP.NET Core application displays the actually tenant, the organization and the roles for this identity in this tenant.

public void OnGet()
{
	var name = User.Identity!.Name;

	if(name != null)
	{
		AvailableAppTenants = _tenantProvider.GetAvailableTenants();
		AppTenantName = _tenantProvider.GetTenant(name).Text;

		List<Claim> roleClaims = HttpContext.User.FindAll(ClaimTypes.Role).ToList();

		foreach (var role in roleClaims)
		{
			RolesInTenant.Add(role.Value);
		}

		TenantId = HttpContext.User.FindFirstValue(
			"http://schemas.microsoft.com/identity/claims/tenantid");
	}
}

After a successful authentication using Azure AD and the multi-tenant Azure App registration, the user can see the assigned roles and the tenant.

The tenant switch is displayed in a HTML list and the authentication request with the select account prompt is sent to Azure AD.

The new tenant and the new corresponding roles for the authorization are displayed after a successful authentication.

Switching tenants is becoming a required feature in most applications now that we have access to multiple Azure AD tenants and domains using the same email. This makes using external identities for an Azure AD user in a multiple domain environment a little less painful.

Notes

If using this in environment where all tenants are not allowed, the tid claim must be validated. You should always restrict the tenants in a multi tenant application if possible. You could force this by adding a tenant requirement.

services.AddRazorPages().AddMvcOptions(options =>
{
    var policy = new AuthorizationPolicyBuilder()
                     .RequireAuthenticatedUser()
                     // Eanble to force tenant restrictions
                     .AddRequirements(new[] { new TenantRequirement() })
                     .Build();
    options.Filters.Add(new AuthorizeFilter(policy));
}).AddMicrosoftIdentityUI();

Links

https://learn.microsoft.com/en-us/azure/active-directory/fundamentals/multi-tenant-user-management-introduction

https://github.com/AzureAD/microsoft-identity-web

https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app

https://learn.microsoft.com/en-us/azure/active-directory/manage-apps/add-application-portal

5 comments

  1. […] Switch tenants in an ASP.NET Core app using Azure AD with multi tenants (Damien Bowden) […]

  2. […] Switch tenants in an ASP.NET Core app using Azure AD with multi tenants – Damien Bowden […]

  3. […] Switch tenants in an ASP.NET Core app using Azure AD with multi tenants […]

  4. […] Switch tenants in an ASP.NET Core app using Azure AD with multi tenants [#.NET #.NET Core #App Service #ASP.NET Core #Azure #Azure AD #dotnet #OAuth2 #aspnetcore #AzureAD #IdentityServer4 #OIDC #openid-connect #tenant] […]

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.