Sitecore 9 - Integrating Azure AD along with Identity Server 3

Overview

For the past few days, I've been deep in the rabbit hole trying to integrate Azure AD with Identity Server 3 to allow users to authenticate and login to Sitecore Admin portal using their Azure AD credentials. It has been a challenge as this integration isn't well documented and requires a good understanding of how things work not only within Sitecore but also within Identity Server. Let's review some of the details and steps that I followed to get this to work flawlessly.

Requirements

Allow internal users (e.g. content managers, authors, admins, etc.) to be able to login to Sitecore Admin portal using their Azure AD account.

Technology Stack Used

  1. Sitecore 9 Update 1 (rev. 9.0.171219)
  2. Identity Server 3
  3. Azure AD

Login Flow

Below is a simplified version of the entire login flow that captures what occurs when a user tries to login to Sitecore Admin portal using their Azure AD account.

Sitecore-Admin-Portal-Login-Flow

Preparation

The following NuGet packages are required to get this integration working with Identity Server 3 and Azure AD. Please note that I am working on an existing library that is being used by another project (the other project utilizes Sitecore 8). So I've had to make sure that I use -IgnoreDependency flag to indicate to NuGet that I don't want to pull in any dependencies. I had to be extra meticulous and manage all the dependencies on my own to get them to work together appropriately. Your situation may differ depending on whether you're using Identity Server 3 or 4. You may find that you're OK using the latest NuGet packages if you're on Identity Server 4.

Install-Package Sitecore.Owin.NoReferences
Install-Package Sitecore.Owin.Authentication.NoReferences
Install-Package System.IdentityModel.Tokens.Jwt -v 4.0.0.0
Install-Package Microsoft.IdentityModel.Protocol.Extensions -v 1.0.4.4
Install-Package Microsoft.Owin.Security.OpenIdConnect -v 3.1.0
Install-Package Microsoft.Owin.Security.Cookies -v 4.0.0

Development & Integration

1. Enable Federated Authentication

In the config file of your site project (should be under App_Config > Include), patch the following setting along with services to enable Federated Authentication in Sitecore:

<settings>
  <setting name="FederatedAuthentication.Enabled">
    <patch:attribute name="value">true</patch:attribute>
  </setting>
</settings>

 <services>
      <register serviceType="Sitecore.Abstractions.BaseAuthenticationManager, Sitecore.Kernel"
                implementationType="Sitecore.Owin.Authentication.Security.AuthenticationManager, Sitecore.Owin.Authentication"
                lifetime="Singleton" />
      <register serviceType="Sitecore.Abstractions.BaseTicketManager, Sitecore.Kernel"
                implementationType="Sitecore.Owin.Authentication.Security.TicketManager, Sitecore.Owin.Authentication"
                lifetime="Singleton" />
      <register serviceType="Sitecore.Abstractions.BasePreviewManager, Sitecore.Kernel"
                implementationType="Sitecore.Owin.Authentication.Publishing.PreviewManager, Sitecore.Owin.Authentication"
                lifetime="Singleton" />
</services>
2. Register Pipeline

Within the same file, register a new custom identity provider pipeline that we will create later below.

<pipelines>
  <owin.identityProviders>
    <processor type="MyLibrary.Sitecore.Pipelines.IdentityProviderPipeline, MyLibrary.Sitecore" resolve="true" />
  </owin.identityProviders>
</pipelines>

Here's the custom identity provider pipeline code that I had to add to get Identity Server 3 working with Azure AD and Sitecore Admin portal. You may want to change the implementation to suit your needs.

#region Namespaces

using System.Threading.Tasks;
using Microsoft.IdentityModel.Protocols;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.Notifications;
using Microsoft.Owin.Security.OpenIdConnect;
using Owin;
using Sitecore.Diagnostics;
using Sitecore.Owin.Authentication.Configuration;
using Sitecore.Owin.Authentication.Pipelines.IdentityProviders;
using Sitecore.Owin.Authentication.Services;

#endregion

namespace MyLibrary.Pipelines
{
    public class IdentityProviderPipeline : IdentityProvidersProcessor
    {
        private readonly FederatedAuthenticationConfiguration _configuration;

        public IdentityProviderPipeline(FederatedAuthenticationConfiguration configuration) : base(configuration)
        {
            _configuration = configuration;
        }

