Summary

I am using Sitecore for a Multisite that is already hosting two publicly available sites. We wanted to create a new intranet site using the same instance of Sitecore. Since this is an internal site one of the requirements was to secure all content using Azure Active Directory, keep in mind we are not talking about the Sitecore Client, but the actual site. So to sum up the challenge: Secure the intranet using Azure Active Directory while leaving the other two public sites available to all. We also wanted to use Microsoft Graph API (Delegated) to update their intranet profile immediately after the user logs in using AAD and using an OAuth Token, but we will tackle that in a later blog post.

The following steps will be outlined below:

  • Turning on Sitecore’s Federated Authentication
  • Building a custom IdentityProvidersProcessor for Azure AD or OpenId
  • Coding Azure AD Identity Provider
  • Mapping Claims
  • Creating a Sitecore User Builder
  • Setup the AppRegistration in Azure Active Directory
  • Forcing Intranet Site to use login

Turning on Sitecore’s Federated Authentication

The following config will enable Sitecore’s federated authentication. This configuration is also located in an example file located in \\App_Config\\Include\\Examples\\Sitecore.Owin.Authentication.Enabler.example. I decided to create my own patch file and install it in the Include folder.

<?xml version="1.0" encoding="utf-8"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:role="http://www.sitecore.net/xmlconfig/role/"  xmlns:env="http://www.sitecore.net/xmlconfig/env/">
  <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>
    </sitecore>
</configuration>

IdentityProvidersProcessor for Azure AD using the OpenId Protocol

The following Class inherits Sitecore’s IdentityProvidersProcessor

using Owin;
using Sitecore.Configuration;
using Sitecore.Diagnostics;
using Sitecore.Owin.Authentication.Configuration;
using Sitecore.Owin.Authentication.Pipelines.IdentityProviders;
using Sitecore.Owin.Authentication.Services;
using System.Globalization;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.OpenIdConnect;

namespace Foundation.Auth.Processors
{
    public class AzureADIdentityProviderProcessor : IdentityProvidersProcessor
    {

    }
}
The ProviderProcessor should override the IdentityProviderName property and the ProcessCore method

protected override void ProcessCore(IdentityProvidersArgs args)
{
            Assert.ArgumentNotNull(args, nameof(args));

            var identityProvider = this.GetIdentityProvider();
            var authenticationType = this.GetAuthenticationType();

            string aadInstance = Settings.GetSetting("AADInstance");
            string tenant = Settings.GetSetting("Tenant");
            string clientId = Settings.GetSetting("ClientId");
            string postLogoutRedirectURI = Settings.GetSetting("PostLogoutRedirectURI");
            string redirectURI = Settings.GetSetting("RedirectURI");
            string authority = string.Format(CultureInfo.InvariantCulture, aadInstance, tenant);

            args.App.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
            {
                Caption = identityProvider.Caption,
                AuthenticationType = authenticationType,
                AuthenticationMode = AuthenticationMode.Passive,
                ClientId = clientId,
                Authority = authority,
                PostLogoutRedirectUri = postLogoutRedirectURI,
                RedirectUri = redirectURI,
                Scope = "offline_access",

                // Watch for Events
                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    // When everything is passed
                    SecurityTokenValidated = async notification =>
                    {
                        // Get the Ident object from Ticket
                        var identity = notification.AuthenticationTicket.Identity;

                        // Use Sitecore Claim Transformation Service to generate additional claims like role or admin
                        foreach (var claimTransformationService in identityProvider.Transformations)
                        {
                            claimTransformationService.Transform(identity,
                                new TransformationContext(FederatedAuthenticationConfiguration,identityProvider));
                        }

                        // Create new Auth Ticket
                        notification.AuthenticationTicket = new AuthenticationTicket(identity, notification.AuthenticationTicket.Properties);

                        //Returns blank task
                        return; 
                    }

                }
            });
        }

