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

8 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?

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

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

%d bloggers like this: