Sign-in using multiple clients or tenants in ASP.NET Core and Azure AD

The article shows how an ASP.NET Core application could implement a sign in and a sign out with two different Azure App registrations which could also be implemented using separate identity providers (tenants). The user of the application can decide to authenticate against either one of the Azure AD clients. The clients can also be deployed on separate Azure Active directories. Separate authentication schemes are used for both of the clients. Each client requires a scheme for the Open ID Connect sign in and the cookie session. The Azure AD client authentication is implemented using Microsoft.Identity.Web.

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

The clients are setup to use a non default Open ID Connect scheme and also a non default cookie scheme. After a successful authentication, the OnTokenValidated event is used to sign into the default cookie scheme using the claims principal returned from the Azure AD client. “t1” is used for the Open ID Connect scheme and “cookiet1” is used for the second scheme. No default schemes are defined. The second Azure App Registration client configuration is setup in the same way.

services.AddAuthentication()
   .AddMicrosoftIdentityWebApp(
	Configuration.GetSection("AzureAdT1"), "t1", "cookiet1");

services.Configure<OpenIdConnectOptions>("t1", options =>
{
	var existingOnTokenValidatedHandler 
		= options.Events.OnTokenValidated;
		
	options.Events.OnTokenValidated = async context =>
	{
		await existingOnTokenValidatedHandler(context);

		await context.HttpContext.SignInAsync(
			CookieAuthenticationDefaults
				.AuthenticationScheme, context.Principal);

	};
});

services.AddAuthentication()
	.AddMicrosoftIdentityWebApp(
		Configuration.GetSection("AzureAdT2"), "t2", "cookiet2");

services.Configure<OpenIdConnectOptions>("t2", options =>
{
	var existingOnTokenValidatedHandler = options.Events.OnTokenValidated;
	options.Events.OnTokenValidated = async context =>
	{
		await existingOnTokenValidatedHandler(context);

		await context.HttpContext.SignInAsync(
		   CookieAuthenticationDefaults
				.AuthenticationScheme, context.Principal);
	};
});

The AddAuthorization is used in a standard way and no default policy is defined. We would like the user to have the possibility to choose against what tenant and client to authenticate.

services.AddAuthorization();

services.AddRazorPages()
	.AddMvcOptions(options => { })
	.AddMicrosoftIdentityUI();

A third default scheme is added to keep the session after a successful authentication using the client schemes which authenticated. The identity is signed into this scheme after a successfully Azure AD authentication. The SignInAsync method is used for this in the OnTokenValidated event.

 services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie();

The Configure method is setup in a standard way.

public void Configure(IApplicationBuilder app)
{
	app.UseHttpsRedirection();
	app.UseStaticFiles();

	app.UseRouting();

	app.UseAuthentication();
	app.UseAuthorization();

	app.UseEndpoints(endpoints =>
	{
		endpoints.MapRazorPages();
		endpoints.MapControllers();
	});
}

The sign in and the sign out needs custom implementations. The SignInT1 method is used to authenticate using the first client and the SignInT2 is used for the second. This can be called from the Razor page view. The CustomSignOut is used to sign out the correct schemes and redirect to the Azure AD endsession endpoint. The CustomSignOut method uses the clientId of the Azure AD configuration to sign out the correct session. This value can be read using the aud claim.

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;

namespace AspNetCoreRazorMultiClients
{
    [AllowAnonymous]
    [Route("[controller]")]
    public class CustomAccountController : Controller
    {
        private readonly IConfiguration _configuration;

        public CustomAccountController(IConfiguration configuration)
        {
            _configuration = configuration;
        }

        [HttpGet("SignInT1")]
        public IActionResult SignInT1([FromQuery] string redirectUri)
        {
            var scheme = "t1";
            string redirect;
            if (!string.IsNullOrEmpty(redirectUri) && Url.IsLocalUrl(redirectUri))
            {
                redirect = redirectUri;
            }
            else
            {
                redirect = Url.Content("~/")!;
            }

            return Challenge(new AuthenticationProperties { RedirectUri = redirect }, scheme);
        }