        protected override void ProcessCore(IdentityProvidersArgs args)
        {
            Assert.ArgumentNotNull(args, nameof(args));
            var identityProvider = GetIdentityProvider();
            var authenticationType = GetAuthenticationType();
            var authority = global::Sitecore.Configuration.Settings.GetSetting("BaseIdentityServerUrl");
            var redirectUri = global::Sitecore.Configuration.Settings.GetSetting("RedirectUri");

            args.App.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
            {
                Caption = identityProvider.Caption,
                Scope = $"profile openid",
                AuthenticationType = authenticationType,
                AuthenticationMode = AuthenticationMode.Active,
                ResponseType = $"code id_token token",
                SignInAsAuthenticationType = Web.Constants.AuthenticationType.Cookies,
                ClientId = IdentityProviderName,
                ClientSecret = "ClientSecretKey",
                Authority = authority,
                RedirectUri = redirectUri,
                TokenValidationParameters = new System.IdentityModel.Tokens.TokenValidationParameters
                {
					//validate issuer during token validation process (security check)
					ValidateIssuer = true,
					ValidIssuer  = "[URL + Tenant Id of the application created on Azure]"
                },
                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    SecurityTokenValidated = notification => SecurityTokenValidated(notification, _configuration, identityProvider),
                    RedirectToIdentityProvider = RedirectToIdentityProvider
                }
            });
        }

        private static Task SecurityTokenValidated(SecurityTokenValidatedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> notification, 
            FederatedAuthenticationConfiguration configuration, IdentityProvider identityProvider)
        {
            var identity = notification.AuthenticationTicket.Identity;
            foreach (var claimTransformationService in identityProvider.Transformations)
            {
                claimTransformationService.Transform(identity, new TransformationContext(configuration, identityProvider));
            }

            notification.AuthenticationTicket = new AuthenticationTicket(identity, notification.AuthenticationTicket.Properties);
            return Task.CompletedTask;
        }

        private static Task RedirectToIdentityProvider(RedirectToIdentityProviderNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> notification)
        {
            if (notification.ProtocolMessage.RequestType == OpenIdConnectRequestType.LogoutRequest)
            {
                var acessToken = notification.OwinContext.Authentication.User.FindFirst(Common.Constants.ClaimType.AccessToken);
                if (acessToken != null)
                {
                    //received a logout request, revoke the access token (part of the cleanup process)
                }
            }
            else if (notification.ProtocolMessage.RequestType == OpenIdConnectRequestType.AuthenticationRequest)
            {
				//adding the following acr value tells Identity Server to automatically use Azure AD as the identity provider.
                notification.ProtocolMessage.AcrValues = $"idp:Azure AD";
                return Task.CompletedTask;
            }

            return Task.CompletedTask;
        }

        protected override string IdentityProviderName => "sitecoreAdminClient";
    }
}
3. Configure Federated Authentication
<federatedAuthentication type="Sitecore.Owin.Authentication.Configuration.FederatedAuthenticationConfiguration, Sitecore.Owin.Authentication">
      <!--Definitions of providers-->
      <identityProviders hint="list:AddIdentityProvider">
        <!--Identity Provider-->
        <identityProvider id="sitecoreAdminClient" type="Sitecore.Owin.Authentication.Configuration.DefaultIdentityProvider, Sitecore.Owin.Authentication">
          <param desc="name">$(id)</param>
          <param desc="domainManager" type="Sitecore.Abstractions.BaseDomainManager" resolve="true" />
          <!--This text will be showed for button-->
          <caption>Login with AzureAD</caption>
          <icon>/sitecore/shell/themes/standard/Images/24x24/msazure.png</icon>
          <!--Domain name which will be added when create a user-->
          <domain>sitecore</domain>
          <!--list of identity transfromations which are applied to the provider when a user signin-->
          <transformations hint="list:AddTransformation">
            <!--SetIdpClaim transformation-->
            <transformation name="set idp claim" ref="federatedAuthentication/sharedTransformations/setIdpClaim" />
          </transformations>
        </identityProvider>
      </identityProviders>
      <sharedTransformations hint="list:AddTransformation">
      </sharedTransformations>
      
      <!--Provider mappings to sites-->
      <identityProvidersPerSites hint="list:AddIdentityProvidersPerSites">
        <!--The list of providers assigned to all sites-->
        <mapEntry name="all" type="Sitecore.Owin.Authentication.Collections.IdentityProvidersPerSitesMapEntry, Sitecore.Owin.Authentication">
          <sites hint="list">
              <site>shell</site>
              <site>admin</site>
          </sites>
          <identityProviders hint="list:AddIdentityProvider">
            <identityProvider ref="federatedAuthentication/identityProviders/identityProvider[@id='sitecoreAdminClient']" />
          </identityProviders>
          <externalUserBuilder type="Sitecore.Owin.Authentication.Services.DefaultExternalUserBuilder, Sitecore.Owin.Authentication">
            <param desc="isPersistentUser">true</param>
          </externalUserBuilder>
        </mapEntry>
      </identityProvidersPerSites>

      <propertyInitializer type="Sitecore.Owin.Authentication.Services.PropertyInitializer, Sitecore.Owin.Authentication">
        <maps hint="list">
          <map name="set name" type="Sitecore.Owin.Authentication.Services.DefaultClaimToPropertyMapper, Sitecore.Owin.Authentication">
            <data hint="raw:AddData">
              <source name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn" />
              <target name="FullName" />
            </data>
          </map>
          <map name="set as admin" type="Sitecore.Owin.Authentication.Services.DefaultClaimToPropertyMapper, Sitecore.Owin.Authentication">
            <data hint="raw:AddData">
              <source name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"></source>
              <target name="IsAdministrator" value="true" />
            </data>
          </map>
        </maps>
      </propertyInitializer>
</federatedAuthentication>

Once all of the configuration is out of the way, do a build and you should see the Azure AD login button show up on the Sitecore Admin Portal as seen below. If you don't see it, chances are that you've got malformed XML that Sitecore doesn't recognize so double check your config file!

Login-with-Azure-AD---Sitecore-9