The ProcessCore method should look familiar if you have used OWIN middleware in other .NET projects and used IAppBuilder. This method sets up the OpenId connect provider and also handles the SecurityTokenValidated event. This is also where we set up the endpoints and all the properties needed from the Azure AD Tenant. These settings are not hard coded, but pulled in from Sitecore settings config.

Within the SecurityTokenValidated anonymous function, Sitecore’s Claims transformation service is being used to map claims returned from AAD and mapped to Sitecore Claims. These newly created Sitecore claims will later be used to create a Sitecore user and log them in.

The following are examples of the settings:

<sitecore role:require="Standalone or ContentDelivery or ContentManagement">
    
    <!-- Common Settings Should be same for each Environment -->
    <settings>
      <setting name="AADInstance" value="https://login.microsoftonline.com/{0}" />
      <setting name="AuthorityUri" value="https://login.microsoftonline.com/common/" />
    </settings>
    
    <!-- Settings for Local -->
    <settings env:require="Local">
      <setting name="ClientId" value="XXXXX-3586-XXX-XXX-4XXXXXd2a18" />
      <setting name="ClientSecret" value="Goblyhash" />
      <setting name="Tenant" value="yourtenant.com" />
      <setting name="PostLogoutRedirectURI" value="https://intranet.local/sitecore/login"/>
      <setting name="RedirectURI" value="https://intranet.local/sitecore" />
    </settings>

User Builder

Because we are using Sitecore Federated authentication, the creation of Sitecore users is handled automatically. Sitecore does, however, give you the opportunity to inject your own custom user builder. This is great because I wanted to be able to control which domain the intranet users were assigned.

I inherited the DefaultExternalUserBuilder then rewrote the domain name in the CreateUniqueUserName method.

using Microsoft.AspNet.Identity;
using Sitecore.Diagnostics;
using Sitecore.Owin.Authentication.Configuration;
using Sitecore.Owin.Authentication.Identity;
using System;

namespace Foundation.Auth.UserBuilder
{
    public class IntranetUserBuilder : Sitecore.Owin.Authentication.Services.DefaultExternalUserBuilder
    {
        public IntranetUserBuilder(bool isPersistentUser) :base(isPersistentUser)
        {
        }

        public IntranetUserBuilder(string isPersistentUser) : base(bool.Parse(isPersistentUser))
        {
        }

        protected override string CreateUniqueUserName(UserManager userManager, Microsoft.AspNet.Identity.Owin.ExternalLoginInfo externalLoginInfo)
        {
            Assert.ArgumentNotNull((object)userManager, nameof(userManager));
            Assert.ArgumentNotNull((object)externalLoginInfo, nameof(externalLoginInfo));
            IdentityProvider identityProvider = this.FederatedAuthenticationConfiguration.GetIdentityProvider(externalLoginInfo.ExternalIdentity);
            if (identityProvider == null)
                throw new InvalidOperationException("Unable to retrieve identity provider for given identity");
            string domain = identityProvider.Domain;
            string email = externalLoginInfo.DefaultUserName;

            // return email and domain
            return $"{domain}\\\\\\\\{email}";

        }

    }
}

Configuring the User Builder

Now that we have created a custom user builder we will need to teach Sitecore how to use it, with the following config, although the IdentityProvidersPerSites element also tells Sitecore which Site Definitions are using the custom IdentityProvider

<identityProvidersPerSites>
        <mapEntry name="all" type="Sitecore.Owin.Authentication.Collections.IdentityProvidersPerSitesMapEntry, Sitecore.Owin.Authentication">
          <sites hint="list">
            <site>intranet</site>
          </sites>
          <!-- Registered identity providers for above providers -->
          <identityProviders hint="list:AddIdentityProvider">
            <identityProvider ref="federatedAuthentication/identityProviders/identityProvider[@id='azureAD']" />
          </identityProviders>
          <!-- ExternalUserBuilder is what creates a user with custom username in Sitecore and assigns roles based on claim transformation configured above -->
          <externalUserBuilder type="Foundation.Auth.UserBuilder.IntranetUserBuilder, Foundation.Auth">
            <param desc="isPersistentUser">true</param>
          </externalUserBuilder>
        </mapEntry>
      </identityProvidersPerSites>
</federatedAuthentication>

Note that the externalUserBuilder is pointed to our custom user builder. Also note that the parameter isPersistentUser is set to true which tells Sitecore’s user builder to create a permanent user rather then a virtual user in Sitecore. Refer to Sitecore’s documentation about when to use Virtual Users or Persistent Users.

Configuring the Auth

We need to tell Sitecore to use our Azure Identity Provider with the following configuration.

<pipelines>
      <owin.identityProviders>
        <!-- This is the custom processor that gets executed when azure AD posts the token to Sitecore -->
        <processor type="Foundation.Auth.Processors.AzureADIdentityProviderProcessor, Foundation.Auth" resolve="true" />
      </owin.identityProviders>
</pipelines>

And the following configuration tells Sitecore how to map Claims from AAD into Sitecore.

<federatedAuthentication>
      <!-- Property initializer assigns claim values to sitecore user properties -->
      <propertyInitializer type="Sitecore.Owin.Authentication.Services.PropertyInitializer, Sitecore.Owin.Authentication">
        <patch:attribute name="type">Foundation.Auth.UserBuilder.UserPropertyInitializer, Foundation.Auth</patch:attribute>
        <maps hint="list">
          <map name="email claim" type="Sitecore.Owin.Authentication.Services.DefaultClaimToPropertyMapper, Sitecore.Owin.Authentication">
            <data hint="raw:AddData">
              <!--claim name-->
              <source name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn" />
              <!--property name-->
              <target name="Email" />
            </data>
          </map>
          <map name="Name claim" type="Sitecore.Owin.Authentication.Services.DefaultClaimToPropertyMapper, Sitecore.Owin.Authentication">
            <data hint="raw:AddData">
              <!--claim name-->
              <source name="name" />
              <!--property name-->
              <target name="Fullname" />
            </data>
          </map>
          <map name="Other claim" type="Sitecore.Owin.Authentication.Services.DefaultClaimToPropertyMapper, Sitecore.Owin.Authentication">
            <data hint="raw:AddData">
              <!--claim name-->
              <source name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn" />
              <!--property name-->
              <target name="Comment" />
            </data>
          </map>
        </maps>
      </propertyInitializer>
    </federatedAuthentication>

The example above maps Azure’s UPN to Sitecore’s Email field and Azure’s Name Field into Sitecore’s Fullname Field

Register the new Identity Provider with Sitecore

<identityProviders hin="list:AddIdentityProvider">
        <identityProvider id="azureAD" type="Sitecore.Owin.Authentication.Configuration.DefaultIdentityProvider, Sitecore.Owin.Authentication">
          <param desc="name">$(id)</param>
          <param desc="domainManager" type="Sitecore.Abstractions.BaseDomainManager" resolve="true" />
          <caption>Sign-in with Azure Active Directory</caption>
          <domain>intranet</domain>
          <icon>/sitecore/shell/themes/standard/Images/24x24/msazure.png</icon>
          <transformations hint="list:AddTransformation">
            <!-- you need to have and Idp Claim for this to work -->
            <transformation name="Idp Claim" ref="federatedAuthentication/sharedTransformations/setIdpClaim" />
            <!-- This is to transform your Azure group into Sitecore Role. The claim value below is the object id of the role that needs to be copied from Azure -->
            <transformation name="devRole" type="Sitecore.Owin.Authentication.Services.DefaultTransformation, Sitecore.Owin.Authentication">
              <sources hint="raw:AddSource">
                <claim name="groups" value="9ae0af64-3b5b-45b7-b6e8-62a613250824" />
              </sources>
              <targets hint="raw:AddTarget">
                <claim name="http://schemas.microsoft.com/ws/2008/06/identity/claims/role" value="Sitecore\\Developer" />
              </targets>
              <keepSource>true</keepSource>
            </transformation>
          </transformations>
        </identityProvider>
      </identityProviders>
Assign Identity Providers the intranet site

<identityProvidersPerSites>
        <mapEntry name="all" type="Sitecore.Owin.Authentication.Collections.IdentityProvidersPerSitesMapEntry, Sitecore.Owin.Authentication">
          <sites hint="list">
            <site>intranet</site>
          </sites>
          <!-- Registered identity providers for above providers -->
          <identityProviders hint="list:AddIdentityProvider">
            <identityProvider ref="federatedAuthentication/identityProviders/identityProvider[@id='azureAD']" />
          </identityProviders>
        </mapEntry>
      </identityProvidersPerSites>

Forcing a Login

On the Site Definition we need to tell Sitecore where the login Page is located effectively putting that site behind a login wall. We did not want to the Sitecore Login page as a Idp Portal so I whipped up a custom aspx page that is publicly available

Here is the SiteDefinition Example

      <site name="intranet" patch:before="site[@name='website']"
         enableTracking="true"
         scheme="https"
         hostName="www.intranet.com"
         virtualFolder="/"
         physicalFolder="/"
         rootPath="/sitecore/content"
         startItem="/Intranet"
         database="web"
         domain="intranet"
         allowDebug="true"
         cacheHtml="true"
         filteredItemsCacheSize="10MB"
         enablePreview="true"
         enableWebEdit="true"
         enableDebugger="true"
         disableClientData="false"
         requireLogin="true"
         loginPage="/sitecore/login/IntranetLogin.aspx"  />

And the following code is how the login functions

[AllowDependencyInjection]
    public partial class IntranetLogin : Page
    {
        // The Injected CorePipeline
        private BaseCorePipelineManager CorePipelineManager;

        public IntranetLogin()
        {
        }      

       public IntranetLogin(BaseCorePipelineManager corePipelineManager)
        {
            this.CorePipelineManager = corePipelineManager;
        }

        protected override void OnInit(EventArgs e)
        {
            RedirectToIdp();
            base.OnInit(e);
        }

        internal void RedirectToIdp()
        {
            //Run Pipeline to get list of external logins
            GetSignInUrlInfoArgs args = new GetSignInUrlInfoArgs("intranet", WebUtil.GetQueryString("returnUrl"));
            GetSignInUrlInfoPipeline.Run(this.CorePipelineManager, args);

            //Get link to IDP
            var redirectToIdp = args.Result.First().Href;

            //Response.Redirect(redirectToIdp);
           PostRedirect(redirectToIdp);

       }

       private void PostRedirect(string url)
        {
            Response.Clear();
            var sb = new System.Text.StringBuilder();
            sb.Append("<html>");
            sb.AppendFormat("<body onload='document.forms[0].submit()'>");
            sb.AppendFormat("<form action='{0}' method='post'>", url);
            sb.Append("</form>");
            sb.Append("</body>");
            sb.Append("</html>");
            Response.Write(sb.ToString());
            Response.End();
       }
       
      internal SignInUrlInfo PrepareSignInInfo(SignInUrlInfo info)
      {
            int num = info.Href.IndexOf("?", StringComparison.Ordinal);
            string href = info.Href;
            if (num > -1)
            {
               NameValueCollection queryString1 = HttpUtility.ParseQueryString(info.Href.Substring(num + 1));
                NameValueCollection queryString2 = HttpUtility.ParseQueryString(string.Empty);
                foreach (string key in queryString1.Keys)
                    queryString2[key] = HttpUtility.UrlEncode(queryString1[key]);
                href = info.Href.Substring(0, num + 1) + (object)queryString2;
            }
            return new SignInUrlInfo(info.IdentityProvider, href, info.Caption, info.Icon);
        }

}
Once everything is all wired up, the login should work as expected. Finito!