Multiple authentication schemes with ASP.NET Core and Azure Active Directory
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.
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:
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':
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
-
Install Microsoft.Identity.Web and Microsoft.Identity.Web.UI Nuget packages in the project
-
Update
appsettings.json
with the app registration details (found in the 'Overview' tab in the Azure portal)
{
"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
calledAddMicrosoftIdentityWebApp
which is also in the documentation. I also initialised the Microsoft.Identity.Web.UI package withAddMicrosoftIdentityUI
to handle the sign in screen.
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! 😄
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.
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:
- Searching for text in PDFs at increasing scale with C# and Python