Back

Multiple authentication schemes with ASP.NET Core and Azure Active Directory

June 25, 2021 8 minute read
Black handled key on key hole
Source: Pexels

I recently came across an interesting and challenging problem. I was asked to add Azure Active Directory (AAD) authentication to an existing ASP.NET Core web app, which already had two sign in options. I had added AAD to an application as the only sign in option before, but not alongside other sign in options.

I found that within the documentation adding AAD to an application as the only sign option was fairly straightforward - as mentioned I’d done this before. However, when trying to add it as a third authentication scheme, things got a little more tricky. There was some guidance for multiple authentication but not much. Although this article is not extensive and I can’t share all the code because it was at work, hopefully it will provide enough information to help you out if you find yourself attempting the same thing. This article is certainly not a tutorial, more of a reflection on how I arrived at the solution.

The starting point

The application I was working on already had two sign in options. There was a selection screen flow which looked something like the image below. Another option would need adding to this for internal AAD users. The first and second option would go off to the existing sign in options, the third would direct to the AAD / Microsoft Identity sign in page. Excuse the bad flow diagram 😆

The existing authentication schemes were configured in the Startup class using a method AddAndConfigureExternalAuthentication. I have only included relevant parts in the code snippets, so these are not working examples.

Startup.cs
using Microsoft.Identity.Web.UI;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.OpenApi.Models;
...

namespace ShedloadOfCode.Web
{
    public class Startup
    {
        private readonly IConfiguration _configuration;
        private readonly IHostEnvironment _hostEnvironment;

        public Startup(IConfiguration configuration,
            IHostEnvironment hostEnvironment)
        {
            _configuration = configuration;
            _hostEnvironment = hostEnvironment;
        }

        public void ConfigureServices(IServiceCollection services)
        {
          ...
          
          services.AddAndConfigureExternalAuthentication(_configuration);
          
          ... 
        }

        ...

    }
}

The app handled sign in and sign out within an AccountController, particularly important is the ExternalLogin action, as when the option in the diagram is selected this action will take the given authentication scheme and issue a new challenge redirecting to the relevant identity provider:

AccountController.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using System.Linq;
using System.Threading.Tasks;

namespace ShedloadOfCode.Web.Controllers
{
    public class AccountController : Controller
    {
        ...

        [HttpGet]
        [AllowAnonymous]
        public async Task<IActionResult> Login(string returnUrl = null)
        {
            var result = await _appAuthenticationHandler.SignInAsync(returnUrl, this);
            return result;
        }

        [HttpPost]
        [AllowAnonymous]
        public async Task<IActionResult> Login(
            LoginViewModel credentials, string returnUrl = null)
        {
            var result = await _appAuthenticationHandler.SignInAsync(
                credentials, returnUrl, this);
            return result;
        }

        public new IActionResult SignOut()
        {
            var callbackUrl = Url.Action("Index", "Home");
            HttpContext.ClearAllTempData();
            return _appAuthenticationHandler.SignOut(callbackUrl, this);
        }

        public IActionResult SignedOut()
        {
            if (User.Identity.IsAuthenticated)
            {
                return RedirectToAction(nameof(HomeController.Welcome), "Home");
            }

            return RedirectToAction(nameof(HomeController.Index), "Home");
        }

        [HttpGet]
        public async Task<IActionResult> Selector()
        {
            if ((await _authenticationSchemeProvider.GetRequestHandlerSchemesAsync()).Count() < 2)
            {
                return NotFound();
            }

            return View();
        }

        [HttpGet]
        [AllowAnonymous]
        public async Task<IActionResult> ExternalLogin(
            [FromQuery] string provider,
            [FromQuery] string returnUrl = "/")
        {
            if ((await _authenticationSchemeProvider.GetRequestHandlerSchemesAsync()).Count() < 2)
            {
                return NotFound();
            }

            string authenticationScheme = _appAuthenticationHandler.GetAuthenticationScheme(provider);

            if (string.IsNullOrWhiteSpace(authenticationScheme))
            {
                ModelState.AddModelError(nameof(provider), "Select a sign in option");
                return View("Selector");
            }

            var auth = new AuthenticationProperties
            {
                RedirectUri = Url.Action(nameof(LoginCallback), new { provider, returnUrl })
            };

            return new ChallengeResult(authenticationScheme, auth);
        }

        public IActionResult LoginCallback(
            string provider,
            string returnUrl = "~/")
        {
            if (User.Identity.IsAuthenticated)
            {
                return LocalRedirect(string.IsNullOrEmpty(returnUrl) ? "~/" : returnUrl);
            }

            return RedirectToAction(nameof(Selector), new { returnUrl = returnUrl });
        }
    }
}

As you might have noticed this controller had a few helper methods injected from a service. I added a new value 'AAD' to the GetAuthenticationScheme lookup method - this would return an authentication scheme called 'AzureAd':