        [HttpGet("SignInT2")]
        public IActionResult SignInT2([FromQuery] string redirectUri)
        {
            var scheme = "t2";
            string redirect;
            if (!string.IsNullOrEmpty(redirectUri) && Url.IsLocalUrl(redirectUri))
            {
                redirect = redirectUri;
            }
            else
            {
                redirect = Url.Content("~/")!;
            }

            return Challenge(new AuthenticationProperties { RedirectUri = redirect }, scheme);
        }

        [HttpGet("CustomSignOut")]
        public async Task<IActionResult> CustomSignOut()
        {
            var aud = HttpContext.User.FindFirst("aud");
            if (aud.Value == _configuration["AzureAdT1:ClientId"])
            {

                await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
                await HttpContext.SignOutAsync("cookiet1");
                var authSignOut = new AuthenticationProperties
                {
                    RedirectUri = "https://localhost:44348/SignoutCallbackOidc"
                };
                return SignOut(authSignOut, "t1");
            }
            else
            {
                await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
                await HttpContext.SignOutAsync("cookiet2");
                var authSignOut = new AuthenticationProperties
                {
                    RedirectUri = "https://localhost:44348/SignoutCallbackOidc"
                };
                return SignOut(authSignOut, "t2");
            }
        }
    }
}

The _LoginPartial.cshtml Razor view can use the CustomAccount controller method to sign in or sign out. The available clients can be selected in a drop down control.

<ul class="navbar-nav">
@if (User.Identity.IsAuthenticated)
{
        <li class="nav-item">
            <span class="navbar-text text-dark">Hello @User.Identity.Name!</span>
        </li>
        <li class="nav-item">
            <a class="nav-link text-dark" asp-controller="CustomAccount" asp-action="CustomSignOut">Sign out</a>
        </li>
}
else
{
        <li>
            <div class="main-menu">
                <div class="dropdown">
                    <button class="btn btn-primary dropdown-toggle" type="button" id="dropdownLangButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                        sign-in
                    </button>
                    <div class="dropdown-menu" aria-labelledby="dropdownLangButton">
                        <a class="dropdown-item" asp-controller="CustomAccount" asp-action="SignInT1" >t1</a>

                        <a class="dropdown-item"  asp-controller="CustomAccount" asp-action="SignInT2">t2</a>
                    </div>
                </div>
            </div>
        </li>
}
</ul>

The app.settings have the Azure AD settings for each client as required.

{
  "AzureAdT1": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "damienbodhotmail.onmicrosoft.com",
    "TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
    "ClientId": "46d2f651-813a-4b5c-8a43-63abcb4f692c",
    "CallbackPath": "/signin-oidc/t1",
    "SignedOutCallbackPath ": "/SignoutCallbackOidc"
    // "ClientSecret": "add secret to the user secrets"
  },
  "AzureAdT2": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "damienbodhotmail.onmicrosoft.com",
    "TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
    "ClientId": "8e2b45c2-cad0-43c3-8af2-b32b73de30e4",
    "CallbackPath": "/signin-oidc/t2",
    "SignedOutCallbackPath ": "/SignoutCallbackOidc"
    // "ClientSecret": "add secret to the user secrets"
  },

When the application is started, the user can login using any client as required.

This works really good, if you don’t know which tenant is your default scheme. If you always use a default scheme with one tenant default, then you can use the multiple-authentication-schemes example like defined in the Microsoft.Identity.Web docs.

Links:

https://github.com/AzureAD/microsoft-identity-web/wiki/multiple-authentication-schemes

https://github.com/AzureAD/microsoft-identity-web/wiki/customization#openidconnectoptions

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

https://docs.microsoft.com/en-us/aspnet/core/security/authentication

16 comments

  1. […] Sign-in using multiple clients or tenants in ASP.NET Core and Azure AD (Damien Bowden) […]

  2. Alexandre Jobin · · Reply

    You are right in time with this article! But one question, what’s the purpose of creating the default sign-in cookie? Do you really need it since that like a duplication of the one from the tenant?

    What I had to do to make it work without a second cookie is declaring my setup like this. But maybe it’s better your way since that the entire application code doesn’t have to know from which provider you come from. What’s your tought?

    services.AddControllersWithViews(options =>
    {
    var policy = new AuthorizationPolicyBuilder(
    “t1”,
    “t2”)
    .RequireAuthenticatedUser()
    .Build();

    options.Filters.Add(new AuthorizeFilter(policy));
    });

    services.AddAuthorization(options =>
    {
    options.DefaultPolicy = new AuthorizationPolicyBuilder(
    “t1”,
    “t2”)
    .RequireAuthenticatedUser()
    .Build();
    });

    1. Hi Alexandre, thanks, yes I use the third default cookie so that I do not need to fix the default to one of the clients. Maybe if I set one to the default and the other would sign-in here after, it might work as well => tried this and it does not work, so the third cookie for the sign-in looks like it is required

      Greetings Damien

      1. Alexandre Jobin · ·

        I can confirm that if you do like in the multiple-authentication-schemes demo, it will not work. The user cannot navigate on your website with the t2 cookie because all the back-end only check the default one. I had an hard time configuring the demo project so it can work on both providers. My solution was to setup the site so it listen to the 2 cookies like I posted earlier.

        Now, I’m not sure which one I should chose. My solution or your solution. Maybe mine will be more granular since that I can decorate my action with [Authorize(AuthenticationSchemes = “t2”)] so it will only allow the t2 users?

      2. Alexandre Jobin · ·

        Well it seems that [Authorize(AuthenticationSchemes = “t2”)] does not work with my solution anyway.

      3. You can create a policy for this which will work, for example just create a policy using the aud claim or something like this. greetings Damien

  3. […] Sign-in using multiple clients or tenants in ASP.NET Core and Azure AD – Damien Bowden […]

  4. Alexandre Jobin · · Reply

    Have you tested to configure GraphClient when you have 2 providers by user the AddMicrosoftGraph extension? It only seems to work if you configure only one provider with all the defaults values but with the setup that you have, the Graph client is not well configured. Any thought?

  5. @damienbod What strategy would you take when using IdentityServer 4/5 + ASP.Net Identity (no AAD), when taking into consideration that you could also have different roles in each tenant?

    My initial thoughts are to implement some custom logic to support this via `ApplicationUser` & `ApplicationRole` when registering Identity e.g.
    `services.AddIdentity()`, which is similar to Scott Brady’s approach here:
    https://www.scottbrady91.com/aspnet-identity/quick-and-easy-aspnet-identity-multitenancy

    I would just persist data somewhere to store the last active tenant so what when you sign in, there’s no tenant picker, initially. Keen to hear if there would be a better approach here. Thank you!

    1. Your approach seems good. There are many different approaches to this and once it matches your solution requirements and it is as simple as possible, then it’s good. The solution from Scott is good. I also extend the DB sometimes and create multiple tenants and each user could join any tenant but only login to one at at a time. This worked well. (remembering the last) Once problem with this is switching tenants One choice you need to make is where to split and how to authorization between the tenants. I assume it is a disaster if tenant 1 sees the data from tenant 2.

      1. I also avoided using the roles so far, just created a separate authorization DB and used the identity id. I used this in the apps then with policies, handlers and requirements but keeping this as static as possible.

  6. This was an excellent example to get me what I needed. One question I have. I’m using AD for authentication purposes but my app is responsible for authorization. The OnTokenValidated event handler signs the user into the local application using the info from AD. How would I link the AD user to the user defined in my database, which is where roles are managed?

  7. Savage · · Reply

    I’m looking for exact this example, nice, but is there a way to add a new tenant at runtime?
    I mean, how can I use services.AddAuthentication().AddMicrosoftIdentityWebApp(.. outside Startup? My goal is that a user can add the Azure AD settings without restarting the app.

  8. Thanks for sharing this solution. I have attempted this, but Azure AD always wants to return to /signin-oidc (instead of /signin-oidc/t2). So, it fails with a redirect URI error. If I change Azure AD to go to /signin-oidc, it authenticates successfully, but then gets stuck in a redirect loop trying to go to the path /Account/Login

  9. shashank · · Reply

    I have integrated this solution but after signin redirect url not executing my controller action methods.

Leave a comment

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