FederationAppAuthenticationHandler.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authentication.WsFederation;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace ShedloadOfCode.Web.Services
{
    public class FederationAppAuthenticationHandler : IAppAuthenticationHandler
    {
        private readonly IHttpContextAccessor _httpContextAccessor;

        public FederationAppAuthenticationHandler(
            IHttpContextAccessor httpContextAccessor)
        {
            _httpContextAccessor = httpContextAccessor;
        }

        public Task<IActionResult> SignInAsync(
            string returnUrl, Controller controller)
        {
            throw new NotSupportedException("No such page exists");
        }

        public Task<IActionResult> SignInAsync(
            LoginViewModel credentials, string returnUrl, Controller controller)
        {
            throw new NotSupportedException();
        }

        public IActionResult SignOut(string callbackUrl, Controller controller)
        {
            var provider = _httpContextAccessor.HttpContext.User.AuthenticationProvider();
            var authenticationScheme = GetAuthenticationScheme(provider);

            return controller.SignOut(
                new AuthenticationProperties { RedirectUri = callbackUrl },
                CookieAuthenticationDefaults.AuthenticationScheme,
                authenticationScheme);
        }

        public string GetAuthenticationScheme(string provider)
        {
            string authenticationScheme = null;

            if (String.Equals("FirstAuthenticationProviderName",
                provider, StringComparison.OrdinalIgnoreCase))
            {
                authenticationScheme = WsFederationDefaults.AuthenticationScheme;
            }
            else if (String.Equals("SecondAuthenticationProviderName",
                provider, StringComparison.OrdinalIgnoreCase))
            {
                authenticationScheme = OpenIdConnectDefaults.AuthenticationScheme;
            }
            else if (String.Equals("AAD",
                provider, StringComparison.OrdinalIgnoreCase))
            {
                authenticationScheme = "AzureAd";
            }

            return authenticationScheme;
        }
    }
}

My first steps

I recalled how I had added AAD as the only sign in method to an app before, and tried those steps first:

  • Create an app registration in the AAD in the Azure Portal
  • Create a sign-in and sign-out route for the new app registration, and enable ID tokens
  • Create a client secret for the new app registration
appsettings.json
{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "yourdomain.onmicrosoft.com",
    "ClientId": "11adca46-d907-4803-945f-demoClientId",
    "TenantId": " b3b8b34a82f9-c69a-4da1-a5f2-demoTenantId",
    "ClientSecret": ".dVv3r.2g2ED6_Xb-bSaXROml~demoClientSecret",
    "MetadataAddress": "https://login.microsoftonline.com/b3b8b34a82f9-c69a-4da1-a5f2-demoTenantId/v2.0/.well-known/openid-configuration",
    "CallbackPath": "/signin-oidc",
    "SignedOutCallbackPath": "/signout-callback-oidc",
    "SignedOutRedirectUri": "/"
  }
  ...
}
  • Add the same method I had used before for AAD authentication to Startup.cs called AddMicrosoftIdentityWebApp which is also in the documentation. I also initialised the Microsoft.Identity.Web.UI package with AddMicrosoftIdentityUI to handle the sign in screen.
Startup.cs
using Microsoft.Identity.Web.UI;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.OpenApi.Models;
...

namespace ShedloadOfCode.Web
{
    public class Startup
    {
        private readonly IConfiguration _configuration;
        private readonly IHostEnvironment _hostEnvironment;

        public Startup(IConfiguration configuration,
            IHostEnvironment hostEnvironment)
        {
            _configuration = configuration;
            _hostEnvironment = hostEnvironment;
        }

        public void ConfigureServices(IServiceCollection services)
        {
          ...
          
          services.AddAndConfigureExternalAuthentication(_configuration);
          
          services.AddAuthentication()
            .AddMicrosoftIdentityWebApp(_configuration,
              configSectionName: "AzureAd",
              openIdConnectScheme: "AzureAd",
              cookieScheme: "AzureAdCookies")

          services.AddRazorPages()
                .AddMicrosoftIdentityUI();

          ... 
        }

        ...

    }
}

I had to add a distinct openIdConnect and cookieScheme to avoid scheme conflicts when using this approach. configSectionName just pulls the relevent config section AzureAd from appsettings.json.

However, after selecting the new sign in option for AAD, being sent to the Microsoft Identity sign in page and entering credentials and clicking login, I was redirected back to the application, but wasn't authenticated! I was very confused by this, especially since it had worked so well in other apps as the only sign in method. Plus we can see quite clearly here in the docs for single authentication and multiple authentication this is the recommended approach:

The solution - using AddOpenIdConnect()

So I came across this super helpful article, and thought okay I should try using the AddOpenIdConnect method to sign in. I added the configuration for each option... and this time, after the redirect back to the application, the user was authenticated! 😄

Startup.cs
using Microsoft.Identity.Web.UI;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.OpenApi.Models;
...

namespace ShedloadOfCode.Web
{
    public class Startup
    {
        private readonly IConfiguration _configuration;
        private readonly IHostEnvironment _hostEnvironment;

        public Startup(IConfiguration configuration,
            IHostEnvironment hostEnvironment)
        {
            _configuration = configuration;
            _hostEnvironment = hostEnvironment;
        }

        public void ConfigureServices(IServiceCollection services)
        {
          ...
          
          services.AddAndConfigureExternalAuthentication(_configuration);
          
          var azureAdConfiguration = _configuration.GetSection("AzureAd").Get<AzureAdConfigOptions>();

          services.AddAuthentication()
            .AddOpenIdConnect("AzureAd", options =>
            {
                options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                options.Authority = azureAdConfiguration.MetadataAddress;
                options.ClientId = azureAdConfiguration.ClientId;
                options.ClientSecret = _configuration.GetValue<string>(azureAdConfiguration.ClientSecret);
                options.CallbackPath = new PathString(azureAdConfiguration.CallbackPath);
                options.MetadataAddress = azureAdConfiguration.MetadataAddress;
                options.SignedOutCallbackPath = new PathString(azureAdConfiguration.SignedOutCallbackPath);
                options.SignedOutRedirectUri = new PathString(azureAdConfiguration.SignedOutRedirectUri);
                options.ResponseType = OpenIdConnectResponseType.Code;
                options.UsePkce = true;
                options.Scope.Add("openid");
                options.Scope.Add("profile");
                options.SaveTokens = true;
                options.Events.OnSignedOutCallbackRedirect += context =>
                {
                    context.Response.Redirect(azureAdConfiguration.SignedOutRedirectUri);
                    context.HandleResponse();

                    return Task.CompletedTask;
                };
                options.Events.OnTokenValidated = async (context) =>
                {
                    if (context.Principal.Identity.IsAuthenticated)
                    {
                        // Set auth provider using an extension method to facilitate logout
                        context.Principal.SetAuthenticationProvider("AAD");

                        // Get AAD username from claims
                        var emailAddress = context.Principal.Claims
                            .Where(c => c.Type == "preferred_username")
                            .Select(c => c.Value)
                            .ToList()
                            .First();

                        // Get AAD security groups from claims
                        var groups = context.Principal.Claims
                            .Where(c => c.Type == "groups")
                            .Select(c => c.Value)
                            .ToList();
                    }
                };
            });

          services.AddRazorPages()
                .AddMicrosoftIdentityUI();

          ... 
        }

        ...

    }
}

I set the authentication scheme as AzureAd so the controller knows which challenge to issue after the selection screen. After the token validates, I can see the user is authenticated and I can get the user details and claims that are returned from AAD. No separate cookieScheme needs setting for this approach either, it will just use CookieAuthenticationDefaults.AuthenticationScheme which is 'Cookies'. This code is still using the values we set in appsettings.json just mapping them to AzureAdConfigOptions and using them individually.

AzureAdConfigOptions.cs
namespace ShedloadOfCode.Web.Options
{
  public class AzureAdConfigOptions
  {
    public string Instance { get; set; }
    public string Domain { get; set; }
    public string ClientId { get; set; }
    public string TenantId { get; set; }
    public string ClientSecret { get; set; }
    public string MetadataAddress { get; set; }
    public string CallbackPath { get; set; }
    public string SignedOutCallbackPath { get; set; }
    public string SignedOutRedirectUrl { get; set; }
  }
}

I was really pleased with this outcome. Usually, when it comes to searching documentation, reading Stack Overflow and general Google-Fu, I’m quite skilled. However the answer to this one evaded me for some time! I traced back the usage of AddOpenIdConnect within the AddMicrosoftIdentityWebApp method in the Microsoft.Identity.Web source code.

Getting AAD group information

One requirement for authorisation was to only allow users with a specific AAD group to access the application - others needed to ask permission to be added to the AAD group. I retrieved them in the solution code in the groups variable, however for group claims to be returned from AAD, they need enabling in Azure.

To enable group claims, you head back to the app registration and select 'Add groups claim' inside 'Token configuration'.

This allows the AAD group information to be returned for the authenticated user. You can then access these as claims and use specific groups a user belongs to for authorisation and access control.

Next steps

My next steps will be code clean up. I’ll move the ClientSecret into Azure Key Vault, and move the AAD authentication code into an AddAndConfigureAzureAdAuthentication method to tidy things up. So now any user who selects that new option, and is part of the organisation's AAD and within the specific AAD group can access the application 😄 Well it was a tough journey, but got there in the end. I would be lying if I said I didn't nearly give up on it a few times!

I really hope this article has helped you to avoid the issues I had trying to set this up.

If you enjoyed this article be sure to check out other articles on the site including: